Spring Security详解

简介

Spring Security是一个功能强大且高度可定制的身份验证和访问控制框架,专门为Java应用程序提供安全解决方案。主要提供身份验证和授权两大功能。在身份验证方面,它支持多种认证机制,如用户名密码认证、OAuth2、SAML等。在授权方面,它提供了基于角色的访问控制(RBAC)和访问控制列表(ACL)等功能。Spring Security还提供了一系列过滤器,用于拦截和检查进入应用程序的请求,确保只有经过身份验证和授权的用户才能访问受保护的资源。

Spring Security认证

环境搭建

创建一个SpringBoot项目,在pom.xml中添加springboot和security依赖

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<version>2.3.2.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>2.3.2.RELEASE</version>
</dependency>
<dependency>
<groupId>com.spring4all</groupId>
<artifactId>swagger-spring-boot-starter</artifactId>
<version>1.9.1.RELEASE</version>
</dependency>
</dependencies>

创建控制器类com.acaiblog.auth.controller.HelloController

package com.acaiblog.auth.controller;

import io.swagger.annotations.Api;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Api(tags = "test", description = "测试接口")
@RestController
@RequestMapping("/test")
public class HelloController {
@GetMapping("/hello")
public String hello() {
return "hello world";
}
}

创建SpringBoot启动类com.acaiblog.auth.HelloApplication

package com.acaiblog.auth;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class HelloApplication {
public static void main(String[] args) {
SpringApplication.run(HelloApplication.class, args);
System.out.println("http://127.0.0.1:8080/test/swagger-ui.html#");
}
}

浏览器访问:http://127.0.0.1:8080/test/hello 要求使用用户名和密码进行验证,验证通过之后才能访问接口;用户名:user,密码:在控制台日志Using generated security password

认证流程

  1. 客户端请求/test/hello接口,请求经过SecurityFilterChain
  2. FilterSecurityInterceptor过滤器中进行拦截,如果没有进行用户认证则会抛出AccessDeniedException异常
  3. 抛出的AccessDeniedException异常在ExceptionTranslationFilter中捕获,ExceptionTranslationFilter通过调用LoginUrlAuthenticationEntryPoint#commence方法给客户端返回302错误,要求客户端重定向到login页面
  4. 客户端发送login请求
  5. login请被DefaultLoginPageGenerationFilter过滤拦截,并在该过滤器中返回登录页面。所以当用户访问/test/hello接口时会看到login页面

认证原理

  1. SpringSecurity自动化配置开启后,会自动创建一个springSecurityFilterChain的过滤器并注入到Spring容器中。这个过滤器负责所有的安全管理,包括用户认证、授权、重定向到登录页面等。
  2. 创建一个UserDetailService实例,负责提供用户数据。用户数据保存在内存中,用户名为user,密码为随机生成的字符串。
  3. 给用户生成一个登录页面
  4. 开启CSRF攻击防御
  5. 开启会话固定攻击防御
  6. 集成X-Xss-Protection
  7. 集成X-Frame-Option防止单击劫持

默认认证用户

Spring SecurityUserDetails接口是Spring Security框架中的一个核心概念。它表示用户的详细信息,在身份验证过程中起着关键作用。当用户尝试进行身份验证时,Spring Security需要加载用户的详细信息,包括用户名、密码和权限(角色)。UserDetails接口提供了一种标准化的方式来封装这些信息。
源码如下所示:


package org.springframework.security.core.userdetails;

import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;

import java.io.Serializable;
import java.util.Collection;

public interface UserDetails extends Serializable {
Collection<? extends GrantedAuthority> getAuthorities();
String getPassword();
String getUsername();
boolean isAccountNonExpired();
boolean isAccountNonLocked();
boolean isCredentialsNonExpired();
boolean isEnabled();
}

源码方法描述:

方法 描述
getAuthorities 返回当前账户拥有的权限
getPassword 返回当前账户密码
getUsername 返回当前账户用户名
isAccountNonExpired 返回当前账户是否过期
isCredentialsNonExpired 返回当前账户凭证是否过期
isEnabled 返回当前账户是否被禁用

UserDetails接口是对用户对象的定义,而负责用户数据源的接口是UserDetailsService,源码如下:

package org.springframework.security.core.userdetails;

public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

在项目中一般需要开发者自定义UserDetailsService接口的实现,如果没有定义Spring Security也为UserDetailsService提供了默认实现,如图

  1. UserDetailsManagerUserDetailsService的基础上,定义了增、删、改、查用户和用户密码,以及判断用户是否存在的5种方法
  2. JdbcDaoImplUserDetailsService的基础上,通过spring-jdbc实现了从数据库查询用户的方法
  3. InMemoryUserDetailsManager实现了UserDetailsManager中用户增删改查的方法,不过都是基于内存操作数据并没有被持久化
  4. JdbcUserDetailsManager继承自JdbcDaoImpl同时又实现UserDetailsManager接口,因此可以通过JdbcUserDetailsManager实现对用户的增删改查,这些操作都会被持久化到数据库中。不过有个局限性就是操作数据库中用户的SQL需要提前写好,不够灵活。
  5. CachingUserDetailsService的特点是将UserDetailsService缓存起来

当使用Spring Security是,如果只引入Spring Security依赖默认使用的用户就是InMemoryUserDetailsManager提供的。Spring Boot之所以能够做到零配置就是因为它提供了众多的自动化配置类其中UserDetailsService自动化配置类是UserDetailsServiceAutoConfiguration,源码如下:

package org.springframework.boot.autoconfigure.security.servlet;

@Configuration(
proxyBeanMethods = false
)
@ConditionalOnClass({AuthenticationManager.class})
@ConditionalOnBean({ObjectPostProcessor.class})
@ConditionalOnMissingBean(
value = {AuthenticationManager.class, AuthenticationProvider.class, UserDetailsService.class},
type = {"org.springframework.security.oauth2.jwt.JwtDecoder", "org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector"}
)
public class UserDetailsServiceAutoConfiguration {
private static final String NOOP_PASSWORD_PREFIX = "{noop}";
private static final Pattern PASSWORD_ALGORITHM_PATTERN = Pattern.compile("^\\{.+}.*$");
private static final Log logger = LogFactory.getLog(UserDetailsServiceAutoConfiguration.class);

public UserDetailsServiceAutoConfiguration() {
}

@Bean
@ConditionalOnMissingBean(
type = {"org.springframework.security.oauth2.client.registration.ClientRegistrationRepository"}
)
@Lazy
public InMemoryUserDetailsManager inMemoryUserDetailsManager(SecurityProperties properties, ObjectProvider<PasswordEncoder> passwordEncoder) {
SecurityProperties.User user = properties.getUser();
List<String> roles = user.getRoles();
return new InMemoryUserDetailsManager(new UserDetails[]{User.withUsername(user.getName()).password(this.getOrDeducePassword(user, (PasswordEncoder)passwordEncoder.getIfAvailable())).roles(StringUtils.toStringArray(roles)).build()});
}

private String getOrDeducePassword(SecurityProperties.User user, PasswordEncoder encoder) {
String password = user.getPassword();
if (user.isPasswordGenerated()) {
logger.info(String.format("%n%nUsing generated security password: %s%n", user.getPassword()));
}

return encoder == null && !PASSWORD_ALGORITHM_PATTERN.matcher(password).matches() ? "{noop}" + password : password;
}
}

从上面源码可以看出用户名使用的是SecurityProperties类的getUser()方法获取用户,getUser()方法调用了User方法,User方法中定义了用户名和密码,参考SecurityProperties如下:

public class SecurityProperties {
public User getUser() {
return this.user;
}
public static class User {

/**
* Default user name.
*/
private String name = "user";

/**
* Password for the default user name.
*/
private String password = UUID.randomUUID().toString();
}
}

通过SecurityProperties源码可以了解到根据Spring Boot中properties的加载机制只要在项目resources目录下application.yaml配置文件中添加SecurityProperties.User类中的属性值,Spring Security就会使用配置文件中的配置

登录用户认证

根据上面源码分析,在项目resources目录下创建application.yml配置文件,重启项目测试使用acai/123用户和密码进行验证

spring:
security:
user:
name: acai
password: 123

获取登录用户数据

用户登录成功之后,用户的数据可以在SecurityContextHolder和当前请求对象中获取数据。无论哪种方式获取用户数据都离不开一个重要的对象Authentication,在Spring Security中Authentication对象主要有以下两个功能:

  1. 作为AuthenticationManager的输入参数,提供用户身份认证凭证,当它作为一个输入参数时,它的isAuthenticated方法返回false,表示用户还未认证
  2. 经过认证的用户此时的Authentication可以从SecurityContext中获取

Authentication对象包含以下三个重要的属性:

属性 描述
Principal 定义认证的用户,如果用户通过认证,返回UserDetails对象
Credentials 用户登录凭证,一般是用户密码。当用户登录成功后,登录凭证会被自动删除防止泄漏
Authorities 用户拥有的权限

Spring SecurityAUthentication对象中定义了用户登录信息,参考源码如下:


package org.springframework.security.core;

import java.io.Serializable;
import java.security.Principal;
import java.util.Collection;

import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.core.context.SecurityContextHolder;

public interface Authentication extends Principal, Serializable {

Collection<? extends GrantedAuthority> getAuthorities();

Object getCredentials();

Object getDetails();

Object getPrincipal();

boolean isAuthenticated();

void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}

Authentication方法描述如下:

方法 描述
getAuthorities 获取用户权限
getCredentials 获取用户凭证也就是用户密码
getDetails 获取用户详细信息
getPrincipal 获取用户信息
isAuthenticated 获取当前用户是否认证成功

SecurityContextHolder获取用户数据

编辑com.acaiblog.auth.controller.HelloController新增userInfo接口,获取认证用户和用户权限

package com.acaiblog.auth.controller;

import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Collection;

@Slf4j
@Api(tags = "test", description = "测试接口")
@RestController
@RequestMapping("/test")

public class HelloController {

@ApiOperation("用户信息接口")
@GetMapping("/user")
public void userInfo() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String name = authentication.getName();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
log.info("name: {}", name);
log.info("authorities: {}", authorities);
}
}

从上面代码可以看出获取用户数据是从SecurityContextHolder.getContext()静态方法获取到的。

SecurityContextHolder

SecurityContextHolder中存储的是SecurityContextSecurityContext中存储的则是AuthenticationSecurityContext中定义了三种不通的数据存储策略:

  1. MODE_THREADLOCAL: 将SecurityContext存在ThreadLocal中,ThreadLocal的特点是在哪个线程中存储就在哪个线程处理。是SecurityContext默认的策略,这种策略如果开启了子线程,然后通过子线程获取用户数据就会获取不到。
  2. MODE_INHERITABLETHREADLOCAL: 这种存储策略适用于多线程环境,如果你希望在子线程也能获取到用户数据,可以使用这种策略。
  3. MODE_GLOBAL: 这种策略是将数据存储到静态变量中,一般很少用到。

Spring Security中定义了SecurityContextHolderStrategy接口用来规范存储策略的方法,源码如下:


package org.springframework.security.core.context;

public interface SecurityContextHolderStrategy {

void clearContext();

SecurityContext getContext();

void setContext(SecurityContext context);

SecurityContext createEmptyContext();
}

这4个方法的作用:

方法 描述
clearContext() 清除当前线程中的SecurityContext
getContext() 获取当前线程中的SecurityContext
setContext(context) 将指定的SecurityContext设置为当前线程的上下文。
createEmptyContext() 创建一个空的SecurityContext对象,通常用于初始化上下文。

Spring SecuritySecurityContextHolderStrategy接口共有三个实现类,对应了三种不到的存储策略,如图:

ThreadLocalSecurityContextHolderStrategy策略

ThreadLocalSecurityContextHolderStrategy类实现了SecurityContextHolderStrategy接口,并且实现了接口中的方法。存储数据的载体ThreadLocal,所以SecurityContext中的数据操作都在ThreadLocal中实现的。源码如下:

package org.springframework.security.core.context;

import org.springframework.util.Assert;

final class ThreadLocalSecurityContextHolderStrategy implements SecurityContextHolderStrategy {
private static final ThreadLocal<SecurityContext> contextHolder = new ThreadLocal<>();

public void clearContext() {
contextHolder.remove();
}

public SecurityContext getContext() {
SecurityContext ctx = contextHolder.get();

if (ctx == null) {
ctx = createEmptyContext();
contextHolder.set(ctx);
}

return ctx;
}

public void setContext(SecurityContext context) {
Assert.notNull(context, "Only non-null SecurityContext instances are permitted");
contextHolder.set(context);
}

public SecurityContext createEmptyContext() {
return new SecurityContextImpl();
}
}

InheritableThreadLocalSecurityContextHolderStrategy策略

InheritableThreadLocalSecurityContextHolderStrategy和ThreadLocalSecurityContextHolderStrategy策略基本一样,不通的是载体不一样。ThreadLocalSecurityContextHolderStrategy使用的是ThreadLocal而InheritableThreadLocalSecurityContextHolderStrategy使用的是InheritableThreadLocal。InheritableThreadLocal又继承ThreadLocal,但是多了一个特性,在创建子线程的时候会把父线程数据复制到子线程,所以子线程可以获取到用户数据。

package org.springframework.security.core.context;

import org.springframework.util.Assert;

final class InheritableThreadLocalSecurityContextHolderStrategy implements
SecurityContextHolderStrategy {

private static final ThreadLocal<SecurityContext> contextHolder = new InheritableThreadLocal<>();

public void clearContext() {
contextHolder.remove();
}

public SecurityContext getContext() {
SecurityContext ctx = contextHolder.get();

if (ctx == null) {
ctx = createEmptyContext();
contextHolder.set(ctx);
}

return ctx;
}

public void setContext(SecurityContext context) {
Assert.notNull(context, "Only non-null SecurityContext instances are permitted");
contextHolder.set(context);
}

public SecurityContext createEmptyContext() {
return new SecurityContextImpl();
}
}

GlobalSecurityContextHolderStrategy策略

GlobalSecurityContextHolderStrategy使用静态变量来保存SecurityContext,所以子线程也可以获取到用户数据。

package org.springframework.security.core.context;

import org.springframework.util.Assert;

final class GlobalSecurityContextHolderStrategy implements SecurityContextHolderStrategy {

private static SecurityContext contextHolder;

public void clearContext() {
contextHolder = null;
}

public SecurityContext getContext() {
if (contextHolder == null) {
contextHolder = new SecurityContextImpl();
}

return contextHolder;
}

public void setContext(SecurityContext context) {
Assert.notNull(context, "Only non-null SecurityContext instances are permitted");
contextHolder = context;
}

public SecurityContext createEmptyContext() {
return new SecurityContextImpl();
}
}

测试子线程获取用户数据

编辑com.acaiblog.auth.controller.HelloController

package com.acaiblog.auth.controller;

import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Collection;

@Slf4j
@Api(tags = "test", description = "测试接口")
@RestController
@RequestMapping("/test")

public class HelloController {

@ApiOperation("用户信息接口")
@GetMapping("/user")
public void userInfo() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String name = authentication.getName();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
log.info("name: {}", name);
log.info("authorities: {}", authorities);
new Thread(new Runnable() {
@Override
public void run() {
Authentication authentication1 = SecurityContextHolder.getContext().getAuthentication();
if (authentication1 == null) {
log.error("获取用户数据失败");
return;
}
String name1 = authentication1.getName();
Collection<? extends GrantedAuthority> authorities1 = authentication1.getAuthorities();
String ThreadName = Thread.currentThread().getName();
log.info("ThreadName: {} name: {}", ThreadName, name1);
log.info("ThreadName: {} authorities: {}", ThreadName, authorities1);
}
}).start();
}
}

运行代码测试结果:

2024-02-01 15:00:55.199  INFO 34137 --- [nio-8080-exec-8] c.a.auth.controller.HelloController      : name: acai
2024-02-01 15:00:55.200 INFO 34137 --- [nio-8080-exec-8] c.a.auth.controller.HelloController : authorities: []
2024-02-01 15:00:55.205 ERROR 34137 --- [ Thread-3] c.a.auth.controller.HelloController : 获取用户数据失败

从上面示例可以看出,子线程之所以读取不到数据是因为SecurityContextHolder是将数据存储在ThreadLocal中,存储和读取不是一个线程所以获取不到。通过IDE工具设置VM option变量:

-Dspring.security.strategy=MODE_INHERITABLETHREADLOCAL

重启工程,发现子线程可以获取到用户数据

2024-02-01 15:16:12.537  INFO 39816 --- [nio-8080-exec-1] c.a.auth.controller.HelloController      : name: acai
2024-02-01 15:16:12.538 INFO 39816 --- [nio-8080-exec-1] c.a.auth.controller.HelloController : authorities: []
2024-02-01 15:16:12.544 INFO 39816 --- [ Thread-3] c.a.auth.controller.HelloController : ThreadName: Thread-3 name: acai
2024-02-01 15:16:12.546 INFO 39816 --- [ Thread-3] c.a.auth.controller.HelloController : ThreadName: Thread-3 authorities: []

SecurityContextPersistenceFilter

默认情况下,在Spring Security过滤链中,SecurityContextPersistenceFilter是第二道防线,位于WebAsyncManagerIntegrationFilter之后。从SecurityContextPersistenceFilter这个Filter名字就可以看出来,它的作用是为了存储SecurityContext设计。SecurityContextPersistenceFilter主要处理以下两件事情:

  1. 当收到请求时,从HttpSession中获取SecurityContext并存入SecurityContextHolder中。所以可以通过SecurityContextHolder获取到当前用户的登录信息
  2. 当请求处理完时,从SecurityContextHolder中获取SecurityContext并存HttpSession中。方便下次使用再次从HttpSession中拿出,同时删除SecurityContextHolder中用户的登录信息

请求对象获取用户数据

从当前请求对象获取用户数据可以从以下两种方式实现,编辑com.acaiblog.auth.controller.HelloController,添加以下代码:

package com.acaiblog.auth.controller;

import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.security.Principal;
import java.util.Collection;

@Slf4j
@Api(tags = "test", description = "测试接口")
@RestController
@RequestMapping("/test")

public class HelloController {

@ApiOperation("从当前请求对象获取authentication")
@GetMapping("/authentication")
public void authentication(Authentication authentication) {
log.info("authentication: {}", authentication);
}

@ApiOperation("从当前请求对象获取principal")
@GetMapping("/principal")
public void principal(Principal principal) {
log.info("principal: {}", principal);
}
}

