Spring Security is a powerful and highly customizable authentication and access-control framework. It is the de-facto standard for securing Spring-based applications.

快速入门

首先新建一个 spring boot 带有 spring-boot-starter-web 的依赖的项目。然后加入 security starter 的依赖如下。

1
2
3
4
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

之后访问任意前端页面就会发现跳转到 spring security 自带的登录页面了,同时可以注意到控制台有输出 Using generated security password: xxx 这样一句日志,这是因为只加入依赖 spring security 会提供一个名为 user 且密码随机生成并输出到控制台的用户;如果想自定义密码而不是随机生成或者更改用户名,可以直接在配置里加上:

1
2
spring.security.user.name=admin
spring.security.user.password=password

重启项目会发现,用上面的用户名和密码是可以登录的,这就是 spring boot 的 starter 方便快捷之处。

如果想接入来自定义的用户验证(来至数据库的用户数据或者服务调用),就需要编写一个实现 UserDetailsService 的类 完成 loadUserByUsername 方法,毕竟登录验证时候是通过用户名获取密码的,之后验证的业务交给 Spring security 就行。下面简单演示下,为了控制篇幅,从数据库中获取用户数据部分就不实现了,直接使用固定的 user 用户名和密码,同时 role 部分先不处理添加一个默认的 user role:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
    @Autowired
    PasswordEncoder passwordEncoder;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        List<GrantedAuthority> authorities = new ArrayList<>();

        // 通常会这样 User user = userDAO.getUserByName(username);

        String uname = "user";
        String passwd = "password";
        authorities.add(new SimpleGrantedAuthority("user"));

        if (!username.equals(uname)) throw new UsernameNotFoundException(username);

        // 封装成 Spring security 定义的 User 对象
        return new org.springframework.security.core.userdetails.User(uname, passwordEncoder.encode(passwd), authorities);
    }
}

然后需要将该实现配置到 security 中,编写一个配置类:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    @Qualifier("userDetailsServiceImpl")
    private UserDetailsService userDetailsService;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 自定义认证
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    }
}

几点说明:

  • 上面的密码部分都是使用的 BCrypt,即存储到数据中的是 BCrypt 之后的 shadowpassword,需要保证密码存储和验证的 PasswordEncoder 是同一种实现,BCrypt 的优势和原理可以自行搜索。
  • UserDetailsService 主要实现 loadUserByUsername 方法即可,这里就直接使用的固定的用户和密码,通常应该是使用 Dao 或者 service 层从数据库或者远程调用中获取到用户的信息,之后将 username, password, role 等信息填入,role 的数据库设计通常使用多对多(many to many)的数据库设计,role 可以根据用户的角色对页面进行限制访问。

自定义登录页面与权限控制

上面的登录页面还是 Spring security 提供的,如果想自定制前端登录页面和后端验证怎么办?同时静态文件访问不需要鉴权,怎么设置权限?

为了演示简单这里就直接使用 html 了,没有使用如 thymeleaf 等模板引擎,先为登录编写一个登录页。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Login</title>
</head>
<body>
<form action="/login" method="post">
    <div>
        <label>username: </label><input type="text" name="username">
    </div>
    <div>
        <label>password: </label><input type="password" name="password">
    </div>
    <div>
        <label>remember me: </label><input type="checkbox" name="remember-me"/>
    </div>
    <button type="submit">login</button>
</form>
</body>
</html>

上面的文件命名存储在 resources/staic/login.html,这是因为需要将其当做静态文件进行分发,只要为于 spring.resources.static-locations=classpath:/META-INF/resources/,classpath:/resources/,classpath:/static/,classpath:/public/ 配置下即可,也可以在 application.properties 文件为 spring.resources.static-locations 项 append 新的路径。

相应的登录控制器:

1
2
3
4
5
6
7
8
@Controller
public class LoginController {
    @GetMapping("login")
    public String getLogin() {
        // 这里的 .html 不能像 thymeleaf 省略,否则就是重定向视图了
        return "login.html";
    }
}

