前言最近在进行SpringBoot开发相关的工作时想向里面加入权限认证的功能,但是在网上找学习资料找得比较痛苦,所以就有了这篇文章。先说需求吧,就是简单的登录、授权、鉴权和权限控制。
环境搭建已经2023年了,SpringBoot都更到3了,这个时候就应该直接上JDK20+SpringBoot3+SpringSecurity6。首先用IDEA创建SpringBoot项目,然后在项目中引入相关的依赖:
1
2
3
4
5
<dependency>
<groupId> org.springframework.boot</groupId>
<artifactId> spring-boot-starter-security</artifactId>
<version> 3.2.0</version>
</dependency>
加入这一段之后启动项目,控制台会打印出密码,再访问8080端口就会被重定向到/login
页面,输入用户名user
和控制台打印出来的密码就能登录。
好的,已经实现了一个简单的登录页面了。不过我不想用随机生成的uuid
作为密码,想指定一个账号密码,就可以把账号密码写到配置文件里面:
1
2
3
4
5
spring :
security :
user :
name : admin
password : 123456
SecurityFilterChain
例如现在想实现一个业务逻辑:/
路由允许所有人访问,/user
路由仅允许用户登录后访问,/admin
路由仅允许拥有管理员权限的用户访问。
首先得启用WebSecurity
配置,先新建一个SecurityConfig
类,给他加上@Configuration
和@EnableWebSecurity
两个注解:
1
2
3
4
5
@Configuration
@EnableWebSecurity
public class SecurityConfig {
}
然后注册SecurityFilterChain
,把下面这一段放在SecurityConfig
中:
1
2
3
4
5
6
7
8
9
10
11
12
@Bean
public SecurityFilterChain filterChain ( HttpSecurity http ) throws Exception {
http . formLogin (( form ) -> form
. loginProcessingUrl ( "/login" ). permitAll ()
);
http . authorizeHttpRequests ( auth -> auth
. requestMatchers ( "/user/**" ). authenticated ()
. requestMatchers ( "/admin/**" ). hasRole ( "ADMIN" )
. anyRequest (). permitAll ()
);
return http . build ();
}
这样就实现了一个权限校验,访问/user
路由或者/admin
路由的时候会要求登录,而使用普通用户权限访问/admin
路由会出现403错误码。
Spring Security 6
官方推荐使用Lambda DSL
配置方法来配置Spring Security
。原有的不使用Lambda DSL
的方法上面已经添加了@Deprecated
注解,在Spring Security 7
中将会完全移除这些方法:https://docs.spring.io/spring-security/reference/migration-7/configuration.html
之前的语法也能用,但是会产生红色的告警:
然后再说回SecurityFilterChain
,列举几个利用SecurityFilterChain
可以实现的一些功能:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//开启http Basic Authorization
http . httpBasic ( Customizer . withDefaults ());
//关闭csrf
http . csrf ( AbstractHttpConfigurer :: disable );
//设置logout
http . logout ( logout -> logout
. logoutSuccessUrl ( "/" )
. logoutUrl ( "/logout" )
. invalidateHttpSession ( true )
. addLogoutHandler ( new SecurityContextLogoutHandler ())
. deleteCookies ( "JSESSIONID" )
. clearAuthentication ( true )
);
//设置sessionManagement
http . sessionManagement ( session -> session
. maximumSessions ( 1 )
. maxSessionsPreventsLogin ( false )
);
因为我这里不可能写得很全面,所以还有很多配置请自己查阅官方文档
WebSecurityCustomizer1
2
3
4
@Bean
public WebSecurityCustomizer webSecurityCustomizer () {
return ( web ) -> web . ignoring (). requestMatchers ( "/ignore1" , "/ignore2" );
}
上面的代码由于忽略/ignore1
和/ignore2
。例如/static/**
这样的静态页面可能并不需要认证。不过官方也说明了,通过HttpSecurity#authorizeHttpRequests
设置permitAll
会比直接ignore
更安全,因为ignore
了就没办法让Spring Security
设置响应头了
It’s more secure because even with static resources it’s important to write secure headers, which Spring Security cannot do if the request is ignored.
In this past, this came with a performance tradeoff since the session was consulted by Spring Security on every request. As of Spring Security 6, however, the session is no longer pinged unless required by the authorization rule. Because the performance impact is now addressed, Spring Security recommends using at least permitAll
for all requests.
https://spring.io/blog/2022/02/21/spring-security-without-the-websecurityconfigureradapter/
https://docs.spring.io/spring-security/reference/servlet/authorization/authorize-http-requests.html#favor-permitall
PasswordEncoder众所周知,密码不应该明文储存,所以Spring Security
就提供了一些密码编码器,包括bcrypt 、PBKDF2 、scrypt 、 argon2 ,像这样就可以指定密码编码器为BCryptPasswordEncoder
:
1
2
3
4
@Bean
public PasswordEncoder passwordEncoder () {
return new BCryptPasswordEncoder ();
}
用上这个之后至少密码不会明文储存了。在我的项目里面使用了Argon2 储存密码,首先得先引入相关的依赖:
1
2
3
4
5
<dependency>
<groupId> org.bouncycastle</groupId>
<artifactId> bcprov-jdk18on</artifactId>
<version> 1.77</version>
</dependency>
然后把PasswordEncoder
换为Argon2PasswordEncoder
:
1
2
3
4
@Bean
public PasswordEncoder passwordEncoder () {
return Argon2PasswordEncoder . defaultsForSpringSecurity_v5_8 ();
}
有那么一些系统是从旧版本迁移过来的,Spring Security
也提供DelegatingPasswordEncoder
使得系统可以兼容不同编码方式,具体去翻翻官方文档 吧
UserDetailsService下一个需求是将密码保存在数据库中,这里介绍两个Spring Security
自带的UserDetailsService
,一个是InMemoryUserDetailsManager
,可以将用户信息储存在内存中:
1
2
3
4
5
6
7
8
9
@Bean
public UserDetailsService userDetailsService ( PasswordEncoder passwordEncoder ) {
UserDetailsManager manager = new InMemoryUserDetailsManager ();
manager . createUser ( User . withUsername ( "user" )
. password ( passwordEncoder . encode ( "123456" ))
. roles ( "USER" )
. build ());
return manager ;
}
还有一个是JdbcUserDetailsManager
,可以将用户信息储存在数据库中:
1
2
3
4
5
6
7
8
9
10
11
12
13
@Autowired
private DataSource dataSource ;
@Bean
public UserDetailsService userDetailsService ( PasswordEncoder passwordEncoder ) {
UserDetailsManager manager = new JdbcUserDetailsManager ( dataSource );
if (! manager . userExists ( "admin" )) {
manager . createUser ( User . withUsername ( "admin" )
. password ( passwordEncoder . encode ( "123456" ))
. roles ( "ADMIN" )
. build ());
}
return manager ;
}
JdbcUserDetailsManager
还提供了一个数据库模型,模型保存的位置在org/springframework/security/core/userdetails/jdbc/users.ddl
,不过这个是针对HSQLDB
数据库的,我后台使用postgresql
的话就得进行一些修改了:
1
2
3
create table users ( id serial primary key , username varchar ( 50 ) unique not null , password varchar ( 50 ) 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 );
点进去这个类看源码的话发现其实就是实现了一些常用的增删改查的逻辑:
还有其他的问题可以翻阅官方文档
越权漏洞产生 requestMatchers如果使用这一段代码保护/user
路由:
1
2
3
4
http . authorizeHttpRequests ( auth -> auth
. requestMatchers ( "/user" ). authenticated ()
. anyRequest (). permitAll ()
);
看起来没什么问题,但实际可以访问/user/
进行绕过。并且requestMatchers("/user")
是不保护/user/add
这样的路径的。如果要对/user
下面所有的路径进行匹配,需要写requestMatchers("/user/**")
regexMatcher1
2
3
4
http . authorizeHttpRequests ( auth -> auth
. requestMatchers ( RegexRequestMatcher . regexMatcher ( "/admin" )). hasRole ( "ADMIN" )
. anyRequest (). permitAll ()
);
正则表达式问题,/admin
正则表达式不匹配/admin/**
路由,例如访问/admin/add
是不需要认证的。除此之外还有regexMatcher("/admin/.*")
和regexMatcher("/admin/")
不匹配/admin
路径也会导致越权。
正确写法应该是regexMatcher("/admin.*?")