上一章,我们已经给 TaskHub 写了服务层、数据层和 Web 层测试。那些测试能回答“任务能不能创建”“参数错误会不会返回 400”“任务不存在时会不会返回 404”。应用看上去已经很像一个能交付的后端了。
但现在请打开一个没有登录信息的终端,直接发出下面的请求:
DELETE /api/tasks/42 HTTP/1.1
Host: localhost:8080如果接口返回 204 No Content,问题就很具体了:任何知道任务编号的人都能删除任务。再坏一点,即使我们补了登录,只检查“已经登录”仍然不够。用户小林登录后把地址里的 42 改成 41,若 41 属于小周而删除仍然成功,TaskHub 只是从“谁都能删”变成了“任意用户都能删别人的任务”。
这正是应用安全最容易被误解的地方。安全并不是给项目加一个登录页,也不是在配置类里写一句 authenticated() 就结束。我们需要连续回答四个问题:请求者是谁、这个身份从哪里得到、他能做什么、他能否操作这一条具体数据。前两个问题属于身份认证,后两个问题属于授权与业务所有权。
这一章沿用上一章的测试思路:先把危险行为写成会失败的测试,再逐层加入 Spring Security、密码哈希、角色规则、任务所有权、CSRF 与 CORS 配置,最后让测试锁住边界。完成后,TaskHub 才适合进入下一章的打包与部署。
“接口藏得很深”“前端没有显示删除按钮”“任务 ID 很难猜”都不是权限控制。只要后端收到请求后没有核对身份与权限,攻击者就可以绕过页面直接调用接口。
TaskHub 目前有普通成员和管理员两类用户。我们先把删除任务的规则说成人能读懂的话:
这里的 401 与 403 不应混用。401 表示服务器还没有得到可接受的身份,客户端下一步通常是登录;403 表示身份已经明确,但这个身份无权执行操作。404 表示目标不存在。对于高度敏感的资源,有的系统会故意把“无权查看”也收敛成 404,避免暴露资源是否存在;那是一项产品级策略,必须在整个接口中保持一致,不能一会儿 403、一会儿 404。
认证与授权可以这样拆开看:
认证成功不等于授权成功。就像一张公司门禁卡可以证明持卡人的身份,却不代表他能进入每一间机房。authenticated() 只完成“有卡”这一步,角色与所有权才决定具体门能否打开。
我们不妨把最初的删除事故再走一遍。匿名请求进入应用时,服务器连“请求者是 lin 还是 zhou”都不知道,所以它不应查询任务,也不应调用删除逻辑,而要直接结束在认证入口,返回 401。lin 登录以后再次请求任务 42,身份这一关通过了;授权层接着读取任务归属。如果任务 42 的所有者是 zhou,操作停在方法调用之前并返回 403。只有任务归属 lin,或者当前身份拥有管理员角色时,事务才真正开始查找和删除。
这条顺序不只是为了节省一次数据库查询。越早拒绝,越少业务代码会观察到不可信输入,审计记录也更容易解释。安全测试因此要同时断言响应和副作用:看到 401 或 403 还不够,还要验证 Repository 没有执行删除。否则完全可能出现“数据已经删了,最后响应却被改成 403”的荒唐实现。
读列表时也有同样的问题。普通成员的 GET /api/tasks 不应先查出所有任务,再依靠前端把别人的行隐藏。响应到达前端时数据已经泄露了。查询条件要在服务或仓库层带上当前用户,管理员才走全量查询。单条详情、更新、完成任务、附件下载都要复用同一套边界;只保护 DELETE 会留下其他入口。
权限设计最好写成一张可以评审的矩阵,而不是散落在需求讨论里的“这个应该可以吧”。每增加一种角色或一种动作,就补一列或一行。产品、后端与测试可以围绕同一张矩阵确认差异,代码再通过路径规则、方法规则和查询条件落实。这样做稍显啰嗦,却比上线后从日志里推断“当时到底想让谁访问”省心得多。

我们还要接受一个看起来有点麻烦的事实:安全规则不能只放在 Controller。Controller 是 HTTP 入口,但同一个 Service 将来可能被定时任务、消息消费者或别的 Controller 调用。只在 URL 上设防,相当于给正门上锁,却默认侧门永远不会出现。TaskHub 会同时使用请求级授权和方法级授权,让两层各自处理擅长的问题。
先在 pom.xml 中加入安全与安全测试依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-test</artifactId>
<scope>test</scope>
</dependency>刷新 Maven 后,先别急着写配置。直接启动应用,会发现原来的接口都需要认证,启动日志里还会出现一个仅供开发使用的随机密码。这不是 TaskHub 最终的用户系统,而是 Spring Boot 在类路径发现 Spring Security 后提供的默认保护。它用这种“先关门”的方式提醒我们:必须明确决定哪些接口公开、哪些接口受保护。
浏览器或客户端发来的请求不会直接进入 TaskController。Servlet 容器会先让请求经过一组 Filter。Spring Security 在其中注册了 FilterChainProxy,它再选择一条匹配当前请求的 SecurityFilterChain。链上的过滤器分别处理 CORS、CSRF、安全上下文、登录、匿名身份、授权和异常转换等事情。过滤器全部放行后,请求才会到达 Spring MVC。