用户定义

基于内存

通过自定义WebSecurityConfigurerAdapter实现基于内存的用户定义

package com.acaiblog.config;

import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@Slf4j
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
log.info("manager: {}", manager);
manager.createUser(User.withUsername("aaa").password("{noop}123").roles("admin").build());
auth.userDetailsService(manager);
}
}

首先构造了一个InMemoryUserDetailsManager实例,调用该实例的createUser方法来创建用户对象。
InMemoryUserDetailsManager类继承UserDetailsManagerUserDetailsManager继承UserDetailsService接口,通过重写了loadUserByUsername方法实现用户的创建

基于JdbcUserDetailsManager

JdbcUserDetailsManager支持将用户数据持久化到数据库中,同时封装了对用户增删改查的方法。Spring Security框架为JdbcUserDetailsManager提供了数据库脚本,脚本位置:org/springframework/security/spring-security-core/5.3.3.RELEASE/spring-security-core-5.3.3.RELEASE.jar!/org/springframework/security/core/userdetails/jdbc/users.ddl
SQL脚本是针对HSQLDB,所以需要将varchar_ignorecase修改为varchar,修改后的SQL脚本如下所示,并在数据库中运行SQL脚本

create table users(username varchar(50) not null primary key,password varchar(500) not null,enabled boolean not null);
create table authorities (username varchar(50) not null,authority varchar(50) not null,constraint fk_authorities_users foreign key(username) references users(username));
create unique index ix_auth_username on authorities (username,authority);

在pom.xml文件中添加数据库依赖

<dependencies>
<!-- Spring Boot Starter for JDBC -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
<version>2.3.2.RELEASE</version>
</dependency>

<!-- MariaDB Connector/J -->
<dependency>
<groupId>org.mariadb.jdbc</groupId>
<artifactId>mariadb-java-client</artifactId>
<version>3.3.0</version> <!-- 根据需要替换为最新版本 -->
</dependency>
</dependencies>

在resources目录下application.yml配置文件中添加数据库配置

spring:
datasource:
url: jdbc:mariadb://localhost:3306/spring_security?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
username: root
password: 123456
driver-class-name: org.mariadb.jdbc.Driver

编辑com.acaiblog.config.SecurityConfig重写WebSecurityConfigurerAdapter类实现持久化用户数据到数据库中

package com.acaiblog.config;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.provisioning.JdbcUserDetailsManager;

import javax.sql.DataSource;

@Slf4j
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
DataSource dataSource;

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
JdbcUserDetailsManager manager = new JdbcUserDetailsManager(dataSource);
log.info("manager: {}", manager);
if (!manager.userExists("acaiblog")) {
manager.createUser(User.withUsername("acaiblog").password("{noop}123").roles("admin").build());
}
auth.userDetailsService(manager);
}
}

重启项目,mysql中会创建用户数据。使用用户和密码访问接口:http://127.0.0.1:8080/swagger-ui.html#

基于MyBatis

配置数据库

创建数据库表,表结构如下:

create table user (
id int(11),
username varchar(32),
password varchar(255),
enabled tinyint(1),
accountNonExpired tinyint(1),
accountNonLocked tinyint(1),
credentialsNonExpired tinyint(1)
);
create table role (
id int(11),
name varchar(32),
nameZh varchar(32)
);
create table user_role (
id int(11),
uid int(11),
rid int(11)
)

在数据库表中插入数据

INSERT INTO `spring_security`.`role`(`id`, `name`, `nameZh`) VALUES (1, 'dba', '数据库管理员');
INSERT INTO `spring_security`.`role`(`id`, `name`, `nameZh`) VALUES (2, 'admin', '系统管理员');
INSERT INTO `spring_security`.`role`(`id`, `name`, `nameZh`) VALUES (3, 'user', '用户');
INSERT INTO `spring_security`.`user`(`id`, `username`, `password`, `enabled`, `accountNonExpired`, `accountNonLocked`, `credentialsNonExpired`) VALUES (1, 'root', '{noop}123', 1, 1, 1, 1);
INSERT INTO `spring_security`.`user`(`id`, `username`, `password`, `enabled`, `accountNonExpired`, `accountNonLocked`, `credentialsNonExpired`) VALUES (2, 'admin', '{noop}123', 1, 1, 1, 1);
INSERT INTO `spring_security`.`user`(`id`, `username`, `password`, `enabled`, `accountNonExpired`, `accountNonLocked`, `credentialsNonExpired`) VALUES (3, 'acai', '{noop}123', 1, 1, 1, 1);
INSERT INTO `spring_security`.`user_role`(`id`, `uid`, `rid`) VALUES (1, 1, 1);
INSERT INTO `spring_security`.`user_role`(`id`, `uid`, `rid`) VALUES (2, 1, 2);
INSERT INTO `spring_security`.`user_role`(`id`, `uid`, `rid`) VALUES (3, 2, 2);
INSERT INTO `spring_security`.`user_role`(`id`, `uid`, `rid`) VALUES (4, 3, 3);

在pom.xml文件中添加mybatis依赖

<dependencies>
<!-- Spring Boot Starter for JDBC -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
<version>2.3.2.RELEASE</version>
</dependency>

<!-- MyBatis Starter for Spring Boot -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.2.0</version> <!-- 根据需要替换为最新版本 -->
</dependency>

<!-- MariaDB Connector/J -->
<dependency>
<groupId>org.mariadb.jdbc</groupId>
<artifactId>mariadb-java-client</artifactId>
<version>3.3.0</version> <!-- 根据需要替换为最新版本 -->
</dependency>
</dependencies>

在resources目录下application.yml配置文件中添加数据库源

spring:
datasource:
url: jdbc:mariadb://localhost:3306/spring_security?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
username: root
password: 123456
driver-class-name: org.mariadb.jdbc.Driver

实体类

创建角色实体类com.acaiblog.entities.Role

package com.acaiblog.entities;

public class Role {
private Integer id;
private String name;
private String nameZh;

public String getName() {
return name;
}
}

创建用户实体类com.acaiblog.entities.User

package com.acaiblog.entities;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;

public class User implements UserDetails {
private Integer id;
private String username;
private String password;
private Boolean enabled;
private Boolean accountNonExpired;
private Boolean accountNonLocked;
private Boolean credentialsNonExpired;
private List<Role> roles = new ArrayList<>();

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
for (Role role : roles) {
authorities.add(new SimpleGrantedAuthority(role.getName()));
}
return authorities;
}

@Override
public String getPassword() {
return this.password;
}

@Override
public String getUsername() {
return this.username;
}

@Override
public boolean isAccountNonExpired() {
return this.accountNonExpired;
}

@Override
public boolean isAccountNonLocked() {
return this.accountNonLocked;
}

@Override
public boolean isCredentialsNonExpired() {
return this.credentialsNonExpired;
}

@Override
public boolean isEnabled() {
return this.enabled;
}

public void setRoles(List<Role> roles) {
this.roles = roles;
}

public Integer getId() {
return id;
}
}

数据访问层

创建数据访问层com.acaiblog.mapper.UserMapper定义UserMapper接口

package com.acaiblog.mapper;

import com.acaiblog.entities.Role;
import com.acaiblog.entities.User;
import org.apache.ibatis.annotations.Mapper;

import java.util.List;

@Mapper
public interface UserMapper {
List<Role> getRolesByUid(Integer id);
User loadUserByUsername(String username);
}

创建SQL映射配置文件com/acaiblog/mapper/xml/UserMapper.xml

<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.acaiblog.mapper.UserMapper">
<select id="loadUserByUsername" resultType="com.acaiblog.entities.User">
select * from user where username=#{username};
</select>
<select id="getRolesByUid" resultType="com.acaiblog.entities.Role">
select r.* from role r,user_role ur where r.`id`=ur.`rid`
</select>
</mapper>

为了防止Maven打包时忽略了XML文件,在pom.xml中添加如下配置