之后在配置类 SecurityConfig 中添加下面的方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
@Override
protected void configure(HttpSecurity http) throws Exception {
    http.csrf().disable()   // 没有使用模板,不好处理 csrf,所以这里禁止掉
        .authorizeRequests()
            .antMatchers("/static/**").permitAll()  // 静态文件路径下的文件无需鉴权
            .anyRequest().authenticated()   // 其他页面需要鉴权
            .and()
        .formLogin()    // 配置自定义登录页面的权限
            .loginPage("/login")    // 表单的用户和密码字段名为:username,password,可以使用 usernameParameter 和 passwordParameter 指定
            .failureUrl("/login?error")
            .permitAll()
            .and()
        .logout()       // 登出页面
            .permitAll()
            .and()
        .rememberMe()   // 默认表单字段名 remember-me,可以使用 rememberMeParameter 自定义
            .key("uniqueAndSecret") // 最后会参与生成 cookie,相当于盐
            .tokenValiditySeconds(3600*24);
}

几点说明:

  • 登录错误时候会被定向到自定义的 failureUrl,可以加上参数如这里是 /login?error 然后 controller 可以获取这个 error 然后渲染一下什么 用户名或密码错误 的。
  • 可以通过 hasRole 的方式进行角色权限的配置。
  • csrf 如果使用的是 cookie 保存用户登录态的话最好是加上的,同时权限的控制还有很多细节,具体参考文档。
  • 默认的 session 时间是 30 分钟,可以通过 spring.session.timeout 来设置,如 spring.session.timeout=40m 就是 40 分钟。
  • 在 session 过期之后,如果之前勾选设置了 remember me 选项,登录时候除了设置名为 SESSION 的 cookie 还会生成一个名为 remember-me 的 cookie,该 cookie 的值是由 base64(username+":"+expirationTime+":"+md5Hex(username+":"+expirationTime+":"+password+":"+key)) 这个公式得出的,这个 expirationTime 是由 tokenValiditySeconds 所决定的,当 session 的 cookie 过期之后,这个 remember cookie 由于其内含有用户名和密码的信息,其实就不用在次输入用户名和密码,能够再次生成一个 session,相当于两层 session。

如果想要在 controller 里获取登录的用户名,可以使用 Principal 或者 Authentication:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@RestController
public class HelloController {
    @GetMapping
    public String hello(Principal principal) {
        return "Hello: " + principal.getName();
    }

    @GetMapping("/name")
    public String hello(Authentication authentication) {
        return "Hello: " + authentication.getName();
    }
}

Authentication 功能会更多些,还可以获取到如 role, sessionId 等信息。

自定义登录处理

上面的方式其实也就自己编写了下前端的界面,验证的逻辑其实还是使用的 Spring Security 的,即 post 之后的 controller 还是 Spring security 帮我们完成的。如果要实现注册之后立即自动登录,就可以能要自行设计一下了,或者想要在登录处理时加入自己的逻辑这就需要自定义下了,这里以注册后自动登录为例。

先修改 SecurityConfig 将 registration 权限放开,并将 AuthenticationManager 的 bean 暴露:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    // ...
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()   // 没有使用模板,不好处理 csrf,所以这里禁止掉
                .authorizeRequests()
                    .antMatchers("/static/**", "/registration").permitAll()  // 静态文件路径下的文件无需鉴权
        // ...
    }

    @Bean
    public AuthenticationManager customAuthenticationManager() throws Exception {
        return authenticationManager();
    }
    // ...
}

之后复制一份 login.html 为 registration.html 并将 action 修改为 registration,之后新建 registration controller:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Controller
public class RegistrationController {
    @Autowired
    private AuthenticationManager authenticationManager;

    @Qualifier("userDetailsServiceImpl")
    @Autowired
    private UserDetailsService userDetailsService;

