【SpringSecurity系列(四)】登錄成功返回JSON數據

《深入淺出Spring Security》一書已由清華大學出版社正式出版發行,感興趣的小夥伴戳這裏->->>深入淺出Spring Security,一本書學會 Spring Security。

視頻看完了,如果小夥伴們覺得松哥的視頻風格還能接受,也可以看看松哥自制的 Spring Boot + Vue 系列視頻教程

以下是視頻筆記。

前兩天有個小夥伴在微信上問松哥,這前後端分離開發後,認證這一塊到底是使用傳統的 session 還是使用像 JWT 這樣的 token 來解決呢?

這確實代表了兩種不同的方向。

傳統的通過 session 來記錄用戶認證信息的方式我們可以理解爲這是一種有狀態登錄,而 JWT 則代表了一種無狀態登錄。可能有小夥伴對這個概念還不太熟悉,我這裏就先來科普一下有狀態登錄和無狀態登錄。

1. 無狀態登錄

1.1 什麼是有狀態

有狀態服務,即服務端需要記錄每次會話的客戶端信息,從而識別客戶端身份,根據用戶身份進行請求的處理,典型的設計如 Tomcat 中的 Session。例如登錄:用戶登錄後,我們把用戶的信息保存在服務端 session 中,並且給用戶一個 cookie 值,記錄對應的 session,然後下次請求,用戶攜帶 cookie 值來(這一步有瀏覽器自動完成),我們就能識別到對應 session,從而找到用戶的信息。這種方式目前來看最方便,但是也有一些缺陷,如下:

  • 服務端保存大量數據,增加服務端壓力
  • 服務端保存用戶狀態,不支持集羣化部署

1.2 什麼是無狀態

微服務集羣中的每個服務,對外提供的都使用 RESTful 風格的接口。而 RESTful 風格的一個最重要的規範就是:服務的無狀態性,即:

  • 服務端不保存任何客戶端請求者信息
  • 客戶端的每次請求必須具備自描述信息,通過這些信息識別客戶端身份

那麼這種無狀態性有哪些好處呢?

  • 客戶端請求不依賴服務端的信息,多次請求不需要必須訪問到同一臺服務器
  • 服務端的集羣和狀態對客戶端透明
  • 服務端可以任意的遷移和伸縮(可以方便的進行集羣化部署)
  • 減小服務端存儲壓力

1.3 如何實現無狀態

無狀態登錄的流程:

  • 首先客戶端發送賬戶名/密碼到服務端進行認證
  • 認證通過後,服務端將用戶信息加密並且編碼成一個 token,返回給客戶端
  • 以後客戶端每次發送請求,都需要攜帶認證的 token
  • 服務端對客戶端發送來的 token 進行解密,判斷是否有效,並且獲取用戶登錄信息

1.4 各自優缺點

使用 session 最大的優點在於方便。你不用做過多的處理,一切都是默認的即可。松哥本系列前面幾篇文章我們也都是基於 session 來講的。

但是使用 session 有另外一個致命的問題就是如果你的前端是 Android、iOS、小程序等,這些 App 天然的就沒有 cookie,如果非要用 session,就需要這些工程師在各自的設備上做適配,一般是模擬 cookie,從這個角度來說,在移動 App 遍地開花的今天,我們單純的依賴 session 來做安全管理,似乎也不是特別理想。

這個時候 JWT 這樣的無狀態登錄就展示出自己的優勢了,這些登錄方式所依賴的 token 你可以通過普通參數傳遞,也可以通過請求頭傳遞,怎麼樣都行,具有很強的靈活性。

不過話說回來,如果你的前後端分離只是網頁+服務端,其實沒必要上無狀態登錄,基於 session 來做就可以了,省事又方便。

好了,說了這麼多,本文我還是先來和大家說說基於 session 的認證,關於 JWT 的登錄以後我會和大家細說,如果小夥伴們等不及,也可以先看看松哥之前發的關於 JWT 的教程:Spring Security 結合 Jwt 實現無狀態登錄

2. 登錄交互

上篇文章中,松哥和大家捋了常見的登錄參數配置問題,對於登錄成功和登錄失敗,我們還遺留了一個回調函數沒有講,這篇文章就來和大家細聊一下。

2.1 前後端分離的數據交互

在前後端分離這樣的開發架構下,前後端的交互都是通過 JSON 來進行,無論登錄成功還是失敗,都不會有什麼服務端跳轉或者客戶端跳轉之類。

登錄成功了,服務端就返回一段登錄成功的提示 JSON 給前端,前端收到之後,該跳轉該展示,由前端自己決定,就和後端沒有關係了。

登錄失敗了,服務端就返回一段登錄失敗的提示 JSON 給前端,前端收到之後,該跳轉該展示,由前端自己決定,也和後端沒有關係了。

首先把這樣的思路確定了,基於這樣的思路,我們來看一下登錄配置。

2.2 登錄成功

之前我們配置登錄成功的處理是通過如下兩個方法來配置的:

  • defaultSuccessUrl
  • successForwardUrl

