从用户点击"登录"按钮到认证成功跳转,背后经历了怎样的源码调用链?本文将深入拆解 UsernamePasswordAuthenticationFilter 的 attemptAuthentication、ProviderManager 的委托机制、DaoAuthenticationProvider 的用户查询与密码校验,以及 SecurityContext 的保存。
前言
上一篇文章我们分析了 AuthenticationManager 是如何被创建的。现在我们来看它如何被使用------表单登录认证的完整流程。
这是 Spring Security 中最经典、最常用的认证方式,也是理解其他认证方式(如 JWT、OAuth2)的基础。
一、认证入口概览
1.1 常见认证入口
Spring Security 提供了多种认证入口,每种对应一个 Filter:
| 认证方式 | 过滤器 | 触发条件 |
|---|---|---|
| 表单登录 | UsernamePasswordAuthenticationFilter |
POST /login(可自定义 RequestMatcher) |
| HTTP Basic | BasicAuthenticationFilter |
请求携带 Authorization: Basic xxx Header |
| JWT | BearerTokenAuthenticationFilter |
请求携带 Authorization: Bearer xxx Header |
| Remember-Me | RememberMeAuthenticationFilter |
请求携带 remember-me Cookie |
| OAuth2 | OAuth2LoginAuthenticationFilter |
OAuth2 授权码回调 |
1.2 表单认证的整体流程

