BS系統(tǒng)的登錄鑒權流程演變
1 基礎知識
用戶登錄是使用指定用戶名和密碼登錄到系統(tǒng),以對用戶的私密數(shù)據(jù)進行訪問和操作。在一個有登錄鑒權的BS系統(tǒng)中,通常用戶訪問數(shù)據(jù)時,后端攔截請求,對用戶進行鑒權,以驗證用戶身份和權限。用戶名、密碼等身份信息只需要在登錄時輸入一次,然后通過前后端的配合,在之后的每次訪問都不用再輸入了,通常的方案是將身份標識存在cookie中。
實際的登錄方案通常較為復雜。一方面需要了解系統(tǒng)的整體架構,包括前端的架構,然后按需設計不同的登錄方案;二是需要考慮安全漏洞。我接觸過幾個系統(tǒng),從簡單的系統(tǒng)到復雜的,在這里把它們的登錄方案介紹一下。
在介紹具體的登錄方案前,先介紹下登錄相關的基礎知識。
1.1 Http Cookie
Cookie是由Web服務器向Web瀏覽器發(fā)送的一小段字符串,此后的所有瀏覽器對服務端的訪問都會攜帶這個字符串。Cookie由Netscape發(fā)明,它使得保持HTTP請求的狀態(tài)(Http協(xié)議是無狀態(tài)協(xié)議)變得容易,服務端可以向Cookie中存入任意的信息。Cookie最常見用于已登錄用戶的鑒權,用戶不用每次請求訪問時都在頁面進行登錄。Cookie也有其它用途,比如用于存儲購物車列表[3]。
Cookie的使用是通過Http頭set-cookie和cookie實現(xiàn)的。在接收到Http請求后,服務端可以向瀏覽器發(fā)送一個或多個Set-Cookie應答頭。瀏覽器會自動存儲cookie并在此后的瀏覽器對服務端的請求中攜帶Cookie請求頭[1]。
服務端向瀏覽器發(fā)送的應答頭:
Copy
HTTP/2.0 200 OKContent-Type: text/htmlSet-Cookie: yummy_cookie=chocoSet-Cookie: tasty_cookie=strawberry
瀏覽器自動將cookie保存,在此后每次瀏覽器向服務的請求都會自動攜帶該cookie:
Copy
GET /sample_page.html HTTP/2.0Host: www.example.orgCookie: yummy_cookie=choco; tasty_cookie=strawberry
服務端向瀏覽器發(fā)送應答頭的java代碼實現(xiàn)如Demo1。你可以為cookie設置存活時間,指定Cookie的域名和路徑。Cookie的默認存活時間為瀏覽器會話結束;設置存活時間后,會話結束不會影響cookie的存活;瀏覽器會話結束的場景比如關閉瀏覽器窗口。Cookie的默認所屬路徑是“/項目路徑/相對路徑”的上一層路徑;當請求路徑為Cookie所屬的路徑及其子路徑時,才會攜帶cookie。還可以為Cookie設置Same-Site、HttpOnly等屬性[2],它們與Cookie使用的安全性有關。
Copy
public class CookieTestServlet extends HttpServlet { protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException { // 創(chuàng)建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 創(chuàng)建Cookie并設置其常用屬性的java代碼實現(xiàn)
在服務端設置了Cookie的存活時間和路徑后,服務端向瀏覽器發(fā)送的應答頭如下:
Copy
HTTP/2.0 200 OKContent-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]
早期的系統(tǒng)是前后端不分離的。一個典型的系統(tǒng)使用SSM+JSP的架構。未登錄的用戶訪問系統(tǒng)頁面時,會通過后端重定向到登錄頁,如Demo2。登錄時通常將用戶名、密碼通過form表單提交到后臺,后臺登錄校驗不通過時,也會重定向到登錄頁。
Copy
//權限過濾器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 權限過濾器的簡單實現(xiàn)
1.2.2 Vue-router
在系統(tǒng)的前后端分離后,一個比較常見Web系統(tǒng),前端使用Vue+Nodejs,后端使用SpringBoot+Spring+Mybatis。在用戶認證鑒權業(yè)務中,前端應用可獨立地提供頁面的訪問和實現(xiàn)頁面間的跳轉,后端實現(xiàn)用戶認證鑒權的邏輯并提供接口。用戶未登錄訪問系統(tǒng)頁面時,頁面跳轉通常是通過Vue-router實現(xiàn)的。如Demo3,在router的全局前置守衛(wèi)(router.berforeEach)中,判斷用戶是否已登錄,如果未登錄,則跳轉(通過路由導航)到登錄頁。
Copy
router.beforeEach((to, from, next) => { //判斷是否已登錄
? ?if (getToken()) { ? ? ? ?next() ?//進入管道中的下一個鉤子
? ?}else{ ? ? ? ?next(`/login`) //跳轉到登錄頁
? ?}
}
Demo3 使用Vue-router.beforeEach進行用戶是否登錄的判斷
對于大多數(shù)單頁應用,Vue都推薦使用官方的Vue-router。這是由于前端應用的業(yè)務功能越來越復雜,單頁應用(SPA)成為前端應用的主流形式。Vue-router通過管理URL,實現(xiàn)URL和組件的對應,以及通過URL進行組件之間的切換。可以參考相關博客中的案例,進行Vue-router的安裝和簡單使用[5]。使用Vue-router,通過改變URL,在不重新請求頁面的情況下,就可以更新頁面視圖?!案乱晥D但不重新請求頁面”是前端路由原理的核心之一,目前在瀏覽器環(huán)境中這一功能的實現(xiàn)主要有兩種方式[6]:
利用URL中的hash(“#”)
利用History interface在 HTML5中新增的方法。
如Demo4,在vue-router中是通過mode這一參數(shù)控制路由的實現(xiàn)模式的,mode值為“hash“表示第一種方式,值為”history“表示第二種方式??梢允褂?router.beforeEach 注冊一個全局前置守衛(wèi)。當一個路由導航觸發(fā)時,全局前置守衛(wèi)按照創(chuàng)建順序調用。每個守衛(wèi)方法接收三個參數(shù)[16]:
to: Route: 即將要進入的目標 路由對象
from: Route: 當前導航正要離開的路由
next: Function: 一定要調用該方法來 resolve 這個鉤子。執(zhí)行效果依賴 next 方法的調用參數(shù)。
next(): 進行管道中的下一個鉤子。如果全部鉤子執(zhí)行完了,則導航的狀態(tài)就是 confirmed (確認的)。
next(false): 中斷當前的導航。如果瀏覽器的 URL 改變了 (可能是用戶手動或者瀏覽器后退按鈕),那么 URL 地址會重置到 from 路由對應的地址。
next('/') 或者 next({ path: '/' }): 跳轉到一個不同的地址。當前的導航被中斷,然后進行一個新的導航。你可以向 next 傳遞任意位置對象,且允許設置諸如 replace: true、name: 'home' 之類的選項以及任何用在 router-link 的 to prop 或 router.push 中的選項。
next(error): (2.4.0+) 如果傳入 next 的參數(shù)是一個 Error 實例,則導航會被終止且該錯誤會被傳遞給 router.onError() 注冊過的回調。
確保要調用 next 方法,否則鉤子就不會被 resolved。
Copy
export default new Router({ ?// mode: 'hash',
?mode: 'history', ?scrollBehavior: () => ({ y: 0 }), ?routes: constantRouterMap
})
Demo4 創(chuàng)建Vue-router時指定mode為history
1.3 JWT
1.3.1 JWT簡介[7]
JSON Web Token (JWT) 是一個開放的標準(RFC 7519[8])。它定義了嚴謹且獨立的方式,在多方服務間以JSON對象的格式安全地傳遞信息。數(shù)字簽名使傳遞的信息可驗證可信任。JWT簽名方式有使用一個secret的HMAC算法和使用公鑰/私鑰的RSA或ECDSA算法等。
JWT包含head、payload和signature三個部分。比如signature的簽名方式使用的是公鑰/私鑰的RSA算法。payload中一般包含用戶名和權限等信息,可以直接從token中獲取這些信息。token還有一些其它特性,signature使用公鑰解密后的值與head和playload的值做對比是否一致,可以判斷token是否被篡改。從系統(tǒng)層面講,只有對token的簽名方擁有私鑰。
JWT是基于token鑒權標準之一,常用的標準還有OAuth[9]。token是身份驗證過程中用到的令牌,他是驗證用戶身份和資源權限的臨時密鑰。一個有效的token允許用戶對在線的服務和web應用進行訪問直至token過期。這提供了便利,用戶不用每次都重新進行登錄認證,就可以繼續(xù)訪問資源。這與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內容如下:
Copy
{"alg": "RS256", //使用RSA簽名算法"typ": "JWT"//使用JWT類型的token}
Header的json內容使用Base64Url編碼后構成JWT的第一部分。
1.4.2.2 Payload
Token的第二部分是payload,在payload中包含claims。claims是對實體信息(比如用戶)的陳述以及其它數(shù)據(jù)。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:用戶創(chuàng)建的claims,用于在約定好的多方服務間交換信息。它們既不是Registerd cliams,也不是Public claims。
使用RS256簽名的Payload如下:
Copy
{"sub": "RS256InOTA","name": "John Doe"}
Payload的json內容使用Base64Url編碼后構成JWT的第二部分。
1.4.2.3 Signature
將Base64Url編碼后的Header、Payload和私鑰,使用header中的算法(比如RS256)進行處理后,得到Signature。比如,使用sha256進行加密的Signature的創(chuàng)建方式如下,其中私鑰是自動生成的[11]:
Copy
HMACSHA256(
?base64UrlEncode(header) + "." + ?base64UrlEncode(payload),
?jwtRSA256-private.pem)
Signature構成JWT的第三部分。
1.4.3 JWT在WEB開發(fā)中的使用
在WEB項目中,基于JWT進行鑒權時,通常將token以請求頭Authorization的方式發(fā)送。
Authorization的格式如下:
Copy
HTTPAuthorization: <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的格式如下:
Copy
HTTPAuthorization: Bearer xxxxx.yyyyy.zzzzz
token放在請求頭Authorization中,而不放在cookie中,主要是第三方服務的cookie已經被很多瀏覽器禁用,要支持第三方服務的cookie只能使用Authorization。Authorization和cookie的區(qū)別主要涉及安全性層面,這里不詳述。
查看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 系統(tǒng)對用戶授權
服務端通過授權這個過程決定用戶是否有訪問資源的權限[17]。用戶授權確保用戶在訪問敏感數(shù)據(jù)前擁有適當?shù)臋嘞?,敏感?shù)據(jù)包括個人信息、安全數(shù)據(jù)庫和私密數(shù)據(jù)等。通常授權與訪問控制(access control)或客戶端特權(client priviledge)可互換[18]。
不要將授權和認證混淆,認證和授權是管理員保護系統(tǒng)和信息的兩個至關重要的過程。授權和認證通常成對出現(xiàn),服務端需要先認證以確認用戶身份。授權通常分解為以下步驟[18]:
身份識別:在賦予任何權限前,系統(tǒng)需要識別用戶的身份。通常通過用戶名、郵件名或其它唯一標識。
認證:在識別用戶身份后,通常通過輸入密碼、生物掃描或多重身份驗證的方法對用戶進行認證。
分配權限:在認證成功后,系統(tǒng)獲取用戶所擁有的權限和角色。
確保只有授權用戶可以訪問:根據(jù)獲取的用戶權限和角色,系統(tǒng)決定用戶可以訪問哪些資源或函數(shù)。常用方法有檢查訪問控制列表(Access Control Lists,簡稱ACLs)、基于角色的權限或其它的授權方法;
審計和監(jiān)控:系統(tǒng)持續(xù)的輸出日志并監(jiān)控用戶的行為。這幫助識別未授權和可疑的行為,同時也可以周期性回顧用戶的權限,以確保符合他們的角色和職責。
會話終止:在會話到期或用戶登出后,會話會終止,確保會話終止后出現(xiàn)未授權的訪問。
有些情況下,沒有授權這個過程,任意用戶都通過請求來使用資源或訪問文件。大多數(shù)的Web頁面都是不需要認證和授權的。
1.4.2.2 其它含義[21]
在信息安全領域,授權是指資源所有者委派執(zhí)行者,賦予執(zhí)行者指定范圍的資源操作權限,以便執(zhí)行者代理執(zhí)行對資源的相關操作。授權的實現(xiàn)方式非常多也很廣泛,我們常見的銀行卡、門禁卡、鑰匙、公證書,這些都是現(xiàn)實生活中授權的實現(xiàn)方式。
在互聯(lián)網應用開發(fā)領域,授權所用到的授信媒介主要包括如下幾種:
通過web服務器的session機制,一個訪問會話保持著用戶的授權信息
通過web瀏覽器的cookie機制,一個網站的cookie保持著用戶的授權信息
頒發(fā)授權令牌(token),一個合法有效的令牌中保持著用戶的授權信息
簡單理解,授權指系統(tǒng)為用戶頒發(fā)用戶憑證的過程。
1.4.2 鑒權
鑒權是通信行業(yè)中的術語[19][20]。 鑒權含義之一是通過評估可應用的訪問控制信息來確定是否允許某主體具有接入到特定資源所規(guī)定的類型的過程。通常情況下,鑒權在認證上下文中進行。一旦某主體被認證,它可以被授權執(zhí)行不同類型的接入。簡單理解是驗證用戶身份,判斷是否有權限。有一篇文章贊同這個觀點[21],而另一篇文章則沒有引入鑒權這個概念,直接將鑒權的過程包含在1.5.2.1 系統(tǒng)對用戶授權中[18]。
1.4.3 本文用語約定
因為對于授權、鑒權的含義有不同看法,現(xiàn)對本文用語做以下約定:
1.認證:用戶第一次登錄,服務端進行驗證用戶名密碼,并應答鑒權憑據(jù)的過程;
2.鑒權:用戶每次訪問接口時,對用戶的鑒權憑據(jù)進行校驗,并判斷用戶是否有權限訪問該接口或資源;
3.授權:根據(jù)鑒權結果,確保用戶只能訪問有權限的接口或資源。
4.訪問控制:包含鑒權和授權。
這樣的約定可簡化后文的表述。比如用戶登錄校驗可表述為用戶認證,用戶訪問數(shù)據(jù)時的訪問控制也可表述為鑒權授權,SpringSecurity安全框架可描述為包含用戶認證(身份認證)、鑒權授權(訪問控制)兩個部分。一篇關于微服務架構的文章中有使用用戶認證、鑒權授權的表述[22]。
1.5 Spring-Security
Spring Security框架提供了身份認證、權限控制和安全漏洞防護等功能。它在保護spring應用方面是實際上的標準,為保護命令式和反應式應用程序提供一流的支持。
1.5.1 框架的結構
1.5.1.1 過濾器鏈[24]
Spring Security的servlet實現(xiàn)是基于servlet過濾器的。如圖1中的圖①,當客戶端向應用發(fā)送請求后,servlet容器依據(jù)請求的URL創(chuàng)建FilterChain,F(xiàn)ilterChain中包含F(xiàn)ilter實例和處理HttpServetRequest的Servlet。
Spring提供了DelegatingFilterProxy這個過濾器,它將Servlet容器的生命周期和Spring中的ApplicationContext連接起來。Servlet容器允許使用自己的標準注冊Filter實例,但無法識別Spring中定義的beans。你可以通過Servlet容器的機制注冊DelegatingFilterProxy并將所有的工作委托給實現(xiàn)Filter接口的Spring Bean。圖1中的②展示了DelegatingFilterProxy與FilterChain和Spring的Filter實例的關系。DelegatingFilterProxy從ApplicationContext中搜尋Bean Filter0并調用該Bean Filter0,DelegatingFilterProxy的偽代碼實現(xiàn)如Demo5。
Copy
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依據(jù)SecurityFilterChain決定哪個Spring Security過濾器實例在當前請求被調用。圖1中的④展示了SecurityFilterChain的角色。SecurityFilterChain中的Security過濾器都注冊在FilterChainProxy,F(xiàn)ilterChainProxy提供了所有Spring Security過濾器的入口。
圖1中的⑤展示了多SecurityFilterChain實例的情況。如圖,F(xiàn)ilterChainProxy決定哪個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的實現(xiàn)類,且使用@HandlesTypes注解。當容器啟動后,會調用SpringServletContainerInitializer的onStartup方法,收集WebApplicationInitializer的子類,并循環(huán)調用這些子類的onStartup方法。AbstractSecurityWebApplicationInitializer是WebApplicationInitializer的子類,它的onStartup方法被調用,對DelegationFilterProxy進行實例化并配置攔截路徑。圖2展示了從容器啟動到DelegationFilterProxy完成實例化和攔截配置的流程。

圖2 從容器啟動到DelegationFilterProxy完成實例化和攔截配置的流程
1.5.1.3 過濾器日志
一個特定請求到達服務器,知道哪些過濾器會被調用是有幫助的,比如你想確認你添加的過濾器是否被調用。在應用啟動時將應用的日志級別設置為INFO,你就可以在控制臺看到如Log1的日志。
Copy
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的錯誤時,日志信息將會幫助你定位問題。舉個例子,當你發(fā)送POST請求獲取有CSRF保護的資源,但并未攜帶CSRF token。你將會看到403的錯誤且沒有任何錯誤說明??梢酝ㄟ^如Config1 的配置設置Secuirty的日志級別為TRACE。配置后,出現(xiàn)如上的情況時你除了看到403的錯誤,還可以看到如Log2所示的日志。
Copy
#application.properties in Spring Bootlogging.level.org.springframework.security=TRACE
Config1 在spring Boot項目中配置Security的日志級別為TRACE
Copy
2023-06-14T09:44:25.797-03:00 DEBUG 76975 --- [nio-8080-exec-1] o.s.security.web.FilterChainProxy ? ? ? ?: Securing POST /hello2023-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/hello2023-06-14T09:44:25.814-03:00 DEBUG 76975 --- [nio-8080-exec-1] o.s.s.w.access.AccessDeniedHandlerImpl ? : Responding with 403 status code2023-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。
Copy
public class SecurityConfig { ? ?
? ?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中的過濾器鏈
Copy
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
現(xiàn)在的項目大多都是前后端分離的。以開源項目eladmin[26]為例進行說明。該項目前端使用Vue+Nodejs,后端使用SpringBoot+Spring+Mybatis,基于Spring Security進行用戶認證、鑒權授權。項目中引入Spring Security后,進行了用戶認證、鑒權授權的功能開發(fā)。功能開發(fā)主要分為兩塊內容。
1.5.2.1 用戶認證
用戶在登錄時進行用戶認證。如圖3,用戶登錄時,前端發(fā)送登錄請求到后端的登錄接口。登錄接口的邏輯實現(xiàn)如Demo8,其中調用了Spring Security中的方法。實際用戶認證邏輯是在Spring Security框架中實現(xiàn)。Spring Security會調用接口UserDetailService的loadUserByUsername方法獲取系統(tǒng)中的用戶,需要在系統(tǒng)中添加接口UserDetailService的實現(xiàn)類UserDetailServiceImpl,如Demo9。在獲取系統(tǒng)的用戶后,會調用Spring Security中的DaoAuthenticationProvider的additionalAuthenticationChecks方法,該方法對比登錄密碼與系統(tǒng)中的密碼是否一致,如果一致則用戶認證成功,否則認證失敗。需要開發(fā)者實現(xiàn)的是后臺登錄接口和獲取用戶信息的實現(xiàn)類UserDetailServiceImpl,用戶認證的邏輯是由Spring Security框架實現(xiàn)的。用戶登錄接口的url為“/login”,該接口在過濾器配置中配置了所有用戶(包括未登錄用戶)都可以訪問。

圖3 eladmin項目中用戶登錄時,后臺基于Spring Security進行用戶認證
Copy
public ResponseEntity<Object> login( 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 用戶登錄接口代碼實現(xiàn)
Copy
public class UserDetailsServiceImpl implements UserDetailsService { ? ?
? ?public JwtUserDto loadUserByUsername(String username) {
? ? ? ?user = userService.findByName(username);
? ? ? ? ?
? ? ? ?jwtUserDto = new JwtUserDto(
? ? ? ? ? ?user,
? ? ? ? ? ?dataService.getDeptIds(user),
? ? ? ? ? ?roleService.mapToGrantedAuthorities(user)
? ? ? ?); ? ? ? ? ? ? ? ?
? ? ? ?return jwtUserDto;
? ?}
}
Demo9 系統(tǒng)中添加了UserDetailsService的實現(xiàn)類UserDetailsServiceImpl
1.5.2.2 鑒權授權
如圖4,當前端向后端發(fā)送請求后,后端的spring-security會進行鑒權授權,判斷用戶是否有請求該資源的權限,如果沒有權限則拒絕。如果用戶已登錄,會攜帶請求頭Authrozation,它的值是token。如Demo10,自定義過濾器TokenFilter繼承GenericFilterBean類,在doFilter方法中根據(jù)請求頭中的token判斷用戶是否已登錄,如果已登錄,將token中的用戶及權限信息存入 SecurityContextHolder.getContext()中。然后請求進入Spring Security的過濾器鏈,在過濾器FilterSecurityInterceptor中通過AccessDecisionManager的decide方法進行鑒權。decide方法有3個參數(shù),第一個參數(shù)authenticated是用戶信息和權限信息,來源于SecurityContextHolder.getContext();第二個參數(shù)objcet是spring-security過濾器鏈相關的對象,第三個參數(shù)attributes的來源是SecurityConfig中的接口權限配置或默認配置。一個典型的SecurityConfig的配置如Demo11,其中配置了訪問接口需要的權限,比如.antMatchers(HttpMethod.OPTIONS, "/**").permitAll()表示對所有OPTIONS的請求進行無條件放行;.anyRequest().authenticated()表示對所有接口需要認證成功后才能訪問。鑒權通過后,請求進入到后臺接口的Servlet中,Servlet處理該請求并向前臺發(fā)送應答??偟膩碚f,需要開發(fā)者實現(xiàn)的是自定義TokenFilter,在TokenFilter中判斷用戶是否已登錄,如果已登錄,將token存在SecurityContextHolder.getContext()中;還需要自定義SecurityConfig繼承WebSecurityConfigurerAdapter,在SecrityConfig中進行過濾器配置,接口權限配置等。具體的鑒權授權是Spring Security框架實現(xiàn)的,它獲取在項目啟動時加載的SecurityConfig中的接口權限配置數(shù)據(jù),并獲取SecurityContextHolder.getContext()中的用戶權限數(shù)據(jù),然后通過SPEL表達式判斷是否鑒權通過。

圖4 eladmin項目中前臺向后臺發(fā)送請求時,后臺基于Spring Security進行鑒權授權的流程
Copy
public class TokenFilter extends GenericFilterBean { ? ?
? ?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 續(xù)期
? ? ? ? ? ? ? ?tokenProvider.checkRenewal(token);
? ? ? ? ? ?}
? ? ? filterChain.doFilter(servletRequest, servletResponse);
? ? }
? ?}
}
Demo10 自定義TokenFilter繼承GenericFilterBean
Copy
public class SecurityConfig extends WebSecurityConfigurerAdapter { ? ?
? ?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() ? ? ? ? ? ? ? ?// 不創(chuàng)建會話
? ? ? ? ? ? ? ?.and()
? ? ? ? ? ? ? ?.sessionManagement()
? ? ? ? ? ? ? ?.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
? ? ? ? ? ? ? ?.and()
? ? ? ? ? ? ? ?.authorizeRequests() ? ? ? ? ? ? ? ?// 靜態(tài)資源等等
? ? ? ? ? ? ? ?.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實現(xiàn)的,實現(xiàn)細節(jié)也較為復雜,但這不影響對前后端交互流程的理解,本文只需對Spring Security有個初步認識即可。
2. 登錄鑒權方式演變
登錄鑒權方式是隨著前后端架構的變化而變化的。早期的系統(tǒng)是前后端不分離的。通常前端是freemaker/velocity/jsp+html。后端是SSH或SSM。
后來Vue等前端框架的興起,使得前后端得以分離。前端是Vue+nodejs,后端是SSM或SpirngBoot。SpringBoot大大簡化了應用的配置。
再后來微服務SpringCloud興起,它包含網關、配置中心、注冊中心等組件。多個微服務的登錄鑒權實現(xiàn)和單應用系統(tǒng)又略有差異。
2.1前后端不分離的登錄鑒權流程
早期的系統(tǒng)是前后端不分離的。一個典型的系統(tǒng)使用SSM+JSP的架構,技術棧為SpringMVC + Spring + Mybatis + JSP+ Apache + Weblogic。系統(tǒng)的架構圖如圖5所示。系統(tǒng)的登錄鑒權流程如圖6所示。系統(tǒng)未登錄時,訪問系統(tǒng)的請求將被用戶權限過濾器攔截,首次進入權限過濾器會生成會話Session,然后通過后端重定向跳轉到登錄頁。在登錄頁中,用戶填寫好用戶名密碼后,提交form表單,請求后臺登錄接口,后臺進行登錄校驗,登錄成功后將用戶名、密碼等用戶信息存入Session中。在后面的每次訪問后端接口時,根據(jù)攜帶cookie的jessionId就可以從Session中獲取用戶信息,如果用戶名不為空,就說明已認證過,然后可正常訪問后端接口,不用每次訪問都進行用戶認證。用戶登錄認證失敗后,會跳轉到錯誤頁。

圖5 一個前后端不分離的典型系統(tǒng)的架構圖

圖6 一個前后端不分離的典型系統(tǒng)的登錄鑒權流程
2.2前后端分離后的登錄鑒權流程
2.2.1 單應用系統(tǒng)
在系統(tǒng)的前后端分離后,一個比較常見Web系統(tǒng),前端使用Vue+Nodejs,后端使用SpringBoot+Spring+Mybatis。以開源項目eladmin[26]為例進行說明。系統(tǒng)的架構圖如圖7所示。系統(tǒng)的用戶認證流程如圖8所示,在用戶認證流程中,前端應用可獨立地提供頁面的訪問和實現(xiàn)頁面間的跳轉,后端實現(xiàn)用戶認證的邏輯并提供接口。訪問系統(tǒng)的請求通過Vue-Router導航到相應頁面,路由導航會觸發(fā)全局前置守衛(wèi)的調用。在全局守衛(wèi)的邏輯中,如果用戶未登錄,路由會導航到登錄頁。用戶填好用戶名和密碼進行登錄,向后臺發(fā)起登錄的ajax請求,后臺會校驗用戶名密碼,校驗邏輯是基于SpringSecurity實現(xiàn)的,如果校驗通過,則生成JWT類型的token,應答到瀏覽器。瀏覽器端會保存token到cookie中,并創(chuàng)建后端接口請求攔截器,攔截請求并將cookie中的token放到請求頭Authentication中。請求到達后端服務后,后端的用戶權限過濾器會判斷Authentication中的token是否已登錄過(判斷在redis中是否存在),并基于SpringSecurity進行鑒權,如果鑒權通過,則正常訪問接口。不用每次訪問后端接口都進行用戶認證。如果登錄失敗,則應答錯誤狀態(tài)碼和錯誤信息,瀏覽器會在頁面進行錯誤提示。

圖7 前后端分離的典型單應用系統(tǒng)的架構圖

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

圖9 前后端分離的包含多個微服務的系統(tǒng)架構圖

圖10 前后端分離的包含多個微服務的系統(tǒng)登錄鑒權流程
引用:
[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/
轉自:http://www.npqdlp.com/