Spring Security 源码解析(五)表单登录认证全流程:UsernamePasswordAuthenticationFilter 拆解

从用户点击"登录"按钮到认证成功跳转,背后经历了怎样的源码调用链?本文将深入拆解 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);
    }
}

关键点解读

  1. requiresAuthentication() 通过 RequestMatcher 判断当前请求是否匹配 /login 且为 POST(默认配置)。注意 UsernamePasswordAuthenticationFilter 已通过 DEFAULT_ANT_PATH_REQUEST_MATCHER 同时匹配路径和方法,所以 attemptAuthentication() 内部的 postOnly 检查是一种额外的防御。

  2. sessionStrategy.onAuthentication() 是在认证成功后、保存 SecurityContext 之前执行的关键步骤,主要用于会话固定攻击防御 (默认实现 ChangeSessionIdAuthenticationStrategy 会更换 Session ID)。

  3. 两种异常捕获InternalAuthenticationServiceException(如 UserDetailsService 返回 null、内部异常包装)被单独 catch 并记录 error 日志;普通 AuthenticationException(如密码错误)则直接交给失败处理。

  4. 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);
}

关键步骤

  1. 校验请求方法postOnly 默认为 true,只接受 POST。可通过 setPostOnly(false) 关闭此校验。
  2. 提取凭据obtainUsername()obtainPassword() 可通过子类覆写来支持自定义参数名或组合逻辑。参数名可通过 setUsernameParameter() / setPasswordParameter() 变更。
  3. 封装 Token :使用 UsernamePasswordAuthenticationToken.unauthenticated() 工厂方法创建,标记 authenticated = false
  4. 委托认证 :调用 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 异常处理策略

ProviderManagerAuthenticationException 的子类采用了不同的处理策略:

异常类型 处理策略 原因
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):

  • 默认的 SecurityContextRepositoryRequestAttributeSecurityContextRepository (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 = trueUsernameNotFoundException 转换为 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 无状态认证。

相关推荐
Dilee1 小时前
Spring AI 对话记忆:MessageChatMemoryAdvisor 最小接入
后端
游码峰行1 小时前
游戏脚本挂攻防-在PoW中实现动态Hash策略及应用实践
后端
一条泥憨鱼1 小时前
苍穹外卖【day6|微信登录与商品浏览功能】
后端·mybatis·苍穹外卖
用户762352425911 小时前
Kafka客户端消息流转流程
后端
橘子星1 小时前
深入理解 AJAX 中的 JSON 序列化与 JS 异步处理
前端·javascript·后端
SimonKing1 小时前
Qoder 提供免费 Qwen3.7-Max,无需订阅
java·后端·程序员
IT_陈寒2 小时前
SpringBoot自动配置这么智能,为啥我写的Bean注入不了?
前端·人工智能·后端
Csvn2 小时前
日志管理与排查 — journalctl & 系统日志实战
后端
zhenlai20123 小时前
Vue3 + SpringBoot + AI:我做了一个股票分析工具(第1周复盘)
人工智能·spring boot·后端