二、UsernamePasswordAuthenticationFilter 源码拆解
2.1 类继承关系
markdown
UsernamePasswordAuthenticationFilter
→ AbstractAuthenticationProcessingFilter
→ GenericFilterBean
AbstractAuthenticationProcessingFilter 是所有认证处理过滤器的基类,定义了认证的通用流程。
构造函数 :UsernamePasswordAuthenticationFilter 在构造时即通过 super(DEFAULT_ANT_PATH_REQUEST_MATCHER) 传入默认的 RequestMatcher:
java
private static final RequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER =
PathPatternRequestMatcher.withDefaults().matcher(HttpMethod.POST, "/login");
这个 RequestMatcher 同时匹配了路径 /login 和 HTTP 方法 POST。
2.2 doFilter 方法(AbstractAuthenticationProcessingFilter)
以下是基于 Spring Security 6.5.9 源码的真实核心流程:
java
private void doFilter(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws IOException, ServletException {
// 1. 检查是否需要认证(是否匹配登录请求)
if (!requiresAuthentication(request, response)) {
chain.doFilter(request, response);
return;
}
try {
// 2. 调用子类实现的认证方法
Authentication authenticationResult = attemptAuthentication(request, response);
if (authenticationResult == null) {
// 如果配置了 AuthenticationConverter 且需继续链,则放行
if (this.continueChainWhenNoAuthenticationResult) {
chain.doFilter(request, response);
return;
}
// 否则直接返回,认证未完成(如多阶段认证的重定向场景)
return;
}
// 3. 会话策略处理(防会话固定攻击等)
this.sessionStrategy.onAuthentication(authenticationResult, request, response);
// 4. 如果配置了在成功认证前继续过滤器链
if (this.continueChainBeforeSuccessfulAuthentication) {
chain.doFilter(request, response);
}
// 5. 认证成功处理
successfulAuthentication(request, response, chain, authenticationResult);
} catch (InternalAuthenticationServiceException failed) {
// 6a. 内部服务异常(UserDetailsService 内部错误等)
this.logger.error(
"An internal error occurred while trying to authenticate the user.", failed);
unsuccessfulAuthentication(request, response, failed);
} catch (AuthenticationException ex) {
// 6b. 认证失败处理
unsuccessfulAuthentication(request, response, ex);
}
}
关键点解读:
-
requiresAuthentication()通过RequestMatcher判断当前请求是否匹配/login且为 POST(默认配置)。注意UsernamePasswordAuthenticationFilter已通过DEFAULT_ANT_PATH_REQUEST_MATCHER同时匹配路径和方法,所以attemptAuthentication()内部的postOnly检查是一种额外的防御。 -
sessionStrategy.onAuthentication()是在认证成功后、保存 SecurityContext 之前执行的关键步骤,主要用于会话固定攻击防御 (默认实现ChangeSessionIdAuthenticationStrategy会更换 Session ID)。 -
两种异常捕获 :
InternalAuthenticationServiceException(如 UserDetailsService 返回 null、内部异常包装)被单独 catch 并记录 error 日志;普通AuthenticationException(如密码错误)则直接交给失败处理。 -
SecurityContext 的保存已内聚到
successfulAuthentication()中 (见第五节),而非由后续的SecurityContextHolderFilter负责。
2.3 attemptAuthentication 方法(UsernamePasswordAuthenticationFilter)
java
@Override
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response) throws AuthenticationException {
// 1. 可选的 POST 校验(postOnly 默认为 true,可通过 setPostOnly(false) 关闭)
if (this.postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException(
"Authentication method not supported: " + request.getMethod());
}
// 2. 从请求中获取用户名和密码(默认参数名 "username" / "password")
String username = obtainUsername(request);
username = (username != null) ? username.trim() : "";
String password = obtainPassword(request);
password = (password != null) ? password : "";
// 3. 封装为未认证的 UsernamePasswordAuthenticationToken
UsernamePasswordAuthenticationToken authRequest =
UsernamePasswordAuthenticationToken.unauthenticated(username, password);
// 4. 设置详情信息(如远程地址、Session ID 等)
setDetails(request, authRequest);
// 5. 调用 AuthenticationManager 委托认证
return this.getAuthenticationManager().authenticate(authRequest);
}
关键步骤:
- 校验请求方法 :
postOnly默认为true,只接受 POST。可通过setPostOnly(false)关闭此校验。 - 提取凭据 :
obtainUsername()和obtainPassword()可通过子类覆写来支持自定义参数名或组合逻辑。参数名可通过setUsernameParameter()/setPasswordParameter()变更。 - 封装 Token :使用
UsernamePasswordAuthenticationToken.unauthenticated()工厂方法创建,标记authenticated = false。 - 委托认证 :调用
AuthenticationManager.authenticate(),该AuthenticationManager即之前文章分析的ProviderManager。
2.4 UsernamePasswordAuthenticationToken 的两种状态
java
public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {
private final Object principal; // 用户名或 UserDetails 对象
private Object credentials; // 密码
// ===== 工厂方法 =====
// 未认证状态(只有用户名和密码)
public static UsernamePasswordAuthenticationToken unauthenticated(
Object principal, Object credentials) {
return new UsernamePasswordAuthenticationToken(principal, credentials);
}
// 已认证状态(有用户对象和权限列表)
public static UsernamePasswordAuthenticationToken authenticated(
Object principal, Object credentials,
Collection<? extends GrantedAuthority> authorities) {
return new UsernamePasswordAuthenticationToken(principal, credentials, authorities);
}
// ===== 构造方法(均为 public) =====
// 构造方法1:未认证(无权限)
public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {
super(null); // 调用父类,authorities = null
this.principal = principal;
this.credentials = credentials;
setAuthenticated(false); // 标记为未认证
}
// 构造方法2:已认证(有权限)
public UsernamePasswordAuthenticationToken(Object principal, Object credentials,
Collection<? extends GrantedAuthority> authorities) {
super(authorities); // 调用父类,设置权限列表
this.principal = principal;
this.credentials = credentials;
super.setAuthenticated(true); // 必须用 super 调用,因为子类覆写了 setAuthenticated
}
// ===== 关键安全机制:覆写 setAuthenticated =====
@Override
public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
// 禁止外部代码将 Token 设置为"已认证"状态
Assert.isTrue(!isAuthenticated,
"Cannot set this token to trusted - use constructor which takes "
+ "a GrantedAuthority list instead");
// 只能设置为 false
super.setAuthenticated(false);
}
}
安全设计 :
setAuthenticated()被覆写为只能将 Token 设置为未认证状态 (传入true会抛出异常)。这意味着任何外部代码都无法通过token.setAuthenticated(true)伪造一个已认证的 Token。要创建已认证的 Token,必须通过构造方法传入GrantedAuthority列表------而这通常只有AuthenticationProvider内部在完成密码校验后才做。
两种状态对比:
| 维度 | unauthenticated | authenticated |
|---|---|---|
| principal | 用户名字符串 | UserDetails 对象(或用户名,取决于 forcePrincipalAsString) |
| credentials | 密码字符串 | 原始密码字符串(认证后通过 eraseCredentials() 清除) |
| authorities | 空集合 | 用户的权限列表 |
| authenticated | false | true |
| 创建时机 | 登录时封装请求参数 | Provider 认证成功后在 createSuccessAuthentication() 中创建 |
2.5 父类的默认 attemptAuthentication(AuthenticationConverter 机制)
AbstractAuthenticationProcessingFilter 还提供了另一个认证路径------基于 AuthenticationConverter,这是 Spring Security 5.2+ 引入的更灵活的扩展点:
java
// AbstractAuthenticationProcessingFilter 中的默认实现
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response)
throws AuthenticationException, IOException, ServletException {
Authentication authentication = this.authenticationConverter.convert(request);
if (authentication == null) {
return null; // 不进行认证
}
Authentication result = this.authenticationManager.authenticate(authentication);
if (result == null) {
throw new ServletException(
"AuthenticationManager should not return null Authentication object.");
}
return result;
}
UsernamePasswordAuthenticationFilter 覆写了此方法,走自己的参数提取逻辑,不使用 AuthenticationConverter。
三、ProviderManager 的认证委托
3.1 authenticate 方法
java
@Override
public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
Class<? extends Authentication> toTest = authentication.getClass();
AuthenticationException lastException = null;
AuthenticationException parentException = null;
Authentication result = null;
Authentication parentResult = null;
int currentPosition = 0;
int size = this.providers.size();
// 1. 遍历所有 Provider
for (AuthenticationProvider provider : getProviders()) {
// 2. 检查是否支持当前 Authentication 类型
if (!provider.supports(toTest)) {
continue;
}
try {
// 3. 委托 Provider 执行认证
result = provider.authenticate(authentication);
if (result != null) {
copyDetails(authentication, result);
break; // 成功则停止遍历
}
} catch (AccountStatusException ex) {
// 4a. 账户状态异常(锁定/禁用/过期等),立即抛出,不再尝试其他 Provider
prepareException(ex, authentication);
throw ex;
} catch (InternalAuthenticationServiceException ex) {
// 4b. 内部服务异常,立即抛出
prepareException(ex, authentication);
throw ex;
} catch (AuthenticationException ex) {
// 4c. 普通认证异常(如 BadCredentialsException),记录后继续尝试下一个 Provider
lastException = ex;
}
}
// 5. 所有 Provider 都失败,尝试父级 AuthenticationManager
if (result == null && this.parent != null) {
try {
parentResult = this.parent.authenticate(authentication);
result = parentResult;
} catch (ProviderNotFoundException ex) {
// 父级也没有 Provider 能处理,忽略此异常
} catch (AuthenticationException ex) {
parentException = ex;
lastException = ex;
}
}
// 6. 认证成功后的处理
if (result != null) {
// 6a. 擦除凭证(默认开启)
if (this.eraseCredentialsAfterAuthentication
&& (result instanceof CredentialsContainer)) {
((CredentialsContainer) result).eraseCredentials();
}
// 6b. 发布认证成功事件
if (parentResult == null) {
this.eventPublisher.publishAuthenticationSuccess(result);
}
return result;
}
// 7. 没有任何 Provider 能处理
if (lastException == null) {
lastException = new ProviderNotFoundException(
this.messages.getMessage("ProviderManager.providerNotFound",
new Object[] { toTest.getName() },
"No AuthenticationProvider found for {0}"));
}
if (parentException == null) {
prepareException(lastException, authentication);
}
throw lastException;
}
3.2 ProviderManager 异常处理策略
ProviderManager 对 AuthenticationException 的子类采用了不同的处理策略:
| 异常类型 | 处理策略 | 原因 |
|---|---|---|
AccountStatusException |
立即抛出,不继续尝试 | 账户锁定/禁用是终局判断,换 Provider 也没用 |
InternalAuthenticationServiceException |
立即抛出,不继续尝试 | 系统内部错误,不应继续 |
BadCredentialsException 等 |
记录下来,继续尝试下一个 Provider | 换个 Provider 可能认证成功(如 LDAP → 数据库) |
3.3 supports 方法(AbstractUserDetailsAuthenticationProvider)
java
// 定义在 AbstractUserDetailsAuthenticationProvider 中,DaoAuthenticationProvider 继承
@Override
public boolean supports(Class<?> authentication) {
// 只支持 UsernamePasswordAuthenticationToken 及其子类
return (UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication));
}
四、DaoAuthenticationProvider 的认证执行
4.1 authenticate 方法(父类 AbstractUserDetailsAuthenticationProvider)
此方法实现了完整的认证流程,包含用户缓存 、用户名不存在隐藏 、缓存失败重试等机制:
java
@Override
public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
// 0. 断言只处理 UsernamePasswordAuthenticationToken
Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
() -> this.messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.onlySupports",
"Only UsernamePasswordAuthenticationToken is supported"));
String username = determineUsername(authentication);
boolean cacheWasUsed = true;
// 1. 尝试从缓存获取用户
UserDetails user = this.userCache.getUserFromCache(username);
if (user == null) {
cacheWasUsed = false;
try {
// 2. 缓存未命中,调用子类的 retrieveUser() 查询用户
user = retrieveUser(username,
(UsernamePasswordAuthenticationToken) authentication);
} catch (UsernameNotFoundException ex) {
this.logger.debug(LogMessage.format("Failed to find user '%s'", username));
if (!this.hideUserNotFoundExceptions) {
throw ex; // 配置为不隐藏时,直接抛出
}
// 默认隐藏用户名不存在,统一返回 "Bad credentials"
throw new BadCredentialsException(this.messages
.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}
Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract");
}
try {
// 3. 前置校验(账户是否锁定/禁用/过期)
this.preAuthenticationChecks.check(user);
// 4. 密码校验(子类实现)
additionalAuthenticationChecks(user,
(UsernamePasswordAuthenticationToken) authentication);
} catch (AuthenticationException ex) {
if (!cacheWasUsed) {
throw ex; // 未使用缓存,直接抛出
}
// 5. 缓存可能过期,重新查询用户并再次校验
cacheWasUsed = false;
user = retrieveUser(username,
(UsernamePasswordAuthenticationToken) authentication);
this.preAuthenticationChecks.check(user);
additionalAuthenticationChecks(user,
(UsernamePasswordAuthenticationToken) authentication);
}
// 6. 后置校验(凭证是否过期)
this.postAuthenticationChecks.check(user);
// 7. 放入缓存(下次相同用户名可直接命中)
if (!cacheWasUsed) {
this.userCache.putUserInCache(user);
}
// 8. 决定 principal 类型(UserDetails 对象 or 用户名字符串)
Object principalToReturn = user;
if (this.forcePrincipalAsString) {
principalToReturn = user.getUsername();
}
// 9. 创建已认证的 Authentication
return createSuccessAuthentication(principalToReturn, authentication, user);
}
关键机制解读:
| 机制 | 默认值 | 作用 |
|---|---|---|
hideUserNotFoundExceptions |
true |
将 UsernameNotFoundException 转换为 BadCredentialsException,防止用户名枚举攻击 |
userCache |
NullUserCache |
默认不缓存。在有状态应用中 SecurityContext 存于 Session,无需额外缓存 |
| 缓存失败重试 | --- | 缓存的密码校验失败时,重新查询 UserDetailsService 再校验一次,防止缓存过期 |
forcePrincipalAsString |
false |
默认 principal 为 UserDetails 对象;设为 true 则仅保留用户名字符串 |
4.2 additionalAuthenticationChecks(DaoAuthenticationProvider)
java
@Override
protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
// 1. 凭证为空 → 直接失败
if (authentication.getCredentials() == null) {
this.logger.debug("Failed to authenticate since no credentials provided");
throw new BadCredentialsException(this.messages
.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}
// 2. 密码比对
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"));
}
}
4.3 时序攻击防御
在 DaoAuthenticationProvider.retrieveUser() 中,还有时序攻击(Timing Attack)防御机制:
java
@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;
}
}
当用户不存在时,仍然执行一次 PasswordEncoder.matches(),消耗与正常密码校验相近的时间,防止攻击者通过响应时间差异枚举有效用户名。
4.4 createSuccessAuthentication(DaoAuthenticationProvider 覆写)
java
@Override
protected Authentication createSuccessAuthentication(Object principal,
Authentication authentication, UserDetails user) {
String presentedPassword = authentication.getCredentials().toString();
// 1. 泄露密码检测(6.3+)
boolean isPasswordCompromised = this.compromisedPasswordChecker != null
&& this.compromisedPasswordChecker.check(presentedPassword).isCompromised();
if (isPasswordCompromised) {
throw new CompromisedPasswordException(
"The provided password is compromised, please change your password");
}
// 2. 密码编码升级(如从 bcrypt 迁移到 argon2)
boolean upgradeEncoding = this.userDetailsPasswordService != null
&& this.passwordEncoder.get().upgradeEncoding(user.getPassword());
if (upgradeEncoding) {
String newPassword = this.passwordEncoder.get().encode(presentedPassword);
user = this.userDetailsPasswordService.updatePassword(user, newPassword);
}
// 3. 调用父类创建已认证 Token
return super.createSuccessAuthentication(principal, authentication, user);
}
父类 createSuccessAuthentication 实现:
java
protected Authentication createSuccessAuthentication(Object principal,
Authentication authentication, UserDetails user) {
UsernamePasswordAuthenticationToken result =
UsernamePasswordAuthenticationToken.authenticated(principal,
authentication.getCredentials(),
this.authoritiesMapper.mapAuthorities(user.getAuthorities()));
result.setDetails(authentication.getDetails());
return result;
}
注意:已认证 Token 的 credentials 字段仍保留了用户提交的原始密码,但 ProviderManager 会在返回前调用 eraseCredentials() 将其清除(默认 eraseCredentialsAfterAuthentication = true)。
4.5 前置与后置校验
DefaultPreAuthenticationChecks(前置):
java
private class DefaultPreAuthenticationChecks implements UserDetailsChecker {
@Override
public void check(UserDetails user) {
if (!user.isAccountNonLocked()) {
throw new LockedException("User account is locked");
}
if (!user.isEnabled()) {
throw new DisabledException("User is disabled");
}
if (!user.isAccountNonExpired()) {
throw new AccountExpiredException("User account has expired");
}
}
}
DefaultPostAuthenticationChecks(后置):
java
private class DefaultPostAuthenticationChecks implements UserDetailsChecker {
@Override
public void check(UserDetails user) {
if (!user.isCredentialsNonExpired()) {
throw new CredentialsExpiredException("User credentials have expired");
}
}
}
校验顺序总结:先前置(锁定 → 启用 → 账户过期),再密码比对,再后置(凭证过期)。
五、SecurityContext 的保存
5.1 保存时机与方式
在 Spring Security 6.x 中,SecurityContext 的保存已内聚到 AbstractAuthenticationProcessingFilter.successfulAuthentication() 中:
java
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response, FilterChain chain,
Authentication authResult) throws IOException, ServletException {
// 1. 创建新的空 SecurityContext
SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
context.setAuthentication(authResult);
// 2. 设置到当前线程的 SecurityContextHolder
this.securityContextHolderStrategy.setContext(context);
// 3. 立即持久化到 SecurityContextRepository
this.securityContextRepository.saveContext(context, request, response);
if (this.logger.isDebugEnabled()) {
this.logger.debug(LogMessage.format("Set SecurityContextHolder to %s", authResult));
}
// 4. RememberMe 登录成功处理
this.rememberMeServices.loginSuccess(request, response, authResult);
// 5. 发布认证成功事件
if (this.eventPublisher != null) {
this.eventPublisher.publishEvent(
new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));
}
// 6. 调用成功处理器(跳转页面或返回 JSON)
this.successHandler.onAuthenticationSuccess(request, response, authResult);
}
关键变化(相较于老版本 Spring Security):
- 默认的
SecurityContextRepository是RequestAttributeSecurityContextRepository(6.0+),而非HttpSessionSecurityContextRepository。这意味着默认情况下 SecurityContext 存储在HttpServletRequest的属性中,仅在当前请求范围内有效,更加轻量和安全。 - SecurityContext 的持久化在
successfulAuthentication()中即时完成 ,不再依赖SecurityContextHolderFilter在请求结束时保存。
5.2 SecurityContextHolderFilter 的角色
SecurityContextHolderFilter(5.7+ 引入,替代老的 SecurityContextPersistenceFilter)只负责加载 SecurityContext,不负责保存:
java
private void doFilter(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
if (request.getAttribute(FILTER_APPLIED) != null) {
chain.doFilter(request, response);
return;
}
request.setAttribute(FILTER_APPLIED, Boolean.TRUE);
// 从 SecurityContextRepository 加载 SecurityContext(延迟加载)
Supplier<SecurityContext> deferredContext =
this.securityContextRepository.loadDeferredContext(request);
try {
this.securityContextHolderStrategy.setDeferredContext(deferredContext);
chain.doFilter(request, response);
} finally {
this.securityContextHolderStrategy.clearContext(); // 请求结束仅清除,不保存
request.removeAttribute(FILTER_APPLIED);
}
}
核心区别:
| 对比维度 | 老版 SecurityContextPersistenceFilter | 新版 SecurityContextHolderFilter |
|---|---|---|
| 加载 | filter 开始时加载 | filter 开始时延迟加载 |
| 保存 | filter 结束时自动保存 | 不保存,由认证 Filter 显式调用 |
| 好处 | 自动持久化 | 更高效,允许不同认证机制自行决定是否持久化 |
5.3 认证失败的处理
java
protected void unsuccessfulAuthentication(HttpServletRequest request,
HttpServletResponse response, AuthenticationException failed)
throws IOException, ServletException {
// 1. 清除 SecurityContextHolder
this.securityContextHolderStrategy.clearContext();
this.logger.trace("Failed to process authentication request", failed);
this.logger.trace("Cleared SecurityContextHolder");
this.logger.trace("Handling authentication failure");
// 2. RememberMe 登录失败处理
this.rememberMeServices.loginFail(request, response);
// 3. 调用失败处理器
this.failureHandler.onAuthenticationFailure(request, response, failed);
}
六、认证异常体系
| 异常 | 触发条件 | 抛出阶段 |
|---|---|---|
BadCredentialsException |
密码错误 / 用户名不存在(默认隐藏) | additionalAuthenticationChecks |
UsernameNotFoundException |
用户名不存在(未隐藏时) | retrieveUser → 被转为 BadCredentialsException |
AccountExpiredException |
账户过期 | preAuthenticationChecks |
LockedException |
账户被锁定 | preAuthenticationChecks |
DisabledException |
账户被禁用 | preAuthenticationChecks |
CredentialsExpiredException |
凭证过期 | postAuthenticationChecks |
CompromisedPasswordException |
密码已泄露(6.3+) | createSuccessAuthentication |
InternalAuthenticationServiceException |
UserDetailsService 内部错误 | retrieveUser |
AuthenticationServiceException |
请求方法不支持 | attemptAuthentication |
ProviderNotFoundException |
没有 Provider 能处理当前 Token 类型 | ProviderManager |
注意 :Spring Security 默认通过 AbstractUserDetailsAuthenticationProvider.hideUserNotFoundExceptions = true 将 UsernameNotFoundException 转换为 BadCredentialsException,防止攻击者通过错误信息枚举用户名。如需区分,可将此属性设为 false。
七、完整认证流程图

