BS系統的登入鑑權流程演變

風吹草發表於2023-10-06

1 基礎知識

使用者登入是使用指定使用者名稱和密碼登入到系統,以對使用者的私密資料進行訪問和操作。在一個有登入鑑權的BS系統中,通常使用者訪問資料時,後端攔截請求,對使用者進行鑑權,以驗證使用者身份和許可權。使用者名稱、密碼等身份資訊只需要在登入時輸入一次,然後透過前後端的配合,在之後的每次訪問都不用再輸入了,通常的方案是將身份標識存在cookie中。

實際的登入方案通常較為複雜。一方面需要了解系統的整體架構,包括前端的架構,然後按需設計不同的登入方案;二是需要考慮安全漏洞。我接觸過幾個系統,從簡單的系統到複雜的,在這裡把它們的登入方案介紹一下。

在介紹具體的登入方案前,先介紹下登入相關的基礎知識。

Cookie是由Web伺服器向Web瀏覽器傳送的一小段字串,此後的所有瀏覽器對服務端的訪問都會攜帶這個字串。Cookie由Netscape發明,它使得保持HTTP請求的狀態(Http協議是無狀態協議)變得容易,服務端可以向Cookie中存入任意的資訊。Cookie最常見用於已登入使用者的鑑權,使用者不用每次請求訪問時都在頁面進行登入。Cookie也有其它用途,比如用於儲存購物車列表[3]

Cookie的使用是透過Http頭set-cookie和cookie實現的。在接收到Http請求後,服務端可以向瀏覽器傳送一個或多個Set-Cookie應答頭。瀏覽器會自動儲存cookie並在此後的瀏覽器對服務端的請求中攜帶Cookie請求頭[1]

服務端向瀏覽器傳送的應答頭:

HTTP/2.0 200 OK
Content-Type: text/html
Set-Cookie: yummy_cookie=choco
Set-Cookie: tasty_cookie=strawberry

瀏覽器自動將cookie儲存,在此後每次瀏覽器向服務的請求都會自動攜帶該cookie:

GET /sample_page.html HTTP/2.0
Host: www.example.org
Cookie: yummy_cookie=choco; tasty_cookie=strawberry

服務端向瀏覽器傳送應答頭的java程式碼實現如Demo1。你可以為cookie設定存活時間,指定Cookie的域名和路徑。Cookie的預設存活時間為瀏覽器會話結束;設定存活時間後,會話結束不會影響cookie的存活;瀏覽器會話結束的場景比如關閉瀏覽器視窗。Cookie的預設所屬路徑是“/專案路徑/相對路徑”的上一層路徑;當請求路徑為Cookie所屬的路徑及其子路徑時,才會攜帶cookie。還可以為Cookie設定Same-Site、HttpOnly等屬性[2],它們與Cookie使用的安全性有關。

public class CookieTestServlet extends HttpServlet {
	protected void doGet(HttpServletRequest request, HttpServletResponse response)
			throws ServletException, IOException {
		// 建立cookie物件
		Cookie yummy = new Cookie("yummy_cookie", "choco");
		Cookie tasty = new Cookie("tasty_cookie", "strawberry");

		//預設存活時間為瀏覽器會話結束;設定存活時間後,會話結束不會影響cookie的存活;瀏覽器會話結束的場景比如關閉瀏覽器視窗
		tasty.setMaxAge(3600);

		//預設路徑是“/專案路徑/相對路徑”的上一層路徑;當請求路徑為設定的路徑及其子路徑時,才會攜帶cookie;
		tasty.setPath("/test");

		//不設定Domain時,Domain的預設值為當前請求的域名(比如localhost、www.example.org等)

		//將cookie返回給瀏覽器,透過應答頭set-cookie
		response.addCookie(yummy);
		response.addCookie(tasty);
	}
}

Demo1 建立Cookie並設定其常用屬性的java程式碼實現

在服務端設定了Cookie的存活時間和路徑後,服務端向瀏覽器傳送的應答頭如下:

HTTP/2.0 200 OK
Content-Type: text/html
Set-Cookie:tasty_cookie=strawberry; Max-Age=3600; Expires=Thu, 21-Sep-2023 08:33:24 GMT; Path=/test
Set-Cookie:yummy_cookie=choco

1.2 重定向與前端路由Vue-router

1.2.1 後端重定向[4]

早期的系統是前後端不分離的。一個典型的系統使用SSM+JSP的架構。未登入的使用者訪問系統頁面時,會透過後端重定向到登入頁,如Demo2。登入時通常將使用者名稱、密碼透過form表單提交到後臺,後臺登入校驗不透過時,也會重定向到登入頁。

//許可權過濾器
public class PermissionFilter implements Filter {
    public void doFilter(ServletRequest _request, ServletResponse _response,
			FilterChain chain) throws IOException, ServletException {
        //如果未登入,重定向到登入頁
        if (!checkLogin(request, response)){
            response.sendRedirect(request.getContextPath() + "/login.jsp")
        }
        chain.doFilter(request, response);
    }
}

Demo2 許可權過濾器的簡單實現

1.2.2 Vue-router

在系統的前後端分離後,一個比較常見Web系統,前端使用Vue+Nodejs,後端使用SpringBoot+Spring+Mybatis。在使用者認證鑑權業務中,前端應用可獨立地提供頁面的訪問和實現頁面間的跳轉,後端實現使用者認證鑑權的邏輯並提供介面。使用者未登入訪問系統頁面時,頁面跳轉通常是透過Vue-router實現的。如Demo3,在router的全域性前置守衛(router.berforeEach)中,判斷使用者是否已登入,如果未登入,則跳轉(透過路由導航)到登入頁。

router.beforeEach((to, from, next) => {
	//判斷是否已登入
    if (getToken()) {
        next()  //進入管道中的下一個鉤子
    }else{
        next(`/login`) //跳轉到登入頁
    }
}

Demo3 使用Vue-router.beforeEach進行使用者是否登入的判斷

對於大多數單頁應用,Vue都推薦使用官方的Vue-router。這是由於前端應用的業務功能越來越複雜,單頁應用(SPA)成為前端應用的主流形式。Vue-router透過管理URL,實現URL和元件的對應,以及透過URL進行元件之間的切換。可以參考相關部落格中的案例,進行Vue-router的安裝和簡單使用[5]。使用Vue-router,透過改變URL,在不重新請求頁面的情況下,就可以更新頁面檢視。“更新檢視但不重新請求頁面”是前端路由原理的核心之一,目前在瀏覽器環境中這一功能的實現主要有兩種方式[6]:

  • 利用URL中的hash(“#”)
  • 利用History interface在 HTML5中新增的方法。

如Demo4,在vue-router中是透過mode這一引數控制路由的實現模式的,mode值為“hash“表示第一種方式,值為”history“表示第二種方式。可以使用 router.beforeEach 註冊一個全域性前置守衛。當一個路由導航觸發時,全域性前置守衛按照建立順序呼叫。每個守衛方法接收三個引數[16]

  • to: Route: 即將要進入的目標 路由物件
  • from: Route: 當前導航正要離開的路由
  • next: Function: 一定要呼叫該方法來 resolve 這個鉤子。執行效果依賴 next 方法的呼叫引數。
  1. next(): 進行管道中的下一個鉤子。如果全部鉤子執行完了,則導航的狀態就是 confirmed (確認的)。
  2. next(false): 中斷當前的導航。如果瀏覽器的 URL 改變了 (可能是使用者手動或者瀏覽器後退按鈕),那麼 URL 地址會重置到 from 路由對應的地址。
  3. next('/') 或者 next({ path: '/' }): 跳轉到一個不同的地址。當前的導航被中斷,然後進行一個新的導航。你可以向 next 傳遞任意位置物件,且允許設定諸如 replace: true、name: 'home' 之類的選項以及任何用在 router-link 的 to prop 或 router.push 中的選項。
  4. next(error): (2.4.0+) 如果傳入 next 的引數是一個 Error 例項,則導航會被終止且該錯誤會被傳遞給 router.onError() 註冊過的回撥。

確保要呼叫 next 方法,否則鉤子就不會被 resolved。

export default new Router({
  // mode: 'hash',
  mode: 'history',
  scrollBehavior: () => ({ y: 0 }),
  routes: constantRouterMap
})

Demo4 建立Vue-router時指定mode為history

1.3 JWT

1.3.1 JWT簡介[7]

JSON Web Token (JWT) 是一個開放的標準(RFC 7519[8])。它定義了嚴謹且獨立的方式,在多方服務間以JSON物件的格式安全地傳遞資訊。數字簽名使傳遞的資訊可驗證可信任。JWT簽名方式有使用一個secret的HMAC演算法和使用公鑰/私鑰的RSA或ECDSA演算法等。

JWT包含head、payload和signature三個部分。比如signature的簽名方式是使用公鑰/私鑰的RSA演算法。如果用在使用者登入中,登入後生成的公鑰加密的token是唯一的,可根據token獲取使用者的登入狀態。payload中一般包含使用者名稱和許可權等資訊,可以直接從token中獲取這些資訊。token還有一些其它特性,signature使用私鑰解密後的值與head和playload的值做對比是否一致,可以判斷token是否被篡改。從系統層面講,只有對token的簽名方擁有私鑰。

JWT是基於token鑑權標準之一,常用的標準還有OAuth[9]。token是身份驗證過程中用到的令牌,他是驗證使用者身份和資源許可權的臨時金鑰。一個有效的token允許使用者對線上的服務和web應用進行訪問直至token過期。這提供了便利,使用者不用每次都重新進行登入認證,就可以繼續訪問資源。這與cookie中的sessionId有類似之處。

1.3.2 JWT的構成

JSON Web Token(JWT)包含Header、Payload和Signature3個部分,3個部分以點號(.)隔開,他的格式可以表示為xxxxx.yyyyy.zzzzz。

1.3.2.1 Header

Header通常包含2部分,一是token的型別,比如JWT或OAuth;二是簽名所用的演算法,比如HMAC SHA256或RSA。以使用公鑰私鑰加密解密的RSA演算法為例,它的Header內容如下:

{
"alg": "RS256", //使用RSA簽名演算法
"typ": "JWT"//使用JWT型別的token
}

Header的json內容使用Base64Url編碼後構成JWT的第一部分。

1.4.2.2 Payload

Token的第二部分是payload,在payload中包含claims。claims是對實體資訊(比如使用者)的陳述以及其它資料。claims分為registered、public和private等3種型別。

  • Registered claims:指一些預定義的claims,這些claims不是強制的但推薦使用,它們實用性強、互動性好。比如iss(issuer)、exp(expiration time)、sub(subject)和aud(audience)等。Registered claims的名稱都是3個字元的,很緊湊。
  • Public claims:可由JWTs的使用者自定義。但為了防止命名衝突,這些claims應在IANA JSON WebToken Registry[10]中定義,或者定義為包含防衝突名稱空間的URI(Uniform Resource Identifier)。
  • Private claims:使用者建立的claims,用於在約定好的多方服務間交換資訊。它們既不是Registerd cliams,也不是Public claims。

使用RS256簽名的Payload如下:

{
"sub": "RS256InOTA",
"name": "John Doe"
}

Payload的json內容使用Base64Url編碼後構成JWT的第二部分。

1.4.2.3 Signature

將Base64Url編碼後的Header、Payload和私鑰,使用header中的演算法(比如RS256)進行處理後,得到Signature。比如,使用sha256進行加密的Signature的建立方式如下,其中私鑰是自動生成的[11]

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  jwtRSA256-private.pem)

Signature構成JWT的第三部分。

1.4.3 JWT在WEB開發中的使用

在WEB專案中,基於JWT進行鑑權時,通常將token以請求頭Authorization的方式傳送。

Authorization的格式如下:

HTTP
Authorization: <type> <credentials>

HTTP提供了一個訪問控制和身份驗證的框架(見RFC 7235[12])。它可以用於服務端質詢客戶端請求,以及客戶端向服務端提供使用者認證的資訊。Authorization是該框架中的一個請求頭,用於瀏覽器向服務端提供使用者認證的資訊。該框架定義了使用者認證的多種機制,其中包含“Bearer”這種機制[13]。Bearer(見RFC 6750[14])機制的官方定義是使用bearer tokens 獲取 OAuth 2.0保護的資源。JWT與OAuth是token生成的2種不同方式。使用JWT的web專案Authorization的格式如下:

HTTP
Authorization: Bearer xxxxx.yyyyy.zzzzz

token放在請求頭Authorization中,而不放在cookie中,主要是第三方服務的cookie已經被很多瀏覽器禁用,要支援第三方服務的cookie只能使用Authorization。Authorization和cookie的區別主要涉及安全性層面,這裡不詳述。

檢視JWT解析的原始碼[15],JWT解析時會經過一系列步驟,其中包含以下步驟:

  • 檢查token是否被篡改。將token中的signature使用公鑰進行解密,與Header、payload做比對是否一致,如果不一致說明被篡改,直接丟擲異常;
  • 檢查token的payload中的過期時間(exp),如果token已經過期,直接丟擲異常。

1.4 認證、鑑權和授權的含義

1.4.1 認證(Authentication)[17]

  • 當服務端需要知道誰在訪問它們的資訊或網站時,會對使用者進行認證;
  • 在認證中,使用者需要向服務端證明自己的身份;
  • 通常,服務端認證需要使用使用者名稱和密碼。其它的身份驗證方式包括(銀行)卡、視網膜掃描、語言識別和指紋等;
  • 認證不決定使用者可以訪問哪些資源。認證僅僅識別和驗證使用者是誰。

1.4.2 授權(Authorization)

1.4.2.1 系統對使用者授權

服務端透過授權這個過程決定使用者是否有訪問資源的許可權[17]。使用者授權確保使用者在訪問敏感資料前擁有適當的許可權,敏感資料包括個人資訊、安全資料庫和私密資料等。通常授權與訪問控制(access control)或客戶端特權(client priviledge)可互換[18]

不要將授權和認證混淆,認證和授權是管理員保護系統和資訊的兩個至關重要的過程。授權和認證通常成對出現,服務端需要先認證以確認使用者身份。授權通常分解為以下步驟[18]

  • 身份識別:在賦予任何許可權前,系統需要識別使用者的身份。通常透過使用者名稱、郵件名或其它唯一標識。
  • 認證:在識別使用者身份後,通常透過輸入密碼、生物掃描或多重身份驗證的方法對使用者進行認證。
  • 分配許可權:在認證成功後,系統獲取使用者所擁有的許可權和角色。
  • 確保只有授權使用者可以訪問:根據獲取的使用者許可權和角色,系統決定使用者可以訪問哪些資源或函式。常用方法有檢查訪問控制列表(Access Control Lists,簡稱ACLs)、基於角色的許可權或其它的授權方法;
  • 審計和監控:系統持續的輸出日誌並監控使用者的行為。這幫助識別未授權和可疑的行為,同時也可以週期性回顧使用者的許可權,以確保符合他們的角色和職責。
  • 會話終止:在會話到期或使用者登出後,會話會終止,確保會話終止後出現未授權的訪問。

有些情況下,沒有授權這個過程,任意使用者都透過請求來使用資源或訪問檔案。大多數的Web頁面都是不需要認證和授權的。

1.4.2.2 其它含義[21]

在資訊保安領域,授權是指資源所有者委派執行者,賦予執行者指定範圍的資源操作許可權,以便執行者代理執行對資源的相關操作。授權的實現方式非常多也很廣泛,我們常見的銀行卡、門禁卡、鑰匙、公證照,這些都是現實生活中授權的實現方式。

在網際網路應用開發領域,授權所用到的授信媒介主要包括如下幾種:

  • 透過web伺服器的session機制,一個訪問會話保持著使用者的授權資訊
  • 透過web瀏覽器的cookie機制,一個網站的cookie保持著使用者的授權資訊
  • 頒發授權令牌(token),一個合法有效的令牌中保持著使用者的授權資訊

簡單理解,授權指系統為使用者頒發使用者憑證的過程。

1.4.2 鑑權

鑑權是通訊行業中的術語[19][20]。 鑑權含義之一是透過評估可應用的訪問控制資訊來確定是否允許某主體具有接入到特定資源所規定的型別的過程。通常情況下,鑑權在認證上下文中進行。一旦某主體被認證,它可以被授權執行不同型別的接入。簡單理解是驗證使用者身份,判斷是否有許可權。有一篇文章贊同這個觀點[21],而另一篇文章則沒有引入鑑權這個概念,直接將鑑權的過程包含在1.5.2.1 系統對使用者授權中[18]

1.4.3 本文用語約定

因為對於授權、鑑權的含義有不同看法,現對本文用語做以下約定:

1.認證:使用者第一次登入,服務端進行驗證使用者名稱密碼,並應答鑑權憑據的過程;

2.鑑權:使用者每次訪問介面時,對使用者的鑑權憑據進行校驗,並判斷使用者是否有許可權訪問該介面或資源;

3.授權:根據鑑權結果,確保使用者只能訪問有許可權的介面或資源。

4.訪問控制:包含鑑權和授權。

這樣的約定可簡化後文的表述。比如使用者登入校驗可表述為使用者認證,使用者訪問資料時的訪問控制也可表述為鑑權授權,SpringSecurity安全框架可描述為包含使用者認證(身份認證)、鑑權授權(訪問控制)兩個部分。一篇關於微服務架構的文章中有使用使用者認證、鑑權授權的表述[22]

1.5 Spring-Security

Spring Security框架提供了身份認證、許可權控制和安全漏洞防護等功能。它在保護spring應用方面是實際上的標準,為保護命令式和反應式應用程式提供一流的支援。

1.5.1 框架的結構

1.5.1.1 過濾器鏈[24]

Spring Security的servlet實現是基於servlet過濾器的。如圖1中的圖①,當客戶端嚮應用傳送請求後,servlet容器依據請求的URL建立FilterChain,FilterChain中包含Filter例項和處理HttpServetRequest的Servlet。

Spring提供了DelegatingFilterProxy這個過濾器,它將Servlet容器的生命週期和Spring中的ApplicationContext連線起來。Servlet容器允許使用自己的標準註冊Filter例項,但無法識別Spring中定義的beans。你可以透過Servlet容器的機制註冊DelegatingFilterProxy並將所有的工作委託給實現Filter介面的Spring Bean。圖1中的②展示了DelegatingFilterProxy與FilterChain和Spring的Filter例項的關係。DelegatingFilterProxy從ApplicationContext中搜尋Bean Filter0並呼叫該Bean Filter0,DelegatingFilterProxy的虛擬碼實現如Demo5。

public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
	Filter delegate = getFilterBean(someBeanName); 
	delegate.doFilter(request, response); 
}

