都前後端分離了,咱就別做頁面跳轉了!統統 JSON 交互


前兩天有個小夥伴在微信上問松哥,這前後端分離開發後,認證這一塊到底是使用傳統的 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 的功能。我們來看一下:

<code>.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();
})/<code>

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

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

  • HttpServletRequest
  • HttpServletResponse
  • Authentication

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

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

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


都前後端分離了,咱就別做頁面跳轉了!統統 JSON 交互


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

2.3 登錄失敗

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

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

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

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

<code>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();/<code>

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

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

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

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

  • UsernameNotFoundException

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

  • BadCredentialsException

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

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

<code>public Authentication authenticate(Authentication authentication)
\t\tthrows AuthenticationException {
\ttry {
\t\tuser = retrieveUser(username,
\t\t\t\t(UsernamePasswordAuthenticationToken) authentication);
\t}
\tcatch (UsernameNotFoundException notFound) {
\t\tlogger.debug("User '" + username + "' not found");
\t\tif (hideUserNotFoundExceptions) {
\t\t\tthrow new BadCredentialsException(messages.getMessage(
\t\t\t\t\t"AbstractUserDetailsAuthenticationProvider.badCredentials",
\t\t\t\t\t"Bad credentials"));
\t\t}
\t\telse {
\t\t\tthrow notFound;
\t\t}
\t}
}/<code>

從這段代碼中,我們看出,在查找用戶時,如果拋出了 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,如下:

<code>/**
* Performs the redirect (or forward) to the login form URL.
*/
public void commence(HttpServletRequest request, HttpServletResponse response,
\t\tAuthenticationException authException) {
\tString redirectUrl = null;
\tif (useForward) {
\t\tif (forceHttps && "http".equals(request.getScheme())) {
\t\t\tredirectUrl = buildHttpsRedirectUrlForRequest(request);
\t\t}
\t\tif (redirectUrl == null) {
\t\t\tString loginForm = determineUrlToUseForThisRequest(request, response,
\t\t\t\t\tauthException);
\t\t\tif (logger.isDebugEnabled()) {
\t\t\t\tlogger.debug("Server side forward to: " + loginForm);
\t\t\t}
\t\t\tRequestDispatcher dispatcher = request.getRequestDispatcher(loginForm);
\t\t\tdispatcher.forward(request, response);

\t\t\treturn;
\t\t}
\t}
\telse {
\t\tredirectUrl = buildRedirectUrlToLoginPage(request, response, authException);
\t}
\tredirectStrategy.sendRedirect(request, response, redirectUrl);
}/<code>

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

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

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

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

4. 註銷登錄

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

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

<code>.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()/<code>

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


都前後端分離了,咱就別做頁面跳轉了!統統 JSON 交互


好了,本文就和小夥伴們介紹下前後端分離中常見的 JSON 交互問題,小夥伴們如果覺得文章有幫助,記得點一下贊哦。


作者:江南一點雨
鏈接:https://juejin.im/post/5e853b8de51d4546f46f33a5


分享到:


相關文章: