SpringSecurity系列之降低 RememberMe 的安全风险

开发 架构
持久化令牌就是在基本的自动登录功能基础上,又增加了新的校验参数,来提高系统的安全性,这一些都是由开发者在后台完成的,对于用户来说,登录体验和普通的自动登录体验是一样的。

[[394666]]

在上篇文章中,我们提到了 Spring Boot 自动登录存在的一些安全风险,在实际应用中,我们肯定要把这些安全风险降到最低,今天就来和大家聊一聊如何降低安全风险的问题。

降低安全风险,我主要从两个方面来给大家介绍:

  1. 持久化令牌方案
  2. 二次校验

当然,还是老规矩,阅读本文一定先阅读本系列前面的文章,这有助于更好的理解本文:

好了,我们就不废话了,来看今天的文章。

1.持久化令牌

1.1 原理

要理解持久化令牌,一定要先搞明白自动登录的基本玩法,参考(Spring Boot + Spring Security 实现自动登录功能)。

持久化令牌就是在基本的自动登录功能基础上,又增加了新的校验参数,来提高系统的安全性,这一些都是由开发者在后台完成的,对于用户来说,登录体验和普通的自动登录体验是一样的。

在持久化令牌中,新增了两个经过 MD5 散列函数计算的校验参数,一个是 series,另一个是 token。其中,series 只有当用户在使用用户名/密码登录时,才会生成或者更新,而 token 只要有新的会话,就会重新生成,这样就可以避免一个用户同时在多端登录,就像手机 QQ ,一个手机上登录了,就会踢掉另外一个手机的登录,这样用户就会很容易发现账户是否泄漏(之前看到松哥交流群里有小伙伴在讨论如何禁止多端登录,其实就可以借鉴这里的思路)。

持久化令牌的具体处理类在 PersistentTokenBasedRememberMeServices 中,上篇文章我们讲到的自动化登录具体的处理类是在 TokenBasedRememberMeServices 中,它们有一个共同的父类:

而用来保存令牌的处理类则是 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 

这里的 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 = ?"

根据这段 SQL 定义,我们就可以分析出来表的结构,松哥这里给出一段 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; 

首先我们在数据库中准备好这张表。

既然要连接数据库,我们还需要准备 jdbc 和 mysql 依赖,如下:

  1. <dependency> 
  2.     <groupId>org.springframework.boot</groupId> 
  3.     <artifactId>spring-boot-starter-jdbc</artifactId> 
  4. </dependency> 
  5. <dependency> 
  6.     <groupId>mysql</groupId> 
  7.     <artifactId>mysql-connector-java</artifactId> 
  8. </dependency> 

然后修改 application.properties ,配置数据库连接信息:

  1. spring.datasource.url=jdbc:mysql:///oauth2?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai 
  2. spring.datasource.username=root 
  3. spring.datasource.password=123 