這兩個都是配置跳轉地址的,適用於前後端不分的開發。除了這兩個方法之外,還有一個必殺技,那就是 successHandler。

successHandler 的功能十分強大,甚至已經囊括了 defaultSuccessUrl 和 successForwardUrl 的功能。我們來看一下:

.successHandler((req, resp, authentication) -> {
    Object principal = authentication.getPrincipal();
    resp.setContentType("application/json;charset=utf-8");
    PrintWriter out = resp.getWriter();
    out.write(new ObjectMapper().writeValueAsString(principal));
    out.flush();
    out.close();
})

successHandler 方法的參數是一個 AuthenticationSuccessHandler 對象,這個對象中我們要實現的方法是 onAuthenticationSuccess。

onAuthenticationSuccess 方法有三個參數,分別是:

  • HttpServletRequest
  • HttpServletResponse
  • Authentication

有了前兩個參數,我們就可以在這裏隨心所欲的返回數據了。利用 HttpServletRequest 我們可以做服務端跳轉,利用 HttpServletResponse 我們可以做客戶端跳轉,當然,也可以返回 JSON 數據。

第三個 Authentication 參數則保存了我們剛剛登錄成功的用戶信息。

配置完成後,我們再去登錄,就可以看到登錄成功的用戶信息通過 JSON 返回到前端了,如下:

當然用戶的密碼已經被擦除掉了。擦除密碼的問題,松哥之前和大家分享過,大家可以參考這篇文章:手把手帶你捋一遍 Spring Security 登錄流程

2.3 登錄失敗

登錄失敗也有一個類似的回調,如下:

.failureHandler((req, resp, e) -> {
    resp.setContentType("application/json;charset=utf-8");
    PrintWriter out = resp.getWriter();
    out.write(e.getMessage());
    out.flush();
    out.close();
})

失敗的回調也是三個參數,前兩個就不用說了,第三個是一個 Exception,對於登錄失敗,會有不同的原因,Exception 中則保存了登錄失敗的原因,我們可以將之通過 JSON 返回到前端。

當然大家也看到,在微人事中,我還挨個去識別了一下異常的類型,根據不同的異常類型,我們可以給用戶一個更加明確的提示:

resp.setContentType("application/json;charset=utf-8");
PrintWriter out = resp.getWriter();
RespBean respBean = RespBean.error(e.getMessage());
if (e instanceof LockedException) {
    respBean.setMsg("賬戶被鎖定,請聯繫管理員!");
else if (e instanceof CredentialsExpiredException) {
    respBean.setMsg("密碼過期,請聯繫管理員!");
else if (e instanceof AccountExpiredException) {
    respBean.setMsg("賬戶過期,請聯繫管理員!");
else if (e instanceof DisabledException) {
    respBean.setMsg("賬戶被禁用,請聯繫管理員!");
else if (e instanceof BadCredentialsException) {
    respBean.setMsg("用戶名或者密碼輸入錯誤,請重新輸入!");
}
out.write(new ObjectMapper().writeValueAsString(respBean));
out.flush();
out.close();

這裏有一個需要注意的點。

我們知道,當用戶登錄時,用戶名或者密碼輸入錯誤,我們一般只給一個模糊的提示,即用戶名或者密碼輸入錯誤,請重新輸入,而不會給一個明確的諸如“用戶名輸入錯誤”或“密碼輸入錯誤”這樣精確的提示,但是對於很多不懂行的新手小夥伴,他可能就會給一個明確的錯誤提示,這會給系統帶來風險。

但是使用了 Spring Security 這樣的安全管理框架之後,即使你是一個新手,也不會犯這樣的錯誤。

在 Spring Security 中,用戶名查找失敗對應的異常是:

  • UsernameNotFoundException

密碼匹配失敗對應的異常是:

  • BadCredentialsException

但是我們在登錄失敗的回調中,卻總是看不到 UsernameNotFoundException 異常,無論用戶名還是密碼輸入錯誤,拋出的異常都是 BadCredentialsException。

這是爲什麼呢?松哥在之前的文章手把手帶你捋一遍 Spring Security 登錄流程中介紹過,在登錄中有一個關鍵的步驟,就是去加載用戶數據,我們再來把這個方法拎出來看一下(部分):

public Authentication authenticate(Authentication authentication)
  throws AuthenticationException 
{
 try {
  user = retrieveUser(username,
    (UsernamePasswordAuthenticationToken) authentication);
 }
 catch (UsernameNotFoundException notFound) {
  logger.debug("User '" + username + "' not found");
  if (hideUserNotFoundExceptions) {
   throw new BadCredentialsException(messages.getMessage(
     "AbstractUserDetailsAuthenticationProvider.badCredentials",
     "Bad credentials"));
  }
  else {
   throw notFound;
  }
 }
}

從這段代碼中,我們看出,在查找用戶時,如果拋出了 UsernameNotFoundException,這個異常會被捕獲,捕獲之後,如果 hideUserNotFoundExceptions 屬性的值爲 true,就拋出一個 BadCredentialsException。相當於將 UsernameNotFoundException 異常隱藏了,而默認情況下,hideUserNotFoundExceptions 的值就爲 true。

看到這裏大家就明白了爲什麼無論用戶還是密碼寫錯,你收到的都是 BadCredentialsException 異常。

一般來說這個配置是不需要修改的,如果你一定要區別出來 UsernameNotFoundException 和 BadCredentialsException,我這裏給大家提供三種思路:

  1. 自己定義 DaoAuthenticationProvider 代替系統默認的,在定義時將 hideUserNotFoundExceptions 屬性設置爲 false。
  2. 當用戶名查找失敗時,不拋出 UsernameNotFoundException 異常,而是拋出一個自定義異常,這樣自定義異常就不會被隱藏,進而在登錄失敗的回調中根據自定義異常信息給前端用戶一個提示。
  3. 當用戶名查找失敗時,直接拋出 BadCredentialsException,但是異常信息爲 “用戶名不存在”。

三種思路僅供小夥伴們參考,除非情況特殊,一般不用修改這一塊的默認行爲。

官方這樣做的好處是什麼呢?很明顯可以強迫開發者給一個模糊的異常提示,這樣即使是不懂行的新手,也不會將系統置於危險之中。

好了,這樣配置完成後,無論是登錄成功還是失敗,後端都將只返回 JSON 給前端了。

3. 未認證處理方案

那未認證又怎麼辦呢?

有小夥伴說,那還不簡單,沒有認證就訪問數據,直接重定向到登錄頁面就行了,這沒錯,系統默認的行爲也是這樣。

但是在前後端分離中,這個邏輯明顯是有問題的,如果用戶沒有登錄就訪問一個需要認證後才能訪問的頁面,這個時候,我們不應該讓用戶重定向到登錄頁面,而是給用戶一個尚未登錄的提示,前端收到提示之後,再自行決定頁面跳轉。

要解決這個問題,就涉及到 Spring Security 中的一個接口 AuthenticationEntryPoint ,該接口有一個實現類:LoginUrlAuthenticationEntryPoint ,該類中有一個方法 commence,如下:

/**
 * Performs the redirect (or forward) to the login form URL.
 */

public void commence(HttpServletRequest request, HttpServletResponse response,
  AuthenticationException authException)
 
{
 String redirectUrl = null;
 if (useForward) {
  if (forceHttps && "http".equals(request.getScheme())) {
   redirectUrl = buildHttpsRedirectUrlForRequest(request);
  }
  if (redirectUrl == null) {
   String loginForm = determineUrlToUseForThisRequest(request, response,
     authException);
   if (logger.isDebugEnabled()) {
    logger.debug("Server side forward to: " + loginForm);
   }
   RequestDispatcher dispatcher = request.getRequestDispatcher(loginForm);
   dispatcher.forward(request, response);
   return;
  }
 }
 else {
  redirectUrl = buildRedirectUrlToLoginPage(request, response, authException);
 }
 redirectStrategy.sendRedirect(request, response, redirectUrl);
}

首先我們從這個方法的註釋中就可以看出,這個方法是用來決定到底是要重定向還是要 forward,通過 Debug 追蹤,我們發現默認情況下 useForward 的值爲 false,所以請求走進了重定向。

那麼我們解決問題的思路很簡單,直接重寫這個方法,在方法中返回 JSON 即可,不再做重定向操作,具體配置如下:

.csrf().disable().exceptionHandling()
.authenticationEntryPoint((req, resp, authException) -> {
            resp.setContentType("application/json;charset=utf-8");
            PrintWriter out = resp.getWriter();
            out.write("尚未登錄,請先登錄");
            out.flush();
            out.close();
        }
);

在 Spring Security 的配置中加上自定義的 AuthenticationEntryPoint 處理方法,該方法中直接返回相應的 JSON 提示即可。這樣,如果用戶再去直接訪問一個需要認證之後纔可以訪問的請求,就不會發生重定向操作了,服務端會直接給瀏覽器一個 JSON 提示,瀏覽器收到 JSON 之後,該幹嘛幹嘛。

4. 註銷登錄

最後我們再來看看註銷登錄的處理方案。

註銷登錄我們前面說過,按照前面的配置,註銷登錄之後,系統自動跳轉到登錄頁面,這也是不合適的,如果是前後端分離項目,註銷登錄成功後返回 JSON 即可,配置如下:

.and()
.logout()
.logoutUrl("/logout")
.logoutSuccessHandler((req, resp, authentication) -> {
    resp.setContentType("application/json;charset=utf-8");
    PrintWriter out = resp.getWriter();
    out.write("註銷成功");
    out.flush();
    out.close();
})
.permitAll()
.and()

這樣,註銷成功之後,前端收到的也是 JSON 了:

好了,本文就和小夥伴們介紹下前後端分離中常見的 JSON 交互問題,








加微信進羣



一起切磋Web安全

(已添加松哥微信的小夥伴請勿重複添加)

小夥伴們如果覺得文章有幫助,記得點一下在看哦。

本文分享自微信公衆號 - 江南一點雨(a_javaboy)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章