Spring Security:前後端分離登錄

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

這前後端分離開發後,認證這一塊到底是使用傳統的 session 還是使用像 JWT 這樣的 token 來解決呢?

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

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

無狀態登錄

什麼是有狀態

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

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

什麼是無狀態

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

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

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

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

如何實現無狀態

無狀態登錄的流程:

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

各自優缺點

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

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

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

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

好了,說了這麼多,本文我還是先來和大家說說基於 session 的認證,關於 JWT 的登錄以後我會和大家細說。

登錄交互

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

前後端分離的數據交互

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

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

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

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

登錄成功

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

  • 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 返回到前端了,如下:
在這裏插入圖片描述
當然用戶的密碼已經被擦除掉了。

登錄失敗

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

.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 中,用戶名查找失敗對應的異常是:

  • UsernameNotFoundException

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

  • BadCredentialsException

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

這是爲什麼呢?在登錄中有一個關鍵的步驟,就是去加載用戶數據,我們再來把這個方法拎出來看一下(部分):

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 給前端了。

未認證處理

那未認證又怎麼辦呢?

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

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

要解決這個問題,就涉及到 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 之後,該幹嘛幹嘛。

註銷登錄

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

註銷登錄我們前面說過,按照前面的配置,註銷登錄之後,系統自動跳轉到登錄頁面,這也是不合適的,如果是前後端分離項目,註銷登錄成功後返回 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 了:
在這裏插入圖片描述

代碼託管:springsecurity_example_3

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