在上篇文章Spring Security 实现自动登录功能(源码篇)中,在最后总结部分提到了Spring Security自动登录存在的一些安全风险,在实际项目中,我们肯定是要把这些安全风险降到最低。两个方面来降低安全风险:
- 持久化令牌方案
- 二次校验
1、持久化令牌
1.1、原理
要想理解持久化令牌,一定要先搞明白自动登录的基本玩法。持久化令牌就是在基本的自动登录功能基础上,又增加了新的校验参数,来提高系统的安全性,这些操作都是由开发者在后台完成的,对于用户来说,登录体验和普通的自动体验是一样的。
在持久化令牌中,新增了两个经过md5散列函数计算的校验参数,一个是series,另一个是token。其中,series只有当用户在使用用户名/密码登录时,才会生成或者更新,而token只要有新的会话,就会重新生成,这样可以避免一个用户同时在多端登录,就像手机QQ,一个手机登录了,就会踢掉另外一个手机的登录,这样用户就会很容易发现账户是否泄露。
持久化令牌的具体处理类在PersistentTokenBasedRememberMeServices中,在Spring Security 实现自动登录功能(源码篇)中讲到的自动化登录具体处理类是在TokenBasedRememberMeServices中,它们有一个共同的父类:
用来保存令牌的处理类是PersistentRememberMeToken
这里的public class PersistentRememberMeToken {private final String username;private final String series;private final String tokenValue;private final Date date;//省略 getter}
date表示上一次使用自动登录的时间点。1.2、代码演示
接下来,通过代码来给大家演示一下持久化令牌的具体用法。
首先我们需要一张表来保存令牌信息,这张表完全可以自定义,也可以使用系统默认提供的JDBC来操作,如果使用默认的JDBC,即JdbcTokenRepositoryImpl。
根据这段SQL定义,我们可以分析出表的结构:public class JdbcTokenRepositoryImpl extends JdbcDaoSupport implementsPersistentTokenRepository {public static final String CREATE_TABLE_SQL = "create table persistent_logins (username varchar(64) not null, series varchar(64) primary key, "+ "token varchar(64) not null, last_used timestamp not null)";public static final String DEF_TOKEN_BY_SERIES_SQL = "select username,series,token,last_used from persistent_logins where series = ?";public static final String DEF_INSERT_TOKEN_SQL = "insert into persistent_logins (username, series, token, last_used) values(?,?,?,?)";public static final String DEF_UPDATE_TOKEN_SQL = "update persistent_logins set token = ?, last_used = ? where series = ?";public static final String DEF_REMOVE_USER_TOKENS_SQL = "delete from persistent_logins where username = ?";}
既然需要用到数据库,则需要添加CREATE TABLE `persistent_logins` (`username` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,`series` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,`token` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,`last_used` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,PRIMARY KEY (`series`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
spring-data-jpa和mysql依赖,如下:
然后在<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-jpa</artifactId></dependency><dependency><groupId>mysql</groupId><artifactId>mysql-connector-java</artifactId></dependency>
application.yml文件中配置数据库连接信息:
再然后修改我们的自定义配置spring:datasource:username: rootpassword: 123456url: jdbc:mysql://120.78.177.161:3306/simple?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghaidriver-class-name: com.mysql.cj.jdbc.Drivertype: com.zaxxer.hikari.HikariDataSource
SecurityConfig类,如下:
💡注意@AutowiredDataSource dataSource;@BeanPersistentTokenRepository tokenRepository() {JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();tokenRepository.setDataSource(dataSource);// tokenRepository.setCreateTableOnStartup(true); 项目启动是否创建表return tokenRepository;}@Overrideprotected void configure(HttpSecurity http) throws Exception {http.authorizeRequests().anyRequest().authenticated().and().formLogin().and().rememberMe().key("security").tokenRepository(tokenRepository()).and().csrf().disable();}
一定要往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。
这个令牌经过解析之后,格式如下:
此时,查看数据库,发现数据库中的记录和remember-me令牌解析后是一致的。
1.4、源码分析🎯
这里的源码分析和Spring Security 实现自动登录功能(源码篇)的流程基本一致,只不过实现类变了,也就是生成令牌和解析令牌的实现变了,所以在这里主要和大家展示不一样的地方,至于流程问题,大家可以参考上篇文章.
这次的实现类主要是PersistentTokenBasedRememberMeServices,我们先来看看里面几个和生成令牌相关的方法:protected void onLoginSuccess(HttpServletRequest request,HttpServletResponse response, Authentication successfulAuthentication) {String username = successfulAuthentication.getName();PersistentRememberMeToken persistentToken = new PersistentRememberMeToken(username, generateSeriesData(), generateTokenData(), new Date());tokenRepository.createNewToken(persistentToken);addCookie(persistentToken, request, response);}protected String generateSeriesData() {byte[] newSeries = new byte[seriesLength];random.nextBytes(newSeries);return new String(Base64.getEncoder().encode(newSeries));}protected String generateTokenData() {byte[] newToken = new byte[tokenLength];random.nextBytes(newToken);return new String(Base64.getEncoder().encode(newToken));}private void addCookie(PersistentRememberMeToken token, HttpServletRequest request,HttpServletResponse response) {setCookie(new String[] { token.getSeries(), token.getTokenValue() },getTokenValiditySeconds(), request, response);}
- 在登录成功之后,首先还是获取用户名和密码.
- 接下来构建一个
PersistentRememberMeToken实例,generateSeriesData和generateTokenData方法分别用来获取series和token,具体的生成过程实际上就是调用SecureRandom生成随机数再进行base64编码,不同于我们以前用的Math.random或者java.util.Random这种伪随机数,SecureRandom则采用的是类似于密码学的随机数生成规则,其输出结果比较难预测,适合在登录这样的场景下使用. - 调用
tokenReposiroty实例中的createNewToken方法,tokenReposiroty实际上就是我们一开始配置的JdbcTokenRepositoryImpl,所以这行代码实际上就是将PersistentRememberMeToken存入数据库中. - 最后
addCookie,可以看到,就是添加了series和token.
上面是令牌生成的过程,还有令牌校验过程也在该类中:
protected UserDetails processAutoLoginCookie(String[] cookieTokens,HttpServletRequest request, HttpServletResponse response) {final String presentedSeries = cookieTokens[0];final String presentedToken = cookieTokens[1];PersistentRememberMeToken token = tokenRepository.getTokenForSeries(presentedSeries);if (!presentedToken.equals(token.getTokenValue())) {tokenRepository.removeUserTokens(token.getUsername());throw new CookieTheftException(messages.getMessage("PersistentTokenBasedRememberMeServices.cookieStolen","Invalid remember-me token (Series/token) mismatch. Implies previous cookie theft attack."));}if (token.getDate().getTime() + getTokenValiditySeconds() * 1000L < System.currentTimeMillis()) {throw new RememberMeAuthenticationException("Remember-me login has expired");}PersistentRememberMeToken newToken = new PersistentRememberMeToken(token.getUsername(), token.getSeries(), generateTokenData(), new Date());tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(),newToken.getDate());addCookie(newToken, request, response);return getUserDetailsService().loadUserByUsername(token.getUsername());}
- 首先从前端传来的
Cookie中解析出series和token. - 根据
series从数据库中查询出一个PersistentRememberMeToken实例. - 如果查出来的
token与前端传来的token不同,说明令牌可能被人盗用(别人用你的令牌登录之后,token会变).此时根据用户名移除相关的token,相当于必须重新输入用户名密码登录才能获取新的自动登录权限. - 接下来校验
token是否过期. - 构造新的
PersistentRememberMeToken对象,并且更新数据库中的token(这就是文章开头说的,新的会话都会对应一个新的token). - 将新的令牌重新添加到
Cookie中返回. -
2、二次校验
相比于上篇文章,持久化令牌的方式其实已经安全很多了,但是依然存在用户身份被盗用的问题,这个问题实际上很难完美解决,我们能做的,只能是当用户身份被盗用这样的事情发生时,将损失降低到最小.
因此,我们来看下另一种方案,就是二次校验.为了让用户使用方便,我们开通了自动登录功能,但是自动登录功能又带来了安全风险,一个规避的办法就是如果用户使用了自动登录功能,我们可以只让他做一些常规的不敏感的操作,例如数据浏览,查看,但是不允许他做任何的修改,删除操作,如果用户点了修改,删除按钮,可以跳转回登录页面,让用户重新输入密码确认身份,然后再允许他执行敏感操作.
如:@RestControllerpublic class HelloController {@GetMapping("/hello")public String hello() {return "hello";}@GetMapping("/admin")public String admin() {return "admin";}@GetMapping("/rememberme")public String rememberme() {return "rememberme";}}
第一个
/hello接口,只要认证后就可以访问,无论时通过用户名密码认证还是通过自动登录认证,只要认证成功了,就可以访问.- 第二个
/admin接口,必须要用户名密码认证之后才能访问,如果用户是通过自动登录认证的,则必须重新输入用户名密码才能访问该接口. - 第三个
/rememberme接口,必须是通过自动登录认证后才能访问,如果用户是通过用户名密码认证的,则无法访问该接口.
接下来看看具体该怎么配置:
@Overrideprotected void configure(HttpSecurity http) throws Exception {http.authorizeRequests().antMatchers("/rememberme").rememberMe().antMatchers("/admin").fullyAuthenticated().anyRequest().authenticated().and().formLogin().and().rememberMe().key("javaboy").tokenRepository(jdbcTokenRepository()).and().csrf().disable();}
可以看到:
/rememberme接口需要rememberMe才能访问./admin接口需要fullyAuthenticated,fullyAuthenticated不同于authenticated,fullyAuthenticated不包含自动登录的形式,而authenticated包含自动登录的形式./hello接口只要authenticated就能访问.
