在上篇文章中,我們提到了 Spring Boot 自動登錄存在的一些安全風險,在實際應用中,我們肯定要把這些安全風險降到最低,下面就來和大家聊一聊如何降低安全風險的問題。
降低安全風險,我主要從兩個方面來給大家介紹:
- 持久化令牌方案
- 二次校驗
持久化令牌
原理
要理解持久化令牌,一定要先搞明白自動登錄的基本玩法,參考(Spring Security:自動登錄)。
持久化令牌就是在基本的自動登錄功能基礎上,又增加了新的校驗參數,來提高系統的安全性,這一些都是由開發者在後臺完成的,對於用戶來說,登錄體驗和普通的自動登錄體驗是一樣的。
在持久化令牌中,新增了兩個經過 MD5 散列函數計算的校驗參數,一個是 series,另一個是 token。其中,series 只有當用戶在使用用戶名/密碼登錄時,纔會生成或者更新,而 token 只要有新的會話,就會重新生成,這樣就可以避免一個用戶同時在多端登錄,就像手機 QQ ,一個手機上登錄了,就會踢掉另外一個手機的登錄,這樣用戶就會很容易發現賬戶是否泄漏。
持久化令牌的具體處理類在 PersistentTokenBasedRememberMeServices 中,上篇文章我們講到的自動化登錄具體的處理類是在 TokenBasedRememberMeServices 中,它們有一個共同的父類:
而用來保存令牌的處理類則是 PersistentRememberMeToken,該類的定義也很簡潔命令:
public class PersistentRememberMeToken {
private final String username;
private final String series;
private final String tokenValue;
private final Date date;
//省略 getter
}
這裏的 Date 表示上一次使用自動登錄的時間。
代碼演示
接下來,我通過代碼來給大家演示一下持久化令牌的具體用法。
首先我們需要一張表來記錄令牌信息,這張表我們可以完全自定義,也可以使用系統默認提供的 JDBC 來操作,如果使用默認的 JDBC,即 JdbcTokenRepositoryImpl,我們可以來分析一下該類的定義:
public class JdbcTokenRepositoryImpl extends JdbcDaoSupport implements
PersistentTokenRepository {
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 = ?";
}
根據這段 SQL 定義,我們就可以分析出來表的結構,這裏給出一段 SQL 腳本:
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;
首先我們在數據庫中準備好這張表。
既然要連接數據庫,我們還需要準備 jdbc 和 mysql 依賴,如下:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
然後修改 application.properties ,配置數據庫連接信息:
spring.datasource.username=root
spring.datasource.password=123456
spring.datasource.url=jdbc:mysql://localhost:3306/javakf_test1?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
接下來,我們修改 SecurityConfig,如下:
@Autowired
DataSource dataSource;
@Bean
JdbcTokenRepositoryImpl jdbcTokenRepository() {
JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
jdbcTokenRepository.setDataSource(dataSource);
return jdbcTokenRepository;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.rememberMe()
.key("javakf")
.tokenRepository(jdbcTokenRepository())
.and()
.csrf().disable();
}
提供一個 JdbcTokenRepositoryImpl 實例,並給其配置 DataSource 數據源,最後通過 tokenRepository 將 JdbcTokenRepositoryImpl 實例納入配置中。
OK,做完這一切,我們就可以測試了。
測試
我們還是先去訪問 /hello 接口,此時會自動跳轉到登錄頁面,然後我們執行登錄操作,記得勾選上“記住我”這個選項,登錄成功後,我們可以重啓服務器、然後關閉瀏覽器再打開,再去訪問 /hello 接口,發現依然能夠訪問到,說明我們的持久化令牌配置已經生效。
查看 remember-me 的令牌,如下:
這個令牌經過解析之後,格式如下:
gAnM2kjaP5tNZh3rHPR2OA%3D%3D:qVhKovcIayxvGNefBvKiHw%3D%3D
這其中,%3D 表示 =,所以上面的字符實際上可以翻譯成下面這樣:
gAnM2kjaP5tNZh3rHPR2OA==:qVhKovcIayxvGNefBvKiHw==
此時,查看數據庫,我們發現之前的表中生成了一條記錄:
數據庫中的記錄和我們看到的 remember-me 令牌解析後是一致的。
源碼分析
這裏的源碼分析和上篇文章的流程基本一致,只不過實現類變了,也就是生成令牌/解析令牌的實現變了,所以這裏我主要和大家展示不一樣的地方,流程問題,大家可以參考上篇文章。
這次的實現類主要是: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);
}
可以看到:
- 在登錄成功後,首先還是獲取到用戶名,即 username。
- 接下來構造一個 PersistentRememberMeToken 實例,generateSeriesData 和generateTokenData 方法分別用來獲取 series 和 token,具體的生成過程實際上就是調用SecureRandom 生成隨機數再進行 Base64 編碼,不同於我們以前用的 Math.random 或者java.util.Random 這種僞隨機數,SecureRandom則採用的是類似於密碼學的隨機數生成規則,其輸出結果較難預測,適合在登錄這樣的場景下使用。
- 調用 tokenRepository 實例中的 createNewToken 方法,tokenRepository實際上就是我們一開始配置的 JdbcTokenRepositoryImpl,所以這行代碼實際上就是將PersistentRememberMeToken 存入數據庫中。
- 最後 addCookie,大家可以看到,就是添加了 series 和 token。
這是令牌生成的過程,還有令牌校驗的過程,也在該類中,方法是:processAutoLoginCookie:
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 中返回。
- 根據用戶名查詢用戶信息,再走一波登錄流程。
OK,這裏和小夥伴們簡單理了一下令牌生成和校驗的過程,具體的流程,大家可以參考上篇文章。
二次校驗
相比於上篇文章,持久化令牌的方式其實已經安全很多了,但是依然存在用戶身份被盜用的問題,這個問題實際上很難完美解決,我們能做的,只能是當發生用戶身份被盜用這樣的事情時,將損失降低到最小。
因此,我們來看下另一種方案,就是二次校驗。
二次校驗這塊,實現起來要稍微複雜一點,我先來和大家說說思路。
爲了讓用戶使用方便,我們開通了自動登錄功能,但是自動登錄功能又帶來了安全風險,一個規避的辦法就是如果用戶使用了自動登錄功能,我們可以只讓他做一些常規的不敏感操作,例如數據瀏覽、查看,但是不允許他做任何修改、刪除操作,如果用戶點擊了修改、刪除按鈕,我們可以跳轉回登錄頁面,讓用戶重新輸入密碼確認身份,然後再允許他執行敏感操作。
這個功能在 Shiro 中有一個比較方便的過濾器可以配置,Spring Security 當然也一樣,例如我現在提供三個訪問接口:
@RestController
public 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 接口,必須是通過自動登錄認證後才能訪問,如果用戶是通過用戶名/密碼認證的,則無法訪問該接口。
好了,我們來看下接口的訪問要怎麼配置:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/rememberme").rememberMe()
.antMatchers("/admin").fullyAuthenticated()
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.rememberMe()
.key("javakf")
.tokenRepository(jdbcTokenRepository())
.and()
.csrf().disable();
}
可以看到:
- /rememberme 接口是需要 rememberMe 才能訪問。
- /admin 是需要 fullyAuthenticated,fullyAuthenticated 不同於authenticated,fullyAuthenticated 不包含自動登錄的形式,而 authenticated包含自動登錄的形式。
- 最後剩餘的接口(/hello)都是 authenticated 就能訪問。
OK,配置完成後,重啓測試,測試過程我就不再贅述了。