都前後端分離了,我們就別做頁面跳轉了!統統 JSON 互動

江南一點雨發表於2020-04-02

@[toc] 這是本系列的第四篇,有小夥伴找不到之前文章,鬆哥給大家列一個索引出來:

  1. 挖一個大坑,Spring Security 開搞!
  2. 鬆哥手把手帶你入門 Spring Security,別再問密碼怎麼解密了
  3. 手把手教你定製 Spring Security 中的表單登入

前兩天有個小夥伴在微信上問鬆哥,這前後端分離開發後,認證這一塊到底是使用傳統的 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 返回到前端了,如下:

都前後端分離了,我們就別做頁面跳轉了!統統 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 互動

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

相關文章