# Spring Security 源码分析(二):表单登录

# 自定义表单登录

FormLoginConfigurer 提供了 loginPageloginProcessingUrl 方法分别用于配置登录页面和表单提交请求处理路径。继承 WebSecurityConfigurerAdapter,重载关于过滤器和忽略请求的配置方法:

  public class CustomSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
      // 表单登录,配置表单页面和表单提交URL
      http.formLogin().loginPage("/login.html").loginProcessingUrl("/login").and()
      // 任何请求都需要认证
      .authorizeRequests().anyRequest().authenticated();
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
      // 登录页面请求不走 Spring Security 过滤器链
      web.ignoring().mvcMatchers("/login.html");
    }
  }

# loginPage 自定义登录页面

  protected T loginPage(String loginPage) {
    // 配置自定义登录
    setLoginPage(loginPage);
    // 更新登录错误 / 登出路径(基于 loginPage)
    updateAuthenticationDefaults();
    // 更新自定义标识
    this.customLoginPage = true;
    return getSelf();
  }

在配置自定义登录页面后,同时会更新认证错误认证策略:

  private void setLoginPage(String loginPage) {
    this.loginPage = loginPage;
    // 更新认证异常时跳转页面
    this.authenticationEntryPoint = new LoginUrlAuthenticationEntryPoint(loginPage);
  }

上一节中说到,当 ExceptionTranslationFilter 拦截到认证异常后,会调用 LoginUrlAuthenticationEntryPoint#commence 方法进行处理,其主要逻辑为将用户请求重定向到登录页面。

  public void commence(HttpServletRequest request, HttpServletResponse response,
      AuthenticationException authException) throws IOException, ServletException {
    // ...

    // 获取配置的 loginPage
    String loginForm = determineUrlToUseForThisRequest(request, response, authException);
    // 请求重定向
    RequestDispatcher dispatcher = request.getRequestDispatcher(loginForm);
    dispatcher.forward(request, response);

    // ...
  }

loginPage 方法更新 customLoginPage 标识后,DefaultLoginPageGeneratingFilter 的过滤逻辑也随之改变,其默认配置的需要拦截的相关路径为 /login,当开发者自定义登录页面路径后,经由此过滤器就不会再被拦截。

# loginPage 定义表单提交路径

loginPage 方法中调用了 updateAuthenticationDefaults 方法,可见当不手动配置 loginProcessingUrl 时,会使用 loginPage 作为表单提交路径。

  protected final void updateAuthenticationDefaults() {
    if (loginProcessingUrl == null) {
      // 默认实用 loginPage 作为表单提交路径
      loginProcessingUrl(loginPage);
    }

    // ...
  }

但是 FormLoginConfigurer 配置的 UsernamePasswordAuthenticationFilter 则在默认构造方法中指定表单提交路径为 /login,也就是说如果开发者配置 loginPage 为其它路径,就无法正常进行认证。

  public UsernamePasswordAuthenticationFilter() {
    super(new AntPathRequestMatcher("/login", "POST"));
  }

  /**
   * 路径匹配才进行认证
   */
  protected boolean requiresAuthentication(HttpServletRequest request,
    HttpServletResponse response) {
    return requiresAuthenticationRequestMatcher.matches(request);
  }

因此为了 UsernamePasswordAuthenticationFilter 能够正常执行,开发者需要手动指定 loginProcessingUrl

  public T loginProcessingUrl(String loginProcessingUrl) {
    this.loginProcessingUrl = loginProcessingUrl;
    // 设置过滤器处理登录逻辑的请求URL(可以指定其它名称覆盖 /login)
    authFilter.setRequiresAuthenticationRequestMatcher(createLoginProcessingUrlMatcher(loginProcessingUrl));
    return getSelf();
  }

# 忽略对自定义登录页面的拦截

上文示例中配置的是对所有请求进行拦截,当过滤器链发现没有认证就会跳转到登录页面,但是访问登录页面也需要认证,这就会造成一直重定向,无法完成登录。因此需要配置登录页面请求不走 Spring Security 过滤器链,这样所有人就可以正常访问登录页面。

# 登录后处理