    @GetMapping("/registration")
    public String registration() {
        return "registration.html";
    }

    @PostMapping("/registration")
    public String registration(@Valid @ModelAttribute UserForm userForm) {

        // 这里省略了验证存储用户表单的逻辑

        UserDetails userDetails = userDetailsService.loadUserByUsername(userForm.getUsername());

        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userDetails, userForm.getPassword(), userDetails.getAuthorities());
        authenticationManager.authenticate(authenticationToken);

        // 这里的验证还会调用一次 userDetailsService.loadUserByUsername 方法
        if (authenticationToken.isAuthenticated()) {
            SecurityContextHolder.getContext().setAuthentication(authenticationToken);
            System.out.println("registration ok!");
        }
        return "redirect:/";
    }
}

上面的例子由于 UserDetailsServiceImpl 没有完整的实现,没有接入数据库或用户服务,所以只能使用其内固定的 user 和 password 注册进行测试,可能结果不太好相同,可以在 loadUserByUsername 和几个关键的点加上控制台输出,在走一下流程试试。其实最好的方式是完整的为 UserDetailsServiceImpl 和 registration 加上用户的持久化,或者自己实现 AuthenticationProvider 尝试一下。

使用 Spring session

默认的是使用的内存存储,这样的话如果在分布式的环境下如果用户不被分配到一个运行实例上,会出现要重复登录的情况。所以需要将 session 存储到统一的地方,比如 redis,由于有 Spring session 的存在这一切换也十分简单,其教程可以参考 Spring Session - Spring Boot 这里简单的说明下。

先加上依赖:

1
2
3
4
<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>

对于 spring boot 最好不要使用如下自己构建的方式:

1
2
3
4
5
@EnableWebSecurity
@EnableRedisHttpSession
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    // ...
}

上面的 @EnableRedisHttpSession 使用之后关于 redis 的 namespace、timeout 什么的配置就会失效,需要使用 @EnableRedisHttpSession(maxInactiveIntervalInSeconds = 3600) 的方式,参考 server.servlet.session.timeout not work

最好的使用方式就是加入依赖之后什么都不做,增加下面的配置即可:

1
2
3
4
5
6
7
8
9
spring.redis.host=127.0.0.1
spring.redis.port=6379
spring.redis.database=1
spring.redis.password=

spring.session.store-type=redis
server.servlet.session.timeout=35m
spring.session.redis.flush-mode=on_save
spring.session.redis.namespace=demo

上面关键的一点就是 spring.session.store-type=redis ,但是建议开发阶段可以使用 none,不使用 redis 以加快调试速度。server.servlet.session.timeout 指明了 session 的存活时间,spring.session.redis.namespace 为存储时候的键名前缀。

同时注意的是 Session 默认使用的是 HttpSession 序列化方式,如果想要使用 json 序列方式存储到 redis,参考 SessionConfig 可以编写下面的配置类:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
@Configuration
public class SessionConfig implements BeanClassLoaderAware {
    private ClassLoader loader;

    private ObjectMapper objectMapper() {
        ObjectMapper mapper = new ObjectMapper();
        mapper.registerModules(SecurityJackson2Modules.getModules(this.loader));
        return mapper;
    }

    @Bean
    public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
        return new GenericJackson2JsonRedisSerializer(objectMapper());
    }

    @Override
    public void setBeanClassLoader(ClassLoader classLoader) {
        this.loader = classLoader;
    }
}

之后可以在 redis-cli 使用 MONITOR 命令进行检验。

需要注意的一点是,使用 spring security 之后会更改 http header 的默认参数,参考 Security HTTP Response Headers 更改过后的 header 会导致缓存失效。解决方法:根据 Caching in Spring Boot with Spring Security 这篇文章可以配置 spring.resources.cache.cachecontrol.max-age= 缓存过期时间即可

当然这篇文章只给出了一个简单的入门, Spring security 不仅仅是上面的一些内容,以后有时间可以再深入了解下。

参考