Demo5 DelegatingFilterProxy將工作委託給Spring Beans的虛擬碼

Spring Security對Servlet的支援都包含在FilterChainProxy中。FilterChainProxy是一個由Spring Security提供的特殊的Filter,它允許透過SecurityFilterChain將工作委託給許多Filter例項。FilterChainProxy是一個Bean,通常包裝在DelegatingFilterProxy中。圖1中的③展示了FilterChainProxy的角色。

FilterChainProxy依據SecurityFilterChain決定哪個Spring Security過濾器例項在當前請求被呼叫。圖1中的④展示了SecurityFilterChain的角色。SecurityFilterChain中的Security過濾器都註冊在FilterChainProxy,FilterChainProxy提供了所有Spring Security過濾器的入口。

圖1中的⑤展示了多SecurityFilterChain例項的情況。如圖,FilterChainProxy決定哪個SecurityFilterChain被使用。第一個匹配到的SecurityFilterChain被呼叫。比如一個URL為/api/message的請求,它首先匹配到SecurityFilterChain0的模式/api/**,儘管它也匹配SecurityFilterChainn的模式,但只有SecurityFilterChain0被呼叫。

圖1 過濾器鏈的結構:①Servlet容器中的過濾器鏈; ②Spring中的DelegatingFilterProxy; ③FilterChainProxy; ④SecurityFilterChain; ⑤多SecurityFilterChain。

1.5.1.2 DelegationFilterProxy的例項化和攔截配置

DelagtingFilterProxy的初始化和攔截配置在容器啟動的時候就完成了[23]。SpringServletContainerInitializer是ServletContainerInitializer的實現類,且使用@HandlesTypes註解。當容器啟動後,會呼叫SpringServletContainerInitializer的onStartup方法,收集WebApplicationInitializer的子類,並迴圈呼叫這些子類的onStartup方法。AbstractSecurityWebApplicationInitializer是WebApplicationInitializer的子類,它的onStartup方法被呼叫,對DelegationFilterProxy進行例項化並配置攔截路徑。圖2展示了從容器啟動到DelegationFilterProxy完成例項化和攔截配置的流程。

圖2 從容器啟動到DelegationFilterProxy完成例項化和攔截配置的流程

1.5.1.3 過濾器日誌

一個特定請求到達伺服器,知道哪些過濾器會被呼叫是有幫助的,比如你想確認你新增的過濾器是否被呼叫。在應用啟動時將應用的日誌級別設定為INFO,你就可以在控制檯看到如Log1的日誌。

2023-06-14T08:55:22.321-03:00  INFO 76975 --- [           main] o.s.s.web.DefaultSecurityFilterChain     : Will secure any request with [
org.springframework.security.web.session.DisableEncodeUrlFilter@404db674,
org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@50f097b5,
org.springframework.security.web.context.SecurityContextHolderFilter@6fc6deb7,
org.springframework.security.web.header.HeaderWriterFilter@6f76c2cc,
org.springframework.security.web.csrf.CsrfFilter@c29fe36,
org.springframework.security.web.authentication.logout.LogoutFilter@ef60710,
org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter@7c2dfa2,
org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter@4397a639,
org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter@7add838c,
org.springframework.security.web.authentication.www.BasicAuthenticationFilter@5cc9d3d0,
org.springframework.security.web.savedrequest.RequestCacheAwareFilter@7da39774,
org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@32b0876c,
org.springframework.security.web.authentication.AnonymousAuthenticationFilter@3662bdff,
org.springframework.security.web.access.ExceptionTranslationFilter@77681ce4,
org.springframework.security.web.access.intercept.AuthorizationFilter@169268a7]

Log1 當應用日誌級別設定為INFO時,應用啟動時應用日誌記錄了配置的所有過濾器

Spring Security在security日誌級別為DEBUG和TRACE級別時提供了security相關事件的全面記錄。這對你除錯應用很有幫助,Spring Security為確保安全,當一個請求被拒絕時應答體並未包含錯誤資訊。但你遇到401或403的錯誤時,日誌資訊將會幫助你定位問題。舉個例子,當你傳送POST請求獲取有CSRF保護的資源,但並未攜帶CSRF token。你將會看到403的錯誤且沒有任何錯誤說明。可以透過如Config1 的配置設定Secuirty的日誌級別為TRACE。配置後,出現如上的情況時你除了看到403的錯誤,還可以看到如Log2所示的日誌。

#application.properties in Spring Boot
logging.level.org.springframework.security=TRACE

Config1 在spring Boot專案中配置Security的日誌級別為TRACE

2023-06-14T09:44:25.797-03:00 DEBUG 76975 --- [nio-8080-exec-1] o.s.security.web.FilterChainProxy        : Securing POST /hello
2023-06-14T09:44:25.797-03:00 TRACE 76975 --- [nio-8080-exec-1] o.s.security.web.FilterChainProxy        : Invoking DisableEncodeUrlFilter (1/15)
2023-06-14T09:44:25.798-03:00 TRACE 76975 --- [nio-8080-exec-1] o.s.security.web.FilterChainProxy        : Invoking WebAsyncManagerIntegrationFilter (2/15)
2023-06-14T09:44:25.800-03:00 TRACE 76975 --- [nio-8080-exec-1] o.s.security.web.FilterChainProxy        : Invoking SecurityContextHolderFilter (3/15)
2023-06-14T09:44:25.801-03:00 TRACE 76975 --- [nio-8080-exec-1] o.s.security.web.FilterChainProxy        : Invoking HeaderWriterFilter (4/15)
2023-06-14T09:44:25.802-03:00 TRACE 76975 --- [nio-8080-exec-1] o.s.security.web.FilterChainProxy        : Invoking CsrfFilter (5/15)
2023-06-14T09:44:25.814-03:00 DEBUG 76975 --- [nio-8080-exec-1] o.s.security.web.csrf.CsrfFilter         : Invalid CSRF token found for http://localhost:8080/hello
2023-06-14T09:44:25.814-03:00 DEBUG 76975 --- [nio-8080-exec-1] o.s.s.w.access.AccessDeniedHandlerImpl   : Responding with 403 status code
2023-06-14T09:44:25.814-03:00 TRACE 76975 --- [nio-8080-exec-1] o.s.s.w.header.writers.HstsHeaderWriter  : Not injecting HSTS header since it did not match request to [Is Secure]

Log2 Security的日誌級別設定為DEBUG或TRACE後,日誌對security相關事件進行了全面記錄

1.5.1.4 過濾器配置

Security過濾器透過SecurityFilterChain的API插入到FilterChainProxy中。這些過濾器有不同的用途,比如身份認證、訪問控制和漏洞防護等。Demo6展示了透過SecurityFilterChain配置了一系列過濾器。.csrf()表示配置csrfFilter,.authorizeHttpRequests()表示配置UsernamePasswordAuthenticationFilter,.httpBasic表示配置BasicAuthenticationFilter,.formLogin表示配置AuthorizationFilter。

如果未進行過濾器的配置,則會使用預設配置。在springSecurityFilterChain初始化時,判斷當前是否配置了webSecurityConfigurers,如果沒有,則會生成一個預設的:new WebSecurityConfigurerAdapter()。[25]WebSecurityConfigurerAdapter類的init方法中進行了過濾器的預設配置。預設配置相關程式碼如Demo7。

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(Customizer.withDefaults())
            .authorizeHttpRequests(authorize -> authorize
                .anyRequest().authenticated()
            )
            .httpBasic(Customizer.withDefaults())
            .formLogin(Customizer.withDefaults());
        return http.build();
    }

}

Demo6 透過SecurityFilterChain配置seucrity中的過濾器鏈

public abstract class WebSecurityConfigurerAdapter implements
		WebSecurityConfigurer<WebSecurity> {
		
	public void init(final WebSecurity web) throws Exception {
		final HttpSecurity http = getHttp();
    }
    
    protected final HttpSecurity getHttp() throws Exception {
		if (!disableDefaults) {
			// @formatter:off
			http
				.csrf().and()
				.addFilter(new WebAsyncManagerIntegrationFilter())
				.exceptionHandling().and()
				.headers().and()
				.sessionManagement().and()
				.securityContext().and()
				.requestCache().and()
				.anonymous().and()
				.servletApi().and()
				.apply(new DefaultLoginPageConfigurer<>()).and()
				.logout();
			// @formatter:on
			ClassLoader classLoader = this.context.getClassLoader();
			List<AbstractHttpConfigurer> defaultHttpConfigurers =
					SpringFactoriesLoader.loadFactories(AbstractHttpConfigurer.class, classLoader);

			for (AbstractHttpConfigurer configurer : defaultHttpConfigurers) {
				http.apply(configurer);
			}
		}
		configure(http);
		return http;
	}
}

Demo7 security過濾器鏈的預設配置

1.5.2 在專案中使用Spring Security

現在的專案大多都是前後端分離的。以開源專案eladmin[26]為例進行說明。該專案前端使用Vue+Nodejs,後端使用SpringBoot+Spring+Mybatis,基於Spring Security進行使用者認證、鑑權授權。專案中引入Spring Security後,進行了使用者認證、鑑權授權的功能開發。功能開發主要分為兩塊內容。

1.5.2.1 使用者認證

使用者在登入時進行使用者認證。如圖3,使用者登入時,前端傳送登入請求到後端的登入介面。登入介面的邏輯實現如Demo8,其中呼叫了Spring Security中的方法。實際使用者認證邏輯是在Spring Security框架中實現。Spring Security會呼叫介面UserDetailService的loadUserByUsername方法獲取系統中的使用者,需要在系統中新增介面UserDetailService的實現類UserDetailServiceImpl,如Demo9。在獲取系統的使用者後,會呼叫Spring Security中的DaoAuthenticationProvider的additionalAuthenticationChecks方法,該方法對比登入密碼與系統中的密碼是否一致,如果一致則使用者認證成功,否則認證失敗。需要開發者實現的是後臺登入介面和獲取使用者資訊的實現類UserDetailServiceImpl,使用者認證的邏輯是由Spring Security框架實現的。使用者登入介面的url為“/login”,該介面在過濾器配置中配置了所有使用者(包括未登入使用者)都可以訪問。

圖3 eladmin專案中使用者登入時,後臺基於Spring Security進行使用者認證

@AnonymousPostMapping(value = "/login")
public ResponseEntity<Object> login(@Validated @RequestBody AuthUserDto authUser, HttpServletRequest request) throws Exception {
    // 密碼解密
    String password = RsaUtils.decryptByPrivateKey(RsaProperties.privateKey, authUser.getPassword());
    UsernamePasswordAuthenticationToken authenticationToken =
        new UsernamePasswordAuthenticationToken(authUser.getUsername(), password);
    
    //呼叫spring-security框架中的方法,進行使用者認證
    Authentication authentication = 
    		authenticationManagerBuilder.getObject().authenticate(authenticationToken);

    // 生成令牌
    String token = tokenProvider.createToken(authentication);

    // 將令牌token存入redis中
    
    // 將令牌token應答到前端
}

Demo8 使用者登入介面程式碼實現

@Service("userDetailsService")
public class UserDetailsServiceImpl implements UserDetailsService {
    @Override
    public JwtUserDto loadUserByUsername(String username) {
        user = userService.findByName(username);
          
        jwtUserDto = new JwtUserDto(
            user,
            dataService.getDeptIds(user),
            roleService.mapToGrantedAuthorities(user)
        );
                
        return jwtUserDto;
    }
}

Demo9 系統中新增了UserDetailsService的實現類UserDetailsServiceImpl

1.5.2.2 鑑權授權

如圖4,當前端向後端傳送請求後,後端的spring-security會進行鑑權授權,判斷使用者是否有請求該資源的許可權,如果沒有許可權則拒絕。如果使用者已登入,會攜帶請求頭Authrozation,它的值是token。如Demo10,自定義過濾器TokenFilter繼承GenericFilterBean類,在doFilter方法中根據請求頭中的token判斷使用者是否已登入,如果已登入,將token中的使用者及許可權資訊存入 SecurityContextHolder.getContext()中。然後請求進入Spring Security的過濾器鏈,在過濾器FilterSecurityInterceptor中透過AccessDecisionManager的decide方法進行鑑權。decide方法有3個引數,第一個引數authenticated是使用者資訊和許可權資訊,來源於SecurityContextHolder.getContext();第二個引數objcet是spring-security過濾器鏈相關的物件,第三個引數attributes的來源是SecurityConfig中的介面許可權配置或預設配置。一個典型的SecurityConfig的配置如Demo11,其中配置了訪問介面需要的許可權,比如.antMatchers(HttpMethod.OPTIONS, "/**").permitAll()表示對所有OPTIONS的請求進行無條件放行;.anyRequest().authenticated()表示對所有介面需要認證成功後才能訪問。鑑權透過後,請求進入到後臺介面的Servlet中,Servlet處理該請求並向前臺傳送應答。總的來說,需要開發者實現的是自定義TokenFilter,在TokenFilter中判斷使用者是否已登入,如果已登入,將token存在SecurityContextHolder.getContext()中;還需要自定義SecurityConfig繼承WebSecurityConfigurerAdapter,在SecrityConfig中進行過濾器配置,介面許可權配置等。具體的鑑權授權是Spring Security框架實現的,它獲取在專案啟動時載入的SecurityConfig中的介面許可權配置資料,並獲取SecurityContextHolder.getContext()中的使用者許可權資料,然後透過SPEL表示式判斷是否鑑權透過。

圖4 eladmin專案中前臺向後臺傳送請求時,後臺基於Spring Security進行鑑權授權的流程

public class TokenFilter extends GenericFilterBean {
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
        String token = resolveToken(httpServletRequest);
        // 對於 Token 為空的不需要去查 Redis
        if (StrUtil.isNotBlank(token)) {
            OnlineUserDto onlineUserDto = null;
            boolean cleanUserCache = false;
            
            onlineUserDto = onlineUserService.getOne("online-token-" + token);
           	
            //如果redis中包含該使用者,則說明已登入,將登入資訊設定到SecurityContextHolder.getContext()中;
            if (onlineUserDto != null && StringUtils.hasText(token)) {
                Authentication authentication = tokenProvider.getAuthentication(token);
                SecurityContextHolder.getContext().setAuthentication(authentication);
                // Token 續期
                tokenProvider.checkRenewal(token);
            }
       		filterChain.doFilter(servletRequest, servletResponse);
    	}
    }
}

Demo10 自定義TokenFilter繼承GenericFilterBean

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        // 搜尋匿名標記 url: @AnonymousAccess
        Map<RequestMappingInfo, HandlerMethod> handlerMethodMap = applicationContext.getBean(RequestMappingHandlerMapping.class).getHandlerMethods();
        // 獲取匿名標記
        Map<String, Set<String>> anonymousUrls = getAnonymousUrl(handlerMethodMap);
        httpSecurity
                // 禁用 CSRF
                .csrf().disable()
                .addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
                // 授權異常
                .exceptionHandling()
                .authenticationEntryPoint(authenticationErrorHandler)
                .accessDeniedHandler(jwtAccessDeniedHandler)
                // 防止iframe 造成跨域
                .and()
                .headers()
                .frameOptions()
                .disable()
                // 不建立會話
                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                // 靜態資源等等
                .antMatchers(
                        HttpMethod.GET,
                        "/*.html",
                        "/**/*.html",
                        "/**/*.css",
                        "/**/*.js",
                        "/webSocket/**"
                ).permitAll()
                // swagger 文件
                .antMatchers("/swagger-ui.html").permitAll()
                .antMatchers("/swagger-resources/**").permitAll()
                .antMatchers("/webjars/**").permitAll()
                .antMatchers("/*/api-docs").permitAll()
                // 檔案
                .antMatchers("/avatar/**").permitAll()
                .antMatchers("/file/**").permitAll()
                // 阿里巴巴 druid
                .antMatchers("/druid/**").permitAll()
                // 放行OPTIONS請求
                .antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
                // 自定義匿名訪問所有url放行:允許匿名和帶Token訪問,細膩化到每個 Request 型別
                // GET
                .antMatchers(HttpMethod.POST,"/**").permitAll()
                .antMatchers(HttpMethod.GET, anonymousUrls.get(RequestMethodEnum.GET.getType()).toArray(new String[0])).permitAll()
                // POST
                .antMatchers(HttpMethod.POST, anonymousUrls.get(RequestMethodEnum.POST.getType()).toArray(new String[0])).permitAll()
                // PUT
                .antMatchers(HttpMethod.PUT, anonymousUrls.get(RequestMethodEnum.PUT.getType()).toArray(new String[0])).permitAll()
                // PATCH
                .antMatchers(HttpMethod.PATCH, anonymousUrls.get(RequestMethodEnum.PATCH.getType()).toArray(new String[0])).permitAll()
                // DELETE
                .antMatchers(HttpMethod.DELETE, anonymousUrls.get(RequestMethodEnum.DELETE.getType()).toArray(new String[0])).permitAll()
                // 所有型別的介面都放行
                .antMatchers(anonymousUrls.get(RequestMethodEnum.ALL.getType()).toArray(new String[0])).permitAll()
                // 所有請求都需要認證
                .anyRequest().authenticated()
                .and().apply(securityConfigurerAdapter());
    }
}

