参考:认证 :: Spring Security Reference (springdoc.cn)
本文将讲述Spring Security中与认证相关的各个基本模块,力求对整个认证框架提供完善的认知。
众所周知,认证Authentication实际上就是一个代表身份的类,实例就是具体的身份,那么与这个身份有关的操作有哪些呢:
验证用户最常见的方法之一是验证用户名和密码。 Spring Security 为此进行验证提供了全面的支持。
之前的Spring Security:总体架构中,我们已经讲过,Spring Security是依赖于一个个过滤器实现所有功能模块的。读取用户名和密码,自然靠的也是过滤器。
Spring Security提供了对通过HTML表单提供用户名和密码的支持,并通过UsernamePasswordAuthenticationFilter类从用户请求中获得表单的属性。
先讲一下总体流程(可参考Spring Security:认证默认流程解析),然后讲一部分源码。
前置流程
在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
Please Log In
Please Log In
Invalid username and password.
You have been logged out.
如果使用了Spring MVC,需要将 GET /login 映射到我们创建的登录模板。
@Controller
class LoginController {
@GetMapping("/login")
String login() {
return "login";
}
}
接下来讲解一下源码:(看源码前请保证已阅读总体架构、认证架构、默认流程示意图)
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类实现了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进行验证
}
Spring Security同样提供了对 Basic HTTP Authentication(HTTP基础认证) 的支持。
在SecurityFilterChain中启用 HTTP Basic 认证 的最小配置:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
http
// ...
.httpBasic(withDefaults());
return http.build();
}
请自行了解:摘要(Digest)认证 :: Spring Security Reference (springdoc.cn)。
Spring Security提供了优秀的分层机制,之前提过的任何读取用户名和密码的方式都可以和任意密码存储与验证的方式搭配。
不过要讲密码的存储与验证,就必须先知道DaoAuthenticationProvider、UserDetailsService、PasswordEncoder、UserDetails等的概念。
顾名思义,是详细的用户信息,其内部封装了用户的核心安全数据,通常包含(用户名、密码、是否可用、是否过期、是否被锁定、权限/角色集合)。
常用于验证一个 Authentication 是否有效。
用于提供 UserDetails 的 service 类,会被 DaoAuthenticationProvider 调用,用来检索一个 Authentication 对应的实际用户类的用户名、密码和其他属性。
(注意:如果想要自定义认证方式,除了直接使用 AuthenticationManagerBuilder 或 AuthenticationProvider Bean 来提供新的AuthenticationProvider之外,也可以复用 DaoAuthenticationProvider,并使用自定义的 UserDetailsService Bean 来更改自定义认证的逻辑。)
用来对用户密码明文进行加密的工具。
DaoAuthenticationProvider的工作原理:
看一下默认情况下的源码:
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。
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);
}
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;
}
案例:
@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 的管理,提供了一组便利的方法来管理用户的创建、更新、删除等操作。相关的应用可自行了解。
请自行了解:LDAP 认证 :: Spring Security Reference (springdoc.cn)
Spring Security 会将 Authentication 存入到 SecurityContext 中,用以代表用户的身份。
那么当用户在未来再次发起请求时,该如何将未来的请求与当前的 SecurityContext 联系起来呢?
在Spring Security中,用户与未来请求的关联是通过 SecurityContextRepository 实现的。
SecurityContextRepository 是一个接口,通常使用其实现类 DelegatingSecurityContextRepository、HttpSessionSecurityContextRepository、RequestAttributeSecurityContextRepository、NullSecurityContextRepository。
设置方式:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// ...
.securityContext((securityContext) -> securityContext
.securityContextRepository(new DelegatingSecurityContextRepository(
new RequestAttributeSecurityContextRepository(),
new HttpSessionSecurityContextRepository()
))
);
return http.build();
}
与持久化认证的过滤器有两个:SecurityContextPersistenceFilter 和 SecurityContextHolderFilter,不过SecurityContextPersistenceFilter已经弃用了,这里就只讲 SecurityContextHolderFilter 了。
SecurityContextHolderFilter的作用:使用 SecurityContextRepository 加载 SecurityContext。
注意:SecurityContextHolderFilter 只加载 SecurityContext,并不保存 SecurityContext。如果使用的是 SecurityContextHolderFilter 而不是 SecurityContextPersistenceFilter,需要明确地保存 SecurityContext。
保存方式:(参考Spring Security:认证架构)
SecurityContext context = SecurityContextHolder.createEmptyContext(); //创建一个空的 SecurityContext
context.setAuthentication(authentication); //设置认证信息到安全上下文中
SecurityContextHolder.setContext(context); //在 SecurityContextHolder 上设置 SecurityContext。
自行了解,可参考:持久化认证 :: Spring Security Reference (springdoc.cn)。