登录成功后是跳转到首页还是用户原先想访问的页面?失败后是仍跳转到登录页面还是自定义的错误页面?Spring Security 提供了若干方法用于开发者自定义登录后处理:

  @Resource
  private AuthenticationSuccessHandler successHandler;

  @Resource
  private AuthenticationFailureHandler failureHandler;

  protected void configure(HttpSecurity http) throws Exception {
    // 表单登录
    http.formLogin()
            // 认证成功后重定向URL
            .successForwardUrl("/")
            // 认证失败后重定向URL
            .failureForwardUrl("/login.html?error")
            // 如果是从其它页面重定向到登录页面,则成功后跳转到原请求URL,否则跳转到指定URL
            .defaultSuccessUrl("/", false)
            // 认证失败后重定向URL
            .failureUrl("/login.html?error")
            // 自定义认证成功后处理器
            .successHandler(successHandler)
            // 自定义认证失败后处理器
            .failureHandler(failureHandler)
            .and()
            // 任何请求都需要认证
            .authorizeRequests().anyRequest().authenticated();
  }

同样是重载 void configure(HttpSecurity http) 方法,干预认证过滤器的生成逻辑。可以看到 HttpSecurity 提供了 4 个修改重定向地址的方法,而实际上他们最后都是对 successHandlerfailureHandler 进行配置。在 UsernamePasswordAuthenticationFilter 的认证逻辑中,当认证成功后会调用 successfulAuthentication 方法,而在此方法中又调用了 AuthenticationSuccessHandler#onAuthenticationSuccess 方法,如下是认证成功后处理器的一类实现:

  public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
    request.getRequestDispatcher(forwardUrl).forward(request, response);
  }

但是在前后端分离的项目中,认证系统可能作为一个单独的后端模块单独拆出来,配置登录跳转就无法满足与前端交互的任务,因此开发者需要继承 AuthenticationSuccessHandlerAuthenticationFailureHandler,自定义认证后响应,以下为向前端返回认证失败的 Json 数据的一类实现:

@Component
public class JsonAuthenticationFailureHandler implements AuthenticationFailureHandler {

  @Override
  public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
    // 状态码 401
    response.setStatus(HttpStatus.UNAUTHORIZED.value());
    // 设置返回类型为 json
    response.setContentType("application/json;charset=UTF-8");
    response.getWriter().write(JSONUtils.toJSONString(exception));
  }
}

# 自定义过滤器