Demo11 自定義SecurityConfig繼承WebSecurityConfigurerAdapter

總來來說,spring-security是一個使用者認證、鑑權授權的框架。Spring Security的結構是一個過濾器鏈,當請求進來時會經過一系列的過濾器。本文側重的是使用者認證,鑑權授權的前後端互動流程,雖然後臺使用者認證、鑑權授權具體是由Spring Security實現的,實現細節也較為複雜,但這不影響對前後端互動流程的理解,本文只需對Spring Security有個初步認識即可。

2. 登入鑑權方式演變

登入鑑權方式是隨著前後端架構的變化而變化的。早期的系統是前後端不分離的。通常前端是freemaker/velocity/jsp+html。後端是SSH或SSM。

後來Vue等前端框架的興起,使得前後端得以分離。前端是Vue+nodejs,後端是SSM或SpirngBoot。SpringBoot大大簡化了應用的配置。

再後來微服務SpringCloud興起,它包含閘道器、配置中心、註冊中心等元件。多個微服務的登入鑑權實現和單應用系統又略有差異。

2.1前後端不分離的登入鑑權流程

早期的系統是前後端不分離的。一個典型的系統使用SSM+JSP的架構,技術棧為SpringMVC + Spring + Mybatis + JSP+ Apache + Weblogic。系統的架構圖如圖5所示。系統的登入鑑權流程如圖6所示。系統未登入時,訪問系統的請求將被使用者許可權過濾器攔截,首次進入許可權過濾器會生成會話Session,然後透過後端重定向跳轉到登入頁。在登入頁中,使用者填寫好使用者名稱密碼後,提交form表單,請求後臺登入介面,後臺進行登入校驗,登入成功後將使用者名稱、密碼等使用者資訊存入Session中。在後面的每次訪問後端介面時,根據攜帶cookie的jessionId就可以從Session中獲取使用者資訊,如果使用者名稱不為空,就說明已認證過,然後可正常訪問後端介面,不用每次訪問都進行使用者認證。使用者登入認證失敗後,會跳轉到錯誤頁。