这个结构解释了几个常见现象:
请求进入安全链后,安全上下文相关的过滤器先尝试从当前会话恢复上一轮登录保存的身份。如果找到了有效上下文,后续代码就能从 SecurityContextHolder 看到 Authentication;如果没有,链仍可继续,匿名认证机制会提供一个便于统一判断的匿名身份。这不代表匿名用户真的“认证成功”,异常转换逻辑仍能分辨匿名与已登录用户。
登录请求会被专门的认证过滤器识别。它从请求参数取得用户名与密码,构造一个尚未认证的令牌,交给 AuthenticationManager。管理器本身通常不负责查数据库,而是挑选支持这类令牌的 AuthenticationProvider。我们稍后使用的 DaoAuthenticationProvider 会调用 UserDetailsService 取得账户,再让 PasswordEncoder 比对密码。成功结果会包含主体与权限,但不会把原始密码继续留在对象里到处传递。
普通业务请求不会每次都重新执行表单登录。会话方案从服务器端会话恢复安全上下文,Bearer token 方案则由相应过滤器在每次请求中验证令牌。无论身份来自哪里,授权阶段面对的都是 Authentication:主体是谁、是否已认证、拥有哪些 GrantedAuthority。这正是 Spring Security 能把认证来源与授权规则拆开的原因。
当授权规则拒绝请求时,异常不会直接变成一页 Java 堆栈。异常转换过滤器会判断当前请求是否缺少身份:缺少身份就启动 AuthenticationEntryPoint,得到 401;身份存在但权限不足,就交给 AccessDeniedHandler,得到 403。这套分流发生在 MVC 之前,所以仅在 @RestControllerAdvice 中写异常处理并不能统一所有安全错误。
安全上下文默认与当前请求线程绑定。业务代码不要把它缓存进单例字段,也不要假设新开的异步线程会自动继承当前身份。需要异步执行受保护操作时,应使用框架提供的上下文传播方式,或者把必要的不可伪造身份信息显式传入任务,并重新确认异步边界上的授权与审计要求。
现在的配置入口是一个或多个 SecurityFilterChain Bean。网上如果还在让你继承 WebSecurityConfigurerAdapter,或者用大段 <http>、<intercept-url> XML,那是旧版本的配置方式,不适合本课程统一使用的 Spring Boot 4.1.0。我们要理解旧代码表达的意图,但不要照抄它的形状。
先建立一份最小但边界清楚的配置,后面再补登录成功处理、CORS 和 CSRF:
package com.welearn.taskhub.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
@Configuration(proxyBeanMethods = false)
@EnableMethodSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain
规则按声明顺序匹配,前面应当写窄而明确的例外,最后用 anyRequest().denyAll() 收口。/error 允许匿名进入,是为了让原始错误能按统一契约输出,不要让一次业务失败被二次改写成登录失败;这不代表可以在错误响应中暴露堆栈。不要使用“把所有接口先 permitAll(),以后再慢慢补”的做法,因为新增接口很容易在补权限前就被发布。默认拒绝、少量放行,比默认放行、逐个追赶更可靠。
@EnableMethodSecurity 打开 @PreAuthorize 等方法授权能力。它不会替代 URL 规则:URL 规则适合表达“管理接口只给管理员”“其余接口必须登录”,方法规则适合表达“任务创建者或管理员才能删除这条记录”。
如果项目配置多条 SecurityFilterChain,每条链还需要自己的请求匹配范围与顺序。没有匹配到任何链的请求不会凭空得到保护。入门阶段先保持一条覆盖全部请求的链,等确实要把管理端、资源服务器或静态站点分开时再拆。
登录系统要存用户,但绝不能存明文密码,也不应把密码“加密后存起来”。加密强调可以用密钥解密;密码验证不需要恢复原文,只需要判断“本次输入”和“数据库中的结果”是否匹配,因此应该使用专门为密码设计的自适应单向哈希。
普通 SHA-256 很快,这对校验文件是优点,对保存密码却是缺点。数据库泄露后,攻击者可以高速尝试常见密码。BCrypt、PBKDF2、scrypt、Argon2 这类方案故意让每次猜测付出更多计算成本,还会把随机盐值纳入结果。盐值不必保密,它解决的是相同密码得到相同结果和预计算彩虹表的问题。
TaskHub 使用 DelegatingPasswordEncoder:
package com.welearn.taskhub.config;
import org.springframework.context.annotation.Bean;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
@Bean
PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}编码后的字符串通常带有类似 {bcrypt} 的前缀。前缀不是泄密,而是在说明该用哪个编码器验证这条旧数据。未来调整算法或参数时,系统仍有机会验证历史密码,并在用户登录成功后逐步升级存储格式。不要删掉前缀,也不要拿两次 encode() 的结果做字符串相等比较;随机盐会让相同原始密码产生不同结果,验证要调用 matches(rawPassword, encodedPassword)。
自适应哈希的计算成本需要在部署机器上测量。参数太低,攻击者每秒能猜更多次;参数盲目调得太高,正常登录也会长期占用 CPU,攻击者反而可以用少量请求拖垮认证服务。选择参数时要在目标硬件上测一次验证耗时,并配合登录限速。算法升级也不要批量尝试“解开”旧密码——我们本来就不应拥有恢复原文的能力。常见迁移方式是在用户下一次成功登录时,用新参数重新编码本次输入,再替换旧哈希。
密码长度规则也要避免把复杂度口号当成安全本身。强迫用户每隔几十天更换、必须混合若干特殊字符,往往只会产生可预测的变体。TaskHub 示例采用足够的最小长度并允许较长口令,实际产品还应允许密码管理器生成和粘贴内容,检查已泄露的常见密码,并为管理员或高风险操作加入多因素认证。最大长度不能没有上限,否则极长输入会放大哈希计算成本;但也不能先静默截断再编码,因为两个不同输入可能因此被当成同一个密码。
密码重置不是“把新密码写进数据库”这么简单。重置链接需要一次性、短期有效、与具体账户绑定,使用后立即失效;重置成功后应考虑让旧会话和刷新凭证失效。邮箱内容和 URL 中不要包含密码,服务端也不要把重置 token 原文长期存储。虽然本章不实现找回密码,这些边界决定了用户表不能只围绕一次登录来设计。
为了先验证路径与角色规则,课程项目可以从两个内存账户起步。learner 有普通用户角色,admin 同时有普通用户与管理员角色;传给用户构造器的仍然是编码后的密码:
@Bean
UserDetailsService courseUsers(PasswordEncoder encoder) {
var learner = User.withUsername("learner")
.password(encoder.encode("learn1234"))
.roles("USER")
.build();
var admin = User.withUsername("admin")
.password(encoder.encode(
内存账户适合演示过滤器链和 401/403 对照,应用重启后也能回到确定状态,但它不是注册系统。示例密码只能用于本地课程环境,不能带到部署配置。接下来为了让“任务属于谁”和注册流程落到数据库,我们用 AppUser 与自定义 UserDetailsService 替换这个内存 Bean;两套用户来源不要同时不加说明地启用。

用户表只保留认证真正需要的字段。这里用 AppUser 避开数据库中可能保留的 user 关键字:
package com.welearn.taskhub.user;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "app_users")
public class AppUser {
@Id
@GeneratedValue
package com.welearn.taskhub.user;
public enum Role {
USER,
ADMIN
}角色不能从公开注册请求中接收。下面这种接口即使给 role 设置了默认值,仍然意味着调用者可以提交 "role": "ADMIN" 给自己提权:
{
"username": "attacker",
"password": "guess-me",
"role": "ADMIN"
}正确做法是让公开注册固定创建 USER,管理员角色只能通过受控的后台流程、数据库迁移或单独审计过的管理操作授予。DTO 也不要直接复用实体:
package com.welearn.taskhub.auth;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record RegisterRequest(
@NotBlank
@Size(min = 3, max = 50)
String username,
@NotBlank
@Size(min = 12, max = 128)
String password
package com.welearn.taskhub.auth;
import com.welearn.taskhub.user.AppUser;
import com.welearn.taskhub.user.AppUserRepository;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class RegistrationService {
private final AppUserRepository users;
private final PasswordEncoder passwordEncoder;
public RegistrationService(
AppUserRepository users,
PasswordEncoder passwordEncoder
用户名唯一约束必须落在数据库里。existsByUsername 能给出友好提示,但两个并发请求仍可能同时通过检查;最终的唯一约束才是事实边界。服务层应把约束冲突转换成稳定的业务错误,而不是把 SQL 异常或堆栈直接返回给客户端。
注册成功也不该把整个 AppUser 实体直接序列化。实体里有 passwordHash、启用状态和内部角色,把它交给 Jackson 等于期待每一位后续维护者都不会误加 getter 或改变序列化配置。更稳妥的做法是返回专用响应 DTO,只包含用户编号、用户名和创建结果。日志同样只记录用户编号、结果和请求追踪号,不记录请求体。
公开注册固定创建 USER 只是第一层。服务层构造器也不应开放一个随意接收角色的重载,让别的 Controller 一调用就绕过限制。授予管理员必须是单独的用例,调用者先通过管理员授权,变更进入事务并留下审计事件。如果产品未来增加团队管理员,它与平台管理员也不应共用一个含义模糊的 ADMIN。
账户状态要进入认证判断。enabled=false 的用户即使密码匹配也不能得到有效身份;如果还支持锁定、过期或凭据过期,就分别映射到账户状态。返回给外部的登录错误仍可保持统一,内部审计却要区分密码错误、账号停用和限速拒绝,方便排查真实攻击与误操作。这样既减少账号枚举信息,又不牺牲运维可见性。
哈希保护的是数据库副本泄露后的密码,不保护传输中的明文。注册和登录必须运行在 HTTPS 上;日志、异常、链路追踪和监控标签中也不能记录原始密码。
Spring Security 不认识我们的 AppUser。它通过 UserDetailsService 按用户名取出登录所需信息,再由 DaoAuthenticationProvider 调用 PasswordEncoder 比对密码。这个过程没有替我们设计注册接口,也不会猜测 USER 应当映射成什么权限;映射必须明确写出来。
package com.welearn.taskhub.auth;
import com.welearn.taskhub.user.AppUserRepository;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
public class TaskHubUserDetailsService implements UserDetailsService {
private final AppUserRepository users;
public TaskHubUserDetailsService(AppUserRepository users) {
this.users = users;
}
roles("USER") 会生成 ROLE_USER 权限,所以后续写 hasRole("USER") 时不要手工再加一次 ROLE_。如果使用 authorities(...),则要传完整权限名。角色和权限都存放在 GrantedAuthority 集合里,但 hasRole 帮我们处理了约定前缀。
再显式建立认证提供者:
@Bean
DaoAuthenticationProvider authenticationProvider(
TaskHubUserDetailsService userDetailsService,
PasswordEncoder passwordEncoder
) {
var provider = new DaoAuthenticationProvider(userDetailsService);
provider.setPasswordEncoder(passwordEncoder);
return provider;
}认证流程现在是连起来的:登录过滤器取得用户名与密码,AuthenticationManager 把请求交给合适的 AuthenticationProvider,DaoAuthenticationProvider 加载用户并匹配密码。成功后得到一个已认证的 Authentication,其中保存用户名和权限;安全上下文再把它提供给当前请求的后续过滤器与业务代码。
不要在“用户名不存在”和“密码错误”之间返回可区分的公开提示。过于精确的提示会帮助攻击者枚举账号。客户端统一看到“用户名或密码错误”,服务端可以记录不含密码的审计事件,并结合速率限制、短时锁定或风险验证应对暴力尝试。账户锁定也要避免永久拒绝服务:攻击者不应仅凭不断输错密码,就能无限期锁死别人的账号。
TaskHub 是同源或明确跨源的浏览器应用,本章先选择服务端会话。用户登录成功后,服务器在 Session 中保存安全上下文,浏览器持有一个不透明的 JSESSIONID Cookie。后续请求只带会话标识,密码不会反复传输,权限仍从服务端控制。
我们让 Spring Security 内置的表单登录过滤器处理认证,但把地址和响应改成适合 API 的形式。请求体仍使用 application/x-www-form-urlencoded,前端不需要一张传统 HTML 登录页:
package com.welearn.taskhub.config;
import com.welearn.taskhub.auth.TaskHubUserDetailsService;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URI;
import java.util.Map;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.http.ProblemDetail;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
登录成功时,Spring Security 会建立认证并保存到安全上下文。启用会话固定攻击防护后,登录成功会更换会话 ID,避免攻击者事先诱导用户使用一个已知会话。退出登录必须使用会改变状态的 POST 请求,令会话失效并清理 Cookie;不要把退出设计成可被图片标签或普通链接触发的 GET。
配置中的 httpBasic 只为课程里的命令行调用保留一条简单认证入口。客户端在每次请求的 Authorization 头里发送用户名与密码的编码形式,因此它必须运行在 HTTPS 上,编码也绝不等于加密。Basic 没有独立的“退出令牌”,不适合作为浏览器 TaskHub 的长期登录体验;浏览器页面仍走表单登录与会话。真实外部 API 若需要 Bearer token,应使用后文的资源服务器边界。
Session 不是“不适合分布式”的同义词。单实例可以使用容器会话,多实例可以通过粘性会话或 Spring Session 把状态放到共享存储。真正需要判断的是一致性、容量、故障恢复和运维成本。会话的现实优势是服务器可以立即失效它;代价是服务器要保存或查询状态。
生产环境还应配置 Cookie:HTTPS 下设置 Secure,防止客户端脚本读取会话标识的 HttpOnly,以及符合部署拓扑的 SameSite。如果前端和 API 是真正跨站部署并要求携带 Cookie,通常需要 SameSite=None; Secure,同时必须严格限制允许的来源。代理或负载均衡器终止 TLS 时,还要正确传递并信任转发头,否则应用可能误判请求协议。
会话还有三段生命周期需要明确。第一段是创建:匿名访问公开页面未必需要立刻创建 Session,IF_REQUIRED 允许框架只在登录或确实保存状态时创建。第二段是使用:每个请求凭 Cookie 找回会话,空闲超过配置时间后失效;对长时间填写的表单,前端要能识别 401 并引导重新登录,而不是无限重试。第三段是销毁:退出、账号停用、密码重置或管理员强制下线时,要决定哪些会话立即失效。
“只允许一个设备登录”也不是加一个数字就结束。新登录要挤掉旧登录,还是拒绝新登录?用户关闭浏览器不等于可靠退出,服务器如何感知?多实例下并发会话记录放在哪里?这些都属于产品与部署共同决定的策略。本章先保证固定攻击防护、空闲过期和显式退出,部署时再根据实际并发登录需求接入共享会话存储。
不要把业务数据塞进 Session。会话里适合放安全上下文和少量必要状态,不适合缓存整页任务、JPA 实体或不断增长的操作历史。内容越大,多实例复制与共享存储的成本越高,实体还可能脱离持久化上下文变成陈旧副本。业务状态继续放在数据库,当前用户身份通过安全上下文取得,两者在服务层按请求重新组合。
现在匿名请求已经进不了 /api/tasks/**,管理员路径也有角色限制。但普通成员仍可能删除别人的任务,因为 URL 规则只看到了路径和角色,看不到数据库中任务 42 的创建者。
先让任务与创建者建立关系。创建任务时不要接收客户端传入的 ownerId,而是从认证身份得到创建者:
package com.welearn.taskhub.task;
import java.security.Principal;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/tasks")
public class TaskController {
private
接着写一个专门回答所有权问题的组件。用 Repository 的存在性查询,只取授权判断需要的信息:
package com.welearn.taskhub.task;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
@Component("taskAuthorization")
public class TaskAuthorization {
private final TaskRepository tasks;
public TaskAuthorization(TaskRepository tasks) {
this.tasks = tasks;
}
public boolean isOwner(Long taskId, Authentication authentication) {
Repository 方法名对应任务实体上的 owner.username 关系:
boolean existsByIdAndOwnerUsername(Long taskId, String username);最后在服务方法入口声明规则:管理员,或者当前任务的创建者,二者满足其一才允许继续执行。
package com.welearn.taskhub.task;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class TaskService {
private final TaskRepository tasks;
public TaskService(TaskRepository tasks) {
this.tasks = tasks;
}
@Transactional
@PreAuthorize("hasRole('ADMIN') or " +
这里的 authentication 是方法安全表达式提供的当前认证对象,#taskId 是被调用方法的参数。@taskAuthorization 指向我们命名的 Spring Bean。比起把一长串数据库判断塞进注解,单独的组件更容易写测试,也更容易演进为“任务负责人、协作者或管理员都可以操作”。
这段设计仍有一个需要正视的边界:授权查询与真正删除是两个动作。在极端并发场景里,任务所有权可能在检查后、删除前发生变化。TaskHub 当前在事务内执行,且所有权不会随意转移,已能覆盖课程场景;如果业务允许频繁转让,可以把用户条件合并进带行数返回值的条件删除,或者使用锁与版本字段,让“检查并修改”成为数据库层面的原子动作。
还有一个更隐蔽的问题是对象编号泄露。假设 lin 请求 zhou 的任务 41,所有权查询返回 false;若系统随后返回 403,而请求不存在的任务 999 返回 404,lin 就能据此判断 41 确实存在。对普通任务管理工具,这种差异未必构成严重泄露,但对病历、举报记录或私密文件就可能不可接受。那类系统可以让“无权查看”和“不存在”统一返回 404,同时把真正原因写入受控审计。策略一旦选定,详情、更新、删除与下载接口要保持一致。
列表授权不能复用“逐条调用 isOwner”的直觉实现。先查一页全量任务,再为每行查询一次所有权,会产生额外查询,还可能在分页前后泄露总数。普通成员的 Repository 查询应从一开始就带 owner.username = :username,分页总数也只统计其可见任务。管理员走另一条明确的全量查询。授权条件越接近数据查询,越不容易在序列化前漏掉一行。
创建任务时从 Principal 取得用户名比接受 ownerId 安全,但 Service 最好仍将用户名解析为真实 AppUser,再保存实体关系。不能只相信一个客户端可填写的字符串。若用户名允许修改,长期关系应以稳定用户主键关联,展示时再读取当前用户名。删除账号时也要提前决定任务是保留、转移还是级联删除,不能让数据库默认行为替产品做决定。
方法安全依赖 Spring 代理。外部 Bean 调用受保护的公开方法时会经过代理;同一个对象内部用 this.delete(...) 自调用,通常不会重新经过代理,注解可能不执行。这与事务注解的自调用边界相似。不要为了复用把敏感方法藏在同类内部跳转中;可以把受保护用例拆到独立 Service,或确保授权在真正对外的业务入口执行。私有方法上的 @PreAuthorize 也不会凭空得到代理拦截。
还有一种常见写法是先查出任务,再在 Java 里手工 if。它不一定错,但规则很容易散落在多个方法中。方法安全让策略紧贴业务入口,而 TaskAuthorization 把查询细节放在一个地方。对关键写操作,也可以在 Repository 层采用按 id + owner 的条件更新作为第二道约束。
角色只能回答粗粒度问题。给普通用户统一添加 ROLE_USER 后,仍然不能据此允许他操作所有成员数据。凡是“我的订单”“我的文件”“我创建的任务”,都要把当前身份与目标数据建立关系。
现在 TaskHub 用 Cookie 保存会话标识。浏览器向 TaskHub 发请求时会自动携带这个 Cookie,哪怕请求是从恶意网站的页面发起。攻击者读不到 Cookie 也不妨碍浏览器带上它。如果后端只看到有效会话就执行删除,受害者在登录 TaskHub 的同时访问恶意页面,任务仍可能被借用其身份删除。这就是跨站请求伪造关注的问题。
CSRF 防护的核心是要求改变状态的请求再带一个攻击网站拿不到的令牌。Cookie 会被浏览器自动发送,CSRF 请求头却需要前端代码主动从 TaskHub 的响应中取得后再附加。两者同时匹配,服务器才接受 POST、PUT、PATCH、DELETE 等请求。
Spring Security 默认开启 CSRF。对于本课程使用的 Spring Boot 4.1.0 单页应用,可以在安全链中使用 SPA 配置:
http.csrf(csrf -> csrf.spa());这项配置不只是把令牌写入可供前端读取的 Cookie,还处理单页应用中令牌编码、延迟加载,以及登录和退出后旧令牌被清理、需要刷新等细节。前端应在应用初始化时取得令牌,在登录成功后再取一次,在退出后若页面仍保留则再次刷新。随后把令牌放入约定的请求头,而不是只让 Cookie 自动发送。
为了让不同前端框架都能明确取得当前令牌,可以保留一个公开的读取端点:
package com.welearn.taskhub.auth;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/auth")
public class CsrfController {
@GetMapping("/csrf")
CsrfToken csrf(CsrfToken csrfToken) {
return csrfToken;
}
}一次完整登录顺序是:先请求 /api/auth/csrf,把返回令牌放到登录请求头;登录成功后旧令牌会被轮换,再请求一次 /api/auth/csrf;之后每个改变状态的请求都携带新令牌。读取任务的 GET 不改变状态,不应被拿来执行删除、发布或重置密码等动作,否则浏览器和缓存都可能在意外情况下触发它。
为什么攻击页面拿不到同一个 token?浏览器的同源策略通常阻止它读取 TaskHub 的响应内容,因此它可以诱导浏览器提交表单,却无法先读取 TaskHub 返回的随机值,再正确放进自定义请求头。服务器比较会话或 Cookie 中保存的期望值与请求头中的实际值,不匹配就结束请求。这个机制仍然依赖站点没有严重 XSS:如果攻击脚本已经运行在 TaskHub 自己的源中,它就可能读取页面能读取的数据并以用户身份操作,所以输出转义、内容安全策略和依赖治理仍要同时存在。
SameSite Cookie 能降低一部分跨站发送场景,但不应被当作唯一 CSRF 方案。浏览器兼容、同站子域、顶级导航和真实跨站部署会改变它的效果。安全 token 提供的是应用层明确校验,SameSite 更适合作为额外防线。HttpOnly 也只阻止脚本直接读取会话 Cookie,不阻止浏览器自动发送 Cookie,因此它解决的不是同一个问题。
有些团队为了让自动化测试通过,看到 403 就全局关闭 CSRF。这会把测试发现的真实缺口变成生产缺口。正确做法是:需要成功的写请求在测试里加 csrf(),专门再保留缺失和无效 token 的失败用例。若某个机器回调端点确实不能使用 CSRF token,就只忽略那个经过独立认证的精确路径,并测试其他路径仍受保护。

什么时候可以关闭 CSRF?可以考虑的情况是该过滤链只服务于不依赖浏览器自动携带凭证的客户端,例如严格从 Authorization 请求头读取 Bearer token 的资源服务器,而且 token 不放在自动发送的 Cookie 中。即便如此,也应该对这条链的客户端形态做清楚判断,而不是因为路径以 /api 开头就全局 disable()。只要浏览器会自动带凭证,CSRF 风险就还在。
开发时,前端可能运行在 http://localhost:5173,后端运行在 http://localhost:8080。协议、主机、端口任意一项不同,就属于不同源。浏览器会根据 CORS 响应头决定前端脚本能否读取响应;对于带自定义头或非简单方法的请求,还会先发送 OPTIONS 预检。
预检请求通常没有 JSESSIONID。如果安全链先要求登录,它就会被当成匿名请求拒绝,真正的 DELETE 根本没有机会发出。因此 CORS 要在安全认证前得到处理。提供一个 UrlBasedCorsConfigurationSource 后,Spring Security 可以把它接入过滤器链:
package com.welearn.taskhub.config;
import java.util.List;
import org.springframework.context.annotation.Bean;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
@Bean
UrlBasedCorsConfigurationSource corsConfigurationSource() {
var configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("http://localhost:5173"));
configuration.setAllowedMethods(
List.of("GET",
并在 SecurityFilterChain 中启用:
http.cors(cors -> {});允许携带凭证时,来源不能用通配符 * 敷衍过去,应该列出真正受信任的前端来源。生产环境把来源放进经过校验的配置属性中,分别为开发、测试和生产设置明确值;不要反射任意请求的 Origin。
更关键的是,CORS 不是服务器端权限控制。它主要由浏览器执行,命令行客户端、移动应用和另一台服务器都可以直接调用接口。把某个域名写进 allowedOrigins,不代表这个域名里的每个用户都能删除任务;真正决定删除能否发生的仍然是认证、角色、所有权和 CSRF 校验。
可以用一句话记住两者的区别:CORS 决定浏览器是否允许一段跨源脚本读取或发送特定形式的请求,授权决定服务器是否允许当前身份执行操作。
排查跨域问题时,先分清预检失败还是真实请求失败。预检只是在问“这个来源能否用 DELETE、能否带 X-XSRF-TOKEN”,成功通常不代表删除已经发生。浏览器随后才发送带 Cookie 和 token 的真实请求,它仍可能得到 401、403 或业务 404。开发者工具里若只看到 OPTIONS 403,应检查 CORS 是否进入安全链、来源是否精确匹配、请求头是否在允许列表;若 OPTIONS 成功而 DELETE 403,则应转向身份、CSRF 和所有权,不要继续扩大跨域白名单。
来源比较包含协议、主机和端口,https://app.example.com 与 http://app.example.com 不是同一个源,app.example.com 与 admin.example.com 也不同。生产配置不要用字符串后缀判断“只要以 example.com 结尾就允许”,因为相似域名和错误的点号处理容易绕过。优先维护精确来源列表;确实需要模式时,使用框架支持的受控模式,并为允许和拒绝样例写测试。
服务器返回允许跨域的响应头,只是在授权浏览器中的脚本读取结果,并没有保证该前端代码本身可信。前端站点若被 XSS 控制,它仍可借助合法来源执行用户能做的动作。因此 CORS 白名单应尽量短,前端也要有自己的内容安全与依赖安全措施。
安全教程常把 Session 说成“旧”,把 JWT 说成“现代”。这种排序会诱导人先选技术,再寻找理由。两种方案解决的是同一个持续身份问题,但状态放置位置不同。
服务端会话把身份状态保存在服务器一侧,客户端只持有随机会话标识。退出、封禁和权限变更可以快速生效,但多实例部署要共享或正确路由会话。JWT 访问令牌把一组声明连同有效期签名,资源服务器可以本地验证,减少每次请求查询中心状态;代价是已经签发的令牌通常会在过期前继续有效,撤销、密钥轮换、声明变更和泄露处置都要额外设计。
如果 TaskHub 将来要开放给移动端、第三方客户端或多个独立服务,不应在 Controller 里手写一段“用户名加时间戳再用固定字符串签名”的 JWT 工具。比较完整的边界是:
由独立且受信任的授权服务器完成登录、二次验证与同意流程。它持有签名私钥,成功后向被允许的客户端签发短期访问令牌;资源 API 不负责收集用户密码后自行拼令牌。
访问令牌包含最少必要声明,例如主体、签发者、目标受众、签发时间、启用时间、过期时间和授权范围。签名提供完整性与来源证明,并不自动加密内容,所以令牌里不能放密码或不希望客户端看到的敏感数据。
TaskHub 作为资源服务器从约定的密钥集合取得公钥,根据允许的算法和密钥编号验签,同时验证签发者、受众、过期时间与启用时间。只解析 Base64 内容、不验签,或只验签、不检查签发者和受众,都不能建立可信身份。
验证通过后,资源服务器把令牌中的 scope 或权限声明映射为 ,再执行 URL、方法与所有权授权。令牌是真的,不代表它天然拥有删除任何任务的权力。
Spring Security 的资源服务器支持会从 Authorization: Bearer ... 读取令牌,使用 JwtDecoder 验证签名和时间声明,并能通过签发者配置发现公钥。一个负责任的资源服务器配置至少需要可信的签发者和明确受众:
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: ${TASKHUB_ISSUER_URI}
audiences: ${TASKHUB_API_AUDIENCE}这段配置只负责验证访问令牌,不会替 TaskHub 签发令牌。签发应由真正的授权服务器承担。密钥放在代码仓库、访问令牌永不过期、把令牌写进 URL、把长期令牌存在可被任意脚本读取的位置,都会让“无状态”变成难以撤销的泄露窗口。
Token 也不天然免疫 CSRF。如果 Bearer token 只由客户端主动放进 Authorization 头,恶意站点通常不能让浏览器自动附带它;如果为了方便又把 token 放进自动发送的 Cookie,仍要按 Cookie 认证模型评估 CSRF。另一方面,把 token 放进浏览器本地存储会扩大 XSS 窃取风险。安全设计不是消灭风险,而是清楚知道风险被移到了哪里。
TaskHub 这一版继续使用会话,原因很朴素:它是浏览器应用、需要可靠退出、没有独立授权服务器,也不需要向多个资源服务分发身份。以后需求变化时,我们再按上面的完整边界引入 OAuth 2.0 资源服务器,而不是为了追逐名词提前增加密钥与撤销负担。
前面的过滤器已经统一了未认证与无权限响应。业务异常也应保持一致,避免一部分接口返回字符串,一部分接口返回堆栈,一部分由容器输出 HTML。课程前面已经使用 ProblemDetail 固定错误契约,这里继续沿用同一种结构:
package com.welearn.taskhub.web;
import com.welearn.taskhub.auth.UsernameAlreadyExistsException;
import com.welearn.taskhub.task.TaskNotFoundException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(TaskNotFoundException.class)
ProblemDetail handleTaskNotFound(TaskNotFoundException exception) {
var problem = ProblemDetail.forStatusAndDetail
公开响应提供客户端能行动的信息即可。堆栈、SQL、服务器路径、密钥内容和认证实现细节留在受控日志中。日志也不是越多越安全:原始密码、Session ID、Authorization 头、完整访问令牌、CSRF token 都不应进入普通应用日志。安全审计更关心谁在何时对哪个资源做了什么、结果是允许还是拒绝,以及可用于串联请求的追踪标识。
认证失败、授权失败和业务失败发生在不同位置。AuthenticationEntryPoint 处理缺少有效身份,AccessDeniedHandler 处理已有身份却无权访问,以及部分过滤器级拒绝;Controller Advice 处理进入 MVC 后抛出的业务异常。把它们统一成 application/problem+json 可以让客户端使用同一种解析逻辑,但不要误以为一个 @ExceptionHandler 能接住所有安全过滤器异常。
安全配置最危险的失败方式不是应用启动不了,而是应用照常运行,只是某条规则没有生效。上一章建立的测试体系正好可以接住它。我们要测试的不是配置类写了多少行,而是不同身份发出同一个请求时,系统是否做出不同决定。

先写 Web 层安全测试。@WebMvcTest 会加载 MVC 与安全过滤器,spring-security-test 提供 user()、anonymous() 和 csrf() 等请求后处理器:
package com.welearn.taskhub.task;
import com.welearn.taskhub.auth.TaskHubUserDetailsService;
import com.welearn.taskhub.config.SecurityConfig;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
不同 Spring Boot 小版本提供的测试切片包名可能不同。本课程的 Spring Boot 4 使用 org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest 和 @MockitoBean;如果你维护的是旧版项目,可能看到旧包名和 @MockBean。不要混抄两个版本的导入。
Web 测试确认过滤器确实参与请求,但 taskService 在切片测试中是 Mock,方法安全代理不会替这个 Mock 执行真实所有权判断。因此还要给 Service 写方法安全测试:
package com.welearn.taskhub.task;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
这组三个测试分别锁住“他人不可删”“本人可删”“管理员可删”。其中 taskAuthorization 用同名 Mock 替换表达式所引用的 Bean,测试就能控制所有权判断的结果。所有权查询组件自身还应另写一个小型测试,验证它把任务编号与当前用户名一起交给 Repository。管理员用例还有一个容易忽略的细节:表达式使用短路或,hasRole('ADMIN') 为真后不需要再查所有权。这样管理员权限不依赖任务归属查询。
再补密码测试,确认注册时存的是哈希,不是原文:
@Test
void registrationStoresHashInsteadOfRawPassword() {
var encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
var repository = mock(AppUserRepository.class);
var service = new RegistrationService(repository, encoder);
var savedUser = new AtomicReference<AppUser>();
when(repository.existsByUsername("lin")).thenReturn(false);
安全测试还应逐步覆盖这些边界:注册不能提交角色、禁用账号不能登录、退出后旧会话失效、登录后会话 ID 改变、错误密码不暴露用户名是否存在、CORS 只允许配置来源、无效 CSRF token 返回 403、会话过期后返回 401。它们比“测试行覆盖率达到某个数字”更能说明保护是否存在。
可以把回归矩阵固定成下面这样,每一格都是一个具体请求,不是模糊的“测一下权限”:
矩阵能防住“修一个测试、坏另一条路径”的局部思维。例如,为了让匿名健康检查通过而写成 requestMatchers("/actuator/**").permitAll(),健康测试会通过,普通用户读取环境或指标的危险却被打开。正确规则先精确放行 /actuator/health,再限制其余 /actuator/** 为管理员,最后用测试分别覆盖。
测试 401 时要注意 CSRF 与认证的先后影响。如果给一个匿名 DELETE 完全不带 CSRF,不同配置可能先因缺 token 得到 403,测试就没有真正证明未认证入口返回 401。示例给匿名请求加一个测试 token,是为了越过 CSRF 校验,单独观察认证边界;另一条登录用户缺 token 的用例再专门证明 CSRF。测试设计要控制一个变量,才能知道失败到底来自哪一层。
@WithMockUser 和 user() 创建的是测试安全身份,它不访问真实用户表,也不验证密码编码器。它们很适合测授权分支,却不能证明登录流程正确。因此密码匹配、禁用账号和表单登录还要有独立测试,必要时加载真实 UserDetailsService 与安全链。相反,每个所有权分支都启动完整数据库也会拖慢反馈;将授权组件的查询逻辑做小型测试,再保留少量完整集成用例,速度与可信度会更平衡。
CORS 测试要构造带 Origin、目标方法和目标请求头的 OPTIONS 请求,断言可信来源得到相应允许头,不可信来源没有得到放行。它不是用 curl 看一眼 200 就结束,因为非浏览器客户端不会替我们执行同源策略。会话测试则应保存登录响应的 Session,在后续请求中复用,再执行退出并确认同一个 Session 不能访问受保护接口。
最后别忘了拒绝路径的内容类型。401、403 与业务 404 都使用 ProblemDetail 后,前端才能用同一个错误解析器。测试除了状态码,还可断言 Content-Type、title、detail 与 instance,同时确保响应里没有异常类名、SQL 或堆栈。安全不仅是“拒绝了”,还包括拒绝时没有顺手泄露更多信息。
上一章的测试金字塔在这里有了更具体的用途:单元测试检查密码与授权组件,Web 切片检查过滤器和状态码,少量集成测试检查真实安全上下文、事务和数据库约束。三层各自回答不同问题。
测试通过后,再用客户端走一遍完整流程。为了看清 Cookie 和 CSRF token 的变化,可以让 curl 使用 Cookie 文件。下面用环境变量代替返回中的真实 token:
curl -i -c cookies.txt \
http://localhost:8080/api/auth/csrf响应体包含令牌及请求头名称,响应头会写入相应 Cookie。取出令牌后注册普通成员:
curl -i -b cookies.txt -c cookies.txt \
-H 'Content-Type: application/json' \
-H "X-XSRF-TOKEN: ${CSRF_TOKEN}" \
-d '{"username":"lin","password":"correct horse battery staple"}' \
http://localhost:8080/api/auth/register登录请求由表单登录过滤器处理:
curl -i -b cookies.txt -c cookies.txt \
-H 'Content-Type: application/x-www-form-urlencoded' \
-H "X-XSRF-TOKEN: ${CSRF_TOKEN}" \
--data-urlencode 'username=lin' \
--data-urlencode 'password=correct horse battery staple' \
http://localhost:8080/api/auth/login成功响应是:
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
Set-Cookie: JSESSIONID=...; Path=/; HttpOnly
{"username":"lin","message":"登录成功"}登录会清理旧 CSRF token,因此此时重新请求 /api/auth/csrf,更新 CSRF_TOKEN,再删除自己的任务:
curl -i -b cookies.txt \
-X DELETE \
-H "X-XSRF-TOKEN: ${CSRF_TOKEN}" \
http://localhost:8080/api/tasks/42任务属于当前用户时得到:
HTTP/1.1 204 No Content换成别人的任务时得到:
HTTP/1.1 403
Content-Type: application/problem+json
{"type":"about:blank","title":"禁止访问","status":403,"detail":"没有执行此操作的权限","instance":"/api/tasks/41"}删除 Cookie 文件或退出登录后再次请求,结果变成 401。若已登录但故意不发送 CSRF 头,结果是 403,且 Controller 不会执行。这四个结果一起出现,才说明身份、所有权和请求来源三条边界都连起来了。
不要把示例里的 cookies.txt、真实 token、生产 Cookie 或密码提交到 Git。它们是本地验收产生的凭证材料,用完就删除。CI 中使用临时账号和隔离数据库,日志对认证头及 Cookie 做脱敏。
Spring Security 能把规则稳定地放进请求流程,却不能替我们完成所有安全工作。进入部署前,TaskHub 至少要逐项确认:
下一章会把应用打包并部署。到那时,数据库地址、CORS 来源、Cookie 安全属性、会话超时、Actuator 暴露范围都应从环境配置中读取。尤其不要为了“容器里先跑起来”关闭安全过滤器、CSRF 或证书校验;开发环境的便利开关一旦跟着镜像进入生产,就会绕过我们这一章建立的边界。
安全也不是一份写完就永久有效的配置。业务会增加“任务协作者”“团队空间”“只读访客”等关系,授权规则必须跟着领域模型变化。最稳妥的节奏仍然是这一章的做法:先把允许与拒绝写成例子,再修改规则,最后让自动化测试证明危险路径没有重新打开。
到这里,最初那条 DELETE /api/tasks/42 已经不再只是“能不能调用”的接口问题。请求先经过安全过滤器链,匿名身份得到 401;登录用户要携带有效会话与 CSRF token;普通成员还要通过任务所有权判断,管理员则通过受控角色获得额外权限;每条拒绝路径都有测试。
我们没有追求一份看起来很长的安全配置,而是让身份、角色、数据和请求来源各自承担清楚的职责。下一章打包 TaskHub 时,这些规则会和业务代码、数据库迁移与测试一起进入构建产物。部署只是把应用放到更真实的环境,不能替一个没有边界的应用补上边界。
GrantedAuthority访问令牌应当短期有效。需要长期登录时,由授权服务器持有和轮换刷新令牌,客户端用刷新令牌换取新访问令牌。刷新令牌不应发送给每个资源 API,浏览器端还要认真评估 XSS、Cookie、内存存储与后端代理模式。
退出或账号封禁后的即时撤销要单独设计,可以使用短有效期、服务端撤销记录、不透明令牌自省或会话式方案。JWT 的 exp 只说明何时自然过期,并不等于服务器已经具备主动撤回能力。