本节目标:把「一次登录」和「一次访问受保护资源」两条路径的每一步落到具体类与具体方法,讲清认证器如何被挑选、
SecurityContext如何在请求之间存活、授权决策如何做出,并给出可断点、可验证的观察点。
适用版本:Spring Boot 4.1.x(Java 21)
8.2 认证与授权流程
上一节把过滤器链拆成了「谁在什么时候被调用」。本节顺着链往里走一层:UsernamePasswordAuthenticationFilter 拿到用户名密码之后,究竟发生了什么;认证成功后那个「当前用户」存在哪里;AuthorizationFilter 又是怎么决定放行还是拒绝的。
沿用图书借阅系统:读者 reader01 用密码登录,访问 /loans 借书;馆员 librarian01 访问 /admin/** 管理书目。两条请求路径覆盖了认证与授权的全部分支。
8.2.1 AuthenticationManager 与 ProviderManager 的协作
AuthenticationManager 只有一个方法(核实自 spring-security-core-7.1.1.jar):
Authentication authenticate(Authentication authentication) throws AuthenticationException;
接口极简,因为它的职责被刻意收窄成「认证入口」。真正干活的是 ProviderManager,它实现了 AuthenticationManager,内部持有一个 List<AuthenticationProvider>。authenticate 的算法是:
- 按顺序遍历每个
AuthenticationProvider,调用supports(authentication.getClass()); - 第一个返回
true的 provider 负责这次认证,调用它的authenticate(...); - 若该 provider 返回
null,继续找下一个(表示「我认识这个 token 但这次没结论」); - 若所有 provider 都不支持,且构造时给了一个 parent
AuthenticationManager,则委托给 parent; - 认证成功后,若
eraseCredentialsAfterAuthentication为true(默认),抹掉凭据。
| 类 | 方法 | 职责 |
|---|---|---|
AuthenticationManager | authenticate(Authentication) | 认证入口(接口) |
ProviderManager | authenticate(Authentication) | 按 supports 选 provider、抹凭据 |
ProviderManager | getProviders() / setAuthenticationEventPublisher(...) | 查看/发布认证事件 |
AuthenticationProvider | supports(Class<?>) | 声明自己处理哪种 token |
AuthenticationProvider | authenticate(Authentication) | 真正的认证逻辑 |
为什么用「provider 列表 + supports」而不是一个巨型 if-else? 因为认证方式是可插拔的:表单登录用 DaoAuthenticationProvider,Remember-Me 用 RememberMeAuthenticationProvider,测试用 TestingAuthenticationProvider(均已核实存在)。ProviderManager 不需要知道具体有几种认证方式,只按 supports 分发——这是开闭原则在认证层的体现。
手动搭一个 ProviderManager 能看清它的形状(构造器 ProviderManager(List<AuthenticationProvider>) 已核实):
AuthenticationProvider dbProvider = new DaoAuthenticationProvider(userDetailsService);
AuthenticationManager manager = new ProviderManager(List.of(dbProvider));
Authentication result = manager.authenticate(
UsernamePasswordAuthenticationToken.unauthenticated("reader01", rawPassword));
authenticate 返回的是已认证的 token(Authentication.isAuthenticated() 为 true,principal 为 UserDetails);构造未认证 token 用 UsernamePasswordAuthenticationToken.unauthenticated(principal, credentials),构造已认证 token 用 authenticated(principal, credentials, authorities)(两者均已核实)。若还想再挂一层兜底(缓存、远程目录服务),把 ProviderManager 的 parent 设成另一个 manager 即可——双参构造器 ProviderManager(List<AuthenticationProvider>, AuthenticationManager)(已核实)就是为父子链准备的。
8.2.2 DaoAuthenticationProvider 的模板方法
表单登录最终落到 DaoAuthenticationProvider(org.springframework.security.authentication.dao),它继承 AbstractUserDetailsAuthenticationProvider。后者用模板方法把认证拆成固定的五步(方法均已核实):
| 步骤 | 方法 | 做什么 |
|---|---|---|
| 1 | retrieveUser(String, UsernamePasswordAuthenticationToken) | 按用户名取出 UserDetails |
| 2 | getPreAuthenticationChecks().check(user) | 校验账号未锁定、未禁用、未过期 |
| 3 | additionalAuthenticationChecks(user, token) | 校验密码(子类实现) |
| 4 | getPostAuthenticationChecks().check(user) | 校验凭据未过期 |
| 5 | createSuccessAuthentication(principal, auth, user) | 生成已认证的 token |
第 1 步与第 3 步是子类差异化点。DaoAuthenticationProvider 的构造器接收 UserDetailsService(核实签名 DaoAuthenticationProvider(UserDetailsService)),retrieveUser 直接委托 getUserDetailsService().loadUserByUsername(...);additionalAuthenticationChecks 则用 PasswordEncoder.matches(rawPassword, encodedPassword) 比对。若密码不匹配,抛 BadCredentialsException。
AbstractUserDetailsAuthenticationProvider 还提供 setUserCache(UserCache):命中缓存时跳过 retrieveUser,但密码校验永远执行(第 3 步不受缓存影响)——这是设计上的安全底线。第 2、4 步的默认实现是 DefaultPreAuthenticationChecks / DefaultPostAuthenticationChecks(核实存在),分别抛出 LockedException/DisabledException/AccountExpiredException 与 CredentialsExpiredException 家族异常。
8.2.3 UserDetailsService 与 PasswordEncoder 的位置
这两个是最常被自定义的两个扩展点,它们的位置决定了各自的职责边界:
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
public interface PasswordEncoder {
String encode(CharSequence rawPassword);
boolean matches(CharSequence rawPassword, String encodedPassword);
default boolean upgradeEncoding(String encodedPassword);
}
UserDetailsService 负责「按用户名取用户」,不负责比对密码;PasswordEncoder 负责「编码与比对」,不负责取用户。二者由 DaoAuthenticationProvider 在第 1、3 步分别调用。把密码校验从 UserDetailsService 里剥出来,是为了让「存储层」与「加密算法」可以各自替换:数据库从 MySQL 换成 LDAP,加密从 BCrypt 换成 Argon2,互不影响。
注意 PasswordEncoder 不在 spring-security-core 里。从 Spring Security 6.0 起它被拆到独立模块 spring-security-crypto(本机核实:PasswordEncoder、DelegatingPasswordEncoder、PasswordEncoderFactories 均在 spring-security-crypto-7.1.1.jar 的 org.springframework.security.crypto.* 包)。PasswordEncoderFactories.createDelegatingPasswordEncoder() 返回的 DelegatingPasswordEncoder 会按密文里的 {id} 前缀(如 {bcrypt})挑选算法,这就是为什么升级加密算法时老密码仍然能校验——前缀记录了「当初用的是哪种算法」。
8.2.4 SecurityContextHolder 的存储策略
认证成功后,Authentication 被放进 SecurityContext,再由 SecurityContextHolder 保管。SecurityContextHolder 有三个策略常量(核实自 SecurityContextHolder):
| 常量 | 值 | 背后的策略类 | 语义 |
|---|---|---|---|
MODE_THREADLOCAL | "MODE_THREADLOCAL" | ThreadLocalSecurityContextHolderStrategy | 默认,绑定当前线程 |
MODE_INHERITABLETHREADLOCAL | "MODE_INHERITABLETHREADLOCAL" | InheritableThreadLocalSecurityContextHolderStrategy | 子线程继承父线程上下文 |
MODE_GLOBAL | "MODE_GLOBAL" | GlobalSecurityContextHolderStrategy | 全局静态,仅桌面/测试用 |
切换方式有两种:启动时设系统属性 spring.security.strategy(常量 SecurityContextHolder.SYSTEM_PROPERTY),或直接调 SecurityContextHolder.setContextHolderStrategy(...) / setStrategyName(String)。三个策略类都实现了 SecurityContextHolderStrategy 接口(clearContext / getContext / setContext / createEmptyContext)。
默认为什么是 ThreadLocal? 因为 Servlet 请求由单个线程处理,线程隔离天然等价于请求隔离,且无锁开销。代价是上下文不跨线程——这正是 8.3 要解决的 @Async、虚拟线程传播问题的根源。注意 MODE_GLOBAL 在任何多线程环境里都是错的(所有请求共享同一个 SecurityContext),它只用于单线程客户端或测试。
8.2.5 SecurityContext 的请求间保存:SecurityContextRepository
ThreadLocal 只在一次请求内有效,那「下一个请求怎么还认得我」?答案是 SecurityContextRepository:
public interface SecurityContextRepository {
SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder);
default DeferredSecurityContext loadDeferredContext(HttpServletRequest request);
void saveContext(SecurityContext context, HttpServletRequest request, HttpServletResponse response);
boolean containsContext(HttpServletRequest request);
}
有四个常用实现(均已核实):
| 实现 | 存哪里 | 适用 |
|---|---|---|
HttpSessionSecurityContextRepository | HttpSession | 传统有状态会话(默认) |
RequestAttributeSecurityContextRepository | 单次请求属性 | 无状态 / 只存活一次请求 |
DelegatingSecurityContextRepository | 组合多个仓库 | 迁移期同时兼容 session 与 request |
NullSecurityContextRepository | 不存 | 完全无状态(如纯 JWT) |
这里有个容易被忽略的职责切分:SecurityContextHolderFilter(构造器接收 SecurityContextRepository,核实签名)只做两件事——请求进来时 loadDeferredContext 把上下文放进 holder,请求结束时在 finally 里 clearContext。它不负责保存。保存是「改动了上下文的那一方」的职责,例如 AbstractAuthenticationProcessingFilter.successfulAuthentication(...) 在认证成功后调用 securityContextRepository.saveContext(...)(该类确有 setSecurityContextRepository 方法,核实)。
为什么把 load 与 save 拆给不同的组件? 因为旧版 SecurityContextPersistenceFilter 同时负责 load 与 save,导致「无状态场景下也被迫建 session」和「无法区分是谁写的上下文」两个问题。拆开之后,无状态接口只要给认证过滤器配一个 RequestAttributeSecurityContextRepository,session 就永远不会被创建。注意 SecurityContextPersistenceFilter 在 7.1.1 里已标记 @Deprecated(核实),新代码用 SecurityContextHolderFilter。
loadDeferredContext 返回的是 DeferredSecurityContext——一个「延迟加载」包装。它的意义在于:并非每个请求都需要身份,若一开始就 loadContext,无状态接口也会被迫读一次 session。延迟到真正调用 getAuthentication() 时才读,能省掉无谓的 IO。这也是 SecurityContextHolderFilter 选择延迟载入而非立即载入的设计意图。
8.2.6 授权决策链:AuthorizationManager
请求通过认证后进入 AuthorizationFilter,它构造器接收一个 AuthorizationManager<HttpServletRequest>(核实签名)。AuthorizationManager 的抽象方法只有两个(其一有默认实现):
AuthorizationResult authorize(Supplier<? extends Authentication> authentication, T object);
default void verify(Supplier<? extends Authentication> authentication, T object);
AuthorizationResult 是个只有 boolean isGranted() 的接口,具体结果类有 AuthorizationDecision(放行/拒绝)与 AuthorizationDeniedException(拒绝时抛出,核实三者均存在)。常用的 AuthorizationManager 实现:
| 实现 | 工厂方法 | 语义 |
|---|---|---|
AuthorityAuthorizationManager | hasRole / hasAuthority / hasAnyRole / hasAnyAuthority | 按权限/角色判定 |
AuthenticatedAuthorizationManager | authenticated / fullyAuthenticated / rememberMe / anonymous | 按认证状态判定 |
AuthenticatedAuthorizationManager | setTrustResolver(AuthenticationTrustResolver) | 用 AuthenticationTrustResolverImpl 区分匿名/Remember-Me |
多个 AuthorizationManager 可以用 AuthorizationManagers 组合(工厂方法均已核实):allOf(...)、anyOf(...)、not(...)。例如「既要是馆员、又要通过自定义业务校验」:
AuthorizationManager<HttpServletRequest> manager = AuthorizationManagers.allOf(
AuthorityAuthorizationManager.hasRole("LIBRARIAN"),
(authentication, request) -> new AuthorizationDecision(customRule(request)));
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/admin/**").access(manager));
access(AuthorizationManager) 让你在 DSL 里直接挂一个自定义决策器,而不必实现整套 AccessDecisionVoter——这正是新模型降低扩展成本的地方。
这是 6.x 之后最大的授权层变化:AccessDecisionManager、AccessDecisionVoter、AffirmativeBased 这套「投票器」模型已被 AuthorizationManager 取代,并且在本机 spring-security-core-7.1.1.jar 里已经找不到这些类(逐一核实为空)。旧模型是「投票 + 聚合策略」,新模型是「一个函数直接给出结果」,更易组合(AuthorizationManagers.allOf/anyOf)。
授权决策的执行路径是:
AuthorizationFilter.doFilter
└─> AuthorizationManager.authorize(() -> SecurityContextHolder.getContext().getAuthentication(), request)
├─ granted=true → 继续 filterChain.doFilter(...)
└─ granted=false → 抛 AccessDeniedException
└─> ExceptionTranslationFilter 捕获
├─ 未认证 → 401 / 重定向登录页
└─ 已认证 → 403
ExceptionTranslationFilter 之所以紧贴在 AuthorizationFilter 之前(8.1 的顺序表已给出),就是为了 catch 它抛出的 AccessDeniedException 与 AuthenticationException,并按 AuthenticationTrustResolver 判断该返回 401 还是 403。
8.2.7 一次完整请求的调用链
把 reader01 访问 GET /loans 的全过程串起来,每一步都对应一个可下断点的方法(类名均已核实):
GET /loans
└─ DelegatingFilterProxy.doFilter // 容器侧,转发给 FilterChainProxy
└─ FilterChainProxy.doFilter // 选出匹配的 SecurityFilterChain
└─ SecurityContextHolderFilter.doFilter
│ loadDeferredContext 载入 SecurityContext(HttpSession)
└─ AnonymousAuthenticationFilter.doFilter // 未认证时补匿名身份
└─ ExceptionTranslationFilter.doFilter // 只负责 catch 下游异常
└─ AuthorizationFilter.doFilter
│ AuthorizationManager.authorize(...) → 拒绝则抛 AccessDeniedException
└─ DispatcherServlet → LoanController
表单登录(POST /login)走的则是另一条分支:
POST /login
└─ UsernamePasswordAuthenticationFilter.doFilter
└─ attemptAuthentication → AuthenticationManager.authenticate(token)
└─ ProviderManager.authenticate
└─ DaoAuthenticationProvider.authenticate
├─ retrieveUser → UserDetailsService.loadUserByUsername
├─ additionalAuthenticationChecks → PasswordEncoder.matches
└─ createSuccessAuthentication
└─ successfulAuthentication → SecurityContextRepository.saveContext(...)
└─ SecurityContextHolder.getContext() 已被填上已认证的 Authentication
两段链路的交界点就是 SecurityContextRepository:登录时写、后续请求读。理解这一点,就能解释「为什么登录成功、下一个请求却又变匿名」——多半是 save 用的仓库和 load 用的仓库不是同一个(比如一个存 session、一个只存 request attribute)。
8.2.8 知道之后能做什么
自定义认证源。 若用户来自多个库(数据库 + LDAP),实现一个 AuthenticationProvider,在 supports(UsernamePasswordAuthenticationToken.class) 里返回 true,authenticate 里写自己的逻辑,注册进 AuthenticationManagerBuilder。ProviderManager 会按顺序尝试。
自定义用户加载。 实现 UserDetailsService.loadUserByUsername 从数据库取用户,把密码哈希交给 PasswordEncoder 比对——不要在 UserDetailsService 里自己比对密码,那会绕过 upgradeEncoding 与 DelegatingPasswordEncoder 的前缀机制。
无状态化。 若接口用 JWT,给认证过滤器配 RequestAttributeSecurityContextRepository(或直接 NullSecurityContextRepository),避免每次请求都建 session。
排障清单。 UsernameNotFoundException 与 BadCredentialsException 在默认配置下都会包装成 BadCredentialsException(hideUserNotFoundExceptions 默认 true),不要用它区分「用户不存在」和「密码错」;BadCredentialsException 出在 additionalAuthenticationChecks,LockedException 出在第 2 步 DefaultPreAuthenticationChecks;403 说明认证成功但 AuthorizationManager 判了拒绝,去检查 hasRole 的 ROLE_ 前缀。
小结
ProviderManager按AuthenticationProvider.supports(Class)分发,parent 链兜底,认证成功后抹凭据。DaoAuthenticationProvider走retrieveUser → preChecks → additionalAuthenticationChecks → postChecks → createSuccessAuthentication五步;UserDetailsService取用户、PasswordEncoder比密码,职责分离。SecurityContextHolder默认ThreadLocal,可切InheritableThreadLocal/Global;上下文由SecurityContextRepository在请求间保存,load 与 save 分属不同组件。AuthorizationManager.authorize(...)取代了AccessDecisionManager的投票模型,返回AuthorizationResult,拒绝时由ExceptionTranslationFilter翻译成 401/403。
下一节把安全从「过滤器层」推进到「方法层」:@PreAuthorize 如何借 AOP 织入、表达式求值上下文里有什么、SecurityContext 在异步与虚拟线程里为什么会丢、又该怎么补回来。
阅读导航:上一节:8.1 过滤器链构建过程 · 下一节:8.3 方法安全与上下文传播 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。