HttpSecurity 提供了若干方法为 web 请求添加过滤器,例如默认表单、认证过期、CSRF ` 保护等。同时开发者可以定义自已的过滤器,并指定在整个过滤器中的位置。如果需要在表单中添加验证码校验逻辑,可以使用如下示例:

  /**
  * 认证失败后处理器
  */
  @Resource
  private AuthenticationFailureHandler failureHandler;

  @Override
  protected void configure(HttpSecurity http) throws Exception {
    // 在认证登录前验证验证码
    http.addFilterBefore(new CaptchaFilter(failureHandler),
    UsernamePasswordAuthenticationFilter.class)}

在上文中我们说到,登录表单提交请求无论是成功还是失败,默认都会交由认证处理器进行跳转。因此在整个过滤器链中,关于验证码的过滤逻辑需要排在 UsernamePasswordAuthenticationFilter 之前。

  public HttpSecurity addFilterBefore(Filter filter, Class<? extends Filter> beforeFilter) {
    // 过滤器排序器注册
    comparator.registerBefore(filter.getClass(), beforeFilter);
    return addFilter(filter);
  }

所有 Spring Security 配置的过滤器,其顺序由 FilterComparator 管理:

final class FilterComparator implements Comparator<Filter>, Serializable {

  /**
   * 初始化顺序
   */
  private static final int INITIAL_ORDER = 100;

  /**
   * order 步长
   */
  private static final int ORDER_STEP = 100;

  /**
   * 过滤器名称 - 在过滤器中的顺序(order 越小,排序越靠前)
   */
  private final Map<String, Integer> filterToOrder = new HashMap<>();

  FilterComparator() {
    Step order = new Step(INITIAL_ORDER, ORDER_STEP);
    // ...

    put(SecurityContextPersistenceFilter.class, order.next());
    // ...

    put(UsernamePasswordAuthenticationFilter.class, order.next());
    // ...

    put(FilterSecurityInterceptor.class, order.next());
    // ...
  }

  public void registerBefore(Class<? extends Filter> filter, Class<? extends Filter> beforeFilter) {
    Integer position = getOrder(beforeFilter);
    // ...

    // 注册自定义过滤器
    put(filter, position - 1);
  }
}

可以看到,FilterComparatorSpring Security 配置的默认过滤器维护了一个 filterToOrder,用于描述各个过滤器在过滤器链中的顺序,前后两个过滤器的顺序相差 100。registerBefore 方法将开发者自定义的过滤器注册到 FilterComparator 方法中,并指定其顺序与 UsernamePasswordAuthenticationFilter 相差 1。这样在过滤器中就能保证自定义的验证码过滤器 CaptchaFilter 能够在认证过滤器前一位执行。

# 记住我

在过滤器配置中,可以通过 remember 方法配置 记住我 功能:

  @Override
  protected void configure(HttpSecurity http) throws Exception {
    // 在认证登录前验证验证码
    http.rememberMe().and()
        // 任何请求都需要认证
        .authorizeRequests().anyRequest().authenticated();
  }

rememberMe 方法通过 RememberMeConfigurer 配置 RememberMeAuthenticationFilter,在 init 方法中先生成了一个 RememberMeServices

  private RememberMeServices getRememberMeServices(H http, String key) throws Exception {
    // ...

    //
    AbstractRememberMeServices tokenRememberMeServices = createRememberMeServices(
        http, key);
    // 表单登录参数名默认为 remember-me
    tokenRememberMeServices.setParameter(this.rememberMeParameter);
    // Cookie 名称默认为 remember-me
    tokenRememberMeServices.setCookieName(this.rememberMeCookieName);
    // ...

    // 配置记住我过期时间,默认为两周
    if (this.tokenValiditySeconds != null) {
  		tokenRememberMeServices.setTokenValiditySeconds(this.tokenValiditySeconds);
    }
    // ...

    // 如果用户主动登出了,需要清除记住我功能做的相关配置
    this.logoutHandler = tokenRememberMeServices;
    this.rememberMeServices = tokenRememberMeServices;
    return tokenRememberMeServices;
  }

对于创建基础的 AbstractRememberMeServicesSpring Security 提供了两种方式,一种是 TokenBasedRememberMeServices:是否能够使用 记住我 功能、校验都只依赖请求中的 Cookie;另一种是配置 PersistentTokenRepositoryInMemoryTokenRepositoryImpl 在内存中维护一个 Map 用于对 记住我 进行校验,JdbcTokenRepositoryImpl 会从数据库中查询相关的 token 进行校验。这两种方式都是依赖请求中携带的 Cookie。当用户提交登出请求,应该取消 记住我 功能,AbstractRememberMeServices 对此的实现为清除名为 remember-meCookie

  protected void cancelCookie(HttpServletRequest request, HttpServletResponse response) {
    // ...
    Cookie cookie = new Cookie(cookieName, null);
    cookie.setMaxAge(0);
    cookie.setPath(getCookiePath(request));
    // ...

    response.addCookie(cookie);
  }

RememberMeServices 配置完成后,RememberMeAuthenticationFilter 被加到过滤器链中,其过滤逻辑如下:

  public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
    // ...

    if (SecurityContextHolder.getContext().getAuthentication() == null) {
      // 没有经过认证,SecurityContext 为空,尝试使用 记住我 功能

      // 检验 Cookie,如果有效进行自动登录
      Authentication rememberMeAuth = rememberMeServices.autoLogin(request, response);

      if (rememberMeAuth != null) {
        // remeber-me Cookie 有效,走用户认证逻辑
        try {
          rememberMeAuth = authenticationManager.authenticate(rememberMeAuth);
          SecurityContextHolder.getContext().setAuthentication(rememberMeAuth);
          // ...
        } catch (AuthenticationException authenticationException) {
          // 认证失败了,也会清除 remeber-me Cookie
          rememberMeServices.loginFail(request, response);
          // ...
        }
      }

      chain.doFilter(request, response);
    } else {
      // ...

      chain.doFilter(request, response);
    }
  }

如果 SecurityContext 没有认证信息,过滤器会尝试使用 记住我 功能,调用 autoLogin 方法:

  public final Authentication autoLogin(HttpServletRequest request, HttpServletResponse response) {
    // 从请求中获取名为 remember-me 的 Cookie
    String rememberMeCookie = extractRememberMeCookie(request);
    // ...

    UserDetails user = null;
    try {
      // 原 Cookie 解码
      String[] cookieTokens = decodeCookie(rememberMeCookie);
      user = processAutoLoginCookie(cookieTokens, request, response);
      // 校验用户账号是否有效
      userDetailsChecker.check(user);
      // 创建认证信息
      return createSuccessfulAuthentication(request, user);
    } catch (CookieTheftException cte) {
      // 清理客户端的 remember-me Cookie
      cancelCookie(request, response);
      throw cte;
    }
    // ...

    cancelCookie(request, response);
    return null;
  }

TokenBasedRememberMeServices 中对于 processAutoLoginCookie 方法的实现为:

  protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request, HttpServletResponse response) {
    // ...

    long tokenExpiryTime;

    try {
      tokenExpiryTime = new Long(cookieTokens[1]).longValue();
    } catch (NumberFormatException nfe) {
      throw new InvalidCookieException(
          "Cookie token[1] did not contain a valid number (contained '"
              + cookieTokens[1] + "')");
    }

    // ...

    // 根据用户名加载用户信息
    UserDetails userDetails = getUserDetailsService().loadUserByUsername(cookieTokens[0]);

    // 根据过期时间、用户名、密码生成 MD5 签名
    String expectedTokenSignature = makeTokenSignature(tokenExpiryTime,
        userDetails.getUsername(), userDetails.getPassword());

    // 校验签名
    if (!equals(expectedTokenSignature, cookieTokens[2])) {
      throw new InvalidCookieException("Cookie token[2] contained signature '"
          + cookieTokens[2] + "' but expected '" + expectedTokenSignature + "'");
    }

    return userDetails;
  }

Cookie 进行解码后,根据登录用户的相关信息做 MD5 校验,如果认定 Cookie 中的 Token 有效,则会查询用户信息,生成认证身份,通过认证流程。那么,记住我Cookie 是在何时放入的呢?在 RememberMeConfigurer 的初始化过程中,默认的 remember-me 表单名被传递给表单生成过滤器:

private void initDefaultLoginFilter(H http) {
  DefaultLoginPageGeneratingFilter loginPageGeneratingFilter = http
    .getSharedObject(DefaultLoginPageGeneratingFilter.class);
  if (loginPageGeneratingFilter != null) {
    // 将表单名 remember-me 传递给默认表单生成过滤器,生成 记住我 复选框
    loginPageGeneratingFilter.setRememberMeParameter(getRememberMeParameter());
  }
}

表单中会根据传入的名称生成如下 HTML 代码:

<p>
  <input type="checkbox" name="remember-me" /> Remember me on this computer.
</p>

UsernamePasswordAuthenticationFilter 在用户认证成功后会调用 successfulAuthentication 方法,在此方法中会取出 remember-me 参数,判断是否使用 记住我 功能。而后又调用了 RememberMeServicesloginSuccess 方法:

  public final void loginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) {
    // 请求参数中是否要求使用记住我功能
    if (!rememberMeRequested(request, parameter)) {
      logger.debug("Remember-me login not requested.");
      return;
    }

    // 要求使用记住我功能,响应中构造 Cookie
    onLoginSuccess(request, response, successfulAuthentication);
  }

TokenBasedRememberMeServices 中对于 onLoginSuccess 方法的实现为:

public void onLoginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) {

  String username = retrieveUserName(successfulAuthentication);
  String password = retrievePassword(successfulAuthentication);
  // ...

  // Cookie 失效时间
  int tokenLifetime = calculateLoginLifetime(request, successfulAuthentication);
  long expiryTime = System.currentTimeMillis();
  expiryTime += 1000L * (tokenLifetime < 0 ? TWO_WEEKS_S : tokenLifetime);

  // 构造 MD5 签名,与校验 Cookie 的逻辑一致
  String signatureValue = makeTokenSignature(expiryTime, username, password);

  // 在响应中添加 Cookie
  setCookie(new String[] { username, Long.toString(expiryTime), signatureValue },
            tokenLifetime, request, response);
  // ...
}

如果配置了 PersistentTokenRepository 其流程大致相同,区别在于 Cookie 的生成和校验逻辑不同,在此不多做赘述。

# 小结

  • 通过配置 FormLoginConfigurerloginPageloginProcessingUrl 可以切换默认登录页面和表单请求地址。

  • 通过配置 FormLoginConfigurersuccessHandlerfailureHandler 可以干预认证成功 / 失败后的服务端控制行为。

  • Spring Security 过滤器链中的过滤器执行顺序由 FilterComparator 管理。如果需要在过滤器链中增加自定义过滤器,可以通过 HttpSecurityaddFilterBefore 或者 addFilterAfter 方法将自定义过滤器加在指定过滤器前 / 后。

  • 配置 RememberMe 后,认证成功和失败,响应都会返回 Cookie 给客户端,下一次未经认证即访问 web 资源时,会对请求携带的 Cookie 进行校验,如果有效则会自动登录,执行认证逻辑。

  • 表单登录的相关扩展仍然离不开过滤器的支持,以下是几个较为重要的 Spring Security 过滤器:

    SpringSecuirty主要过滤器