實現方式
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);
}
}