1389306-20231006184523985-127537681.jpg

圖5 一個前後端不分離的典型系統的架構圖

1389306-20231006184547088-1357284742.jpg

圖6 一個前後端不分離的典型系統的登入鑑權流程

2.2前後端分離後的登入鑑權流程

2.2.1 單應用系統

在系統的前後端分離後,一個比較常見Web系統,前端使用Vue+Nodejs,後端使用SpringBoot+Spring+Mybatis。以開源專案eladmin[26]為例進行說明。系統的架構圖如圖7所示。系統的使用者認證流程如圖8所示,在使用者認證流程中,前端應用可獨立地提供頁面的訪問和實現頁面間的跳轉,後端實現使用者認證的邏輯並提供介面。訪問系統的請求透過Vue-Router導航到相應頁面,路由導航會觸發全域性前置守衛的呼叫。在全域性守衛的邏輯中,如果使用者未登入,路由會導航到登入頁。使用者填好使用者名稱和密碼進行登入,向後臺發起登入的ajax請求,後臺會校驗使用者名稱密碼,校驗邏輯是基於SpringSecurity實現的,如果校驗透過,則生成JWT型別的token,應答到瀏覽器。瀏覽器端會儲存token到cookie中,並建立後端介面請求攔截器,攔截請求並將cookie中的token放到請求頭Authentication中。請求到達後端服務後,後端的使用者許可權過濾器會判斷Authentication中的token是否已登入過(判斷在redis中是否存在),並基於SpringSecurity進行鑑權,如果鑑權透過,則正常訪問介面。不用每次訪問後端介面都進行使用者認證。如果登入失敗,則應答錯誤狀態碼和錯誤資訊,瀏覽器會在頁面進行錯誤提示。

