自動登錄是我們在軟件開發時一個非常常見的功能,例如我們登錄 QQ 郵箱:
很多網站我們在登錄的時候都會看到類似的選項,畢竟總讓用戶輸入用戶名密碼是一件很麻煩的事。
自動登錄功能就是,用戶在登錄成功後,在某一段時間內,如果用戶關閉了瀏覽器並重新打開,或者服務器重啟了,都不需要用戶重新登錄了,用戶依然可以直接訪問接口數據。
作為一個常見的功能,我們的 Spring Security 肯定也提供了相應的支持,本文我們就來看下 Spring Security 中如何實現這個功能。
這個功能實現起來簡單,但是還是會涉及到很多細節,所以我會分兩篇文章來逐一介紹,本文是第一篇。
1.實戰代碼
首先,要實現記住我這個功能,其實只需要其實只需要在 Spring Security 的配置中,添加如下代碼即可:
<code>@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.rememberMe()
.and()
.csrf().disable();
}/<code>
大家看到,這裡只需要添加一個 .rememberMe() 即可,自動登錄功能就成功添加進來了。
接下來我們隨意添加一個測試接口:
<code>@RestController
public class HelloController {
@GetMapping("/hello")
public String hello() {
return "hello";
}
}/<code>
重啟項目,我們訪問 hello 接口,此時會自動跳轉到登錄頁面:
這個時候大家發現,默認的登錄頁面多了一個選項,就是記住我。我們輸入用戶名密碼,並且勾選上記住我這個框,然後點擊登錄按鈕執行登錄操作:
可以看到,登錄數據中,除了 username 和 password 之外,還有一個 remember-me,之所以給大家看這個,是想告訴大家,如果你你需要自定義登錄頁面,RememberMe 這個選項的 key 該怎麼寫。
登錄成功之後,就會自動跳轉到 hello 接口了。我們注意,系統訪問 hello 接口的時候,攜帶的 cookie:
大家注意到,這裡多了一個 remember-me,這就是這裡實現的核心,關於這個 remember-me 我一會解釋,我們先來測試效果。
接下來,我們關閉瀏覽器,再重新打開瀏覽器。正常情況下,瀏覽器關閉再重新打開,如果需要再次訪問 hello 接口,就需要我們重新登錄了。但是此時,我們再去訪問 hello 接口,發現不用重新登錄了,直接就能訪問到,這就說明我們的 RememberMe 配置生效了(即下次自動登錄功能生效了)。
2.原理分析
按理說,瀏覽器關閉再重新打開,就要重新登錄,現在竟然不用等了,那麼這個功能到底是怎麼實現的呢?
首先我們來分析一下 cookie 中多出來的這個 remember-me,這個值一看就是一個 Base64 轉碼後的字符串,我們可以使用網上的一些在線工具來解碼,可以自己簡單寫兩行代碼來解碼:
<code>@Test
void contextLoads() throws UnsupportedEncodingException {
String s = new String(Base64.getDecoder().decode("amF2YWJveToxNTg5MTA0MDU1MzczOjI1NzhmZmJjMjY0ODVjNTM0YTJlZjkyOWFjMmVmYzQ3"), "UTF-8");
System.out.println("s = " + s);
}/<code>
執行這段代碼,輸出結果如下:
<code>s = javaboy:1589104055373:2578ffbc26485c534a2ef929ac2efc47/<code>
可以看到,這段 Base64 字符串實際上用 : 隔開,分成了三部分:
- 第一段是用戶名,這個無需質疑。
- 第二段看起來是一個時間戳,我們通過在線工具或者 Java 代碼解析後發現,這是一個兩週後的數據。
- 第三段我就不賣關子了,這是使用 MD5 散列函數算出來的值,他的明文格式是 username + ":" + tokenExpiryTime + ":" + password + ":" + key,最後的 key 是一個散列鹽值,可以用來防止令牌被修改。
瞭解到 cookie 中 remember-me 的含義之後,那麼我們對於記住我的登錄流程也就很容易猜到了了。
在瀏覽器關閉後,並重新打開之後,用戶再去訪問 hello 接口,此時會攜帶著 cookie 中的 remember-me 到服務端,服務到拿到值之後,可以方便的計算出用戶名和過期時間,再根據用戶名查詢到用戶密碼,然後通過 MD5 散列函數計算出散列值,再將計算出的散列值和瀏覽器傳遞來的散列值進行對比,就能確認這個令牌是否有效。
流程就是這麼個流程,接下來我們通過分析源碼來驗證一下這個流程對不對。
3.源碼分析
接下來,我們通過源碼來驗證一下我們上面說的對不對。
這裡主要從兩個方面來介紹,一個是 remember-me 這個令牌生成的過程,另一個則是它解析的過程。
3.1 生成
生成的核心處理方法在:TokenBasedRememberMeServices#onLoginSuccess:
<code>@Override
public void onLoginSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication successfulAuthentication) {
String username = retrieveUserName(successfulAuthentication);
String password = retrievePassword(successfulAuthentication);
if (!StringUtils.hasLength(password)) {
UserDetails user = getUserDetailsService().loadUserByUsername(username);
password = user.getPassword();
}
int tokenLifetime = calculateLoginLifetime(request, successfulAuthentication);
long expiryTime = System.currentTimeMillis();
expiryTime += 1000L * (tokenLifetime String signatureValue = makeTokenSignature(expiryTime, username, password);
setCookie(new String[] { username, Long.toString(expiryTime), signatureValue },
tokenLifetime, request, response);
}
protected String makeTokenSignature(long tokenExpiryTime, String username,
String password) {
String data = username + ":" + tokenExpiryTime + ":" + password + ":" + getKey();
MessageDigest digest;
digest = MessageDigest.getInstance("MD5");
return new String(Hex.encode(digest.digest(data.getBytes())));
}/<code>
這段方法的邏輯其實很好理解:
- 首先從登錄成功的 Authentication 中提取出用戶名/密碼。
- 由於登錄成功之後,密碼可能被擦除了,所以,如果一開始沒有拿到密碼,就再從 UserDetailsService 中重新加載用戶並重新獲取密碼。
- 再接下來去獲取令牌的有效期,令牌有效期默認就是兩週。
- 再接下來調用 makeTokenSignature 方法去計算散列值,實際上就是根據 username、令牌有效期以及 password、key 一起計算一個散列值。如果我們沒有自己去設置這個 key,默認是在 RememberMeConfigurer#getKey 方法中進行設置的,它的值是一個 UUID 字符串。
- 最後,將用戶名、令牌有效期以及計算得到的散列值放入 Cookie 中。
關於第四點,我這裡再說一下。
由於我們自己沒有設置 key,key 默認值是一個 UUID 字符串,這樣會帶來一個問題,就是如果服務端重啟,這個 key 會變,這樣就導致之前派發出去的所有 remember-me 自動登錄令牌失效,所以,我們可以指定這個 key。指定方式如下:
<code>@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.rememberMe()
.key("javaboy")
.and()
.csrf().disable();
}/<code>
如果自己配置了 key,「即使服務端重啟,即使瀏覽器打開再關閉」,也依然能夠訪問到 hello 接口。
這是 remember-me 令牌生成的過程。至於是如何走到 onLoginSuccess 方法的,大家可以參考松哥之前的文章:松哥手把手帶你捋一遍 Spring Security 登錄流程。這裡可以給大家稍微提醒一下思路:
AbstractAuthenticationProcessingFilter#doFilter -> AbstractAuthenticationProcessingFilter#successfulAuthentication -> AbstractRememberMeServices#loginSuccess -> TokenBasedRememberMeServices#onLoginSuccess。
3.2 解析
那麼當用戶關掉並打開瀏覽器之後,重新訪問 /hello 接口,此時的認證流程又是怎麼樣的呢?
我們之前說過,Spring Security 中的一系列功能都是通過一個過濾器鏈實現的,RememberMe 這個功能當然也不例外。
Spring Security 中提供了 RememberMeAuthenticationFilter 類專門用來做相關的事情,我們來看下 RememberMeAuthenticationFilter 的 doFilter 方法:
<code>public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
if (SecurityContextHolder.getContext().getAuthentication() == null) {
Authentication rememberMeAuth = rememberMeServices.autoLogin(request,
response);
if (rememberMeAuth != null) {
rememberMeAuth = authenticationManager.authenticate(rememberMeAuth);
SecurityContextHolder.getContext().setAuthentication(rememberMeAuth);
onSuccessfulAuthentication(request, response, rememberMeAuth);
if (this.eventPublisher != null) {
eventPublisher
.publishEvent(new InteractiveAuthenticationSuccessEvent(
SecurityContextHolder.getContext()
.getAuthentication(), this.getClass()));
}
if (successHandler != null) {
successHandler.onAuthenticationSuccess(request, response,
rememberMeAuth);
return;
}
}
chain.doFilter(request, response);
}
else {
chain.doFilter(request, response);
}
}/<code>
可以看到,就是在這裡實現的。
這個方法最關鍵的地方在於,如果從 SecurityContextHolder 中無法獲取到當前登錄用戶實例,那麼就調用 rememberMeServices.autoLogin 邏輯進行登錄,我們來看下這個方法:
<code>public final Authentication autoLogin(HttpServletRequest request,
HttpServletResponse response) {
String rememberMeCookie = extractRememberMeCookie(request);
if (rememberMeCookie == null) {
return null;
}
logger.debug("Remember-me cookie detected");
if (rememberMeCookie.length() == 0) {
logger.debug("Cookie was empty");
cancelCookie(request, response);
return null;
}
UserDetails user = null;
try {
String[] cookieTokens = decodeCookie(rememberMeCookie);
user = processAutoLoginCookie(cookieTokens, request, response);
userDetailsChecker.check(user);
logger.debug("Remember-me cookie accepted");
return createSuccessfulAuthentication(request, user);
}
catch (CookieTheftException cte) {
throw cte;
}
cancelCookie(request, response);
return null;
}/<code>
可以看到,這裡就是提取出 cookie 信息,並對 cookie 信息進行解碼,解碼之後,再調用 processAutoLoginCookie 方法去做校驗,processAutoLoginCookie 方法的代碼我就不貼了,核心流程就是首先獲取用戶名和過期時間,再根據用戶名查詢到用戶密碼,然後通過 MD5 散列函數計算出散列值,再將拿到的散列值和瀏覽器傳遞來的散列值進行對比,就能確認這個令牌是否有效,進而確認登錄是否有效。
好了,這裡的流程我也根據大家大致上梳理了一下。
4.總結
看了上面的文章,大家可能已經發現,如果我們開啟了 RememberMe 功能,最最核心的東西就是放在 cookie 中的令牌了,這個令牌突破了 session 的限制,即使服務器重啟、即使瀏覽器關閉又重新打開,只要這個令牌沒有過期,就能訪問到數據。
一旦令牌丟失,別人就可以拿著這個令牌隨意登錄我們的系統了,這是一個非常危險的操作。
但是實際上這是一段悖論,為了提高用戶體驗(少登錄),我們的系統不可避免的引出了一些安全問題,不過我們可以通過技術將安全風險降低到最小。
剛剛入駐頭條,有什麼理解不對的地方可以在評論區留言,覺得不錯的朋友希望能得到您的轉發支持,同時可以持續關注我,每週定期會分享3到4篇精選乾貨!
您的關注是對我最大的支持謝謝.
閱讀更多 JAVA夢想口服液 的文章