八、Token 状态流转总结

九、实战:自定义认证成功/失败处理
在前后端分离场景下,通常需要返回 JSON 而非页面跳转:
java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.formLogin(form -> form
.loginProcessingUrl("/api/login")
.successHandler((request, response, authentication) -> {
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":200,\"msg\":\"登录成功\"}");
})
.failureHandler((request, response, exception) -> {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(401);
response.getWriter().write(
"{\"code\":401,\"msg\":\"" + exception.getMessage() + "\"}");
})
);
return http.build();
}
注意 :生产环境中不要直接将
exception.getMessage()返回给前端,这可能暴露用户名枚举等安全信息。建议统一返回"用户名或密码错误"。
SecurityContextRepository 选择
如果需要在分布式 Session 或无状态场景下使用,可切换 SecurityContextRepository:
java
// 切换回 Session 存储(需要分布式 Session 支持)
http.securityContext(context -> context
.securityContextRepository(new HttpSessionSecurityContextRepository())
);
十、总结
核心组件职责表
| 步骤 | 组件 | 核心方法 | 说明 |
|---|---|---|---|
| 1 | UsernamePasswordAuthenticationFilter |
attemptAuthentication() |
从请求提取凭据,封装未认证 Token |
| 2 | ProviderManager |
authenticate() |
遍历 Provider,处理异常分层,委托认证 |
| 3 | AbstractUserDetailsAuthenticationProvider |
authenticate() |
用户缓存、用户名校验、前置/后置校验 |
| 4 | DaoAuthenticationProvider |
additionalAuthenticationChecks() |
密码比对、时序攻击防御 |
| 5 | UserDetailsService |
loadUserByUsername() |
根据用户名查询用户信息 |
| 6 | PasswordEncoder |
matches() |
校验密码是否匹配 |
| 7 | AbstractAuthenticationProcessingFilter |
successfulAuthentication() |
创建 SecurityContext、持久化、调用成功处理器 |
Token 两种状态
| Token 状态 | principal | credentials | authorities | authenticated |
|---|---|---|---|---|
| unauthenticated | 用户名字符串 | 密码字符串 | 空集合 | false |
| authenticated | UserDetails 对象 | 原始密码(随后被擦除) | 用户权限列表 | true |
关键安全机制一览
| 机制 | 位置 | 作用 |
|---|---|---|
setAuthenticated() 覆写 |
UsernamePasswordAuthenticationToken |
禁止外部代码伪造已认证 Token |
hideUserNotFoundExceptions |
AbstractUserDetailsAuthenticationProvider |
用户名不存在时统一返回 "Bad credentials" |
| 时序攻击防御 | DaoAuthenticationProvider.retrieveUser() |
用户不存在时仍执行密码比对,防止时间差异泄露 |
| 会话固定防御 | sessionStrategy.onAuthentication() |
认证成功后更换 Session ID |
| 凭证擦除 | ProviderManager.eraseCredentials() |
认证完成后清除 Token 中的密码 |
| 泄露密码检测 | DaoAuthenticationProvider.createSuccessAuthentication() |
检测用户密码是否出现在泄露数据库中 |
| 密码编码升级 | DaoAuthenticationProvider.createSuccessAuthentication() |
自动升级旧密码的编码算法 |
下一篇预告:《Spring Security 会话管理与无状态 JWT 实践》将分析 Session 机制、分布式 Session 共享方案,以及如何在前后端分离场景下实现 JWT 无状态认证。