参考:认证 :: Spring Security Reference (springdoc.cn)
本文将讲述Spring Security中与认证相关的各个基本模块,力求对整个认证框架提供完善的认知。
众所周知,认证Authentication实际上就是一个代表身份的类,实例就是具体的身份,那么与这个身份有关的操作有哪些呢:
- 用户名/密码 认证:通过用户提供的用户名/密码信息构建一个认证实例用于校验;校验后提供用户一个认证代表用户的身份。
- 读取用户名/密码(从用户的请求中获得账号密码)
- 密码存储(获得数据库等空间中的用户信息,进行验证操作)
- 持久化认证:让用户未来的请求也能携带或使用当前的身份(认证实例)。
- 会话管理:管理用户的登录状态,比如限制同时可登录的数量、自己存储认证而不是让 Spring Security 负责。(自行了解:认证持久性和会话(Session)管理 :: Spring Security Reference (springdoc.cn))
- 其他如remember me、预认证等(自行了解:认证 :: Spring Security Reference (springdoc.cn))
一、获得认证
验证用户最常见的方法之一是验证用户名和密码。 Spring Security 为此进行验证提供了全面的支持。
1.1读取用户名和密码
之前的Spring Security:总体架构中,我们已经讲过,Spring Security是依赖于一个个过滤器实现所有功能模块的。读取用户名和密码,自然靠的也是过滤器。
1.1.1读取表单登录参数
Spring Security提供了对通过HTML表单提供用户名和密码的支持,并通过UsernamePasswordAuthenticationFilter类从用户请求中获得表单的属性。
先讲一下总体流程(可参考Spring Security:认证默认流程解析),然后讲一部分源码。
前置流程
- 一个用户向一个需要授权的资源发出一个未经认证的请求。
- AuthorizationFilter 在判断有无权限后抛出 AccessDeniedException 异常。
- 该异常被 ExceptionTranslationFilter 捕获,并判断当前用户认证为匿名认证(即未认证用户)后,调用 Start Authentication 函数,使用一个AuthenticationEntryPoint(默认为LoginUrlAuthenticationEntryPoint的实例)将请求重定向到登录页面。
- 浏览器请求登录页面。
- 后端渲染登录界面,并返回动态网页。
认证流程
- 用户提交他们的用户名和密码后,UsernamePasswordAuthenticationFilter 从请求中提取用户名和密码,并创建一个Authentication(UsernamePasswordAuthenticationToken的实例)。
- UsernamePasswordAuthenticationFilter 调用 AuthenticationManager(ProviderManager 的一个实例)对 Authentication 进行校验。
- 如果认证失败:
- 清除 SecurityContextHolder 中当前的 SecurityContext。
- 调用 RememberMeServices的loginFail 函数。
- 调用 AuthenticationFailureHandler 进行处理。
- 如果认证成功:
- SessionAuthenticationStrategy 被通知有新的登录。
- 将 Authentication 保存到 SecurityContextHolder 中当前的 SecurityContext。
- 调用 RememberMeServices 的 loginSuccess 函数。
- ApplicationEventPublisher 发布 InteractiveAuthenticationSuccessEvent 事件。
- AuthenticationSuccessHandler 被调用,通常这会重定向到原先 ExceptionTranslationFilter 中保存的原请求。
配置方式
在SecurityFilterChain中启用表单登录的最小配置:
public SecurityFilterChain filterChain(HttpSecurity http) {
http
.formLogin(withDefaults());
// ...
}
在SecurityFilterChain中设置一个自定义的登录表单:用于更改重定向到的登录页面
public SecurityFilterChain filterChain(HttpSecurity http) {
http
.formLogin(form -> form
.loginPage("/login")
.permitAll()
);
// ...
}
然后在实现这个登录表单:文件位置为src/main/resources/templates/login.html
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
<head>
<title>Please Log In</title>
</head>
<body>
<h1>Please Log In</h1>
<div th:if="${param.error}">
Invalid username and password.</div>
<div th:if="${param.logout}">
You have been logged out.</div>
<form th:action="@{/login}" method="post">
<div>
<input type="text" name="username" placeholder="Username"/>
</div>
<div>
<input type="password" name="password" placeholder="Password"/>
</div>
<input type="submit" value="Log in" />
</form>
</body>
</html>
- 表单以 post 方法请求 /login。
- 在一个名为 username 的参数中指定用户名。
- 在一个名为 password 的参数中指定密码。
- 如果发现名为 error 的HTTP参数,表明用户未能提供一个有效的用户名或密码。
- 如果发现名为 logout 的HTTP参数,表明用户已经成功注销。
如果使用了Spring MVC,需要将 GET /login 映射到我们创建的登录模板。
@Controller
class LoginController {
@GetMapping("/login")
String login() {
return "login";
}
}
接下来讲解一下源码:(看源码前请保证已阅读总体架构、认证架构、默认流程示意图)
AbstractAuthenticationProcessingFilter
UsernamePasswordAuthenticationFilter的父类,doFilter函数的实现实际上是写在AbstractAuthenticationProcessingFilter中的,大致代码为:
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain){
//...
try {
//...
Authentication authenticationResult = attemptAuthentication(request, response);//调用UsernamePasswordAuthenticationFilter中实现
//...
successfulAuthentication(request, response, chain, authenticationResult);//认证成功的处理流程
}catch(AuthenticationException ex){
unsuccessfulAuthentication(request, response, ex);//认证失败的处理流程
}
}
UsernamePasswordAuthenticationFilter
UsernamePasswordAuthenticationFilter类实现了attemptAuthentication方法,用于获得login表单中参数,并进行验证,大致代码为:
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
if (this.postOnly && !request.getMethod().equals("POST")) {//判断是否为post请求类型
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
String username = obtainUsername(request);//提取username参数
username = (username != null) ? username.trim() : "";
String password = obtainPassword(request);//提取password参数
password = (password != null) ? password : "";
UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username,
password);//封装为Authentication
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);//对封装的Authentication进行验证
}
1.1.2读取Basic认证参数
Spring Security同样提供了对 Basic HTTP Authentication(HTTP基础认证) 的支持。
前置流程
- 一个用户向一个需要授权的资源发出一个未经认证的请求。(与表单登录一样)
- AuthorizationFilter 在判断有无权限后抛出 AccessDeniedException 异常。(与表单登录一样)
- 该异常被 ExceptionTranslationFilter 捕获,并判断当前用户认证为匿名认证(即未认证用户)后,调用 Start Authentication 函数,使用一个AuthenticationEntryPoint(更改为 BasicAuthenticationEntryPoint 的实例)发送一个 WWW-Authenticate 头。(注意:不同于原先会在 ExceptionTranslationFilter 中存储请求,Basic HTTP Authentication 的情况下浏览器会自动重放它最初的请求。)
认证流程
- 用户提交他们的用户名和密码后,BasicAuthenticationFilter 从请求中提取用户名和密码,并创建一个Authentication(UsernamePasswordAuthenticationToken的实例)。
- BasicAuthenticationFilter 调用 AuthenticationManager(ProviderManager 的一个实例)对 Authentication 进行校验。
- 如果认证失败:
-
- 清除 SecurityContextHolder 中当前的 SecurityContext。
- 调用 RememberMeServices的loginFail 函数。
- 调用 AuthenticationFailureHandler 进行处理,触发 WWW-Authenticate 再次发送。
- 如果认证成功:
-
- 将 Authentication 保存到 SecurityContextHolder 中当前的 SecurityContext。
- 调用 RememberMeServices 的 loginSuccess 函数。
- 调用 FilterChain.doFilter(request,response) 来继续执行其余的应用逻辑。
配置方式
在SecurityFilterChain中启用 HTTP Basic 认证 的最小配置:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
http
// ...
.httpBasic(withDefaults());
return http.build();
}
1.1.3摘要认证
请自行了解:摘要(Digest)认证 :: Spring Security Reference (springdoc.cn)。
1.2密码存储与验证
Spring Security提供了优秀的分层机制,之前提过的任何读取用户名和密码的方式都可以和任意密码存储与验证的方式搭配。
不过要讲密码的存储与验证,就必须先知道DaoAuthenticationProvider、UserDetailsService、PasswordEncoder、UserDetails等的概念。
1.2.1基础概念
UserDetails
顾名思义,是详细的用户信息,其内部封装了用户的核心安全数据,通常包含(用户名、密码、是否可用、是否过期、是否被锁定、权限/角色集合)。
常用于验证一个 Authentication 是否有效。
UserDetailsService
用于提供 UserDetails 的 service 类,会被 DaoAuthenticationProvider 调用,用来检索一个 Authentication 对应的实际用户类的用户名、密码和其他属性。
(注意:如果想要自定义认证方式,除了直接使用 AuthenticationManagerBuilder 或 AuthenticationProvider Bean 来提供新的AuthenticationProvider之外,也可以复用 DaoAuthenticationProvider,并使用自定义的 UserDetailsService Bean 来更改自定义认证的逻辑。)
PasswordEncoder
用来对用户密码明文进行加密的工具。
DaoAuthenticationProvider
DaoAuthenticationProvider的工作原理:
- Filter 将用户名/密码封装成了 Authentication,以备校验使用。
- Filter 调用 ProviderManager 进行校验,而 ProviderManager 会使用 DaoAuthenticationProvider 来进行校验。
- DaoAuthenticationProvider 从 UserDetailsService 中查找 UserDetails。
- DaoAuthenticationProvider 使用 PasswordEncoder 来验证上一步返回的 UserDetails 上的密码。
- 当认证成功时,将 Authentication 的 principal(委托人)属性设置为之前返回的 UserDetails,并返回 Authentication。
看一下默认情况下的源码:
DaoAuthenticationProvider 的父类是 AbstractUserDetailsAuthenticationProvider,ProviderManager 调用验证的方法 authenticate 的实现也是写在 AbstractUserDetailsAuthenticationProvider 中的:
public Authentication authenticate(Authentication authentication){
//...
user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
//...
}
retrieveUser的实现在 DaoAuthenticationProvider 中:
protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication){
//...
UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
//...
}
调用了 UserDetailsService(InMemoryUserDetailsManager 的实例)的 loadUserByUsername 方法,用来获得 UserDetails:
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UserDetails user = this.users.get(username.toLowerCase());//获得UserDetails
if (user == null) {
throw new UsernameNotFoundException(username);
}
return new User(user.getUsername(), user.getPassword(), user.isEnabled(), user.isAccountNonExpired(),
user.isCredentialsNonExpired(), user.isAccountNonLocked(), user.getAuthorities());
}
这样就获得了 UserDetails,接下来就要 DaoAuthenticationProvider(AbstractUserDetailsAuthenticationProvider)继续验证 Authentication 了:
public Authentication authenticate(Authentication authentication){
//...
user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);//刚刚运行到这
//...
additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);//现在运行这个
}
additionalAuthenticationChecks 实现在 DaoAuthenticationProvider中:
protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication){
//...
String presentedPassword = authentication.getCredentials().toString();//获得密码
if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {//调用passwordEncoder进行验证,不通过就抛异常
this.logger.debug("Failed to authenticate since password does not match stored value");
throw new BadCredentialsException(this.messages
.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
}
passwordEncoder的代码不重要,忽略,现在 additionalAuthenticationChecks 已经执行完了,继续看 AbstractUserDetailsAuthenticationProvider 的 authenticate 方法:
public Authentication authenticate(Authentication authentication){
//...
user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
//...
additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);//刚刚运行到这
//...
this.userCache.putUserInCache(user);//将验证后的Authentication存入缓存
//...
return createSuccessAuthentication(principalToReturn, authentication, user);
}
createSuccessAuthentication 会返回一个验证成功的 Authentication 对象,并使用PasswordEncoder做好加密处理:
protected Authentication createSuccessAuthentication(Object principal, Authentication authentication, UserDetails user) {
//...
String presentedPassword = authentication.getCredentials().toString();
String newPassword = this.passwordEncoder.encode(presentedPassword);
user = this.userDetailsPasswordService.updatePassword(user, newPassword);
//...
return super.createSuccessAuthentication(principal, authentication, user);//超类的工作请自行了解
}
至此,DaoAuthenticationProvider 的方法执行结束,ProviderManager 已获得一个验证完成、代表用户身份的新 Authentication。
1.2.2存储在内存中的用户名/密码
Spring Security 的 InMemoryUserDetailsManager 实现了 UserDetailsService,为存储在内存中的基于用户名/密码的认证提供支持。
让我们回忆一下,UserDetailsService 干了什么?当然是提供UserDetails。InMemoryUserDetailsManager 是一种直接在内存中存放 UserDetails 的 UserDetailsService 实现类。
通过 InMemoryUserDetailsManager 的构造方法,我们可以直接创建 UserDetails 类用于验证:
@Bean
public UserDetailsService users() {
UserDetails user = User.builder()
.username("user")
.password("{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW")
.roles("USER")
.build();
UserDetails admin = User.builder()
.username("admin")
.password("{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW")
.roles("USER", "ADMIN")
.build();
return new InMemoryUserDetailsManager(user, admin);
}
这种方式需要提前加密 Password,当然也可以现场调用PasswordEncoder进行加密:
@Bean
public UserDetailsService users() {
// The builder will ensure the passwords are encoded before saving in memory
UserBuilder users = User.withDefaultPasswordEncoder();
UserDetails user = users
.username("user")
.password("password")
.roles("USER")
.build();
UserDetails admin = users
.username("admin")
.password("password")
.roles("USER", "ADMIN")
.build();
return new InMemoryUserDetailsManager(user, admin);
}
1.2.3存储在数据库中的用户名/密码
Spring Security 的 JdbcDaoImpl 实现了 UserDetailsService,以提供对基于用户名和密码的认证的支持。
(实际上就是 Spring Security 自己提供了一套使用 JDBC 进行检索的框架,当然我们也可以自己实现 UserDetailsService 进行查询数据库并提供 UserDetails。)
要求数据库格式
默认格式:
create table users(
username varchar_ignorecase(50) not null primary key,
password varchar_ignorecase(500) not null,
enabled boolean not null
);
create table authorities (
username varchar_ignorecase(50) not null,
authority varchar_ignorecase(50) not null,
constraint fk_authorities_users foreign key(username) references users(username)
);
create unique index ix_auth_username on authorities (username,authority);
如果是按组进行权限分配的形式:
create table groups (
id bigint generated by default as identity(start with 0) primary key,
group_name varchar_ignorecase(50) not null
);
create table group_authorities (
group_id bigint not null,
authority varchar(50) not null,
constraint fk_group_authorities_group foreign key(group_id) references groups(id)
);
create table group_members (
id bigint generated by default as identity(start with 0) primary key,
username varchar(50) not null,
group_id bigint not null,
constraint fk_group_members_group foreign key(group_id) references groups(id)
);
设置数据源
必须创建一个数据源用以获得用户信息:
@Bean
DataSource dataSource() {
return new EmbeddedDatabaseBuilder()
.setType(H2)
.addScript(JdbcDaoImpl.DEFAULT_USER_SCHEMA_DDL_LOCATION)
.build();
}
(设置了一个嵌入式的数据源,用默认的 user schema 进行初始化。)
常用数据源设置方法:
@Bean
public DataSource dataSource() {
DriverManagerDataSource dataSource = new DriverManagerDataSource();
dataSource.setDriverClassName("com.mysql.jdbc.Driver");
dataSource.setUrl("jdbc:mysql://localhost:3306/db_name");
dataSource.setUsername("username");
dataSource.setPassword("password");
return dataSource;
}
配置JdbcDaoImpl
案例:
@EnableWebSecurity
@Configuration
public class MySecurityConfig {
public DataSource dataSource() {//创建数据源的函数
DriverManagerDataSource dataSource = new DriverManagerDataSource();
dataSource.setDriverClassName("com.mysql.cj.jdbc.Driver");
dataSource.setUrl("jdbc:mysql://127.0.0.1:3306/securitytest?serverTimezone=UTC&useUnicode=true&characterEncoding=utf-8&useSSL=true");
dataSource.setUsername("XXXXXX");
dataSource.setPassword("XXXXXX");
return dataSource;
}
public JdbcDaoImpl myJdbcDaoImpl(){//创建JdbcDaoImpl的函数
JdbcDaoImpl jdbcDao = new JdbcDaoImpl();
jdbcDao.setDataSource(dataSource());
jdbcDao.setUsersByUsernameQuery("SELECT username, password, enabled FROM users WHERE username = ?");
jdbcDao.setAuthoritiesByUsernameQuery("SELECT username, authority FROM authorities WHERE username = ?");
return jdbcDao;
}
@Bean
public PasswordEncoder nullPassword(){//由于我数据库中存的是明文密码,需要将PasswordEncoder的matches重写为直接比较,而不是原先的加密输入密码后比较
return new PasswordEncoder() {
private final PasswordEncoder delegate = new Pbkdf2PasswordEncoder("mySecret", 10000, 128, Pbkdf2PasswordEncoder.SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA512);
@Override
public String encode(CharSequence rawPassword) {
return delegate.encode(rawPassword);
}
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
return rawPassword.toString().equals(encodedPassword);
}
};
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(authorize -> authorize
.anyRequest().authenticated()
)
.formLogin(Customizer.withDefaults());
http.userDetailsService(myJdbcDaoImpl());//添加一个UserDetailsService用来验证,保证可以根据数据库中账号密码进行登录
return http.build();
}
}
JdbcUserDetailsManager 扩展了 JdbcDaoImpl,通过 UserDetailsManager 接口提供对 UserDetails 的管理,提供了一组便利的方法来管理用户的创建、更新、删除等操作。相关的应用可自行了解。
1.2.4LDAP 认证
请自行了解:LDAP 认证 :: Spring Security Reference (springdoc.cn)
二、持久化认证
Spring Security 会将 Authentication 存入到 SecurityContext 中,用以代表用户的身份。
那么当用户在未来再次发起请求时,该如何将未来的请求与当前的 SecurityContext 联系起来呢?
在Spring Security中,用户与未来请求的关联是通过 SecurityContextRepository 实现的。
2.1常用配置
SecurityContextRepository 是一个接口,通常使用其实现类 DelegatingSecurityContextRepository、HttpSessionSecurityContextRepository、RequestAttributeSecurityContextRepository、NullSecurityContextRepository。
- DelegatingSecurityContextRepository:一个代理类,这个类可以包含很多的 SecurityContextRepository 的具体实现,然后通过这些具体实现将认证保存。(默认包含下面两者)
- HttpSessionSecurityContextRepository:将 SecurityContext 与 HttpSession 相关联,即将 Authentication 存入 HttpSession。
- RequestAttributeSecurityContextRepository:将 SecurityContext 保存为请求属性(request attribute),以确保 SecurityContext 可用于跨调度(dispatch)类型发生的单个请求,这些调度类型可能会清除 SecurityContext。
- NullSecurityContextRepository:不做任何事情。
设置方式:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// ...
.securityContext((securityContext) -> securityContext
.securityContextRepository(new DelegatingSecurityContextRepository(
new RequestAttributeSecurityContextRepository(),
new HttpSessionSecurityContextRepository()
))
);
return http.build();
}
2.2基本原理
与持久化认证的过滤器有两个:SecurityContextPersistenceFilter 和 SecurityContextHolderFilter,不过SecurityContextPersistenceFilter已经弃用了,这里就只讲 SecurityContextHolderFilter 了。
2.2.1SecurityContextHolderFilter
SecurityContextHolderFilter的作用:使用 SecurityContextRepository 加载 SecurityContext。
- SecurityContextHolderFilter 从S ecurityContextRepository 加载 SecurityContext 并将其设置在 SecurityContextHolder 上。
- 接下来,继续运行过滤器链剩余部分。
注意:SecurityContextHolderFilter 只加载 SecurityContext,并不保存 SecurityContext。如果使用的是 SecurityContextHolderFilter 而不是 SecurityContextPersistenceFilter,需要明确地保存 SecurityContext。
保存方式:(参考Spring Security:认证架构)
SecurityContext context = SecurityContextHolder.createEmptyContext(); //创建一个空的 SecurityContext
context.setAuthentication(authentication); //设置认证信息到安全上下文中
SecurityContextHolder.setContext(context); //在 SecurityContextHolder 上设置 SecurityContext。
2.2.2SecurityContextPersistenceFilter
自行了解,可参考:持久化认证 :: Spring Security Reference (springdoc.cn)。