Skip to content

Spring Security - 2 身份验证(Authentication)

本文介绍Spring Security是如何做身份验证的(登录)。 上一篇文章:Spring Security - 1 初认识

1. 项目环境回顾

首先启动一个测试项目,引入Spring Security:

xml
<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>
  <dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>3.0.4</version>
  </dependency>

  <dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
  </dependency>
  <dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter-test</artifactId>
    <version>3.0.4</version>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-test</artifactId>
    <scope>test</scope>
  </dependency>
</dependencies>
properties
server.port=8081

spring.application.name=springsecuritydemo

spring.security.user.name=test
spring.security.user.password=test

spring.datasource.driver-class-name=org.postgresql.Driver
spring.datasource.url=jdbc:postgresql://localhost:5432/demo
spring.datasource.username=demo
spring.datasource.password=demo
java
package org.example.springsecuritydemo.controller;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HelloController {
    @GetMapping("/hello")
    public String hello(){
        return "hello";
    }
  
  	@GetMapping("/num")
    public int num(){
        return new Random().nextInt();
    }
}

简单搭建好项目环境后,当访问/hello路径时,如果没有登录,会跳转到登录界面,输入在配置文件中配置的账号密码(test)后,返回"hello"。

接下来我们就来探究Spring Security 是如何验证用户身份的。

2. 身份验证流程

首先,我们是用表单进行登录的,表单登录验证是使用过滤器UsernamePasswordAuthenticationFilter进行验证的。它的父类是AbstractAuthenticationProcessingFilter,并且doFilter()是在其父类中的,所以我们先看父类中的doFilter()方法:

java
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
    throws IOException, ServletException {
  // 判断是否是登录请求,如果不是,则不拦截请求
  if (!requiresAuthentication(request, response)) {
    chain.doFilter(request, response);
    return;
  }
  // 是登录请求,拦截处理
  try {
    // 关键:调用attemptAuthentication()方法
    Authentication authenticationResult = attemptAuthentication(request, response);
    if (authenticationResult == null) {
      // 登录不成功,直接返回
      return;
    }
    this.sessionStrategy.onAuthentication(authenticationResult, request, response);
    // Authentication success
    if (this.continueChainBeforeSuccessfulAuthentication) {
      chain.doFilter(request, response);
    }
    // 登录成功
    successfulAuthentication(request, response, chain, authenticationResult);
  }
  catch (InternalAuthenticationServiceException failed) {
    this.logger.error("An internal error occurred while trying to authenticate the user.", failed);
    unsuccessfulAuthentication(request, response, failed);
  }
  catch (AuthenticationException ex) {
    // Authentication failed
    unsuccessfulAuthentication(request, response, ex);
  }
}

doFilter()中,调用了一个关键方法attemptAuthentication(),这个方法在子类UsernamePasswordAuthenticationFilter中有实现:

java
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
    throws AuthenticationException {
  // 判断是不是请求是不是POST方法,如果不是直接抛异常
  if (this.postOnly && !request.getMethod().equals("POST")) {
    throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
  }
  // 从表单中获取前台输入的用户名和密码
  String username = obtainUsername(request);
  username = (username != null) ? username.trim() : "";
  String password = obtainPassword(request);
  password = (password != null) ? password : "";
  // 生成一个未验证的Authentication
  UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username,
      password);
  // Allow subclasses to set the "details" property
  setDetails(request, authRequest);
  // 开始验证
  return this.getAuthenticationManager().authenticate(authRequest);
}

在第17行的时候,调用了getAuthenticationManager().authenticate(authRequest)进行验证,这是我们关注的地方。首先是方法getAuthenticationManager(),返回一个AuthenticationManager对象,由这个对象完成用户身份验证。AuthenticationManager是接口,其中一个实现类是ProviderManager,我们查看其中的authenticate()方法(原代码很长,这里简单阐述代码逻辑):

java
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
  // 第一步:通过 AuthenticationProvider 列表,依次验证用户身份,如果某一个Provider验证成功,则提前退出
  // 第二步:如果第一步没有验证成功,则委托给Parent(也是一个AuthenticationManager对象进行验证),更像是一个兜底措施
  // 第三步:如果前两步验证成功,则result不为空,直接返回
  // 第四步:到了这一步,说明未验证成功,直接抛异常
}

所以我们的重点应放在第一二步,看看AuthenticationProvider是如何验证的。AuthenticationProvider也是一个接口,我们查看定义如下:

java
public interface AuthenticationProvider {

