Appearance
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=demojava
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"));
}
}至此,校验成功则一步步回到AbstractAuthenticationProcessingFilter的doFilter()方法中,进行用户身份验证成功后的工作:
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;
}