SpringSecuriy6学习

前言

最近在进行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和控制台打印出来的密码就能登录。

image-20231220205229890

好的,已经实现了一个简单的登录页面了。不过我不想用随机生成的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

之前的语法也能用,但是会产生红色的告警:

image-20231220223408656

然后再说回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)
);

因为我这里不可能写得很全面,所以还有很多配置请自己查阅官方文档

WebSecurityCustomizer

1
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就提供了一些密码编码器,包括bcryptPBKDF2scryptargon2,像这样就可以指定密码编码器为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);

点进去这个类看源码的话发现其实就是实现了一些常用的增删改查的逻辑:

image-20231221215743591

还有其他的问题可以翻阅官方文档

越权漏洞产生

requestMatchers

如果使用这一段代码保护/user路由:

1
2
3
4
http.authorizeHttpRequests(auth -> auth
        .requestMatchers("/user").authenticated()
        .anyRequest().permitAll()
);

看起来没什么问题,但实际可以访问/user/进行绕过。并且requestMatchers("/user")是不保护/user/add这样的路径的。如果要对/user下面所有的路径进行匹配,需要写requestMatchers("/user/**")

regexMatcher

1
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.*?")

updatedupdated2023-12-202023-12-20