重点标识
上一篇文章我们了解到了,rememberMe 将令牌保存在cookie中,每次请求服务端都会去验证令牌的有效性以及合法性,但是,这样就会产生一个问题,如果浏览器中的令牌被人盗用了,那岂不是非常不安全。
关于这个问题,Security也给出了解决方案,那就是持久化令牌,一旦令牌被盗用,用户就可以及时感知,然后重新登陆,覆盖掉之前的令牌。
持久化令牌
在Spring Security中提供了一个持久化token的仓库接口,就是PersistentTokenRepository这个,他有两个实现类。
第一个,就是基于内存来弄,但是我们一般不这么搞,来看看,基于jdbc存入数据库持久化怎么弄。
进入到JdbcTokenRepository这个实现类中看看,注释我直接写到源码里面把,大家注意看。
public class JdbcTokenRepositoryImpl extends JdbcDaoSupport implements PersistentTokenRepository {
//重点是这个,创建一个persistent_logins 表,用来记录token。里面有四个字段,分别解释一下
//username 用户名
//series 生成的字符串,用来找到token
//token 具体的令牌
//last_used 最后一次使用的时间
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 = ?";
private String tokensBySeriesSql = "select username,series,token,last_used from persistent_logins where series = ?";
private String insertTokenSql = "insert into persistent_logins (username, series, token, last_used) values(?,?,?,?)";
private String updateTokenSql = "update persistent_logins set token = ?, last_used = ? where series = ?";
……
}
案例演示
然后,我们在数据库创建上面那个persistent_logins 表,然后创建一个工程,进行测试。
引入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
然后简单配置一下,Security.config
@Configuration
public class SecurityConfig {
@Autowired
DataSource dataSource;
//注入数据源
@Bean
JdbcTokenRepositoryImpl jdbcTokenRepository(){
JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
jdbcTokenRepository.setDataSource(dataSource);
return jdbcTokenRepository;
}
/**
* 这里用户简单点,我就写内存了
* @return
*/
@Bean
UserDetailsService userDetailsService(){
InMemoryUserDetailsManager inMemoryUserDetailsManager = new InMemoryUserDetailsManager();
inMemoryUserDetailsManager.createUser(User.withUsername("root").password("{noop}123").build());
return inMemoryUserDetailsManager;
}
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(a->a
.requestMatchers("/auth/*").fullyAuthenticated()
.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.rememberMe(r ->
r.
key("tongzhou")
//一直开启rememberMe记录,这样前端选不选都会记录
.alwaysRemember(true)
//设置token仓库,我们这里就是通过jdbc存入数据库中
.tokenRepository(jdbcTokenRepository())
)
.csrf(c->c.disable());
return http.build();
}
}
上面也写的比较清楚了,就不多说了,重点是看数据库,存在了一条记录。
然后关闭浏览器,去postman测一下,把remember-me复制过去
这样,也是可以的,但是我们关掉浏览器,然后重新打开,再去访问/hello这个接口,就会让你重新登陆了。
可以看到,series值变了,token值也变了,这是因为我刚刚用浏览器过期的访问去请求他的地址,数据库直接把那条数据删掉了,后面分析源码,可以看一下。
ok了,这就没问题了,后台爆出被攻击信息。
Invalid remember-me token (Series/token) mismatch. Implies previous cookie theft attack.
注意
postman测的时候,要清掉JESSIONID,要清掉,采用rememberMe的认证信息后,他会把JSONID也认证了,所以有时候,我们重新登陆后,工具还能访问。清掉JSONID后,只用原本的RememberMe就登不上了。
源码解析
我们看一下,RememberMeServices ,这个服务接口提供了下面三个方法,
autoLogin 从请求中提取需要的参数
loginFail 自动登录失败后的回调
loginSuccess 自动登录成功后的回调。
public interface RememberMeServices {
Authentication autoLogin(HttpServletRequest request, HttpServletResponse response);
void loginFail(HttpServletRequest request, HttpServletResponse response);
void loginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication);
}
我们来下下它的实现类,主要看AbstractRememberMeServices,extractRememberMeCookie这个方法,获取到请求中的remember-me这个值,如果为null,直接返回。接着,看一下它的长度是不是0,如果是,则将前台的remember-me设置为null。
接下来,就是decodeCookie,对进来的rememberMeCookie进行解析,
public Authentication autoLogin(HttpServletRequest request, HttpServletResponse response) {
String rememberMeCookie = this.extractRememberMeCookie(request);
if (rememberMeCookie == null) {
return null;
} else {
this.logger.debug("Remember-me cookie detected");
if (rememberMeCookie.length() == 0) {
this.logger.debug("Cookie was empty");
this.cancelCookie(request, response);
return null;
} else {
try {
String[] cookieTokens = this.decodeCookie(rememberMeCookie);
UserDetails user = this.processAutoLoginCookie(cookieTokens, request, response);
this.userDetailsChecker.check(user);
this.logger.debug("Remember-me cookie accepted");
return this.createSuccessfulAuthentication(request, user);
} catch (CookieTheftException var6) {
this.cancelCookie(request, response);
throw var6;
} catch (UsernameNotFoundException var7) {
this.logger.debug("Remember-me login was valid but corresponding user not found.", var7);
} catch (InvalidCookieException var8) {
this.logger.debug("Invalid remember-me cookie: " + var8.getMessage());
} catch (AccountStatusException var9) {
this.logger.debug("Invalid UserDetails: " + var9.getMessage());
} catch (RememberMeAuthenticationException var10) {
this.logger.debug(var10.getMessage());
}
this.cancelCookie(request, response);
return null;
}
}
}
附上解析代码,其实就是三部分,第一部分用户名,第二部分,时间戳,第三部分就是加密摘要。
protected String[] decodeCookie(String cookieValue) throws InvalidCookieException {
for(int j = 0; j < cookieValue.length() % 4; ++j) {
cookieValue = cookieValue + "=";
}
String cookieAsPlainText;
try {
cookieAsPlainText = new String(Base64.getDecoder().decode(cookieValue.getBytes()));
} catch (IllegalArgumentException var7) {
throw new InvalidCookieException("Cookie token was not Base64 encoded; value was '" + cookieValue + "'");
}
String[] tokens = StringUtils.delimitedListToStringArray(cookieAsPlainText, ":");
for(int i = 0; i < tokens.length; ++i) {
try {
tokens[i] = URLDecoder.decode(tokens[i], StandardCharsets.UTF_8.toString());
} catch (UnsupportedEncodingException var6) {
this.logger.error(var6.getMessage(), var6);
}
}
return tokens;
}
这部分都拿到后,接下来就是对比了processAutoLoginCookie,这是一个抽象类,具体的实现,去它的子类看看。这里我们使用的数据库,所以看PersistentTokenBasedRememberMeServices
cookieTokens 参数不等于2就报错,这是因为里面有两个参数,series和token,要根据series去数据库里面查看token的值。如果查出来为null ,那就是没有,自动登陆失败。
如果查出来的值和现有的不一样,那就说明token已经泄露了,抛出一个错误,会在控制台打出来,可以去上面看看,是不是一样。
接着,再去看token的时间有没有过期,也没有过期的话,就会
PersistentRememberMeToken newToken = new PersistentRememberMeToken(token.getUsername(), token.getSeries(), this.generateTokenData(), new Date());
用户名,series,不变,使用现在的时间,new Date,重新生成token。
this.tokenRepository.updateToken() 更新token,然后将新的token返回。
if (token == null) {
throw new RememberMeAuthenticationException("No persistent token found for series id: " + presentedSeries);
} else if (!presentedToken.equals(token.getTokenValue())) {
this.tokenRepository.removeUserTokens(token.getUsername());
throw new CookieTheftException(this.messages.getMessage("PersistentTokenBasedRememberMeServices.cookieStolen", "Invalid remember-me token (Series/token) mismatch. Implies previous cookie theft attack."));
} else if (token.getDate().getTime() + (long)this.getTokenValiditySeconds() * 1000L < System.currentTimeMillis()) {
throw new RememberMeAuthenticationException("Remember-me login has expired");
} else {
this.logger.debug(LogMessage.format("Refreshing persistent login token for user '%s', series '%s'", token.getUsername(), token.getSeries()));
PersistentRememberMeToken newToken = new PersistentRememberMeToken(token.getUsername(), token.getSeries(), this.generateTokenData(), new Date());
try {
this.tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(), newToken.getDate());
this.addCookie(newToken, request, response);
} catch (Exception var9) {
this.logger.error("Failed to update token: ", var9);
throw new RememberMeAuthenticationException("Autologin failed due to data access problem");
}
return this.getUserDetailsService().loadUserByUsername(token.getUsername());
}
这就是他的认证了,接下来,我们看看它的成功后回调:
onLoginSuccess
登陆成功后创建一个PersistentRememberMeToken对象,调用addCookie将它存入数据库中。
protected void onLoginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) {
String username = successfulAuthentication.getName();
this.logger.debug(LogMessage.format("Creating new persistent login for user %s", username));
PersistentRememberMeToken persistentToken = new PersistentRememberMeToken(username, this.generateSeriesData(), this.generateTokenData(), new Date());
try {
this.tokenRepository.createNewToken(persistentToken);
this.addCookie(persistentToken, request, response);
} catch (Exception var7) {
this.logger.error("Failed to save persistent token ", var7);
}
}