1389306-20231006184710990-188619528.png

圖7 前後端分離的典型單應用系統的架構圖

1389306-20231006184745529-727326527.jpg

圖8 前後端分離的典型單應用系統的登入鑑權流程

2.2.2 多個微服務的系統

後來微服務逐漸興起,SpringCloud是熱門的技術之一。SpringCloud包含一系列元件,包括Eureka、Ribbon、Zuul、Feign和Config Server等,方便進行微服務的管理、呼叫和配置。一個比較常見的Web系統,前端使用Vue+Nodejs,後端使用SpringBoot+SpringCloud+Spring+Mybatis。系統的架構圖如圖9所示。系統的使用者認證流程如圖10所示。與單應用系統的使用者認證流程相比,主要有2點不同。一是使用者認證邏輯會放在獨立的鑑權微服務中。二是不是每個包含業務介面的微服務都放一個使用者許可權過濾器,而將過濾器放在閘道器微服務中。如圖10的架構圖,每個後端請求都會經過閘道器,在閘道器中放入使用者許可權過濾器是合適的。在實際業務中,閘道器作為後端微服務的唯一入口,後端微服務則放在內網中,不能不透過閘道器直接訪問後端微服務的介面。使用者認證成功後,後端會應答set-cookie:token=aaaaa.bbbb.ccccc到瀏覽器,瀏覽器將token存入cookie中,後續的介面訪問請求都會攜帶該cookie,請求經過許可權過濾器時,過濾器將cookie中的token取出,判斷該token是否已登入過(判斷在redis中是否存在),並基於SpringSecurity進行鑑權,如果鑑權透過,則正常訪問介面,不用每次都進行使用者認證。不過,token放在Cookie中是不建議的,建議放在請求頭Authrization中,詳細可參考1.4.3節。

