代码地址
SpringSecurity提供了两种令牌
使用方式很简单,修改配置文件,加入RememberMe即可
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/css/**", "/img/**", "/js/**", "/bootstrap/**", "/captcha.jpg").permitAll()
.antMatchers("/app/api/**").permitAll()
.anyRequest().authenticated()
.and()
.formLogin().loginPage("/myLogin.html")
.loginProcessingUrl("/login")
.successHandler(successHandler)
.failureHandler(failureHandler)
.authenticationDetailsSource(myWebAuthenticationDetailsSource)
.permitAll()
.and()
// 添加记住我,需要提供UserDetailService
.rememberMe().userDetailsService(userDetailService)
// 使登录页不受限
.and()
.csrf().disable();
}
修改前端代码,添加记住我选择框,name要为remember-me
<div class="form-group">
<label for="captcha">验证码
label>
<input id="captcha" type="text" class="form-control" name="captcha" required>
<img src="/captcha.jpg" alt="captcha" height="50px" width="150px">
div>
<div class="form-group">
<label>
<input type="checkbox" name="remember-me" value="true"> Remember Me
label>
div>
重启项目登录系统,勾选记住我
关闭浏览器后再重新访问路径,可以看到不需要再次登录就可以访问
F12打开谷歌浏览器的开发者工具,查看cookie中,除了JSessionId之外还多出了一个Remember-me的值,这个就是令牌
生成和验证Token的逻辑在AbstractRememberMeServices
中有基本的实现,我们来看一下它的子类TokenBasedRememberMeServices
,它有一个方法用于生成Token
protected String makeTokenSignature(long tokenExpiryTime, String username,
String password) {
String data = username + ":" + tokenExpiryTime + ":" + password + ":" + getKey();
MessageDigest digest;
try {
digest = MessageDigest.getInstance("MD5");
}
catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("No MD5 algorithm available!");
}
return new String(Hex.encode(digest.digest(data.getBytes())));
}
先将用户名、过期时间、密码、key(salt)进行一个MD5加密,再用base64加密用户名+过期时间+上一步md5加密的值。
当验证的时候,使用base64对令牌进行解密获取到用户名、过期时间和加密的散列值,在通过用户名(我们注入了UserDetailService)查询到用户密码,再次正向进行一次散列算法,与之前加密散列值进行对比来判断令牌是否有效。
需要注意的是,我们用到的getKey(),key是在构造函数传入的,通过我们配置的Remember点进去,查找到RememberMeConfigurer
,里面对我们的RememberService进行了初始化,传入了Key值,其中的getKey方法如下
private String getKey() {
if (this.key == null) {
if (this.rememberMeServices instanceof AbstractRememberMeServices) {
this.key = ((AbstractRememberMeServices)this.rememberMeServices).getKey();
} else {
this.key = UUID.randomUUID().toString();
}
}
return this.key;
}
当我们未指定key的情况下,key会被设置为UUID,也就是说当系统每次重启之后的key值是不一样的,当我们系统重启后,用户之前的RememberMe的令牌肯定就失效了,为了避免这个问题我们可以自己指定key的值
.rememberMe().userDetailsService(userDetailService).key("anntly")
在该类中,当登陆成功后,会刷新我们的令牌并将其放入Cookie中
@Override
public void onLoginSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication successfulAuthentication) {
String username = retrieveUserName(successfulAuthentication);
String password = retrievePassword(successfulAuthentication);
// If unable to find a username and password, just abort as
// TokenBasedRememberMeServices is
// unable to construct a valid token in this case.
if (!StringUtils.hasLength(username)) {
logger.debug("Unable to retrieve username");
return;
}
if (!StringUtils.hasLength(password)) {
UserDetails user = getUserDetailsService().loadUserByUsername(username);
password = user.getPassword();
if (!StringUtils.hasLength(password)) {
logger.debug("Unable to obtain password for user: " + username);
return;
}
}
int tokenLifetime = calculateLoginLifetime(request, successfulAuthentication);
long expiryTime = System.currentTimeMillis();
// 计算过期时间,在AbstractRememberMeServices默认设置的是两周
expiryTime += 1000L * (tokenLifetime < 0 ? TWO_WEEKS_S : tokenLifetime);
// 生成Token
String signatureValue = makeTokenSignature(expiryTime, username, password);
// 放入cookie
setCookie(new String[] {
username, Long.toString(expiryTime), signatureValue },
tokenLifetime, request, response);
if (logger.isDebugEnabled()) {
logger.debug("Added remember-me cookie for user '" + username
+ "', expiry: '" + new Date(expiryTime) + "'");
}
}
使用非持久化的方式不用使用其他的存储空间,但是只要获取到用户的RememberId之后,把他放入cookie中,在不同的session中也可以进行访问,存在一定的安全隐患,需要保证在记住我功能下对敏感操作的限制,下图即为使用MsEdge浏览器放入令牌后访问路径成功
保存两个令牌,series和token,均为MD5散列值,series在用户使用密码重新登录时更新,token在每次创建一个新的session的时候就会重新生成。
在非持久化方案中有一个问题是多个客户端使用同一个token都可以登录,在持久化方案中,每个新的session都会导致token的更新,也就是说token只支持单实例登录。
series不会因为自动登录而更改,当自动登录的时候,会验证series和token两个值,当用户在未使用过自动登录的时候被盗,会刷新token值,此时用户的token已经失效,当用户使用自动登录的时候由于在数据库中存储的和series匹配的token不一致,系统就会腿短令牌是否已经被盗用,作出对应的操作
在SpringSecurity中使用PersistentRememberMeToken
封装series和token,对应的表如下,在数据库中创建对应的表,可以看到主键是series
,当自动登录解析出series
时从数据库查询对应的信息就可以比对登录时的token和数据库的是否一致了
DROP TABLE IF EXISTS `persistent_logins`;
CREATE TABLE `persistent_logins` (
`username` varchar(64) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`series` varchar(64) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`token` varchar(64) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`last_used` timestamp(0) NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP(0),
PRIMARY KEY (`series`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
SET FOREIGN_KEY_CHECKS = 1;
修改配置文件
@Autowired
private DataSource dataSource;
@Override
protected void configure(HttpSecurity http) throws Exception {
JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
jdbcTokenRepository.setDataSource(dataSource);
http.authorizeRequests()
.antMatchers("/css/**", "/img/**", "/js/**", "/bootstrap/**", "/captcha.jpg").permitAll()
.antMatchers("/app/api/**").permitAll()
.anyRequest().authenticated()
.and()
.formLogin().loginPage("/myLogin.html")
.loginProcessingUrl("/login")
.successHandler(successHandler)
.failureHandler(failureHandler)
.authenticationDetailsSource(myWebAuthenticationDetailsSource)
.permitAll()
.and()
.rememberMe().userDetailsService(userDetailsService()).tokenRepository(jdbcTokenRepository)
//.rememberMe().userDetailsService(userDetailService).key("anntly")
// 使登录页不受限
.and()
.csrf().disable();
}
使用默认提供的JdbcTokenRepositoryImpl
来获取表的相关信息,如果需要我们也可以自己实现PersistentTokenRepository
接口
配置完毕后重启项目,登录,查看生成的remember-me ,并将其使用base64解密
// remember-me
b01hMmlVaXprcnloblFHNFFlcGc1QSUzRCUzRDpta2xTN1ljaEZVMWpoVWNzZmdzekpnJTNEJTNE
// 解密后
oMa2iUizkryhnQG4Qepg5A%3D%3D:mklS7YchFU1jhUcsfgszJg%3D%3D
解密后的值,冒号前半部分就是我们的series,后半部分当然就是token,可以查看我们创建的表中数据是否一致
在AbstractRememberMeServices
的另外一个实现类PersistentTokenBasedRememberMeServices
中的processAutoLoginCookie方法中可以查看自动登录令牌校验和更新的情况
protected UserDetails processAutoLoginCookie(String[] cookieTokens,
HttpServletRequest request, HttpServletResponse response) {
if (cookieTokens.length != 2) {
throw new InvalidCookieException("Cookie token did not contain " + 2
+ " tokens, but contained '" + Arrays.asList(cookieTokens) + "'");
}
// series
final String presentedSeries = cookieTokens[0];
// token
final String presentedToken = cookieTokens[1];
// 根据series获取令牌信息
PersistentRememberMeToken token = tokenRepository
.getTokenForSeries(presentedSeries);
if (token == null) {
// No series match, so we can't authenticate using this cookie
throw new RememberMeAuthenticationException(
"No persistent token found for series id: " + presentedSeries);
}
// 当数据库查询出来token和当前token不匹配
// We have a match for this user/series combination
if (!presentedToken.equals(token.getTokenValue())) {
// Token doesn't match series value. Delete all logins for this user and throw
// an exception to warn them.
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");
}
// Token also matches, so login is valid. Update the token value, keeping the
// *same* series number.
if (logger.isDebugEnabled()) {
logger.debug("Refreshing persistent login token for user '"
+ token.getUsername() + "', series '" + token.getSeries() + "'");
}
PersistentRememberMeToken newToken = new PersistentRememberMeToken(
token.getUsername(), token.getSeries(), generateTokenData(), new Date());
try {
// 刷新token
tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(),
newToken.getDate());
addCookie(newToken, request, response);
}
catch (Exception e) {
logger.error("Failed to update token: ", e);
throw new RememberMeAuthenticationException(
"Autologin failed due to data access problem");
}
return getUserDetailsService().loadUserByUsername(token.getUsername());
}
当登录成功时令牌的生成
protected void onLoginSuccess(HttpServletRequest request,
HttpServletResponse response, Authentication successfulAuthentication) {
String username = successfulAuthentication.getName();
logger.debug("Creating new persistent login for user " + username);
// 生成令牌
PersistentRememberMeToken persistentToken = new PersistentRememberMeToken(
username, generateSeriesData(), generateTokenData(), new Date());
try {
tokenRepository.createNewToken(persistentToken);
addCookie(persistentToken, request, response);
}
catch (Exception e) {
logger.error("Failed to save persistent token ", e);
}
}