接下来,我们修改 SecurityConfig,如下:

  1. @Autowired 
  2. DataSource dataSource; 
  3. @Bean 
  4. JdbcTokenRepositoryImpl jdbcTokenRepository() { 
  5.     JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl(); 
  6.     jdbcTokenRepository.setDataSource(dataSource); 
  7.     return jdbcTokenRepository; 
  8. @Override 
  9. protected void configure(HttpSecurity http) throws Exception { 
  10.     http.authorizeRequests() 
  11.             .anyRequest().authenticated() 
  12.             .and() 
  13.             .formLogin() 
  14.             .and() 
  15.             .rememberMe() 
  16.             .key("javaboy"
  17.             .tokenRepository(jdbcTokenRepository()) 
  18.             .and() 
  19.             .csrf().disable(); 

提供一个 JdbcTokenRepositoryImpl 实例,并给其配置 DataSource 数据源,最后通过 tokenRepository 将 JdbcTokenRepositoryImpl 实例纳入配置中。

OK,做完这一切,我们就可以测试了。

1.3 测试

我们还是先去访问 /hello 接口,此时会自动跳转到登录页面,然后我们执行登录操作,记得勾选上“记住我”这个选项,登录成功后,我们可以重启服务器、然后关闭浏览器再打开,再去访问 /hello 接口,发现依然能够访问到,说明我们的持久化令牌配置已经生效。

查看 remember-me 的令牌,如下:

这个令牌经过解析之后,格式如下:

  1. emhqATk3ZDBdR8862WP4Ig%3D%3D:ZAEv6EIWqA7CkGbYewCh8g%3D%3D 

这其中,%3D 表示 =,所以上面的字符实际上可以翻译成下面这样:

  1. emhqATk3ZDBdR8862WP4Ig==:ZAEv6EIWqA7CkGbYewCh8g== 

此时,查看数据库,我们发现之前的表中生成了一条记录:

数据库中的记录和我们看到的 remember-me 令牌解析后是一致的。

1.4 源码分析

这里的源码分析和上篇文章的流程基本一致,只不过实现类变了,也就是生成令牌/解析令牌的实现变了,所以这里我主要和大家展示不一样的地方,流程问题,大家可以参考上篇文章。

这次的实现类主要是: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. protected String generateSeriesData() { 
  9.  byte[] newSeries = new byte[seriesLength]; 
  10.  random.nextBytes(newSeries); 
  11.  return new String(Base64.getEncoder().encode(newSeries)); 
  12. protected String generateTokenData() { 
  13.  byte[] newToken = new byte[tokenLength]; 
  14.  random.nextBytes(newToken); 
  15.  return new String(Base64.getEncoder().encode(newToken)); 
  16. private void addCookie(PersistentRememberMeToken token, HttpServletRequest request, 
  17.   HttpServletResponse response) { 
  18.  setCookie(new String[] { token.getSeries(), token.getTokenValue() }, 
  19.    getTokenValiditySeconds(), request, response); 

可以看到:

  1. 在登录成功后,首先还是获取到用户名,即 username。
  2. 接下来构造一个 PersistentRememberMeToken 实例,generateSeriesData 和 generateTokenData 方法分别用来获取 series 和 token,具体的生成过程实际上就是调用 SecureRandom 生成随机数再进行 Base64 编码,不同于我们以前用的 Math.random 或者 java.util.Random 这种伪随机数,SecureRandom 则采用的是类似于密码学的随机数生成规则,其输出结果较难预测,适合在登录这样的场景下使用。
  3. 调用 tokenRepository 实例中的 createNewToken 方法,tokenRepository 实际上就是我们一开始配置的 JdbcTokenRepositoryImpl,所以这行代码实际上就是将 PersistentRememberMeToken 存入数据库中。
  4. 最后 addCookie,大家可以看到,就是添加了 series 和 token。

这是令牌生成的过程,还有令牌校验的过程,也在该类中,方法是:processAutoLoginCookie:

  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()); 

这段逻辑也比较简单:

首先从前端传来的 cookie 中解析出 series 和 token。

根据 series 从数据库中查询出一个 PersistentRememberMeToken 实例。

如果查出来的 token 和前端传来的 token 不相同,说明账号可能被人盗用(别人用你的令牌登录之后,token 会变)。此时根据用户名移除相关的 token,相当于必须要重新输入用户名密码登录才能获取新的自动登录权限。

接下来校验 token 是否过期。

构造新的 PersistentRememberMeToken 对象,并且更新数据库中的 token(这就是我们文章开头说的,新的会话都会对应一个新的 token)。

将新的令牌重新添加到 cookie 中返回。

根据用户名查询用户信息,再走一波登录流程。

OK,这里和小伙伴们简单理了一下令牌生成和校验的过程,具体的流程,大家可以参考上篇文章。

2.二次校验

相比于上篇文章,持久化令牌的方式其实已经安全很多了,但是依然存在用户身份被盗用的问题,这个问题实际上很难完美解决,我们能做的,只能是当发生用户身份被盗用这样的事情时,将损失降低到最小。

因此,我们来看下另一种方案,就是二次校验。

二次校验这块,实现起来要稍微复杂一点,我先来和大家说说思路。

为了让用户使用方便,我们开通了自动登录功能,但是自动登录功能又带来了安全风险,一个规避的办法就是如果用户使用了自动登录功能,我们可以只让他做一些常规的不敏感操作,例如数据浏览、查看,但是不允许他做任何修改、删除操作,如果用户点击了修改、删除按钮,我们可以跳转回登录页面,让用户重新输入密码确认身份,然后再允许他执行敏感操作。

这个功能在 Shiro 中有一个比较方便的过滤器可以配置,Spring Security 当然也一样,例如我现在提供三个访问接口:

  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.     } 
  1. 第一个 /hello 接口,只要认证后就可以访问,无论是通过用户名密码认证还是通过自动登录认证,只要认证了,就可以访问。
  2. 第二个 /admin 接口,必须要用户名密码认证之后才能访问,如果用户是通过自动登录认证的,则必须重新输入用户名密码才能访问该接口。
  3. 第三个 /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(); 

可以看到:

  1. /rememberme 接口是需要 rememberMe 才能访问。
  2. /admin 是需要 fullyAuthenticated,fullyAuthenticated 不同于 authenticated,fullyAuthenticated 不包含自动登录的形式,而 authenticated 包含自动登录的形式。
  3. 最后剩余的接口(/hello)都是 authenticated 就能访问。

OK,配置完成后,重启测试,测试过程我就不再赘述了。

好了,今天从两个方面和小伙伴们分享了在 Spring Boot 自动登录中,如何降低系统风险,感兴趣的小伙伴赶快试一把吧~

 

责任编辑:武晓燕 来源: 江南一点雨
相关推荐

2023-06-09 14:01:56

2014-04-11 11:18:23

2022-07-14 09:20:24

供应链网络攻击

2019-03-29 15:11:13

2011-08-30 14:57:41

2021-05-08 10:44:35

SpringSecur登录详情

2011-07-18 09:01:47

2010-11-29 09:49:59

2009-01-15 09:55:00

局域网安全风险

2019-03-17 16:18:39

影子物联网物联网IOT

2018-12-18 09:20:44

欺骗技术物联网安全物联网

2016-10-28 13:21:36

2021-12-12 11:49:02

电子商务安全网络犯罪

2010-09-26 09:57:41

2022-05-11 10:21:47

物联网安全网络安全物联网

2021-08-31 16:05:19

数据安全数据风险网络安全

2021-07-02 10:45:53

SpringBootCAS登录

2024-01-17 12:05:21

2009-11-30 11:40:52

2010-04-06 15:14:08

点赞
收藏

51CTO技术栈公众号