1389306-20231006184821661-481186955.jpg

圖9 前後端分離的包含多個微服務的系統架構圖

圖10 前後端分離的包含多個微服務的系統登入鑑權流程

引用:

[1] https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies

[2] https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie

[3] https://foldoc.org/Dictionary

[4] https://www.cnblogs.com/jann8/p/17472129.html

[5] https://www.cnblogs.com/xiaohuochai/p/7527273.html

[6] https://zhuanlan.zhihu.com/p/27588422

[7] https://jwt.io/introduction

[8] https://datatracker.ietf.org/doc/html/rfc7519

[9] https://www.strongdm.com/blog/token-based-authentication

[10] https://www.iana.org/assignments/jwt/jwt.xhtml

[11] https://techdocs.akamai.com/iot-token-access-control/docs/generate-rsa-keys

[12] https://datatracker.ietf.org/doc/html/rfc7235

[13] https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication#authentication_schemes

[14] https://datatracker.ietf.org/doc/html/rfc6750

[15] Maven依賴io.jsonwebtoken:jjwt:0.9.0中DefaultJwtParser的原始碼

[16] https://zhuanlan.zhihu.com/p/204946145

[17] https://www.bu.edu/tech/about/security-resources/bestpractice/auth/

[18] https://frontegg.com/blog/user-authorization

[19] https://baike.c114.com.cn/view.asp?id=14-32137803

[20] http://oldmh.ccsa.org.cn/article_new/dic_search.php

[21] https://blog.csdn.net/Amelie123/article/details/125362070

[22] https://wenku.baidu.com/view/e30e5d02a717866fb84ae45c3b3567ec102ddccb.html

[23] https://blog.csdn.net/qq_31063463/article/details/106359804?spm=1001.2014.3001.5502

[24] https://docs.spring.io/spring-security/reference/servlet/architecture.html

[25] https://www.cnblogs.com/vincentren/p/15685730.html

[26] https://github.com/elunez/

相關文章