  // 验证接口,如果验证成功,则将未验证的Authentication转换为验证成功的Authentication
	Authentication authenticate(Authentication authentication) throws AuthenticationException;

  // 该Provider是否支持验证某种类型的Authentication
	boolean supports(Class<?> authentication);

}

在上面的案例中,实际起作用的是DaoAuthenticationProvider,其有一个父类AbstractUserDetailsAuthenticationProvider,我们先从父类的authenticate方法入手(由于代码较多,这里简单阐述代码逻辑):

java
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
  // 第一步:从authentication中获取用户名
  // 第二步:通过UserDetailsService.loadUserByUsername(username)获取受信任的用户信息(这里就是在配置文件中配好的test用户)
  // 第三步,如果没有获取到用户信息,则直接抛异常,验证失败
  // 第四步,如果获取到了用户信息,则调用additionalAuthenticationChecks方法,将用户信息和前台提交的登录请求信息做验证
}

接下来看additionalAuthenticationChecks()的实现:

java
protected void additionalAuthenticationChecks(UserDetails userDetails,
    UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
  if (authentication.getCredentials() == null) {
    this.logger.debug("Failed to authenticate since no credentials provided");
    throw new BadCredentialsException(this.messages
      .getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
  }
  // 从前台提交的登录请求信息中获取密码(Credentials就是密码)
  String presentedPassword = authentication.getCredentials().toString();
  // 用密码编码器校验用户信息中的密码和提交的密码
  if (!this.passwordEncoder.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"));
  }
}

至此,校验成功则一步步回到AbstractAuthenticationProcessingFilterdoFilter()方法中,进行用户身份验证成功后的工作:

java
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
    Authentication authResult) throws IOException, ServletException {
  SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
  context.setAuthentication(authResult);
  this.securityContextHolderStrategy.setContext(context);
  this.securityContextRepository.saveContext(context, request, response);
  if (this.logger.isDebugEnabled()) {
    this.logger.debug(LogMessage.format("Set SecurityContextHolder to %s", authResult));
  }
  this.rememberMeServices.loginSuccess(request, response, authResult);
  if (this.eventPublisher != null) {
    this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));
  }
  this.successHandler.onAuthenticationSuccess(request, response, authResult);
}

我们主要关注3-6行的工作,将用户信息保存起来,供之后的过滤器和下次请求使用。

存在哪呢?在上面的案例中,是存在Session中的(HttpSessionSecurityContextRepository)。

存在Session中可以理解,是用于下次请求的,避免请求一次就要登录一次的窘境,但是,后续的过滤器是如何拿到登录用户信息的呢?答案是存到ThreadLocal中的。由于每个请求进来是一个线程处理的,所以过滤器是在同一个线程进行处理的,把登录信息存在ThreadLocal中,可以使得后续的过滤器拿到用户信息。

实现类:ThreadLocalSecurityContextHolderStrategy

经过上面的流程分析,感受到了面向对象设计中的单一职责原则,每个类只做一小部分事,其他事就调用其他类去做。这样做的好处事每个类的作用单一,代码少,可替换性强,但是缺点就是代码调用链长,逻辑不明显。

3. 流程图

虽然有上面的流程分析,但还是画一下流程图吧,看起来更清晰:

由Gemini生成的流程图,一字未改,太强了!

4. 下一次请求为什么不用登录

登录成功后,会在响应中带上JSESSIONID,下一次请求时会自动带上JSESSIONID,这样过滤器SecurityContextHolderFilter会根据JSESSIONID从SESSION中找到SecurityContext,SecurityContext包含了登录用户信息,所以系统判断该请求已经登录了。

SecurityContextHolderFilter拦截器:

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);
  Supplier<SecurityContext> deferredContext = this.securityContextRepository.loadDeferredContext(request);
  try {
    this.securityContextHolderStrategy.setDeferredContext(deferredContext);
    chain.doFilter(request, response);
  }
  finally {
    this.securityContextHolderStrategy.clearContext();
    request.removeAttribute(FILTER_APPLIED);
  }
}

HttpSessionSecurityContextRepository中的loadDeferredContext()方法:

java
public SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder) {
  HttpServletRequest request = requestResponseHolder.getRequest();
  HttpServletResponse response = requestResponseHolder.getResponse();
  HttpSession httpSession = request.getSession(false);
  SecurityContext context = readSecurityContextFromSession(httpSession);
  if (context == null) {
    context = generateNewContext();
  }
  // ...
  return context;
}