<build>
<resources>
<resource>
<directory>src/main/java</directory>
<includes>
<include>**/*.xml</include>
</includes>
</resource>
<resource>
<directory>src/main/resources</directory>
</resource>
</resources>
</build>

服务层

创建业务层com.acaiblog.services.UserService

package com.acaiblog.services;

import com.acaiblog.entities.User;
import com.acaiblog.mapper.UserMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
public class UserService implements UserDetailsService {
@Autowired
UserMapper userMapper;

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userMapper.loadUserByUsername(username);
if (user == null) {
throw new UsernameNotFoundException("用户不存在");
}
user.setRoles(userMapper.getRolesByUid(user.getId()));
return user;
}
}

重构SecurityConfig

编辑com.acaiblog.config.SecurityConfig并注入UserDetailsService

package com.acaiblog.config;

import com.acaiblog.services.UserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.provisioning.JdbcUserDetailsManager;

import javax.sql.DataSource;

@Slf4j
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
DataSource dataSource;

@Autowired
UserService userService;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userService);
}

@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests();
}
}

启动项目,使用数据库用户进行登录认证。

认证流程分析

登录流程分析

要了解Spring Security的认证流程,先要了解以下几个基本组件:AuthenticationManagerProviderManagerAuthenticationProvider以及认证过滤器AbstractAuthenticationProcessingFilter

AuthenticationManager

AuthenticationManager是一个认证管理器,定义了Spring Security过滤器要如何执行认证操作。AuthenticationManager在认证成功之后会返回一个Authentication对象,Authentication对象会被设置到SecurityContextHolder中。如果需要自定义认证流程需要手动将Authentication存储SecurityContextHolder中,AuthenticationManager源码如下:

package org.springframework.security.authentication;

import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;

public interface AuthenticationManager {

Authentication authenticate(Authentication authentication)
throws AuthenticationException;
}

AuthenticationManager是一个接口,可以自定义AuthenticationManager的实现类。在Spring Security框架中默认使用的是ProviderManager

AuthenticationProvider

Spring Security支持多种不同的认证方式,不同的认证方式对应不同的身份类型。AuthenticationProvider就是针对不同的身份类型执行具体的身份认证,比如:DaoAuthenticationProvider用来支持用户名和密码认证、RememberMeAuthenticationProvider用来支持记住密码的认证。AuthenticationProvider源码如下:

package org.springframework.security.authentication;

import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;

public interface AuthenticationProvider {
Authentication authenticate(Authentication authentication)
throws AuthenticationException;

boolean supports(Class<?> authentication);
}

AuthenticationProvider提供的方法描述:

方法 描述
authenticate 用来执行具体的身份认证
supports 用来判断当前AuthenticationProvider是否支持当前的认证类型

当使用用户名和密码登录认证时,AuthenticationProvider的实现类是DaoAuthenticationProvider,而DaoAuthenticationManager继承自AbstractUserDetailsAuthenticationProvider并且没有重写authenticate方法,所以具体的认证逻辑在AbstractUserDetailsAuthenticationProvider的authenticate方法中。AbstractUserDetailsAuthenticationProvider源码如下:

package org.springframework.security.authentication.dao;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.security.authentication.AccountExpiredException;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.CredentialsExpiredException;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.SpringSecurityMessageSource;
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
import org.springframework.security.core.authority.mapping.NullAuthoritiesMapper;
import org.springframework.security.core.userdetails.UserCache;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsChecker;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.core.userdetails.cache.NullUserCache;

import org.springframework.beans.factory.InitializingBean;

import org.springframework.context.MessageSource;
import org.springframework.context.MessageSourceAware;
import org.springframework.context.support.MessageSourceAccessor;

import org.springframework.util.Assert;

public abstract class AbstractUserDetailsAuthenticationProvider implements
AuthenticationProvider, InitializingBean, MessageSourceAware {

protected final Log logger = LogFactory.getLog(getClass());

protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();
private UserCache userCache = new NullUserCache();
private boolean forcePrincipalAsString = false;
protected boolean hideUserNotFoundExceptions = true;
private UserDetailsChecker preAuthenticationChecks = new DefaultPreAuthenticationChecks();
private UserDetailsChecker postAuthenticationChecks = new DefaultPostAuthenticationChecks();
private GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper();

protected abstract void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException;

public final void afterPropertiesSet() throws Exception {
Assert.notNull(this.userCache, "A user cache must be set");
Assert.notNull(this.messages, "A message source must be set");
doAfterPropertiesSet();
}

public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
() -> messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.onlySupports",
"Only UsernamePasswordAuthenticationToken is supported"));

// Determine username
String username = (authentication.getPrincipal() == null) ? "NONE_PROVIDED"
: authentication.getName();

boolean cacheWasUsed = true;
UserDetails user = this.userCache.getUserFromCache(username);

if (user == null) {
cacheWasUsed = false;

try {
user = retrieveUser(username,
(UsernamePasswordAuthenticationToken) authentication);
}
catch (UsernameNotFoundException notFound) {
logger.debug("User '" + username + "' not found");

if (hideUserNotFoundExceptions) {
throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}
else {
throw notFound;
}
}

Assert.notNull(user,
"retrieveUser returned null - a violation of the interface contract");
}

try {
preAuthenticationChecks.check(user);
additionalAuthenticationChecks(user,
(UsernamePasswordAuthenticationToken) authentication);
}
catch (AuthenticationException exception) {
if (cacheWasUsed) {
// There was a problem, so try again after checking
// we're using latest data (i.e. not from the cache)
cacheWasUsed = false;
user = retrieveUser(username,
(UsernamePasswordAuthenticationToken) authentication);
preAuthenticationChecks.check(user);
additionalAuthenticationChecks(user,
(UsernamePasswordAuthenticationToken) authentication);
}
else {
throw exception;
}
}

postAuthenticationChecks.check(user);

if (!cacheWasUsed) {
this.userCache.putUserInCache(user);
}

Object principalToReturn = user;

if (forcePrincipalAsString) {
principalToReturn = user.getUsername();
}

return createSuccessAuthentication(principalToReturn, authentication, user);
}

protected Authentication createSuccessAuthentication(Object principal,
Authentication authentication, UserDetails user) {
UsernamePasswordAuthenticationToken result = new UsernamePasswordAuthenticationToken(
principal, authentication.getCredentials(),
authoritiesMapper.mapAuthorities(user.getAuthorities()));
result.setDetails(authentication.getDetails());

return result;
}

protected void doAfterPropertiesSet() throws Exception {
}

public UserCache getUserCache() {
return userCache;
}

public boolean isForcePrincipalAsString() {
return forcePrincipalAsString;
}

public boolean isHideUserNotFoundExceptions() {
return hideUserNotFoundExceptions;
}

protected abstract UserDetails retrieveUser(String username,
UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException;

public void setForcePrincipalAsString(boolean forcePrincipalAsString) {
this.forcePrincipalAsString = forcePrincipalAsString;
}

public void setHideUserNotFoundExceptions(boolean hideUserNotFoundExceptions) {
this.hideUserNotFoundExceptions = hideUserNotFoundExceptions;
}

public void setMessageSource(MessageSource messageSource) {
this.messages = new MessageSourceAccessor(messageSource);
}

public void setUserCache(UserCache userCache) {
this.userCache = userCache;
}

public boolean supports(Class<?> authentication) {
return (UsernamePasswordAuthenticationToken.class
.isAssignableFrom(authentication));
}

protected UserDetailsChecker getPreAuthenticationChecks() {
return preAuthenticationChecks;
}

public void setPreAuthenticationChecks(UserDetailsChecker preAuthenticationChecks) {
this.preAuthenticationChecks = preAuthenticationChecks;
}

protected UserDetailsChecker getPostAuthenticationChecks() {
return postAuthenticationChecks;
}

public void setPostAuthenticationChecks(UserDetailsChecker postAuthenticationChecks) {
this.postAuthenticationChecks = postAuthenticationChecks;
}

public void setAuthoritiesMapper(GrantedAuthoritiesMapper authoritiesMapper) {
this.authoritiesMapper = authoritiesMapper;
}

private class DefaultPreAuthenticationChecks implements UserDetailsChecker {
public void check(UserDetails user) {
if (!user.isAccountNonLocked()) {
logger.debug("User account is locked");

throw new LockedException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.locked",
"User account is locked"));
}

if (!user.isEnabled()) {
logger.debug("User account is disabled");

throw new DisabledException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.disabled",
"User is disabled"));
}

if (!user.isAccountNonExpired()) {
logger.debug("User account is expired");

throw new AccountExpiredException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.expired",
"User account has expired"));
}
}
}

private class DefaultPostAuthenticationChecks implements UserDetailsChecker {
public void check(UserDetails user) {
if (!user.isCredentialsNonExpired()) {
logger.debug("User account credentials have expired");

throw new CredentialsExpiredException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.credentialsExpired",
"User credentials have expired"));
}
}
}
}

AbstractUserDetailsAuthenticationProvider是一个抽象类,它的具体实现在DaoAuthenticationProvider类中。AbstractUserDetailsAuthenticationProvider实现流程如下:

  1. 声明一个用户缓存对象userCache
  2. hideUserNotFoundExceptions表示是否不显示用户名查找失败的异常,默认为true。
  3. forcePrincipalAsString表示是否强制将Principal对象当成字符串来处理,默认为false。Authentication的principal属性是一个Object,如果forcePrincipalAsString的值设置为true,则Authentication的principal的属性返回当前登录的用户名。
  4. preAuthenticationChecks对象检查用户状态,如:用户状态是否正常、账户是否被锁定、账户是否可用、账户是否过期等。
  5. postAuthenticationChecks主要检查用户认证通过之后密码是否过期。
  6. additionalAuthenticationChecks是一个抽象方法,主要校验密码是否正确。具体的实现在DaoAuthenticationProvider中。
  7. authenticate方法是核心校验方法,先从数据中获取用户名,然后根据用户名去缓存查询用户对象。如果查询不到根据用户名调用retrieveUser方法从数据库加载用户,如果没有加载到用户抛出异常(用户不存在异常会被隐藏)。获取到用户对象之后先调用preAuthenticationChecks.check方法进行用户状态检查,然后调用additionalAuthenticationChecks方法进行用户密码校验,最后调用postAuthenticationChecks.check方法检查密码是否过期。所有步骤完成之后调用createSuccessAuthentication方法创建一个认证后的UsernamePasswordAuthenticationToken对象并返回,认证后的对象包含认证主体、凭证以及用户角色等。

由于AbstractUserDetailsAuthenticationProvider有几个抽象方法是在DaoAuthenticationProvider中实现的,DaoAuthenticationProvider的源码如下:

package org.springframework.security.authentication.dao;

import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.InternalAuthenticationServiceException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.core.userdetails.UserDetailsPasswordService;
import org.springframework.util.Assert;

public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {

private static final String USER_NOT_FOUND_PASSWORD = "userNotFoundPassword";

private PasswordEncoder passwordEncoder;

private volatile String userNotFoundEncodedPassword;

private UserDetailsService userDetailsService;

private UserDetailsPasswordService userDetailsPasswordService;

public DaoAuthenticationProvider() {
setPasswordEncoder(PasswordEncoderFactories.createDelegatingPasswordEncoder());
}

@SuppressWarnings("deprecation")
protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
if (authentication.getCredentials() == null) {
logger.debug("Authentication failed: no credentials provided");

throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}

String presentedPassword = authentication.getCredentials().toString();

if (!passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
logger.debug("Authentication failed: password does not match stored value");

throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}
}

protected void doAfterPropertiesSet() {
Assert.notNull(this.userDetailsService, "A UserDetailsService must be set");
}

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;
}
catch (InternalAuthenticationServiceException ex) {
throw ex;
}
catch (Exception ex) {
throw new InternalAuthenticationServiceException(ex.getMessage(), ex);
}
}

@Override
protected Authentication createSuccessAuthentication(Object principal,
Authentication authentication, UserDetails user) {
boolean upgradeEncoding = this.userDetailsPasswordService != null
&& this.passwordEncoder.upgradeEncoding(user.getPassword());
if (upgradeEncoding) {
String presentedPassword = authentication.getCredentials().toString();
String newPassword = this.passwordEncoder.encode(presentedPassword);
user = this.userDetailsPasswordService.updatePassword(user, newPassword);
}
return super.createSuccessAuthentication(principal, authentication, user);
}

private void prepareTimingAttackProtection() {
if (this.userNotFoundEncodedPassword == null) {
this.userNotFoundEncodedPassword = this.passwordEncoder.encode(USER_NOT_FOUND_PASSWORD);
}
}

private void mitigateAgainstTimingAttack(UsernamePasswordAuthenticationToken authentication) {
if (authentication.getCredentials() != null) {
String presentedPassword = authentication.getCredentials().toString();
this.passwordEncoder.matches(presentedPassword, this.userNotFoundEncodedPassword);
}
}

public void setPasswordEncoder(PasswordEncoder passwordEncoder) {
Assert.notNull(passwordEncoder, "passwordEncoder cannot be null");
this.passwordEncoder = passwordEncoder;
this.userNotFoundEncodedPassword = null;
}

protected PasswordEncoder getPasswordEncoder() {
return passwordEncoder;
}

public void setUserDetailsService(UserDetailsService userDetailsService) {
this.userDetailsService = userDetailsService;
}

protected UserDetailsService getUserDetailsService() {
return userDetailsService;
}

public void setUserDetailsPasswordService(
UserDetailsPasswordService userDetailsPasswordService) {
this.userDetailsPasswordService = userDetailsPasswordService;
}
}

DaoAuthenticationProvider流程如下:

  1. 首先定义USER_NOT_FOUND_PASSWORD常量,用来保存默认密码加密后的值。userDetailsService用来查找用户,userDetailsPasswordService用来修改密码。
  2. 在DaoAuthenticationProvider的构造方法中默认指定了PasswordEncoder,也可以通过set方法自定义PasswordEncoder。
  3. additionalAuthenticationChecks方法主要进行密码校验,第一个参数是userDetails是从数据库查询出来的用户对象,第二个参数authentication是登录用户输入的参数。从这两个参数分别提取出来密码,然后调用passwordEncoder.matches进行匹配。
  4. retrieveUser方法调用UserDetailsService#loadUserByUsername方法去数据库中查询。

ProviderManager

ProviderManager是AuthenticationManager的一个实现类。在Spring Security框架中,可能支持多种认证方式例如:用户密码认证、RememberMe认证、手机号码动态认证等。不同的认证方式对应不同的AuthenticationProvider,所以一个完整的认证流程可能由多个AuthenticationProvider提供。ProviderManager#authenticate方法源码如下所示:

package org.springframework.security.authentication;

import java.util.Arrays;
import java.util.Collections;
import java.util.List;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.context.MessageSource;
import org.springframework.context.MessageSourceAware;
import org.springframework.context.support.MessageSourceAccessor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.CredentialsContainer;
import org.springframework.security.core.SpringSecurityMessageSource;
import org.springframework.util.Assert;

public class ProviderManager implements AuthenticationManager, MessageSourceAware,
InitializingBean {

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;
boolean debug = logger.isDebugEnabled();

for (AuthenticationProvider provider : getProviders()) {
if (!provider.supports(toTest)) {
continue;
}

if (debug) {
logger.debug("Authentication attempt using "
+ provider.getClass().getName());
}

try {
result = provider.authenticate(authentication);

if (result != null) {
copyDetails(authentication, result);
break;
}
}
catch (AccountStatusException | InternalAuthenticationServiceException e) {
prepareException(e, authentication);
// SEC-546: Avoid polling additional providers if auth failure is due to
// invalid account status
throw e;
} catch (AuthenticationException e) {
lastException = e;
}
}

if (result == null && parent != null) {
// Allow the parent to try.
try {
result = parentResult = parent.authenticate(authentication);
}
catch (ProviderNotFoundException e) {
// ignore as we will throw below if no other exception occurred prior to
// calling parent and the parent
// may throw ProviderNotFound even though a provider in the child already
// handled the request
}
catch (AuthenticationException e) {
lastException = parentException = e;
}
}

if (result != null) {
if (eraseCredentialsAfterAuthentication
&& (result instanceof CredentialsContainer)) {
// Authentication is complete. Remove credentials and other secret data
// from authentication
((CredentialsContainer) result).eraseCredentials();
}

// If the parent AuthenticationManager was attempted and successful than it will publish an AuthenticationSuccessEvent
// This check prevents a duplicate AuthenticationSuccessEvent if the parent AuthenticationManager already published it
if (parentResult == null) {
eventPublisher.publishAuthenticationSuccess(result);
}
return result;
}

// Parent was null, or didn't authenticate (or throw an exception).

if (lastException == null) {
lastException = new ProviderNotFoundException(messages.getMessage(
"ProviderManager.providerNotFound",
new Object[] { toTest.getName() },
"No AuthenticationProvider found for {0}"));
}

// If the parent AuthenticationManager was attempted and failed than it will publish an AbstractAuthenticationFailureEvent
// This check prevents a duplicate AbstractAuthenticationFailureEvent if the parent AuthenticationManager already published it
if (parentException == null) {
prepareException(lastException, authentication);
}

throw lastException;
}
}

代码流程解析:

  1. 首先获取authentication对象
  2. 分别定义当前认证过程抛出的异常,parent中认证抛出的异常,当前认证结果以及parent中认证结果对应的变量。
  3. getProviders方法用来获取当前ProviderManager所代理所有AuthenticationProvider对象,遍历这些AuthenticationProvider对象进行身份认证。
  4. 判断当前AuthenticationProvider对象是否支持Authentication对象,如果不支持继续处理下一个AuthenticationProvider。
  5. 调用provider.authenticate方法进行身份认证,如果认证成功返回Authentication对象,同时调用copyDetails方法给Authentication对象的details属性进行赋值。由于可能调用多个AuthenticationProvider认证,如果抛出异常则通过lastException来记录。
  6. 在for循环执行完成之后,如果result还没有值,说明所有的AuthenticationProvider都认证失败,此时如果parent不为空,则调用parent的authenticate方法进行认证。

AbstractAuthenticationProcessingFilter

AbstractAuthenticationProcessingFilter用来处理任何提交给它的身份认证。如果使用用户名密码登录认证,那么它对应的实现类是UsernamePasswordAuthenticationFilter构造出来的Authentcation对象则是UsernamePasswordAuthenticationToken。根据之前的流程总结为认证流程如下:

  1. 用户登录时,UsernamePasswordAuthenticationFilter会从当前请求HttpServletRequest中提取出用户名和密码,然后创建一个UsernamePasswordAuthenticationToken对象。
  2. UsernamePasswordAuthenticationToken对象将被传入ProviderManager中进行具体认证操作。
  3. 如果认证失败,则SecurityContextHolder中相关信息被删除,登录失败也会被调用。
  4. 如果认证成功,则会进行登录信息存储、Session并发处理、登录成功事件发布以及登录成功方法回调等操作。

AbstractAuthenticationProcessingFilter如下所示:

package org.springframework.security.web.authentication;

import java.io.IOException;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.ApplicationEventPublisherAware;
import org.springframework.context.MessageSource;
import org.springframework.context.MessageSourceAware;
import org.springframework.context.support.MessageSourceAccessor;
import org.springframework.security.authentication.AuthenticationDetailsSource;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.InternalAuthenticationServiceException;
import org.springframework.security.authentication.event.InteractiveAuthenticationSuccessEvent;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.SpringSecurityMessageSource;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.session.NullAuthenticatedSessionStrategy;
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.util.Assert;
import org.springframework.web.filter.GenericFilterBean;

public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean implements ApplicationEventPublisherAware, MessageSourceAware {
protected ApplicationEventPublisher eventPublisher;
protected AuthenticationDetailsSource<HttpServletRequest, ?> authenticationDetailsSource = new WebAuthenticationDetailsSource();
private AuthenticationManager authenticationManager;
protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();
private RememberMeServices rememberMeServices = new NullRememberMeServices();
private RequestMatcher requiresAuthenticationRequestMatcher;
private boolean continueChainBeforeSuccessfulAuthentication = false;
private SessionAuthenticationStrategy sessionStrategy = new NullAuthenticatedSessionStrategy();
private boolean allowSessionCreation = true;
private AuthenticationSuccessHandler successHandler = new SavedRequestAwareAuthenticationSuccessHandler();
private AuthenticationFailureHandler failureHandler = new SimpleUrlAuthenticationFailureHandler();

protected AbstractAuthenticationProcessingFilter(String defaultFilterProcessesUrl) {
this.setFilterProcessesUrl(defaultFilterProcessesUrl);
}

protected AbstractAuthenticationProcessingFilter(RequestMatcher requiresAuthenticationRequestMatcher) {
Assert.notNull(requiresAuthenticationRequestMatcher, "requiresAuthenticationRequestMatcher cannot be null");
this.requiresAuthenticationRequestMatcher = requiresAuthenticationRequestMatcher;
}

public void afterPropertiesSet() {
Assert.notNull(this.authenticationManager, "authenticationManager must be specified");
}

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest)req;
HttpServletResponse response = (HttpServletResponse)res;
if (!this.requiresAuthentication(request, response)) {
chain.doFilter(request, response);
} else {
if (this.logger.isDebugEnabled()) {
this.logger.debug("Request is to process authentication");
}

Authentication authResult;
try {
authResult = this.attemptAuthentication(request, response);
if (authResult == null) {
return;
}

this.sessionStrategy.onAuthentication(authResult, request, response);
} catch (InternalAuthenticationServiceException var8) {
this.logger.error("An internal error occurred while trying to authenticate the user.", var8);
this.unsuccessfulAuthentication(request, response, var8);
return;
} catch (AuthenticationException var9) {
this.unsuccessfulAuthentication(request, response, var9);
return;
}

if (this.continueChainBeforeSuccessfulAuthentication) {
chain.doFilter(request, response);
}

this.successfulAuthentication(request, response, chain, authResult);
}
}

protected boolean requiresAuthentication(HttpServletRequest request, HttpServletResponse response) {
return this.requiresAuthenticationRequestMatcher.matches(request);
}

public abstract Authentication attemptAuthentication(HttpServletRequest var1, HttpServletResponse var2) throws AuthenticationException, IOException, ServletException;

protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
if (this.logger.isDebugEnabled()) {
this.logger.debug("Authentication success. Updating SecurityContextHolder to contain: " + authResult);
}

SecurityContextHolder.getContext().setAuthentication(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);
}

protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
SecurityContextHolder.clearContext();
if (this.logger.isDebugEnabled()) {
this.logger.debug("Authentication request failed: " + failed.toString(), failed);
this.logger.debug("Updated SecurityContextHolder to contain null Authentication");
this.logger.debug("Delegating to authentication failure handler " + this.failureHandler);
}

this.rememberMeServices.loginFail(request, response);
this.failureHandler.onAuthenticationFailure(request, response, failed);
}

protected AuthenticationManager getAuthenticationManager() {
return this.authenticationManager;
}

public void setAuthenticationManager(AuthenticationManager authenticationManager) {
this.authenticationManager = authenticationManager;
}

public void setFilterProcessesUrl(String filterProcessesUrl) {
this.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher(filterProcessesUrl));
}

public final void setRequiresAuthenticationRequestMatcher(RequestMatcher requestMatcher) {
Assert.notNull(requestMatcher, "requestMatcher cannot be null");
this.requiresAuthenticationRequestMatcher = requestMatcher;
}

public RememberMeServices getRememberMeServices() {
return this.rememberMeServices;
}

public void setRememberMeServices(RememberMeServices rememberMeServices) {
Assert.notNull(rememberMeServices, "rememberMeServices cannot be null");
this.rememberMeServices = rememberMeServices;
}

public void setContinueChainBeforeSuccessfulAuthentication(boolean continueChainBeforeSuccessfulAuthentication) {
this.continueChainBeforeSuccessfulAuthentication = continueChainBeforeSuccessfulAuthentication;
}

public void setApplicationEventPublisher(ApplicationEventPublisher eventPublisher) {
this.eventPublisher = eventPublisher;
}

public void setAuthenticationDetailsSource(AuthenticationDetailsSource<HttpServletRequest, ?> authenticationDetailsSource) {
Assert.notNull(authenticationDetailsSource, "AuthenticationDetailsSource required");
this.authenticationDetailsSource = authenticationDetailsSource;
}

public void setMessageSource(MessageSource messageSource) {
this.messages = new MessageSourceAccessor(messageSource);
}

protected boolean getAllowSessionCreation() {
return this.allowSessionCreation;
}

public void setAllowSessionCreation(boolean allowSessionCreation) {
this.allowSessionCreation = allowSessionCreation;
}

public void setSessionAuthenticationStrategy(SessionAuthenticationStrategy sessionStrategy) {
this.sessionStrategy = sessionStrategy;
}

public void setAuthenticationSuccessHandler(AuthenticationSuccessHandler successHandler) {
Assert.notNull(successHandler, "successHandler cannot be null");
this.successHandler = successHandler;
}

public void setAuthenticationFailureHandler(AuthenticationFailureHandler failureHandler) {
Assert.notNull(failureHandler, "failureHandler cannot be null");
this.failureHandler = failureHandler;
}

protected AuthenticationSuccessHandler getSuccessHandler() {
return this.successHandler;
}

protected AuthenticationFailureHandler getFailureHandler() {
return this.failureHandler;
}
}
  1. 首先通过requiresAuthentication方法来判断当前请求是不是登录认证请求,如果是认证请求则执行下面的认证代码,如果不是则继续执行剩余的过滤器。
  2. 调用attemptAuthentication方法来获取经过认证后的Authentication对象,attemptAuthentication是一个抽象方法具体的实现在它的子类UsernamePasswordAuthenticationFilter中。
  3. 认证成功后通过sessionStrategy.onAuthentication方法来处理session并发问题。
  4. continueChainBeforeSuccessfulAuthentication变量用来判断请求是否继续执行,变量值默认为false。如果认证成功之后,后面的过滤器不会继续执行。
  5. unsuccessfulAuthentication方法用来处理认证失败的事情,主要包含:从SecurityContextHolder中删除数据、删除Cookie、调用认证失败的回调方法。
  6. successfulAuthentication方法用来处理认证成功的事情,主要包含:向SecurityContextHolder保存用户数据、处理Cookie、发布认证成功的事件、调用认证成功饿回调方法。

UsernamePasswordAuthenticationFilter源码如下:

package org.springframework.security.web.authentication;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.lang.Nullable;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.util.Assert;

public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";
public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";
private String usernameParameter = "username";
private String passwordParameter = "password";
private boolean postOnly = true;

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

public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
if (this.postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
} else {
String username = this.obtainUsername(request);
String password = this.obtainPassword(request);
if (username == null) {
username = "";
}

if (password == null) {
password = "";
}

username = username.trim();
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
this.setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
}

@Nullable
protected String obtainPassword(HttpServletRequest request) {
return request.getParameter(this.passwordParameter);
}

@Nullable
protected String obtainUsername(HttpServletRequest request) {
return request.getParameter(this.usernameParameter);
}

protected void setDetails(HttpServletRequest request, UsernamePasswordAuthenticationToken authRequest) {
authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
}

public void setUsernameParameter(String usernameParameter) {
Assert.hasText(usernameParameter, "Username parameter must not be empty or null");
this.usernameParameter = usernameParameter;
}

public void setPasswordParameter(String passwordParameter) {
Assert.hasText(passwordParameter, "Password parameter must not be empty or null");
this.passwordParameter = passwordParameter;
}

public void setPostOnly(boolean postOnly) {
this.postOnly = postOnly;
}

public final String getUsernameParameter() {
return this.usernameParameter;
}

public final String getPasswordParameter() {
return this.passwordParameter;
}
}
  1. 首先默认情况下声明了用户名和密码字段,这两个字段也可以自定义比如在SecurityConfig中定义的username和password字段。
  2. 在UsernamePasswordAuthenticationFilter过滤器构建的时候指定了当前过滤器只处理登录请求,默认登录请求是/login,也可以自定义登录请求路径。
  3. 在attemptAuthentication方法中首先确认请求是POST类型,然后通过obtainUsername和obtainPassword方法分别获取用户名和密码,具体的获取方式是调用了request.getParameter方法。获取到用户名和密码之后构造出一个authRequest,调用getAuthenticationManager().authenticate方法进行认证。然后和进入ProviderManager认证流程。

密码加密

PasswordEncoder

在Spring Security框架中通过PasswordEncoder接口定义了密码加密和密码对比操作,源码如下:

package org.springframework.security.crypto.password;

public interface PasswordEncoder {

String encode(CharSequence rawPassword);

boolean matches(CharSequence rawPassword, String encodedPassword);

default boolean upgradeEncoding(String encodedPassword) {
return false;
}
}

PasswordEncoder接口定义了三个方法:

方法 描述
encode 用来对明文密码加密
matches 用来对比密码是否匹配
upgradeEncoding 用来判断当前密码是否需要升级,默认返回false

PasswordEncoder常见实现类

类名 描述
BCryptPasswordEncoder 使用 BCrypt 算法对密码进行哈希加密。推荐在生产环境中使用,提供较高的安全性。
NoOpPasswordEncoder 不对密码进行加密,即明文存储密码。不推荐在生产环境中使用,因为不提供足够的安全性。
Pbkdf2PasswordEncoder 使用 PBKDF2 算法对密码进行哈希加密。提供一定的安全性,但相对较慢。
MessageDigestPasswordEncoder 使用标准的 java.security.MessageDigest 实现对密码进行哈希加密。可以选择不同的哈希算法。
Argon2PasswordEncoder 使用 Argon2 算法对密码进行哈希加密。提供较强的安全性,抵御密码散列攻击。

DelegatingPasswordEncoder

在Spring Security5.0版本之后默认密码加密方案使用的时DelegatingPasswordEncoder,你也可以理解为DelegatingPasswordEncoder是一种PasswordEncoder接口的实现类。DelegatingPasswordEncoder的优势如下:

  1. 兼容性:可以帮助许多使用旧密码的系统顺利迁移到Spring Security中,它允许在同一个系统中同时存在多种不同的密码加密方案。
  2. 便捷性:当需要修改密码加密方案时,只需要修改很小一部分代码就可以实现。
  3. 稳定性:支持对密码加密方案升级,从A加密方案升级到B加密方案。

要学习DelegatingPasswordEncoder先要了解PasswordEncoderFactories类,因为PasswordEncoderFactories的静态方法createDelegatingPasswordEncoder提供了默认的DelegatingPasswordEncoder实例。PasswordEncoderFactories源码如下所示:

package org.springframework.security.crypto.factory;

import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.DelegatingPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder;
import org.springframework.security.crypto.scrypt.SCryptPasswordEncoder;

import java.util.HashMap;
import java.util.Map;

public class PasswordEncoderFactories {

@SuppressWarnings("deprecation")
public static PasswordEncoder createDelegatingPasswordEncoder() {
String encodingId = "bcrypt";
Map<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put(encodingId, new BCryptPasswordEncoder());
encoders.put("ldap", new org.springframework.security.crypto.password.LdapShaPasswordEncoder());
encoders.put("MD4", new org.springframework.security.crypto.password.Md4PasswordEncoder());
encoders.put("MD5", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("MD5"));
encoders.put("noop", org.springframework.security.crypto.password.NoOpPasswordEncoder.getInstance());
encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());
encoders.put("scrypt", new SCryptPasswordEncoder());
encoders.put("SHA-1", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-1"));
encoders.put("SHA-256", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-256"));
encoders.put("sha256", new org.springframework.security.crypto.password.StandardPasswordEncoder());
encoders.put("argon2", new Argon2PasswordEncoder());

return new DelegatingPasswordEncoder(encodingId, encoders);
}

private PasswordEncoderFactories() {}
}
  1. 首先定义了encoders变量,encoders中存储了每一种密码加密方案的id和对应的类名。
  2. encoders创建完成之后,创建一个DelegatingPasswordEncoder实例,并传入encodingId和encoders变量。其中encodingId默认为bcrypt,相当于默认使用的加密方案是BCryptPasswordEncoder。

DelegatingPasswordEncoder源码如下所示:

package org.springframework.security.crypto.password;

import java.util.HashMap;
import java.util.Map;

public class DelegatingPasswordEncoder implements PasswordEncoder {
private static final String PREFIX = "{";
private static final String SUFFIX = "}";
private final String idForEncode;
private final PasswordEncoder passwordEncoderForEncode;
private final Map<String, PasswordEncoder> idToPasswordEncoder;
private PasswordEncoder defaultPasswordEncoderForMatches = new UnmappedIdPasswordEncoder();

public DelegatingPasswordEncoder(String idForEncode,
Map<String, PasswordEncoder> idToPasswordEncoder) {
if (idForEncode == null) {
throw new IllegalArgumentException("idForEncode cannot be null");
}
if (!idToPasswordEncoder.containsKey(idForEncode)) {
throw new IllegalArgumentException("idForEncode " + idForEncode + "is not found in idToPasswordEncoder " + idToPasswordEncoder);
}
for (String id : idToPasswordEncoder.keySet()) {
if (id == null) {
continue;
}
if (id.contains(PREFIX)) {
throw new IllegalArgumentException("id " + id + " cannot contain " + PREFIX);
}
if (id.contains(SUFFIX)) {
throw new IllegalArgumentException("id " + id + " cannot contain " + SUFFIX);
}
}
this.idForEncode = idForEncode;
this.passwordEncoderForEncode = idToPasswordEncoder.get(idForEncode);
this.idToPasswordEncoder = new HashMap<>(idToPasswordEncoder);
}

public void setDefaultPasswordEncoderForMatches(
PasswordEncoder defaultPasswordEncoderForMatches) {
if (defaultPasswordEncoderForMatches == null) {
throw new IllegalArgumentException("defaultPasswordEncoderForMatches cannot be null");
}
this.defaultPasswordEncoderForMatches = defaultPasswordEncoderForMatches;
}

@Override
public String encode(CharSequence rawPassword) {
return PREFIX + this.idForEncode + SUFFIX + this.passwordEncoderForEncode.encode(rawPassword);
}

@Override
public boolean matches(CharSequence rawPassword, String prefixEncodedPassword) {
if (rawPassword == null && prefixEncodedPassword == null) {
return true;
}
String id = extractId(prefixEncodedPassword);
PasswordEncoder delegate = this.idToPasswordEncoder.get(id);
if (delegate == null) {
return this.defaultPasswordEncoderForMatches
.matches(rawPassword, prefixEncodedPassword);
}
String encodedPassword = extractEncodedPassword(prefixEncodedPassword);
return delegate.matches(rawPassword, encodedPassword);
}

private String extractId(String prefixEncodedPassword) {
if (prefixEncodedPassword == null) {
return null;
}
int start = prefixEncodedPassword.indexOf(PREFIX);
if (start != 0) {
return null;
}
int end = prefixEncodedPassword.indexOf(SUFFIX, start);
if (end < 0) {
return null;
}
return prefixEncodedPassword.substring(start + 1, end);
}

@Override
public boolean upgradeEncoding(String prefixEncodedPassword) {
String id = extractId(prefixEncodedPassword);
if (!this.idForEncode.equalsIgnoreCase(id)) {
return true;
}
else {
String encodedPassword = extractEncodedPassword(prefixEncodedPassword);
return this.idToPasswordEncoder.get(id).upgradeEncoding(encodedPassword);
}
}

private String extractEncodedPassword(String prefixEncodedPassword) {
int start = prefixEncodedPassword.indexOf(SUFFIX);
return prefixEncodedPassword.substring(start + 1);
}

private class UnmappedIdPasswordEncoder implements PasswordEncoder {

@Override
public String encode(CharSequence rawPassword) {
throw new UnsupportedOperationException("encode is not supported");
}

@Override
public boolean matches(CharSequence rawPassword,
String prefixEncodedPassword) {
String id = extractId(prefixEncodedPassword);
throw new IllegalArgumentException("There is no PasswordEncoder mapped for the id \"" + id + "\"");
}
}
}
  1. 首先定义了前缀PREFIX和后缀SUFFIX,用来包裹将生成的加密方案的id。
  2. idForEncode表示默认加密方案的id。
  3. passwordEncoderForEncode表示默认的加密方案,它的值是根据idForEncode从idToPasswordEncoder集合中提取出来的。
  4. idToPasswordEncoder用来保存id和加密方案的映射。
  5. defaultPasswordEncoderForMatches是指默认的密码对比器,当根据密码的加密方案的id无法找到对应的加密方案时,就会使用默认的密码对比器defaultPasswordEncoderForMatches。
  6. encode方法还是由加密类实现,只不过在密码加密完成之后会在密码前缀加上一个前缀{},用来描述所使用的加密方案。
  7. matches方法中,先调用extractId方法从加密字符串提取出具体加密方案的id,也就是{}中的字符。拿到id之后,再去idToPasswordEncoder集合中获取对应的加密方案。如果获取的内容为null,就会使用默认的密码匹配器defaultPasswordEncoderForMatches;如果不为空则调用对应matchers方法进行密码匹配。
  8. upgradeEncoding方法中,如果当前密码字符串所采用的密码加密方案不是默认的密码加密方案BCryptPasswordEncoder,就会自动进行密码升级,否则调用默认的密码加密方案的upgradeEncoding方法判断密码是否需要升级。

使用方式

创建一个测试类com.acaiblog.utils.TestPasswordEncoder

package com.acaiblog.utils;

import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

@Slf4j
public class TestPasswordEncoder {
public static void main(String[] args) {
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
log.info("PasswordEncoder: {}", encoder.encode("123"));
}
}

运行代码,结果显示如下:

14:36:13.007 [main] INFO com.acaiblog.utils.TestPasswordEncoder - PasswordEncoder: $2a$10$Lyww6sMhGdLFYniQ/rhSCODuYYbEJFqBUjPb5ZdkoG9Tu6.q9uW0G

重构SecurityConfig类,创建用户使用密文的方式。编辑oauth2/src/main/java/com/acaiblog/config/SecurityConfig.java

package com.acaiblog.config;

import com.acaiblog.services.UserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.provisioning.JdbcUserDetailsManager;

import javax.sql.DataSource;

@Slf4j
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
DataSource dataSource;

@Autowired
UserService userService;

@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication().withUser("acai").password("$2a$10$Lyww6sMhGdLFYniQ/rhSCODuYYbEJFqBUjPb5ZdkoG9Tu6.q9uW0G").roles("admin");

}
}
  1. 首先定义一个BCryptPasswordEncoder实例注册到Spring容器中,代替默认的DelegatingPasswordEncoder
  2. 定义用户时使用的密码时上面测试代码生成的加密密码,然后重新运行项目进行测试登录。

加密方案自动升级

比如user数据库表中有以下数据

MariaDB [spring_security]> select id,username,password from user;
+------+----------+-----------+
| id | username | password |
+------+----------+-----------+
| 1 | root | {noop}123 |
| 2 | admin | {noop}123 |
| 3 | acai | {noop}123 |
+------+----------+-----------+
3 rows in set (0.003 sec)

使用加密方案自动升级将password中的明文密码升级为加密的密码。
pom.xml中添加数据库依赖

<dependencies>
<!-- Spring Boot Starter for JDBC -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
<version>2.3.2.RELEASE</version>
</dependency>
<!-- MariaDB Connector/J -->
<dependency>
<groupId>org.mariadb.jdbc</groupId>
<artifactId>mariadb-java-client</artifactId>
<version>3.3.0</version> <!-- 根据需要替换为最新版本 -->
</dependency>
</dependencies>

在项目resources/application.yml中添加数据库配置

spring:
datasource:
url: jdbc:mariadb://localhost:3306/spring_security?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
username: root
password: 123456
driver-class-name: org.mariadb.jdbc.Driver

创建用户实体类com.acaiblog.entities.User

package com.acaiblog.entities;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;

public class User implements UserDetails {
private Integer id;
private String username;
private String password;
private Boolean enabled;
private Boolean accountNonExpired;
private Boolean accountNonLocked;
private Boolean credentialsNonExpired;
private List<Role> roles = new ArrayList<>();

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
for (Role role : roles) {
authorities.add(new SimpleGrantedAuthority(role.getName()));
}
return authorities;
}

@Override
public String getPassword() {
return this.password;
}

public void setPassword(String password) {
this.password = password;
}

@Override
public String getUsername() {
return this.username;
}

@Override
public boolean isAccountNonExpired() {
return this.accountNonExpired;
}

@Override
public boolean isAccountNonLocked() {
return this.accountNonLocked;
}

@Override
public boolean isCredentialsNonExpired() {
return this.credentialsNonExpired;
}

@Override
public boolean isEnabled() {
return this.enabled;
}

public void setRoles(List<Role> roles) {
this.roles = roles;
}

public Integer getId() {
return id;
}
}

创建com.acaiblog.mapper.UserMapper

package com.acaiblog.mapper;

import com.acaiblog.entities.Role;
import com.acaiblog.entities.User;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

import java.util.List;

@Mapper
public interface UserMapper {
Integer updatePassword(@Param("username") String username, @Param("newPassword") String newPassword);
List<Role> getRolesByUid(Integer id);
User loadUserByUsername(String username);
}

创建oauth2/src/main/java/com/acaiblog/mapper/UserMapper.xml添加更新密码sql实现

<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.acaiblog.mapper.UserMapper">
<select id="loadUserByUsername" resultType="com.acaiblog.entities.User">
select * from user where username=#{username};
</select>
<select id="getRolesByUid" resultType="com.acaiblog.entities.Role">
select r.* from role r,user_role ur where r.`id`=ur.`rid`
</select>
<update id="updatePassword">
update user set password = #{newPassword} where username = #{username}
</update>
</mapper>

创建用户服务层com.acaiblog.services.UserService

package com.acaiblog.services;

import com.acaiblog.entities.User;
import com.acaiblog.mapper.UserMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.*;
import org.springframework.stereotype.Service;

@Slf4j
@Configuration
public class UserService implements UserDetailsService, UserDetailsPasswordService {
@Autowired
UserMapper userMapper;

@Override
public UserDetails updatePassword(UserDetails user, String newPassword) {
Integer result = userMapper.updatePassword(user.getUsername(), newPassword);
if (result == 1) {
User userEntry = (User) user;
userEntry.setPassword(newPassword);
}
return user;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return userMapper.loadUserByUsername(username);
}
}

重构SecurityConfig,编辑com.acaiblog.config.SecurityConfig在passwordEncoder方法添加{noop}

package com.acaiblog.config;

import com.acaiblog.services.UserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.*;
import org.springframework.security.crypto.scrypt.SCryptPasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.provisioning.JdbcUserDetailsManager;

import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;

@Slf4j
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
DataSource dataSource;

@Autowired
UserService userService;

@Bean
PasswordEncoder passwordEncoder() {
String encodingId = "bcrypt";
Map<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put(encodingId, new BCryptPasswordEncoder(31));
encoders.put("ldap", new LdapShaPasswordEncoder());
encoders.put("MD4", new Md4PasswordEncoder());
encoders.put("MD5", new MessageDigestPasswordEncoder("MD5"));
encoders.put("noop", NoOpPasswordEncoder.getInstance());
encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());
encoders.put("scrypt", new SCryptPasswordEncoder());
encoders.put("SHA-1", new MessageDigestPasswordEncoder("SHA-1"));
encoders.put("SHA-256", new MessageDigestPasswordEncoder("SHA-256"));
encoders.put("sha256", new StandardPasswordEncoder());
encoders.put("argon2", new Argon2PasswordEncoder());
return new DelegatingPasswordEncoder(encodingId, encoders);
}

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userService);
}

@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.csrf().disable();
}
}

重启项目,测试登录。登录成功之后密码会被修改为,以下结果:

MariaDB [spring_security]> select id,username,password from user;
+------+----------+----------------------------------------------------------------------+
| id | username | password |
+------+----------+----------------------------------------------------------------------+
| 1 | root | {noop}123 |
| 2 | admin | {noop}123 |
| 3 | acai | {bcrypt}$2a$31$8frmPsNku62Ol5uCYhGTM.sB0aLl.HeFrFCR8U41Wcs5k5rd6H2.2 |
+------+----------+----------------------------------------------------------------------+
3 rows in set (0.002 sec)

RememberMe

简介

传统的登录方式基于Session会话,一旦用户关闭浏览器重新访问旧需要重新登录比较繁琐。RememberMe的机制可以让用户关闭浏览器重登访问能够继续保持认证状态,必须要重新登录。
实现思路:

  1. 当用户登录成功之后,根据算法将用户信息、时间戳等信息进行加密。加密完成之后,通过响应头返回给前端存储在Cookie中。
  2. 当浏览器关闭重新访问时,会自动将Cookie信息发送给服务端。服务端对Cookie信息进行校验分析。

基本用法

在pom.xml中添加Spring Security依赖

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<version>2.3.2.RELEASE</version>
</dependency>

重构SecurityConfig,编辑com.acaiblog.config.SecurityConfig添加rememberMe属性

package com.acaiblog.config;

import com.acaiblog.services.UserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.*;
import org.springframework.security.crypto.scrypt.SCryptPasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.provisioning.JdbcUserDetailsManager;

import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;

@Slf4j
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
DataSource dataSource;

@Autowired
UserService userService;

@Bean
PasswordEncoder passwordEncoder() {
String encodingId = "bcrypt";
Map<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put(encodingId, new BCryptPasswordEncoder(31));
encoders.put("ldap", new LdapShaPasswordEncoder());
encoders.put("MD4", new Md4PasswordEncoder());
encoders.put("MD5", new MessageDigestPasswordEncoder("MD5"));
encoders.put("noop", NoOpPasswordEncoder.getInstance());
encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());
encoders.put("scrypt", new SCryptPasswordEncoder());
encoders.put("SHA-1", new MessageDigestPasswordEncoder("SHA-1"));
encoders.put("SHA-256", new MessageDigestPasswordEncoder("SHA-256"));
encoders.put("sha256", new StandardPasswordEncoder());
encoders.put("argon2", new Argon2PasswordEncoder());
return new DelegatingPasswordEncoder(encodingId, encoders);
}

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userService);
}

@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.defaultSuccessUrl("/swagger-ui.html#")
.and()
.csrf().disable()
.rememberMe();
}
}

再次访问http://127.0.0.1:8080/swagger-ui.html# 会自动转发到 http://127.0.0.1:8080/login 页面进行认证。页面新增Remember me on this computer.选项。选勾登录之后,关闭浏览器重新访问接口发现不需要重新登录了。

实现原理

  1. 当我们登录时,浏览器Form表单中会有username、password、remember-me等属性。
  2. 向服务端发送请求时,remember-me=on告诉服务端开启RememberMe功能。
  3. 当请求成功之后,在响应头中多了一个Set-Cookie
    Set-Cookie:
    remember-me=YWNhaToxNzA4MDc1NTI4NDkxOjc4NTAyOWE1ZDgyMWRlMDYyMWM2ZTU1NjM4NGVmMjIy; Max-Age=1209600; Expires=Fri, 16-Feb-2024 09:25:28 GMT; Path=/; HttpOnly
  4. 服务端根据Set-Cookie中的remember-me信息校验用户是否合法。

持久化令牌

简介

持久化令牌在普通令牌的基础上新增series和token两个校验值,当使用用户名和密码登录时series才会更新。一旦有了新的会话token就会重新生成,如果令牌被盗用,对方基于RememberMe登录成功后会生成新的token,你自己的令牌会失效,这样就能及时发现账户泄漏并作出处理。比如:清楚自动登录令牌,通知用户账户泄漏等。
Spring Security对于持久化令牌提供了两种实现:JdbcTokenRepositoryImpl和InMemoryTokenRepositoryImpl,前者是基于JdbcTemplate来操作数据库,后者则是操作在内存中的数据。

实现方式

首先需要创建数据库表来记录令牌信息,创建表的SQL脚本在JdbcTokenRepositoryImpl#CREATE_TABLE_SQL变量已经定义好了。代码如下:

public class JdbcTokenRepositoryImpl extends JdbcDaoSupport implements PersistentTokenRepository {
public static final String CREATE_TABLE_SQL = "create table persistent_logins (username varchar(64) not null, series varchar(64) primary key, token varchar(64) not null, last_used timestamp not null)";
}

在pom.xml中添加依赖

    <dependencies>
<!-- Spring Boot Starter for JDBC -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
<version>2.3.2.RELEASE</version>
</dependency>
<!-- MariaDB Connector/J -->
<dependency>
<groupId>org.mariadb.jdbc</groupId>
<artifactId>mariadb-java-client</artifactId>
<version>3.3.0</version> <!-- 根据需要替换为最新版本 -->
</dependency>
</dependencies>

在项目resources/application.yaml中添加数据库信息

spring:
datasource:
url: jdbc:mariadb://localhost:3306/spring_security?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
username: root
password: 123456
driver-class-name: org.mariadb.jdbc.Driver

重构SecurityConfig,添加.tokenRepository(jdbcTokenRepository())。编辑com.acaiblog.config.SecurityConfig

package com.acaiblog.config;

import com.acaiblog.services.UserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.*;
import org.springframework.security.crypto.scrypt.SCryptPasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.provisioning.JdbcUserDetailsManager;
import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl;

import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;

@Slf4j
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
DataSource dataSource;

@Bean
JdbcTokenRepositoryImpl jdbcTokenRepository() {
JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
jdbcTokenRepository.setDataSource(dataSource);
return jdbcTokenRepository;
}

@Autowired
UserService userService;

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userService);
}

@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.defaultSuccessUrl("/swagger-ui.html#")
.and()
.csrf().disable()
.rememberMe()
.tokenRepository(jdbcTokenRepository());
}
}

重启项目访问:http://127.0.0.1:8080/swagger-ui.html# 登录测试,登录成功之后会在数据库表生成以下数据:

MariaDB [spring_security]> select * from persistent_logins;
+----------+--------------------------+--------------------------+---------------------+
| username | series | token | last_used |
+----------+--------------------------+--------------------------+---------------------+
| acai | eWvyJ0D17cGP44176a4mBw== | sGNwCrgdwH3cMpD1R8I7sg== | 2024-02-02 18:09:48 |
+----------+--------------------------+--------------------------+---------------------+
1 row in set (0.001 sec)

二次校验

简介

二次校验就是将系统资源分为敏感和不敏感,如果用户使用了RememberMe方式登录访问敏感资源会自动跳转到登录页面,要求重新登录。

需求

接口 需求
/hello 认证后才能访问,不论通过任何方式
/admin 认证后才能访问,必须通过用户名和密码登录认证
/rememberme 认证后才能访问,必须通过RememberMe认证

实现方式

编辑com.acaiblog.auth.controller.HelloController新增API接口

@Slf4j
@Api(tags = "test", description = "测试接口")
@RestController
@RequestMapping("/test")

public class HelloController {
@ApiOperation("hello接口")
@GetMapping("/hello")
public String hello() {
return "hello world";
}

@ApiOperation("admin接口")
@GetMapping("/admin")
public String admin() {
return "hello admin";
}

@ApiOperation("rememberme接口")
@GetMapping("/rememberme")
public String rememberme() {
return "hello RememberMe";
}
}

重构SecurityConfig,编辑com.acaiblog.config.SecurityConfig添加antMatchers属性

package com.acaiblog.config;

import com.acaiblog.services.UserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.*;
import org.springframework.security.crypto.scrypt.SCryptPasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.provisioning.JdbcUserDetailsManager;
import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl;

import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;

@Slf4j
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
DataSource dataSource;

@Bean
JdbcTokenRepositoryImpl jdbcTokenRepository() {
JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
jdbcTokenRepository.setDataSource(dataSource);
return jdbcTokenRepository;
}

@Autowired
UserService userService;

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userService);
}

@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/test/admin").fullyAuthenticated()
.antMatchers("/test/rememberme").rememberMe()
.anyRequest().authenticated()
.and()
.formLogin()
.defaultSuccessUrl("/swagger-ui.html#")
.and()
.csrf().disable()
.rememberMe()
.tokenRepository(jdbcTokenRepository());
}
}

重启项目,分别测试三个接口。

原理分析

RememberMeServices接口源码如下所示:

package org.springframework.security.web.authentication;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;

public interface RememberMeServices {
Authentication autoLogin(HttpServletRequest var1, HttpServletResponse var2);

void loginFail(HttpServletRequest var1, HttpServletResponse var2);

void loginSuccess(HttpServletRequest var1, HttpServletResponse var2, Authentication var3);
}

定义了三个方法:

方法 描述
autoLogin 从请求中获取登录参数,完成自动登录
loginFail 登录失败的回调方法
loginSuccess 登录成功的回调方法

RememberMeServices接口继承关系,如果所示:

AbstractRememberMeServices

AbstractRememberMeServices对于RememberMeServices接口中定义的方法提供了基本的实现,首先分析AbstractRememberMeServices#autoLogin实现方法:

package org.springframework.security.web.authentication.rememberme;

import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.context.support.MessageSourceAccessor;
import org.springframework.security.authentication.AccountStatusException;
import org.springframework.security.authentication.AccountStatusUserDetailsChecker;
import org.springframework.security.authentication.AuthenticationDetailsSource;
import org.springframework.security.authentication.RememberMeAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.SpringSecurityMessageSource;
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
import org.springframework.security.core.authority.mapping.NullAuthoritiesMapper;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsChecker;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.web.authentication.RememberMeServices;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.security.web.authentication.logout.LogoutHandler;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;

public abstract class AbstractRememberMeServices implements RememberMeServices, InitializingBean, LogoutHandler {
public final Authentication autoLogin(HttpServletRequest request, HttpServletResponse response) {
String rememberMeCookie = this.extractRememberMeCookie(request);
if (rememberMeCookie == null) {
return null;
} else {
this.logger.debug("Remember-me cookie detected");
if (rememberMeCookie.length() == 0) {
this.logger.debug("Cookie was empty");
this.cancelCookie(request, response);
return null;
} else {
UserDetails user = null;

try {
String[] cookieTokens = this.decodeCookie(rememberMeCookie);
user = this.processAutoLoginCookie(cookieTokens, request, response);
this.userDetailsChecker.check(user);
this.logger.debug("Remember-me cookie accepted");
return this.createSuccessfulAuthentication(request, user);
} catch (CookieTheftException var6) {
this.cancelCookie(request, response);
throw var6;
} catch (UsernameNotFoundException var7) {
this.logger.debug("Remember-me login was valid but corresponding user not found.", var7);
} catch (InvalidCookieException var8) {
this.logger.debug("Invalid remember-me cookie: " + var8.getMessage());
} catch (AccountStatusException var9) {
this.logger.debug("Invalid UserDetails: " + var9.getMessage());
} catch (RememberMeAuthenticationException var10) {
this.logger.debug(var10.getMessage());
}

this.cancelCookie(request, response);
return null;
}
}
}
}

autoLogin方法主要功能是从当前请求中提取令牌信息,根据令牌信息完成自动登录功能。登录成功之后会返回Authentication对象,autoLogin方法实现流程如下:

  1. 首先调用extractRememberMeCookie方法从当前请求中提取出Cookie信息,即remember-me对应的值。如果值为null,表示本次请求要求携带的Cookie中没有remember-me,本次不需要自动登录直接返回null。如果remember-me对应的长度为0在返回null之前执行cancelCookie函数,将Cookie中remember-me的值设置为null。
  2. 调用decodeCookie方法获取令牌进行解析,并将结果返回。
  3. 调用processAutoLoginCookie方法对Cookie进行验证,如果验证通过返回登录用户对象,然后对用户状态进行验证:账户是否可用、账户是否锁定等。processAutoLoginCookie方法的具体实现在AbstractRememberMeServices的子类中。
  4. 调用createSuccessfulAuthentication方法创建登录成功的用户对象,登录成功创建的用户对象是RememberMeAuthenticationToken。

登录成功和登录失败回调方法源码如下所示:

package org.springframework.security.web.authentication.rememberme;

import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.context.support.MessageSourceAccessor;
import org.springframework.security.authentication.AccountStatusException;
import org.springframework.security.authentication.AccountStatusUserDetailsChecker;
import org.springframework.security.authentication.AuthenticationDetailsSource;
import org.springframework.security.authentication.RememberMeAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.SpringSecurityMessageSource;
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
import org.springframework.security.core.authority.mapping.NullAuthoritiesMapper;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsChecker;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.web.authentication.RememberMeServices;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.security.web.authentication.logout.LogoutHandler;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;

public abstract class AbstractRememberMeServices implements RememberMeServices, InitializingBean, LogoutHandler {
public final void loginFail(HttpServletRequest request, HttpServletResponse response) {
this.logger.debug("Interactive login attempt was unsuccessful.");
this.cancelCookie(request, response);
this.onLoginFail(request, response);
}
public final void loginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) {
if (!this.rememberMeRequested(request, this.parameter)) {
this.logger.debug("Remember-me login not requested.");
} else {
this.onLoginSuccess(request, response, successfulAuthentication);
}
}
protected boolean rememberMeRequested(HttpServletRequest request, String parameter) {
if (this.alwaysRemember) {
return true;
} else {
String paramValue = request.getParameter(parameter);
if (paramValue != null && (paramValue.equalsIgnoreCase("true") || paramValue.equalsIgnoreCase("on") || paramValue.equalsIgnoreCase("yes") || paramValue.equals("1"))) {
return true;
} else {
if (this.logger.isDebugEnabled()) {
this.logger.debug("Did not send remember-me cookie (principal did not set parameter '" + parameter + "')");
}

return false;
}
}
}
}
  1. 登录失败时,先取消Cookie的值然后调用onLoginFail方法完成失败的处理。onLoginFail是一个空方法,可以根据需求重写该方法。
  2. 登录成功时,先调用rememberMeRequested方法判断当前请求是否开启了自动登录,开发者可以在服务端开启alwaysRemember,无论前端参数时什么都会开启自动登录。如果开发者没有设置alwaysRemember,则根据前端传来的remember-me参数判断,remember-me参数的值时true或on表示开启自动登录。如果开启自动登录会调用onLoginSuccess方法进行登录处理,onLoginSuccess方法的具体实现在AbstractRememberMeServices的子类中。

AbstractRememberMeServices#setCookie方法在自动登录之后,调用该方法把令牌信息放入响应头返回给前端。源码如下所示:

public abstract class AbstractRememberMeServices implements RememberMeServices, InitializingBean, LogoutHandler {
protected void setCookie(String[] tokens, int maxAge, HttpServletRequest request, HttpServletResponse response) {
String cookieValue = this.encodeCookie(tokens);
Cookie cookie = new Cookie(this.cookieName, cookieValue);
cookie.setMaxAge(maxAge);
cookie.setPath(this.getCookiePath(request));
if (this.cookieDomain != null) {
cookie.setDomain(this.cookieDomain);
}

if (maxAge < 1) {
cookie.setVersion(1);
}

if (this.useSecureCookie == null) {
cookie.setSecure(request.isSecure());
} else {
cookie.setSecure(this.useSecureCookie);
}

cookie.setHttpOnly(true);
response.addCookie(cookie);
}
protected String encodeCookie(String[] cookieTokens) {
StringBuilder sb = new StringBuilder();

for(int i = 0; i < cookieTokens.length; ++i) {
try {
sb.append(URLEncoder.encode(cookieTokens[i], StandardCharsets.UTF_8.toString()));
} catch (UnsupportedEncodingException var5) {
this.logger.error(var5.getMessage(), var5);
}

if (i < cookieTokens.length - 1) {
sb.append(":");
}
}

String value = sb.toString();
sb = new StringBuilder(new String(Base64.getEncoder().encode(value.getBytes())));

while(sb.charAt(sb.length() - 1) == '=') {
sb.deleteCharAt(sb.length() - 1);
}

return sb.toString();
}
}
  1. 首先调用encodeCookie方法将要返回给前端的数据进行Base64编码。
  2. 将编码后的字符串放入Cookie中,并配置Cookie的过期时间、path、domain、secure、httpOnly等属性,最终将配置好的Cookie放入响应头返回给前端。

TokenBasedRememberMeServices

TokenBasedRememberMeServices是AbstractRememberMeServices的实现类之一,在了解RememberMe基本用法时最终起作用的就是TokenBasedRememberMeServices。最为AbstractRememberMeServices的子类,TokenBasedRememberMeServices中最重要的方法就是对AbstractRememberMeServices多定义的两个抽象方法processAutoLoginCookie、onLoginSuccess的实现。processAutoLoginCookie方法的源码如下所示:

package org.springframework.security.web.authentication.rememberme;

import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.Date;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.codec.Hex;
import org.springframework.security.crypto.codec.Utf8;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;

public class TokenBasedRememberMeServices extends AbstractRememberMeServices {
public TokenBasedRememberMeServices(String key, UserDetailsService userDetailsService) {
super(key, userDetailsService);
}

protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request, HttpServletResponse response) {
if (cookieTokens.length != 3) {
throw new InvalidCookieException("Cookie token did not contain 3 tokens, but contained '" + Arrays.asList(cookieTokens) + "'");
} else {
long tokenExpiryTime;
try {
tokenExpiryTime = new Long(cookieTokens[1]);
} catch (NumberFormatException var8) {
throw new InvalidCookieException("Cookie token[1] did not contain a valid number (contained '" + cookieTokens[1] + "')");
}

if (this.isTokenExpired(tokenExpiryTime)) {
throw new InvalidCookieException("Cookie token[1] has expired (expired on '" + new Date(tokenExpiryTime) + "'; current time is '" + new Date() + "')");
} else {
UserDetails userDetails = this.getUserDetailsService().loadUserByUsername(cookieTokens[0]);
Assert.notNull(userDetails, () -> {
return "UserDetailsService " + this.getUserDetailsService() + " returned null for username " + cookieTokens[0] + ". This is an interface contract violation";
});
String expectedTokenSignature = this.makeTokenSignature(tokenExpiryTime, userDetails.getUsername(), userDetails.getPassword());
if (!equals(expectedTokenSignature, cookieTokens[2])) {
throw new InvalidCookieException("Cookie token[2] contained signature '" + cookieTokens[2] + "' but expected '" + expectedTokenSignature + "'");
} else {
return userDetails;
}
}
}
}
protected String makeTokenSignature(long tokenExpiryTime, String username, String password) {
String data = username + ":" + tokenExpiryTime + ":" + password + ":" + this.getKey();

MessageDigest digest;
try {
digest = MessageDigest.getInstance("MD5");
} catch (NoSuchAlgorithmException var8) {
throw new IllegalStateException("No MD5 algorithm available!");
}

return new String(Hex.encode(digest.digest(data.getBytes())));
}
}

processAutoLoginCookie方法主要用来验证Cookie中的令牌信息是否合法:

  1. 首先判断cookieTokens长度是否为3,如果不为3说明格式不对直接抛出异常。
  2. 从cookieTokens数组中提取出第一项,也就是过期时间,判断令牌是否过期,如果已经过期,则抛出异常。
  3. 从cookieTokens数组中提取出第0项,也就是用户名,根据用户名查询出当前用户对象。
  4. 调用makeTokenSignature方法生成一个签名,签名过程如下:首先将用户名、令牌过期时间、用户名密码以及key组成一个字符串,中间用”:”隔开,然后通过MD5消息摘要算法对该字符串进行加密,并将加密结果转为一个字符串返回。
  5. 判断第4步生成的签名和通过Cookie传来的签名是否相等,即cookieTokens数组的第二项。如果相等令牌合法,则直接返回用户对象,否则抛出异常。

登录成功的回调方法onloginSuccess源码如下所示:

package org.springframework.security.web.authentication.rememberme;

import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.Date;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.codec.Hex;
import org.springframework.security.crypto.codec.Utf8;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;

public class TokenBasedRememberMeServices extends AbstractRememberMeServices {
public void onLoginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) {
String username = this.retrieveUserName(successfulAuthentication);
String password = this.retrievePassword(successfulAuthentication);
if (!StringUtils.hasLength(username)) {
this.logger.debug("Unable to retrieve username");
} else {
if (!StringUtils.hasLength(password)) {
UserDetails user = this.getUserDetailsService().loadUserByUsername(username);
password = user.getPassword();
if (!StringUtils.hasLength(password)) {
this.logger.debug("Unable to obtain password for user: " + username);
return;
}
}

int tokenLifetime = this.calculateLoginLifetime(request, successfulAuthentication);
long expiryTime = System.currentTimeMillis();
expiryTime += 1000L * (long)(tokenLifetime < 0 ? 1209600 : tokenLifetime);
String signatureValue = this.makeTokenSignature(expiryTime, username, password);
this.setCookie(new String[]{username, Long.toString(expiryTime), signatureValue}, tokenLifetime, request, response);
if (this.logger.isDebugEnabled()) {
this.logger.debug("Added remember-me cookie for user '" + username + "', expiry: '" + new Date(expiryTime) + "'");
}

}
}
}
  1. 首先获取用户名和密码信息,如果用户名密码在用户登录成功之后已经从successfulAuthentication对象中擦除,则从数据库中重新加载用户密码。
  2. 计算出令牌过期时间,令牌默认有效期是两周。
  3. 根据令牌过期时间、用户名和用户名密码计算出一个签名。
  4. 调用setCookie方法设置Cookie,第一个参数是数组,数组中包含三项:用户名、过期时间以及签名,在setCookie方法中将数组转换为字符串,并进行Base64编码之后响应给前端。

PersistentTokenBasedRememberMeServices

PersistentTokenBasedRememberMeServices中最重要的方法processAutoLoginCookie和onLoginSuccess,processAutoLoginCookie源码如下所示:

package org.springframework.security.web.authentication.rememberme;

import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Base64;
import java.util.Date;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.util.Assert;

public class PersistentTokenBasedRememberMeServices extends AbstractRememberMeServices {
protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request, HttpServletResponse response) {
if (cookieTokens.length != 2) {
throw new InvalidCookieException("Cookie token did not contain 2 tokens, but contained '" + Arrays.asList(cookieTokens) + "'");
} else {
String presentedSeries = cookieTokens[0];
String presentedToken = cookieTokens[1];
PersistentRememberMeToken token = this.tokenRepository.getTokenForSeries(presentedSeries);
if (token == null) {
throw new RememberMeAuthenticationException("No persistent token found for series id: " + presentedSeries);
} else if (!presentedToken.equals(token.getTokenValue())) {
this.tokenRepository.removeUserTokens(token.getUsername());
throw new CookieTheftException(this.messages.getMessage("PersistentTokenBasedRememberMeServices.cookieStolen", "Invalid remember-me token (Series/token) mismatch. Implies previous cookie theft attack."));
} else if (token.getDate().getTime() + (long)this.getTokenValiditySeconds() * 1000L < System.currentTimeMillis()) {
throw new RememberMeAuthenticationException("Remember-me login has expired");
} else {
if (this.logger.isDebugEnabled()) {
this.logger.debug("Refreshing persistent login token for user '" + token.getUsername() + "', series '" + token.getSeries() + "'");
}

PersistentRememberMeToken newToken = new PersistentRememberMeToken(token.getUsername(), token.getSeries(), this.generateTokenData(), new Date());

try {
this.tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(), newToken.getDate());
this.addCookie(newToken, request, response);
} catch (Exception var9) {
this.logger.error("Failed to update token: ", var9);
throw new RememberMeAuthenticationException("Autologin failed due to data access problem");
}

return this.getUserDetailsService().loadUserByUsername(token.getUsername());
}
}
}
}
  1. 从cookieTokens数组中分别提取出series和token,然后根据series去数据库中查询出一个PersistentRememberToken对象。如果查询出来的对象为null、表示数据库中没有series对应的值本次登录失败;如果查询出来的token和从cookieTokens解析出来的token不同,说明自动登录令牌已经被泄漏(恶意用户利用令牌登录后数据库中的token变了),此时移除当前用户所有自动登录记录并抛出异常。
  2. 根据数据库中查询出来的结果判断令牌是否过期,如果过期抛出异常。
  3. 生成一个新的PersistentRememberMeToken对象,用户名和series不变,token和date重新生成。新的token生成之后,根据series去修改数据库中的token和date。
  4. 调用addCookie方法添加Cookie,在addCookie方法中,会调用setCookie方法。
  5. 最后根据用户名查询对象并将结果返回。

onLoginSuccess源码如下所示:

public class PersistentTokenBasedRememberMeServices extends AbstractRememberMeServices {
protected void onLoginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) {
String username = successfulAuthentication.getName();
this.logger.debug("Creating new persistent login for user " + username);
PersistentRememberMeToken persistentToken = new PersistentRememberMeToken(username, this.generateSeriesData(), this.generateTokenData(), new Date());

try {
this.tokenRepository.createNewToken(persistentToken);
this.addCookie(persistentToken, request, response);
} catch (Exception var7) {
this.logger.error("Failed to save persistent token ", var7);
}

}
}

登录成功之后构建一个PersistentRememberMeToken对象,对象中的series和token参数都是随机生成的。将生成的对象存入数据库中,再调用addCookie方法添加相关的Cookie信息。
PersistentTokenBasedRememberMeServices和PersistentRememberMeToken的区别:前者返回给前端的令牌是将series和token组成的字符串进行Base64编码之后返回给前端;后者返回给前端的令牌是将用户名、过期时间以及签名组成的字符串进行Base64编码之后返回给前端。
当开发者配置.rememberMe().key(‘xxx’)时,实际上引入配置类RememberMeConfigurer。对于RememberMeConfigurer最重要的方法是init和onfigure方法,init方法源码如下:

package org.springframework.security.config.annotation.web.configurers;

public final class RememberMeConfigurer<H extends HttpSecurityBuilder<H>>
extends AbstractHttpConfigurer<RememberMeConfigurer<H>, H> {
@Override
public void init(H http) throws Exception {
validateInput();
String key = getKey();
RememberMeServices rememberMeServices = getRememberMeServices(http, key);
http.setSharedObject(RememberMeServices.class, rememberMeServices);
LogoutConfigurer<H> logoutConfigurer = http.getConfigurer(LogoutConfigurer.class);
if (logoutConfigurer != null && this.logoutHandler != null) {
logoutConfigurer.addLogoutHandler(this.logoutHandler);
}

RememberMeAuthenticationProvider authenticationProvider = new RememberMeAuthenticationProvider(
key);
authenticationProvider = postProcess(authenticationProvider);
http.authenticationProvider(authenticationProvider);

initDefaultLoginFilter(http);
}
}
  1. 首先获取了一个key,这个key就是开发者配置的key。如果没有配置key则会自动生成一个UUID字符串。如果没有配置持久化令牌则会建议开发者配置key,因为使用默认的UUID字符串系统每次重启都会生成新的key,导致之前下发的remember-me失效。
  2. 有了key之后再去获取RememberMeService实例,如果开发者配置了tokenRepository,则获取到的RememberMeService实例是TokenBasedRememberMeServices,否则获取到TokenBasedRememberMeServices。

init方法中还配置了一个RememberMeAuthenticationProvider,该实例用来检验key。RememberMeConfigurer#configure方法源码如下所示:

package org.springframework.security.config.annotation.web.configurers;

public final class RememberMeConfigurer<H extends HttpSecurityBuilder<H>>
extends AbstractHttpConfigurer<RememberMeConfigurer<H>, H> {
@Override
public void configure(H http) {
RememberMeAuthenticationFilter rememberMeFilter = new RememberMeAuthenticationFilter(
http.getSharedObject(AuthenticationManager.class),
this.rememberMeServices);
if (this.authenticationSuccessHandler != null) {
rememberMeFilter
.setAuthenticationSuccessHandler(this.authenticationSuccessHandler);
}
rememberMeFilter = postProcess(rememberMeFilter);
http.addFilter(rememberMeFilter);
}
}

configure方法主要创建了一个RememberMeAuthenticationFilter,同时传入RememberMeService实例,最后将创建好的RememberMeAuthenticationFilter加入到过滤链中。
RememberMeAuthenticationFilter#doFilter源码如下所示:

package org.springframework.security.web.authentication.rememberme;

public class RememberMeAuthenticationFilter extends GenericFilterBean implements ApplicationEventPublisherAware {
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest)req;
HttpServletResponse response = (HttpServletResponse)res;
if (SecurityContextHolder.getContext().getAuthentication() == null) {
Authentication rememberMeAuth = this.rememberMeServices.autoLogin(request, response);
if (rememberMeAuth != null) {
try {
rememberMeAuth = this.authenticationManager.authenticate(rememberMeAuth);
SecurityContextHolder.getContext().setAuthentication(rememberMeAuth);
this.onSuccessfulAuthentication(request, response, rememberMeAuth);
if (this.logger.isDebugEnabled()) {
this.logger.debug("SecurityContextHolder populated with remember-me token: '" + SecurityContextHolder.getContext().getAuthentication() + "'");
}

if (this.eventPublisher != null) {
this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(SecurityContextHolder.getContext().getAuthentication(), this.getClass()));
}

if (this.successHandler != null) {
this.successHandler.onAuthenticationSuccess(request, response, rememberMeAuth);
return;
}
} catch (AuthenticationException var8) {
if (this.logger.isDebugEnabled()) {
this.logger.debug("SecurityContextHolder not populated with remember-me token, as AuthenticationManager rejected Authentication returned by RememberMeServices: '" + rememberMeAuth + "'; invalidating remember-me token", var8);
}

this.rememberMeServices.loginFail(request, response);
this.onUnsuccessfulAuthentication(request, response, var8);
}
}

chain.doFilter(request, response);
} else {
if (this.logger.isDebugEnabled()) {
this.logger.debug("SecurityContextHolder not populated with remember-me token, as it already contained: '" + SecurityContextHolder.getContext().getAuthentication() + "'");
}

chain.doFilter(request, response);
}

}
}
  1. 请求到达过滤器之后,首先判断SecurityContextHolder中是否有值,没有值表示用户还未登录,此时调用autoLogin方法进行自动登录。
  2. 当自动登录成功之后返回的rememberMeAuth不为null时,表示自动登录成功。此时调用authenticate方法对key进行校验,并且将登录成功的用户信息保存到SecurityContextHolder对象中,然后调用登录成功回调方法,并发布登录成功的事件。登录成功的回调不包含RememberMeService中的loginSuccess方法。
  3. 如果自动登录失败,调用remmeberMeService.loginFail方法处理登录失败回调。

会话管理

简介

当浏览器调用登录接口成功登录之后,服务端和浏览器之间会建立一个会话。浏览器在每次发送请求时都会携带一个SessionId,服务端根据这个SessionId判断用户身份。当浏览器关闭之后服务端的Session不会自动销毁,需要开发者在服务端调用销毁Session的方法,或等待Session过期时间自动销毁。
在Spring Security框架中,与HttpSession相关的功能由SessionManagementFilter和SessionAuthenticationStrategy接口来处理。

会话并发管理

会话并发管理指当前系统中同一个用户可以同时创建多少个会话,如果一台设备对应一个会话,那么可以理解为同一个用户可以同时在多少个设备上同时登录。默认情况下并没有会会话进行限制,不过可以在Spring Security中进行配置。

基本用法

在pom.xml配置文件中添加Spring Security的依赖

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<version>2.3.2.RELEASE</version>
</dependency>

创建com.acaiblog.config.SecurityConfigSecurityConfig配置类添加httpSessionEventPublisher方法

package com.acaiblog.config;

import com.acaiblog.services.UserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.*;
import org.springframework.security.crypto.scrypt.SCryptPasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.provisioning.JdbcUserDetailsManager;
import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl;
import org.springframework.security.web.session.HttpSessionEventPublisher;

import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;

@Slf4j
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
DataSource dataSource;

@Bean
JdbcTokenRepositoryImpl jdbcTokenRepository() {
JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
jdbcTokenRepository.setDataSource(dataSource);
return jdbcTokenRepository;
}

@Autowired
UserService userService;

@Bean
HttpSessionEventPublisher httpSessionEventPublisher() {
return new HttpSessionEventPublisher();
}

@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/test/admin").fullyAuthenticated()
.antMatchers("/test/rememberme").rememberMe()
.anyRequest().authenticated()
.and()
.formLogin()
.defaultSuccessUrl("/swagger-ui.html#")
.and()
.csrf().disable()
.rememberMe()
.tokenRepository(jdbcTokenRepository())
.and()
// 添加session会话管理
.sessionManagement()
.maximumSessions(1);
}
}
  1. 在configure(HttpSecurity http)方法中通过sessionManagement()方法开启会话管理,并设置会话并发数量为1。
  2. 提供一个httpSessionEventPublisher实例,Spring Security中通过Map集合来维护当前的HttpSession记录,实现会话并发的管理。
  3. 当用户登录成功之后先Map集合中添加一条HttpSession会话记录,用户会话销毁时从Map集合中删除一条记录。
  4. HttpSessionEventPublisher实现了HttpSessionListener接口,可以监听到HttpSession的创建和销毁事件,并将HttpSession的创建/销毁事件发布出去。这样HttpSession销毁时Spring Security就可以感知该事件了。

测试:使用两个不同的浏览器访问同一个API接口,只有一个会话会生效。

当会话失效之后设置自动跳转到登录页面

protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/test/admin").fullyAuthenticated()
.antMatchers("/test/rememberme").rememberMe()
.anyRequest().authenticated()
.and()
.formLogin()
.defaultSuccessUrl("/swagger-ui.html#")
.and()
.csrf()
.disable()
.rememberMe()
.tokenRepository(jdbcTokenRepository())
.and()
.sessionManagement()
.maximumSessions(1)
.expiredUrl("/login");
}

如果当前用户正在登录中,还有其他设备登录当前用户设置为禁止登录。直到用户注销后才能正常登录

protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/test/admin").fullyAuthenticated()
.antMatchers("/test/rememberme").rememberMe()
.anyRequest().authenticated()
.and()
.formLogin()
.defaultSuccessUrl("/swagger-ui.html#")
.and()
.csrf()
.disable()
.rememberMe()
.tokenRepository(jdbcTokenRepository())
.and()
.sessionManagement()
.maximumSessions(1)
.expiredUrl("/login")
.maxSessionsPreventsLogin(true);
}

原理分析

SessionInformation

SessionInformation主要用作Spring Security框架内的会话记录。源码如下所示:

package org.springframework.security.core.session;

public class SessionInformation implements Serializable {

private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

private Date lastRequest;
private final Object principal;
private final String sessionId;
private boolean expired = false;
}

SessionInformation属性介绍:

属性 描述
lastRequest 最后一次请求时间
principal 会话对应的用户
sessionId session的ID
expired 会话是否过期

SessionRegistry

SessionRegistry是一个接口主要用来维护SessionInformation实例,该接口只有一个实现类SessionRegistryImpl。SessionRegistryImpl源码如下所示:

package org.springframework.security.core.session;

public class SessionRegistryImpl implements SessionRegistry,
ApplicationListener<SessionDestroyedEvent> {

protected final Log logger = LogFactory.getLog(SessionRegistryImpl.class);

private final ConcurrentMap<Object, Set<String>> principals;

private final Map<String, SessionInformation> sessionIds;

public SessionRegistryImpl() {
this.principals = new ConcurrentHashMap<>();
this.sessionIds = new ConcurrentHashMap<>();
}

public SessionRegistryImpl(ConcurrentMap<Object, Set<String>> principals, Map<String, SessionInformation> sessionIds) {
this.principals=principals;
this.sessionIds=sessionIds;
}

public List<Object> getAllPrincipals() {
return new ArrayList<>(principals.keySet());
}

public List<SessionInformation> getAllSessions(Object principal,
boolean includeExpiredSessions) {
final Set<String> sessionsUsedByPrincipal = principals.get(principal);

if (sessionsUsedByPrincipal == null) {
return Collections.emptyList();
}

List<SessionInformation> list = new ArrayList<>(
sessionsUsedByPrincipal.size());

for (String sessionId : sessionsUsedByPrincipal) {
SessionInformation sessionInformation = getSessionInformation(sessionId);

if (sessionInformation == null) {
continue;
}

if (includeExpiredSessions || !sessionInformation.isExpired()) {
list.add(sessionInformation);
}
}

return list;
}

public SessionInformation getSessionInformation(String sessionId) {
Assert.hasText(sessionId, "SessionId required as per interface contract");

return sessionIds.get(sessionId);
}

public void onApplicationEvent(SessionDestroyedEvent event) {
String sessionId = event.getId();
removeSessionInformation(sessionId);
}

public void refreshLastRequest(String sessionId) {
Assert.hasText(sessionId, "SessionId required as per interface contract");

SessionInformation info = getSessionInformation(sessionId);

if (info != null) {
info.refreshLastRequest();
}
}

public void registerNewSession(String sessionId, Object principal) {
Assert.hasText(sessionId, "SessionId required as per interface contract");
Assert.notNull(principal, "Principal required as per interface contract");

if (getSessionInformation(sessionId) != null) {
removeSessionInformation(sessionId);
}

if (logger.isDebugEnabled()) {
logger.debug("Registering session " + sessionId + ", for principal "
+ principal);
}

sessionIds.put(sessionId,
new SessionInformation(principal, sessionId, new Date()));

principals.compute(principal, (key, sessionsUsedByPrincipal) -> {
if (sessionsUsedByPrincipal == null) {
sessionsUsedByPrincipal = new CopyOnWriteArraySet<>();
}
sessionsUsedByPrincipal.add(sessionId);

if (logger.isTraceEnabled()) {
logger.trace("Sessions used by '" + principal + "' : "
+ sessionsUsedByPrincipal);
}
return sessionsUsedByPrincipal;
});
}

public void removeSessionInformation(String sessionId) {
Assert.hasText(sessionId, "SessionId required as per interface contract");

SessionInformation info = getSessionInformation(sessionId);

if (info == null) {
return;
}

if (logger.isTraceEnabled()) {
logger.debug("Removing session " + sessionId
+ " from set of registered sessions");
}

sessionIds.remove(sessionId);

principals.computeIfPresent(info.getPrincipal(), (key, sessionsUsedByPrincipal) -> {
if (logger.isDebugEnabled()) {
logger.debug("Removing session " + sessionId
+ " from principal's set of registered sessions");
}

sessionsUsedByPrincipal.remove(sessionId);

if (sessionsUsedByPrincipal.isEmpty()) {
// No need to keep object in principals Map anymore
if (logger.isDebugEnabled()) {
logger.debug("Removing principal " + info.getPrincipal()
+ " from registry");
}
sessionsUsedByPrincipal = null;
}

if (logger.isTraceEnabled()) {
logger.trace("Sessions used by '" + info.getPrincipal() + "' : "
+ sessionsUsedByPrincipal);
}
return sessionsUsedByPrincipal;
});
}
}

属性和方法描述:

属性或方法 描述
principals 用来保存当前用户和sessionId之间的关系
sessionIds 用来保存sessionId和SessionInformation之间的映射关系
getAllPrincipals 返回当前登录用户的对象
getAllSessions 返回用户所对应的所有SessionInformation
getSessionInformation 根据sessionId从sessionIds集合中获取对应的SessionInformation
refreshLastRequest 根据传入的sessionId找到对应的SessionInformation,并调用自己刷新最后一次请求时间

保存session会话,registerNewSession方法:

  1. 当用户登录操作成功后,执行会话保存操作,传入当前请求的sessionId和当前登录主体principal对象。
  2. 如果sessionId已经存在,先将其删除然后往sessionIds中保存。key是sessionId,value是一个新创建的SessionInformation对象。

移除session会话,removeSessionInformation方法:

  1. 调用remove方法从sessionIds变量中删除。
  2. 从principal变量中删除,key是当前用户对象,value是一个集合保存当前用户对应所有sessionId。

SessionAuthenticationStrategy

SessionAuthenticationStrategy是一个接口,主要在用户登录成功之后对HttpSession进行处理。源码如下所示:

package org.springframework.security.web.authentication.session;

public interface SessionAuthenticationStrategy {
void onAuthentication(Authentication var1, HttpServletRequest var2, HttpServletResponse var3) throws SessionAuthenticationException;
}

SessionAuthenticationStrategy常见的几种实现类:

实现类 描述
ConcurrentSessionControlAuthenticationStrategy 用于限制用户的并发会话数量。默认情况下,超过最大并发数时,新的登录会话将使旧的会话失效。
NullAuthenticatedSessionStrategy 一个简单的实现,不执行任何特定的会话策略。
SessionFixationProtectionStrategy 用于防止会话固定攻击。在用户身份验证成功后,会创建一个新的会话,并将认证信息复制到新会话中。
SessionAuthenticationStrategyAdapter 适配器类,允许将现有的 SessionAuthenticationStrategy 包装为 SessionAuthenticationStrategy
RegisterSessionAuthenticationStrategy 用于注册用户的会话。在用户登录时,将其关联的会话注册到 SessionRegistry 中。
CompositeSessionAuthenticationStrategy 允许将多个 SessionAuthenticationStrategy 组合成一个。可以同时应用多种会话策略。
ChangeSessionIdAuthenticationStrategy 在用户身份验证成功后,更改会话ID,以防止会话固定攻击。
ConcurrentSessionFilter 一个过滤器,用于处理并发会话控制。

ConcurrentSessionControlAuthenticationStrategy#onAuthentication方法源码如下所示:

package org.springframework.security.web.authentication.session;

public class ConcurrentSessionControlAuthenticationStrategy implements MessageSourceAware, SessionAuthenticationStrategy {
protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();
private final SessionRegistry sessionRegistry;
private boolean exceptionIfMaximumExceeded = false;
private int maximumSessions = 1;

public void onAuthentication(Authentication authentication, HttpServletRequest request, HttpServletResponse response) {
List<SessionInformation> sessions = this.sessionRegistry.getAllSessions(authentication.getPrincipal(), false);
int sessionCount = sessions.size();
int allowedSessions = this.getMaximumSessionsForThisUser(authentication);
if (sessionCount >= allowedSessions) {
if (allowedSessions != -1) {
if (sessionCount == allowedSessions) {
HttpSession session = request.getSession(false);
if (session != null) {
Iterator var8 = sessions.iterator();

while(var8.hasNext()) {
SessionInformation si = (SessionInformation)var8.next();
if (si.getSessionId().equals(session.getId())) {
return;
}
}
}
}

this.allowableSessionsExceeded(sessions, allowedSessions, this.sessionRegistry);
}
}
}
}
  1. 首先从sessionRegistry中获取当前用户的所有未失效的SessionInformation实例,然后获取到当前项目允许的最大session数。
  2. 如果获取到的SessionInformation实例数小于当前项目允许的最大session数,说明当前登录没问题直接返回即可。
  3. 如果允许的最大session数量为-1,表示应用并不限制登录并发数,当前登录也没有问题直接返回。
  4. 如果获取到的SessionInformation实例等于当前项目允许的最大session数,判断当前登录的sessionId是否存在于获取到的SessionInformation实例中,如果存在说明登录也没问题。
  5. 如果都没有进入到前面的判断逻辑中说明已经超过并发数了进入allowableSessionsExceeded进行处理。

ConcurrentSessionControlAuthenticationStrategy#allowableSessionsExceeded源码如下所示:

package org.springframework.security.web.authentication.session;

public class ConcurrentSessionControlAuthenticationStrategy implements MessageSourceAware, SessionAuthenticationStrategy {
protected void allowableSessionsExceeded(List<SessionInformation> sessions, int allowableSessions, SessionRegistry registry) throws SessionAuthenticationException {
if (!this.exceptionIfMaximumExceeded && sessions != null) {
sessions.sort(Comparator.comparing(SessionInformation::getLastRequest));
int maximumSessionsExceededBy = sessions.size() - allowableSessions + 1;
List<SessionInformation> sessionsToBeExpired = sessions.subList(0, maximumSessionsExceededBy);
Iterator var6 = sessionsToBeExpired.iterator();

while (var6.hasNext()) {
SessionInformation session = (SessionInformation) var6.next();
session.expireNow();
}

} else {
throw new SessionAuthenticationException(this.messages.getMessage("ConcurrentSessionControlAuthenticationStrategy.exceededAllowed", new Object[]{allowableSessions}, "Maximum sessions of {0} for this principal exceeded"));
}
}
}
  1. 如果exceptionIfMaximumExceeded属性为true,直接抛出异常。该属性的值就是在SecurityConfig中配置maxSessionsPreventsLogin的值,即禁止后来者登录。
  2. 如果exceptionIfMaximumExceeded属性为false,说明不禁止登录,此时对查询出来的当前用户所有登录会话按照最后一次请求时间进行排序,然后计算出要过期session数量,sessions集合中取出来进行遍历,依次调用expireNow方法让其过期。

RegisterSessionAuthenticationStrategy

RegisterSessionAuthenticationStrategy主要作用是向SessionRegistry中记录HttpSession信息,从源码可以看出就是调用registerNewSession方法向SessionRegistry中添加一条登录会话信息。RegisterSessionAuthenticationStrategy#onAuthentication源码如下所示:

package org.springframework.security.web.authentication.session;

public class RegisterSessionAuthenticationStrategy implements SessionAuthenticationStrategy {
private final SessionRegistry sessionRegistry;

public void onAuthentication(Authentication authentication, HttpServletRequest request, HttpServletResponse response) {
this.sessionRegistry.registerNewSession(request.getSession().getId(), authentication.getPrincipal());
}
}

CompositeSessionAuthenticationStrategy

遍历SessionAuthenticationStrategy集合,然后分别调用其onAuthentication方法。源码如下所示:

package org.springframework.security.web.authentication.session;

public class CompositeSessionAuthenticationStrategy implements SessionAuthenticationStrategy {
private final Log logger = LogFactory.getLog(this.getClass());
private final List<SessionAuthenticationStrategy> delegateStrategies;

public void onAuthentication(Authentication authentication, HttpServletRequest request, HttpServletResponse response) throws SessionAuthenticationException {
SessionAuthenticationStrategy delegate;
for(Iterator var4 = this.delegateStrategies.iterator(); var4.hasNext(); delegate.onAuthentication(authentication, request, response)) {
delegate = (SessionAuthenticationStrategy)var4.next();
if (this.logger.isDebugEnabled()) {
this.logger.debug("Delegating to " + delegate);
}
}

}
}

SessionManagementFilter

SessionManagementFilter主要来处理RememberMe登录时的会话管理,如果用户使用RememberMe方式进行认证,认证成功之后需要会话管理,相关操作通过SessionManagementFilter过滤器触发。SessionManagementFilter#doFilter源码如下所示:

package org.springframework.security.web.session;

public class SessionManagementFilter extends GenericFilterBean {
static final String FILTER_APPLIED = "__spring_security_session_mgmt_filter_applied";
private final SecurityContextRepository securityContextRepository;
private SessionAuthenticationStrategy sessionAuthenticationStrategy;
private AuthenticationTrustResolver trustResolver;
private InvalidSessionStrategy invalidSessionStrategy;
private AuthenticationFailureHandler failureHandler;

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest)req;
HttpServletResponse response = (HttpServletResponse)res;
if (request.getAttribute("__spring_security_session_mgmt_filter_applied") != null) {
chain.doFilter(request, response);
} else {
request.setAttribute("__spring_security_session_mgmt_filter_applied", Boolean.TRUE);
if (!this.securityContextRepository.containsContext(request)) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && !this.trustResolver.isAnonymous(authentication)) {
try {
this.sessionAuthenticationStrategy.onAuthentication(authentication, request, response);
} catch (SessionAuthenticationException var8) {
this.logger.debug("SessionAuthenticationStrategy rejected the authentication object", var8);
SecurityContextHolder.clearContext();
this.failureHandler.onAuthenticationFailure(request, response, var8);
return;
}

this.securityContextRepository.saveContext(SecurityContextHolder.getContext(), request, response);
} else if (request.getRequestedSessionId() != null && !request.isRequestedSessionIdValid()) {
if (this.logger.isDebugEnabled()) {
this.logger.debug("Requested session ID " + request.getRequestedSessionId() + " is invalid.");
}

if (this.invalidSessionStrategy != null) {
this.invalidSessionStrategy.onInvalidSessionDetected(request, response);
return;
}
}
}

chain.doFilter(request, response);
}
}
}
  1. 在该过滤器中通过containsContext方法判断当前会话中是否存在SPRING_SECURITY_CONTEXT变量。
  2. 如果时正常的认证流程SPRING_SECURITY_CONTEXT变量会存在于当前会话中。
  3. 如果不存在SPRING_SECURITY_CONTEXT变量有以下两种情况:用户使用了RememberMe方式认证、用户匿名访问某个接口。

ConcurrentSessionFilter

ConcurrentSessionFilter过滤器是一个处理会话并发的管理器,ConcurrentSessionFilter#doFilter方法源码如下所示:

package org.springframework.security.web.session;

public class ConcurrentSessionFilter extends GenericFilterBean {
private final SessionRegistry sessionRegistry;
private String expiredUrl;
private RedirectStrategy redirectStrategy;
private LogoutHandler handlers = new CompositeLogoutHandler(new LogoutHandler[]{new SecurityContextLogoutHandler()});
private SessionInformationExpiredStrategy sessionInformationExpiredStrategy;

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest)req;
HttpServletResponse response = (HttpServletResponse)res;
HttpSession session = request.getSession(false);
if (session != null) {
SessionInformation info = this.sessionRegistry.getSessionInformation(session.getId());
if (info != null) {
if (info.isExpired()) {
if (this.logger.isDebugEnabled()) {
this.logger.debug("Requested session ID " + request.getRequestedSessionId() + " has expired.");
}

this.doLogout(request, response);
this.sessionInformationExpiredStrategy.onExpiredSessionDetected(new SessionInformationExpiredEvent(info, request, response));
return;
}

this.sessionRegistry.refreshLastRequest(info.getSessionId());
}
}

chain.doFilter(request, response);
}
}
  1. 从doFilter方法可以看出,当请求通过时先获取当前会话。
  2. 如果会话不为null,获取当前会话SessionInformation实例;
  3. 如果SessionInformation实例已经过期,调用doLogout方法进行注销操作。同时调用会话过期回调。
  4. 如果SessionInformation实例没有过期,刷新当前会话最后一次请求时间。

Session创建时机

在Spring Security框架中,HttpSession创建策略分为以下四种:

策略 描述
ALWAYS 如果HttpSession不存在就创建
NEVER 从不创建HttpSession,如果HttpSession存在就会使用它
IF_REQUIRED 当需要时会创建HttpSession,默认策略
STATELESS 从不创建HttpSession,也不使用HttpSession

一般来说使用默认的IF_REQUIRED策略即可,如果需要配置为其他策略可以重构SecurityConfig类。源码如下所示:

package com.acaiblog.config;

@Slf4j
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

@Override
protected void configure(HttpSecurity http) throws Exception {
http.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED);
}
}

SessionManagementConfigurer

作为配置类,主要了解SessionManagementConfigurer类的init和configure方法。源码如下所示:

package org.springframework.security.config.annotation.web.configurers;

public final class SessionManagementConfigurer<H extends HttpSecurityBuilder<H>>
extends AbstractHttpConfigurer<SessionManagementConfigurer<H>, H> {

@Override
public void init(H http) {
SecurityContextRepository securityContextRepository = http
.getSharedObject(SecurityContextRepository.class);
boolean stateless = isStateless();

if (securityContextRepository == null) {
if (stateless) {
http.setSharedObject(SecurityContextRepository.class,
new NullSecurityContextRepository());
}
else {
HttpSessionSecurityContextRepository httpSecurityRepository = new HttpSessionSecurityContextRepository();
httpSecurityRepository
.setDisableUrlRewriting(!this.enableSessionUrlRewriting);
httpSecurityRepository.setAllowSessionCreation(isAllowSessionCreation());
AuthenticationTrustResolver trustResolver = http
.getSharedObject(AuthenticationTrustResolver.class);
if (trustResolver != null) {
httpSecurityRepository.setTrustResolver(trustResolver);
}
http.setSharedObject(SecurityContextRepository.class,
httpSecurityRepository);
}
}

RequestCache requestCache = http.getSharedObject(RequestCache.class);
if (requestCache == null) {
if (stateless) {
http.setSharedObject(RequestCache.class, new NullRequestCache());
}
}
http.setSharedObject(SessionAuthenticationStrategy.class,
getSessionAuthenticationStrategy(http));
http.setSharedObject(InvalidSessionStrategy.class, getInvalidSessionStrategy());
}

@Override
public void configure(H http) {
SecurityContextRepository securityContextRepository = http
.getSharedObject(SecurityContextRepository.class);
SessionManagementFilter sessionManagementFilter = new SessionManagementFilter(
securityContextRepository, getSessionAuthenticationStrategy(http));
if (this.sessionAuthenticationErrorUrl != null) {
sessionManagementFilter.setAuthenticationFailureHandler(
new SimpleUrlAuthenticationFailureHandler(
this.sessionAuthenticationErrorUrl));
}
InvalidSessionStrategy strategy = getInvalidSessionStrategy();
if (strategy != null) {
sessionManagementFilter.setInvalidSessionStrategy(strategy);
}
AuthenticationFailureHandler failureHandler = getSessionAuthenticationFailureHandler();
if (failureHandler != null) {
sessionManagementFilter.setAuthenticationFailureHandler(failureHandler);
}
AuthenticationTrustResolver trustResolver = http
.getSharedObject(AuthenticationTrustResolver.class);
if (trustResolver != null) {
sessionManagementFilter.setTrustResolver(trustResolver);
}
sessionManagementFilter = postProcess(sessionManagementFilter);

http.addFilter(sessionManagementFilter);
if (isConcurrentSessionControlEnabled()) {
ConcurrentSessionFilter concurrentSessionFilter = createConcurrencyFilter(http);

concurrentSessionFilter = postProcess(concurrentSessionFilter);
http.addFilter(concurrentSessionFilter);
}
}
}

init方法

  1. 首先从HttpSecurity中获取SecurityContextRepository实例,如果没有获取到就创建。
  2. 如果Spring Security中HttpSession创建策略是STATELESS,使用NullSecurityContextRepository来保存SecurityContext。
  3. xxxxxxxxxx     <context:component-scan base-package=”com.acaiblog.spring”/>    <context:property-placeholder location=”classpath:db.properties”/>                                                                            tx:annotation-driven    <tx:advice id=”tx” transaction-manager=”transactionManager”>        tx:attributes            <tx:method name=”find*” read-only=”true”/>            xml

configure方法

  1. 主要构建了两个过滤器SessionManagementFilter和ConcurrentSessionFilter。
  2. SessionManagementFilter过滤器在创建时,通过getSessionAuthenticationStrategy方法获取SessionAuthenticationStrategy实例并传入sessionManagementFilter实例中。然后为其配置各种回调函数,最终将创建好的sessionManagementFilter加入HttpSecurity策略链中。
  3. 如果配置了会话并发控制(SecurityConfig中调用maximumSessions),就再创建一个ConcurrentSessionFilter过滤器并加入HttpSecurity中。

AbstractAuthenticationFilterConfigurer

AbstractAuthenticationProcessingFilter#doFilter源码如下所示:

package org.springframework.security.web.authentication;

public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean implements ApplicationEventPublisherAware, MessageSourceAware {
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest)req;
HttpServletResponse response = (HttpServletResponse)res;
if (!this.requiresAuthentication(request, response)) {
chain.doFilter(request, response);
} else {
if (this.logger.isDebugEnabled()) {
this.logger.debug("Request is to process authentication");
}

Authentication authResult;
try {
authResult = this.attemptAuthentication(request, response);
if (authResult == null) {
return;
}

this.sessionStrategy.onAuthentication(authResult, request, response);
} catch (InternalAuthenticationServiceException var8) {
this.logger.error("An internal error occurred while trying to authenticate the user.", var8);
this.unsuccessfulAuthentication(request, response, var8);
return;
} catch (AuthenticationException var9) {
this.unsuccessfulAuthentication(request, response, var9);
return;
}

if (this.continueChainBeforeSuccessfulAuthentication) {
chain.doFilter(request, response);
}

this.successfulAuthentication(request, response, chain, authResult);
}
}
}

可以看出在调用attemptAuthentication方法进行登录认证之后,调用了sessionStrategy.onAuthentication方法触发Session并发管理。
调用了sessionStrategy对象则在AbstractAuthenticationFilterConfigurer#configure方法配置的,源码如下所示:

package org.springframework.security.config.annotation.web.configurers;

public abstract class AbstractAuthenticationFilterConfigurer<B extends HttpSecurityBuilder<B>, T extends AbstractAuthenticationFilterConfigurer<B, T, F>, F extends AbstractAuthenticationProcessingFilter>
extends AbstractHttpConfigurer<T, B> {

@Override
public void configure(B http) throws Exception {
PortMapper portMapper = http.getSharedObject(PortMapper.class);
if (portMapper != null) {
authenticationEntryPoint.setPortMapper(portMapper);
}

RequestCache requestCache = http.getSharedObject(RequestCache.class);
if (requestCache != null) {
this.defaultSuccessHandler.setRequestCache(requestCache);
}

authFilter.setAuthenticationManager(http
.getSharedObject(AuthenticationManager.class));
authFilter.setAuthenticationSuccessHandler(successHandler);
authFilter.setAuthenticationFailureHandler(failureHandler);
if (authenticationDetailsSource != null) {
authFilter.setAuthenticationDetailsSource(authenticationDetailsSource);
}
SessionAuthenticationStrategy sessionAuthenticationStrategy = http
.getSharedObject(SessionAuthenticationStrategy.class);
if (sessionAuthenticationStrategy != null) {
authFilter.setSessionAuthenticationStrategy(sessionAuthenticationStrategy);
}
RememberMeServices rememberMeServices = http
.getSharedObject(RememberMeServices.class);
if (rememberMeServices != null) {
authFilter.setRememberMeServices(rememberMeServices);
}
F filter = postProcess(authFilter);
http.addFilter(filter);
}
}

可以看出configure方法中从HttpSecurity的共享对象中获取到SessionAuthenticationStrategy实例,并设置到authFilter过滤器中。

流程梳理

  1. 用户通过用户名和密码发起认证请求。
  2. 当认证成功后在AbstractAuthenticationProcessingFilter#doFilter方法触发session管理。
  3. 默认的sessionStrategy是CompositeSessionAuthenticationStrategy,一共代理了三个SessionAuthenticationStrategy。分别是:ConcurrentSessionControlAuthenticationStrategy、ChangeSessionIdAuthenticationStrategy、RegisterSessionAuthenticationStrategy。
  4. 当前请求在这三个SessionAuthenticationStrategy中执行。
  5. 第一个用来判断当前登录用户的Session数是否已经超出限制,如果超过限制就根据配置好的规则作出处理。
  6. 第二个用来修改sessionId防止会话固定攻击。
  7. 第三个用来将当前Session注册到SessionRegistry中。
  8. 使用用户名和密码登录认证不会涉及ConcurrentSessionFilter、SessionManagementFilter两个过滤器。
  9. 如果使用了RememberMe认证,则会通过SessionManagementFilter#doFilter方法触发Session并发管理。
  10. 当用户认证成功后,以后的每次请求都会经过ConcurrentSessionFilter。在该过滤器中,判断当前会话是否过期。如果过期就执行注销流程。如果没有过期就更新最后一次请求时间。

会话固定攻击于防御

什么是会话固定攻击?

会话固定攻击是一种潜在的风险,恶意攻击者有可能通过访问应用程序来创建会话。然后诱导用户以相同的会话ID登录,从而获取到用户的登录身份。

防御策略

Spring Security框架中使用以下三种方式防御会话固定攻击:

  1. Spring Security默认自带了Http防火墙,如果SessionID放在地址栏中,这个请求会被拦截。
  2. 在Http响应的Set-Cookie字段中有httpOnly属性,避免通过XSS攻击获取Cookie中的会话信息。
  3. 用户登录成功之后,改变sessionId,Spring Security中默认使用这种方案。

通过sessionFixation方法开启会话固定攻击防御的配置,以下四种不同策略,对应了不同的SessionAuthenticationStrategy:

  1. changeSessionId:用户登录成功之后,直接修改HttpSession的Session。对应处理类ChanageSessionIdAuthenticationStrategy。
  2. none:用户登录成功之后,HttpSession不做任何变化。对应处理类NullAuthenticatedStrategy。
  3. migrateSession:用户登录成功之后,创建一个新的HttpSession对象,并将旧的HttpSession中的数据复制到新的HttpSession中。对应处理类SessionFixationProtection
  4. newSession: 用户登录成功之后,创建一个新的HttpSession对象,对应处理类SessionFixationProtection。将其里边的migrateSessionAttributes属性设置为false。

Session共享

集群会话方案

为了解决集群环境下会话问题,可以通过以下三种方式实现:

  1. session复制:多个服务之间互相复制session信息,这样每个服务都包含了session信息。Tomcat通过IP组播这种方式提供支持。但是这种方案占用带宽,有延迟,服务数量越多效率越低。
  2. session会话保持:在Nginx上通过一致性Hash,将Hash结果相同的请求总是发送到一个服务上。缺点:无法解决集群中会话并发管理。
  3. session共享:将不同服务会话统一放在一个地方,所有的服务共享一个会话。一般使用key、value数据库来存储session。例如:memcached、redis等。

Session共享目前使用比较多的是spring-session,利用spring-session可以方便的实现session的管理。

实现方式

创建一个session模块
编辑pom.xml配置文件,添加以下依赖

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>2.3.2.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<version>2.3.2.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>2.3.2.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
<version>2.3.2.RELEASE</version>
</dependency>
</dependencies>

编辑resources/application.yml配置文件,添加redis配置

spring:
redis:
host: acaiblog.top
port: 6379
password: 123456

创建SecurityConfig,com.acaiblog.config.SecurityConfig

package com.acaiblog.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.session.SessionRegistry;
import org.springframework.session.FindByIndexNameSessionRepository;
import org.springframework.session.security.SpringSessionBackedSessionRegistry;

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
FindByIndexNameSessionRepository sessionRepository;

@Bean
SpringSessionBackedSessionRegistry sessionRegistry() {
return new SpringSessionBackedSessionRegistry<>(sessionRepository);
}

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("acai")
.password("{noop}123")
.roles("admin");
}

@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.csrf()
.disable()
.sessionManagement()
.maximumSessions(1)
.sessionRegistry(sessionRegistry());
}

}
  1. 首先注入FindByIndexNameSessionRepository对象,这是一个会话的存储和加载工具。具体的保存和加载过程是FindByIndexNameSessionRepository接口的实现类RedisIndexedSessionRepository完成的。
  2. 配置了一个SpringSessionBackedSessionRegistry实例,构建时传入了sessionRepository。SpringSessionBackedSessionRegistry继承自sessionRepository,用来维护会话信息注册表。
  3. 在HttpSession中配置sessionRegistry,相当于spring-session提供的SpringSessionBackedSessionRegistry接管了会话信息注册表的维护。

需要注意:引入了spring-session之后不需要配置HttpSessionEventPublisher实例,因为spring-session通过SessionRepositoryFilter将请求对象重新封装为SessionRepositoryWrapper,并重写了getSession方法。在重写的getSession方法中,最终返回的是HttpSessionWrapper实例,而在HttpSessionWrapper定义时就重写了invalidate方法。 当调用会话的invalidate方法销毁会话时,调用了RedisIndexedSessionRepository中的方法,从redis中移除对应的会话信息,所以不需要HttpSessionEventPublisher实例。
创建com.acaiblog.controller.SessionController

package com.acaiblog.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpSession;

@RestController
public class SessionController {
@GetMapping("/")
public String hello(HttpSession session) {
return session.getClass().toString();
}
}

点击Intellij IDEA右侧》Maven》Lifecycle》package进行打包,使用打包的jar包启动两个实例,参考以下命令:

java -jar session-1.0-SNAPSHOT.jar --server.port=8080
java -jar session-1.0-SNAPSHOT.jar --server.port=8081

启动之后,先用第一个浏览器访问8080端口。然后用第二个浏览器访问8081端口。最后在第一个浏览器刷新访问8080端口,如果显示以下信息说明集群会话管理已生效。

This session has been expired (possibly due to multiple concurrent logins being attempted as the same user).

HttpFirewall

HttpFirewall是Spring Security提供的HTTP防火墙,用于拒绝潜在的危险请求或者包装这些请求进而控制其行为。通过HttpFirewall可以对各种非法请求提前进行拦截并处理,降低损失。代码层面,HttpFIrewall被注入到FilterChainProxy中,并在Spring Security过滤器链执行之前被触发。

简介

Spring Security中通过HttpFirewall来检查请求路径以及参数是否合法,如果合法才会进入到过滤器链进行处理。HttpFirewall源码如下所示:

package org.springframework.security.web.firewall;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public interface HttpFirewall {
FirewalledRequest getFirewalledRequest(HttpServletRequest var1) throws RequestRejectedException;

HttpServletResponse getFirewalledResponse(HttpServletResponse var1);
}
  1. getFirewalledRequest方法用来对请求对象进行检验并封装。
  2. FirewalledRequest是封装后的请求类,但实际上该类只是在HttpServletRequestWrapper的基础上增加了reset方法。当Spring Security过滤链执行完时,FilterChainProxy负责调用该reset方法,以便重置全部或部分属性。
  3. FirewalledResponse是封装后的响应类,该类主要重写了sendRedirect、setHeader、addHeader以及addCookie四个方法。在每个方法中都对其参数进行校验,以确保参数中不包含有\r和\n。

HttpFirewalld一共有两个实现类: DefaultHttpFirewall、StrictHttpFirewall。HttpFirewall中对于请求的合法校验在FilterChainProxy#doFilterInternal方法中触发。
如果Spring容器中存在HttpFirewall实例,最终使用Spring容器中的HttpFirewall实例。如果Spring容器中不存在HttpFirewall实例,则使用FilterChainProxy中默认定义的StrictFIrewall。

HttpFirewall严格模式

HttpFIrewall严格模式就是使用StrictFirewall,在FilterChainProxy#doFilterInternal中触发校验请求,源码如下所示:

package org.springframework.security.web;

public class FilterChainProxy extends GenericFilterBean {
private void doFilterInternal(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
FirewalledRequest fwRequest = this.firewall.getFirewalledRequest((HttpServletRequest)request);
HttpServletResponse fwResponse = this.firewall.getFirewalledResponse((HttpServletResponse)response);
List<Filter> filters = this.getFilters((HttpServletRequest)fwRequest);
if (filters != null && filters.size() != 0) {
VirtualFilterChain vfc = new VirtualFilterChain(fwRequest, chain, filters);
vfc.doFilter(fwRequest, fwResponse);
} else {
if (logger.isDebugEnabled()) {
logger.debug(UrlUtils.buildRequestUrl(fwRequest) + (filters == null ? " has no matching filters" : " has an empty filter list"));
}

fwRequest.reset();
chain.doFilter(fwRequest, fwResponse);
}
}
}

请求的校验主要是在getFirewalledRequest方法中完成的。在进入Spring Security过滤器之前,请求对象和响应对象都分别换成了FirewalledRequest和FirewalldResponse。
StrictHttpFirewall#getFirewalledRequest方法源码如下:

package org.springframework.security.web.firewall;

public class StrictHttpFirewall implements HttpFirewall {
public FirewalledRequest getFirewalledRequest(HttpServletRequest request) throws RequestRejectedException {
this.rejectForbiddenHttpMethod(request);
this.rejectedBlacklistedUrls(request);
this.rejectedUntrustedHosts(request);
if (!isNormalized(request)) {
throw new RequestRejectedException("The request was rejected because the URL was not normalized.");
} else {
String requestUri = request.getRequestURI();
if (!containsOnlyPrintableAsciiCharacters(requestUri)) {
throw new RequestRejectedException("The requestURI was rejected because it can only contain printable ASCII characters.");
} else {
return new FirewalledRequest(request) {
public void reset() {
}
};
}
}
}
}

在返回对象之前一共做了五个校验:

方法 描述
rejectForbiddenHttpMethod 校验请求是否合法
rejectedBlacklistedUrls 校验请求中的非法字符
rejectedUntrustedHosts 校验主机信息
isNormalized 判断参数格式是否合法
containsOnlyPrintableAsciiCharacters 判断请求字符是否合法

rejectForbiddenHttpMethod

rejectForbiddenHttpMethod用来判断请求是否合法,源码如下:

private void rejectForbiddenHttpMethod(HttpServletRequest request) {
if (this.allowedHttpMethods != ALLOW_ANY_HTTP_METHOD) {
if (!this.allowedHttpMethods.contains(request.getMethod())) {
throw new RequestRejectedException("The request was rejected because the HTTP method \"" + request.getMethod() + "\" was not included within the whitelist " + this.allowedHttpMethods);
}
}
}
  1. allowedHttpMethods是一个Set集合,默认情况下该集合包含七个常见的方法:DELETE、GET、HEAD、OPTIONS、PATCH、POST、PUT、ALLOW_ANY_HTTP_METHOD。
  2. 根据rejectForbiddenHttpMethod中的定义,只要请求是这七个方法中的其中之一,请求都是可以通过的,不会被拦截。

开发者可以根据自己的需求修改allowedHttpMethods变量的值,第一种修改方式:

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
HttpFirewall httpFirewall() {
StrictHttpFirewall strictHttpFirewall = new StrictHttpFirewall();
Set<String> allowedHttpMethods = new HashSet<>();
allowedHttpMethods.add(HttpMethod.POST.name());
strictHttpFirewall.setAllowedHttpMethods(allowedHttpMethods);
return strictHttpFirewall;
}
}

开发者自己配置一个HttpFirewall实例,并调用setAllowedHttpMethods方法传入一个Set集合。集合中保存着允许请求的方法,这个集合最终赋值给allowedHttpMethods变量。配置完成之后重启项目,只有POST请求能被请求,如果请求GET或其他方法会提示以下异常:

org.springframework.security.web.firewall.RequestRejectedException: The request was rejected because the HTTP method "GET" was not included within the whitelist [POST]

第二种修改方式如下:

public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
HttpFirewall httpFirewall() {
StrictHttpFirewall strictHttpFirewall = new StrictHttpFirewall();
strictHttpFirewall.setUnsafeAllowAnyHttpMethod(true);
return strictHttpFirewall;
}
}

这种方式直接调用setUnsafeAllowAnyHttpMethod方法并设置参数为true,表示所有请求都可以通过。该方法会设置allowedHttpMethods等于ALLOW_ANY_HTTP_METHOD,这样导致rejectForbiddenHttpMethod方法的第一个if分支中直接返回,达到允许所有请求通过的目的。

rejectedBlacklistedUrls

rejectedBlacklistedUrls主要校验不合法的URL,源码如下所示:

package org.springframework.security.web.firewall;

public class StrictHttpFirewall implements HttpFirewall {
private void rejectedBlacklistedUrls(HttpServletRequest request) {
Iterator var2 = this.encodedUrlBlacklist.iterator();

String forbidden;
do {
if (!var2.hasNext()) {
var2 = this.decodedUrlBlacklist.iterator();

do {
if (!var2.hasNext()) {
return;
}

forbidden = (String)var2.next();
} while(!decodedUrlContains(request, forbidden));

throw new RequestRejectedException("The request was rejected because the URL contained a potentially malicious String \"" + forbidden + "\"");
}

forbidden = (String)var2.next();
} while(!encodedUrlContains(request, forbidden));

throw new RequestRejectedException("The request was rejected because the URL contained a potentially malicious String \"" + forbidden + "\"");
}
}
  1. 共有两个for循环,第一个校验编码后的请求地址,第二个校验解码后的请求地址。
  2. 在encodedUrlContains方法中主要校验了contextPath和requestURI两个属性,这两个属性是客户端传过来的字符串未做修改。
  3. 在decodedUrlContains方法中主要校验了servletPath、pathInfo两个属性。
  4. requestURI是客户端发过来的请求,而servletPath和pathInfo是经过解码的请求地址,所以两者是不一样的。
  5. 如果客户端发送过来的请求是:http://localhost:8080/get%3baaa 那么requestURI的值就是http://localhost:8080/get%3baaa,而servletPath的值则是/get;aaa,所以在servletPath红将%3b还原为分号了。

rejectedUntrustedHosts

rejectedUntrustedHosts主要校验Host是否受信任,源码如下所示:

package org.springframework.security.web.firewall;

public class StrictHttpFirewall implements HttpFirewall {
private void rejectedUntrustedHosts(HttpServletRequest request) {
String serverName = request.getServerName();
if (serverName != null && !this.allowedHostnames.test(serverName)) {
throw new RequestRejectedException("The request was rejected because the domain " + serverName + " is untrusted.");
}
}
}

从源码可以看出,rejectedUntrustedHosts主要是对serverName进行校验。allowedHostnames默认总是返回true,所以对所有主机都信任。可以在SecurityConfig中定义如下代码:

public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
HttpFirewall httpFirewall() {
StrictHttpFirewall strictHttpFirewall = new StrictHttpFirewall();
strictHttpFirewall.setAllowedHostnames(
(hostname) -> hostname.equalsIgnoreCase("www.acaiblog.top")
);
return strictHttpFirewall;
}
}

这段配置表示主机必须是www.acaiblog.top才能访问,否则会抛出以下异常

org.springframework.security.web.firewall.RequestRejectedException: The request was rejected because the domain 127.0.0.1 is untrusted.

isNormalized

isNormalized方法主要用来检查请求地址是否合法,源码如下所示:

package org.springframework.security.web.firewall;

public class StrictHttpFirewall implements HttpFirewall {
private static boolean isNormalized(HttpServletRequest request) {
if (!isNormalized(request.getRequestURI())) {
return false;
} else if (!isNormalized(request.getContextPath())) {
return false;
} else if (!isNormalized(request.getServletPath())) {
return false;
} else {
return isNormalized(request.getPathInfo());
}
}
}

从源码可以看出,该方法对requestURI、ContextPath、ServletPath、PathInfo分别进行了校验。

HttpFirewalld普通模式

HttpFirewalld普通模式就是使用DefaultHttpFirewall,该类校验规则比较简单,主要看getFirewalledRequest方法,源码如下:

package org.springframework.security.web.firewall;

public class DefaultHttpFirewall implements HttpFirewall {
public FirewalledRequest getFirewalledRequest(HttpServletRequest request) throws RequestRejectedException {
FirewalledRequest fwr = new RequestWrapper(request);
if (this.isNormalized(fwr.getServletPath()) && this.isNormalized(fwr.getPathInfo())) {
String requestURI = fwr.getRequestURI();
if (this.containsInvalidUrlEncodedSlash(requestURI)) {
throw new RequestRejectedException("The requestURI cannot contain encoded slash. Got " + requestURI);
} else {
return fwr;
}
} else {
throw new RequestRejectedException("Un-normalized paths are not supported: " + fwr.getServletPath() + (fwr.getPathInfo() != null ? fwr.getPathInfo() : ""));
}
}
}

首先构建了RequestWrapper对象,在构建RequestWrapper的过程中主要做了以下两件事:将请求地址中//格式化为/、将请求中的servletPath和pathInfo用分号隔开的参数提取出来,只保留路径。
开发者如果要使用DefaultHttpFirewall,只需要在SecurityConfig中添加以下配置即可:

public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
HttpFirewall httpFirewall() {
return new DefaultHttpFirewall();
}
}

HTTP认证

HTTP提供了一个用于权限控制和认证的通用方式,这种认证方式通过HTTP请求头来提供认证信息,而不是通过表单登录。

HTTP Basic authentication

将用户登录的用户名/密码经过Base64编码之后,放在请求头的authorization字段中,从而完成用户身份的认证。

基本用法

创建springboot项目http_auth,并配置pom.xml

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>2.3.2.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<version>2.3.2.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>2.3.2.RELEASE</version>
</dependency>
<dependency>
<groupId>com.spring4all</groupId>
<artifactId>swagger-spring-boot-starter</artifactId>
<version>1.9.1.RELEASE</version>
</dependency>
<!-- Spring Boot Starter for Log4j2 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-log4j2</artifactId>
<version>2.4.2</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>RELEASE</version>
<scope>compile</scope>
</dependency>
</dependencies>

创建SpringBoot启动类


文章作者: 慕容峻才
文章链接: https://www.acaiblog.top/Spring-Security详解/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 阿才的博客
微信打赏
支付宝打赏