在上篇文章Spring Security 实现自动登录功能(源码篇)中,在最后总结部分提到了Spring Security自动登录存在的一些安全风险,在实际项目中,我们肯定是要把这些安全风险降到最低。两个方面来降低安全风险:

  • 持久化令牌方案
  • 二次校验

    1、持久化令牌

    1.1、原理

    要想理解持久化令牌,一定要先搞明白自动登录的基本玩法。持久化令牌就是在基本的自动登录功能基础上,又增加了新的校验参数,来提高系统的安全性,这些操作都是由开发者在后台完成的,对于用户来说,登录体验和普通的自动体验是一样的。
    在持久化令牌中,新增了两个经过md5散列函数计算的校验参数,一个是series,另一个是token。其中,series只有当用户在使用用户名/密码登录时,才会生成或者更新,而token只要有新的会话,就会重新生成,这样可以避免一个用户同时在多端登录,就像手机QQ,一个手机登录了,就会踢掉另外一个手机的登录,这样用户就会很容易发现账户是否泄露。
    持久化令牌的具体处理类在PersistentTokenBasedRememberMeServices中,在Spring Security 实现自动登录功能(源码篇)中讲到的自动化登录具体处理类是在TokenBasedRememberMeServices中,它们有一个共同的父类:
    Spring Security 实现自动登录功能-持久化令牌(源码篇) - 图1
    用来保存令牌的处理类是PersistentRememberMeToken
    1. public class PersistentRememberMeToken {
    2. private final String username;
    3. private final String series;
    4. private final String tokenValue;
    5. private final Date date;
    6. //省略 getter
    7. }
    这里的date表示上一次使用自动登录的时间点。

    1.2、代码演示

    接下来,通过代码来给大家演示一下持久化令牌的具体用法。
    首先我们需要一张表来保存令牌信息,这张表完全可以自定义,也可以使用系统默认提供的JDBC来操作,如果使用默认的JDBC,即JdbcTokenRepositoryImpl
    1. public class JdbcTokenRepositoryImpl extends JdbcDaoSupport implements
    2. PersistentTokenRepository {
    3. public static final String CREATE_TABLE_SQL = "create table persistent_logins (username varchar(64) not null, series varchar(64) primary key, "
    4. + "token varchar(64) not null, last_used timestamp not null)";
    5. public static final String DEF_TOKEN_BY_SERIES_SQL = "select username,series,token,last_used from persistent_logins where series = ?";
    6. public static final String DEF_INSERT_TOKEN_SQL = "insert into persistent_logins (username, series, token, last_used) values(?,?,?,?)";
    7. public static final String DEF_UPDATE_TOKEN_SQL = "update persistent_logins set token = ?, last_used = ? where series = ?";
    8. public static final String DEF_REMOVE_USER_TOKENS_SQL = "delete from persistent_logins where username = ?";
    9. }
    根据这段SQL定义,我们可以分析出表的结构:
    1. CREATE TABLE `persistent_logins` (
    2. `username` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,
    3. `series` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,
    4. `token` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,
    5. `last_used` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    6. PRIMARY KEY (`series`)
    7. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
    既然需要用到数据库,则需要添加spring-data-jpamysql依赖,如下:
    1. <dependency>
    2. <groupId>org.springframework.boot</groupId>
    3. <artifactId>spring-boot-starter-data-jpa</artifactId>
    4. </dependency>
    5. <dependency>
    6. <groupId>mysql</groupId>
    7. <artifactId>mysql-connector-java</artifactId>
    8. </dependency>
    然后在application.yml文件中配置数据库连接信息:
    1. spring:
    2. datasource:
    3. username: root
    4. password: 123456
    5. url: jdbc:mysql://120.78.177.161:3306/simple?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai
    6. driver-class-name: com.mysql.cj.jdbc.Driver
    7. type: com.zaxxer.hikari.HikariDataSource
    再然后修改我们的自定义配置SecurityConfig类,如下:
    1. @Autowired
    2. DataSource dataSource;
    3. @Bean
    4. PersistentTokenRepository tokenRepository() {
    5. JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
    6. tokenRepository.setDataSource(dataSource);
    7. // tokenRepository.setCreateTableOnStartup(true); 项目启动是否创建表
    8. return tokenRepository;
    9. }
    10. @Override
    11. protected void configure(HttpSecurity http) throws Exception {
    12. http.authorizeRequests()
    13. .anyRequest().authenticated()
    14. .and()
    15. .formLogin()
    16. .and()
    17. .rememberMe()
    18. .key("security")
    19. .tokenRepository(tokenRepository())
    20. .and()
    21. .csrf().disable();
    22. }
    💡注意
    一定要往tokenRepository中设置数据源dataSource,用来构造jdbcTemplate,否则在后面保存token的时候会报空指针异常。
    如果需要在项目启动时自动创建表,则需要将createTableOnStartup属性设置为true,并且要将tokenRepository对象注入到Spring容器中,才能执行到initDao方法,从而自动创建表。但是在第二次启动的时候需要注释掉,否则会报该表已经存在的异常。

    1.3、测试

    我们还是发送http://127.0.0.1:9527/api/auth/form?validateCodeType=1&imageCode=6w43&username=admin&password=123456&remember-me=onpost请求,登录成功之后,发现返回结果的Cookie中出现rememeber-me
    image.png
    这个令牌经过解析之后,格式如下:
    image.png
    此时,查看数据库,发现数据库中的记录和remember-me令牌解析后是一致的。
    image.png

    1.4、源码分析🎯

    这里的源码分析和Spring Security 实现自动登录功能(源码篇)的流程基本一致,只不过实现类变了,也就是生成令牌和解析令牌的实现变了,所以在这里主要和大家展示不一样的地方,至于流程问题,大家可以参考上篇文章.
    这次的实现类主要是PersistentTokenBasedRememberMeServices,我们先来看看里面几个和生成令牌相关的方法:
    1. protected void onLoginSuccess(HttpServletRequest request,
    2. HttpServletResponse response, Authentication successfulAuthentication) {
    3. String username = successfulAuthentication.getName();
    4. PersistentRememberMeToken persistentToken = new PersistentRememberMeToken(
    5. username, generateSeriesData(), generateTokenData(), new Date());
    6. tokenRepository.createNewToken(persistentToken);
    7. addCookie(persistentToken, request, response);
    8. }
    9. protected String generateSeriesData() {
    10. byte[] newSeries = new byte[seriesLength];
    11. random.nextBytes(newSeries);
    12. return new String(Base64.getEncoder().encode(newSeries));
    13. }
    14. protected String generateTokenData() {
    15. byte[] newToken = new byte[tokenLength];
    16. random.nextBytes(newToken);
    17. return new String(Base64.getEncoder().encode(newToken));
    18. }
    19. private void addCookie(PersistentRememberMeToken token, HttpServletRequest request,
    20. HttpServletResponse response) {
    21. setCookie(new String[] { token.getSeries(), token.getTokenValue() },
    22. getTokenValiditySeconds(), request, response);
    23. }
  1. 在登录成功之后,首先还是获取用户名和密码.
  2. 接下来构建一个PersistentRememberMeToken实例,generateSeriesDatagenerateTokenData方法分别用来获取seriestoken,具体的生成过程实际上就是调用SecureRandom生成随机数再进行base64编码,不同于我们以前用的Math.random或者java.util.Random这种伪随机数,SecureRandom则采用的是类似于密码学的随机数生成规则,其输出结果比较难预测,适合在登录这样的场景下使用.
  3. 调用tokenReposiroty实例中的createNewToken方法,tokenReposiroty实际上就是我们一开始配置的JdbcTokenRepositoryImpl,所以这行代码实际上就是将PersistentRememberMeToken存入数据库中.
  4. 最后addCookie,可以看到,就是添加了seriestoken.

上面是令牌生成的过程,还有令牌校验过程也在该类中:

  1. protected UserDetails processAutoLoginCookie(String[] cookieTokens,
  2. HttpServletRequest request, HttpServletResponse response) {
  3. final String presentedSeries = cookieTokens[0];
  4. final String presentedToken = cookieTokens[1];
  5. PersistentRememberMeToken token = tokenRepository
  6. .getTokenForSeries(presentedSeries);
  7. if (!presentedToken.equals(token.getTokenValue())) {
  8. tokenRepository.removeUserTokens(token.getUsername());
  9. throw new CookieTheftException(
  10. messages.getMessage(
  11. "PersistentTokenBasedRememberMeServices.cookieStolen",
  12. "Invalid remember-me token (Series/token) mismatch. Implies previous cookie theft attack."));
  13. }
  14. if (token.getDate().getTime() + getTokenValiditySeconds() * 1000L < System
  15. .currentTimeMillis()) {
  16. throw new RememberMeAuthenticationException("Remember-me login has expired");
  17. }
  18. PersistentRememberMeToken newToken = new PersistentRememberMeToken(
  19. token.getUsername(), token.getSeries(), generateTokenData(), new Date());
  20. tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(),
  21. newToken.getDate());
  22. addCookie(newToken, request, response);
  23. return getUserDetailsService().loadUserByUsername(token.getUsername());
  24. }
  1. 首先从前端传来的Cookie中解析出seriestoken.
  2. 根据series从数据库中查询出一个PersistentRememberMeToken实例.
  3. 如果查出来的token与前端传来的token不同,说明令牌可能被人盗用(别人用你的令牌登录之后,token会变).此时根据用户名移除相关的token,相当于必须重新输入用户名密码登录才能获取新的自动登录权限.
  4. 接下来校验token是否过期.
  5. 构造新的PersistentRememberMeToken对象,并且更新数据库中的token(这就是文章开头说的,新的会话都会对应一个新的token).
  6. 将新的令牌重新添加到Cookie中返回.
  7. 根据用户名查询用户信息,再走一波登录流程.

    2、二次校验

    相比于上篇文章,持久化令牌的方式其实已经安全很多了,但是依然存在用户身份被盗用的问题,这个问题实际上很难完美解决,我们能做的,只能是当用户身份被盗用这样的事情发生时,将损失降低到最小.
    因此,我们来看下另一种方案,就是二次校验.为了让用户使用方便,我们开通了自动登录功能,但是自动登录功能又带来了安全风险,一个规避的办法就是如果用户使用了自动登录功能,我们可以只让他做一些常规的不敏感的操作,例如数据浏览,查看,但是不允许他做任何的修改,删除操作,如果用户点了修改,删除按钮,可以跳转回登录页面,让用户重新输入密码确认身份,然后再允许他执行敏感操作.
    如:

    1. @RestController
    2. public class HelloController {
    3. @GetMapping("/hello")
    4. public String hello() {
    5. return "hello";
    6. }
    7. @GetMapping("/admin")
    8. public String admin() {
    9. return "admin";
    10. }
    11. @GetMapping("/rememberme")
    12. public String rememberme() {
    13. return "rememberme";
    14. }
    15. }
  8. 第一个/hello接口,只要认证后就可以访问,无论时通过用户名密码认证还是通过自动登录认证,只要认证成功了,就可以访问.

  9. 第二个/admin接口,必须要用户名密码认证之后才能访问,如果用户是通过自动登录认证的,则必须重新输入用户名密码才能访问该接口.
  10. 第三个/rememberme接口,必须是通过自动登录认证后才能访问,如果用户是通过用户名密码认证的,则无法访问该接口.

接下来看看具体该怎么配置:

  1. @Override
  2. protected void configure(HttpSecurity http) throws Exception {
  3. http.authorizeRequests()
  4. .antMatchers("/rememberme").rememberMe()
  5. .antMatchers("/admin").fullyAuthenticated()
  6. .anyRequest().authenticated()
  7. .and()
  8. .formLogin()
  9. .and()
  10. .rememberMe()
  11. .key("javaboy")
  12. .tokenRepository(jdbcTokenRepository())
  13. .and()
  14. .csrf().disable();
  15. }

可以看到:

  1. /rememberme接口需要rememberMe才能访问.
  2. /admin接口需要fullyAuthenticated,fullyAuthenticated不同于authenticated,fullyAuthenticated不包含自动登录的形式,而authenticated包含自动登录的形式.
  3. /hello接口只要authenticated就能访问.