如何實現一套簡單的oauth2授權碼類型認證,一些思路,供參考

背景

組內人不少,今年陸陸續續研發了不少系統,一般都會包括一個後臺管理系統,現在問題是,每個管理系統都有RBAC那一套用戶權限體系,實在是有點浪費人力,於是今年我們搞了個統一管理各個應用系統的RBAC的系統,叫做應用權限中心,大致就是:

  1. 各個應用在我們系統註冊,並錄入應用支持的各類權限(如菜單權限、數據權限、接口權限等);
  2. 統一管理所有用戶(包括公司員工、合作伙伴員工等);
  3. 各個接入系統的管理員可以在自己的應用建立角色,賦予其某些權限;接下來,又可以給人員分配這些角色。

在以上數據維護完成後,就可以由我們系統提供oauth2認證這一套體系,oauth2簡單理解,類似於平時那些網站的第三方渠道登錄,比如,第一次去到一個陌生網站,不想註冊用戶、密碼那些,此時,如果網站支持微信、qq、google、github等登錄方式,就很方便,登錄完成後,這個網站就已經知道我們是誰了,知道我們是誰之後,再來做權限也就簡單了,還是RBAC那一套。

由於我們的應用權限中心管理了多個應用的權限,所以可以給某個人分配各個系統下的角色,這個人也就有了各個系統下的權限。

oauth2那一套,是在用戶完成身份認證的基礎下才能走完整個流程的,那就是說,已經知道這個用戶是誰了,那就可以去應用權限中心獲取這個人在裏各個應用下有哪些角色,有哪些權限了。

oauth2的整體數據流

oauth2這個東西,流程圖比較複雜,我就不從這方面去講了,我先說下大體思路,然後直接給大家看我們系統的網絡抓包數據,來了解整個數據流向吧。

我們這裏涉及兩個系統的交互,一個是類似於微信、qq、github這種的oauth2授權服務器,一個是需要接入到這些授權服務器的應用,如應用A,它的角色是oauth2客戶端。

現在開發應用A,一般都是前後端分離,前端調用應用A後端接口,此時假設用戶是沒登錄,後端接口判別到這種情況,給前端拋錯誤碼,前端此時就再調用後端另一個接口,該接口會組裝一個指向oauth2授權服務器的授權請求url,意思是前端需要到授權服務器那邊去進行身份認證、授權等,前端拿到這個url,就跳轉過去。

跳轉過去後,oauth2服務器那邊會檢查用戶在這邊登錄了沒有,沒登錄的話,流程沒法繼續往下走,會先把這個授權請求給保存下來,然後讓用戶登錄;用戶登錄成功後,再把之前保存的那個請求拿出來執行。

授權請求主要做的事情就是,檢查參數是否合法,如這個第三方應用在自己這邊註冊了沒,如果檢查沒問題,就會隨機生成一個臨時的code,拼接到第三方應用提供的回調url中,然後302重定向到第三方應用A。

第三方應用A需要拿着這個code,請求自己的後端,第三方應用的後端拿到code後,去通過後臺http調用,調用授權服務器的根據code獲取token的接口,拿到token後,返回給第三方應用A的前端。

後續,第三方應用的前端每次請求就帶着這個token來請求後端,後端拿着token去請求授權服務器,獲取這個token對應的用戶信息,權限信息(如這個人在應用A中有哪些菜單權限等),進行權限控制。

技術選型

目前,要實現oauth2客戶端的話,可以選擇spring security,具體可以看官網文檔。

要實現oauth2授權服務器的話,有如下選擇:

spring-authorization-server

spring官方發佈的第二代的授權服務項目,但目前使用的人較少,感覺也還不是很成熟。且因爲還在使用java8,所以只能用0.4.x的版本,就更不成熟了。

https://spring.io/projects/spring-authorization-server#support

版本 Initial Release End of Support jdk 備註
1.1.x 2023-05-16 2024-05-16 java17 https://docs.spring.io/spring-authorization-server/docs/current/reference/html/getting-started.html
1.0.x 2022-11-21 2025-11-21 java17 https://docs.spring.io/spring-authorization-server/docs/1.0.3/reference/html/getting-started.html
0.4.x 2022-11-20 2025-11-21 Java 8 https://docs.spring.io/spring-authorization-server/docs/0.4.3/reference/html/getting-started.html

座標:

https://mvnrepository.com/artifact/org.springframework.security/spring-security-oauth2-authorization-server

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-oauth2-authorization-server</artifactId>
    <version>0.4.3</version>
</dependency>

image-20230727135633453

OAuth2 For Spring Security

https://mvnrepository.com/artifact/org.springframework.security.oauth/spring-security-oauth2

座標:

<dependency>
    <groupId>org.springframework.security.oauth</groupId>
    <artifactId>spring-security-oauth2</artifactId>
    <version>2.5.2.RELEASE</version>
</dependency>

現狀:

已經不再維護,最新版本2.5.2也還有多個cve漏洞,由於我們目前要求必須解決各種cve漏洞(一般靠版本升級解決,但這個已經是最新版本了沒法升了).

另外,其內部實現中使用了session+cookie機制,當時以爲是有狀態的,不支持集羣部署,後來才知道也是支持用redis之類的(靠spring session項目)。

另外,前後端未分離,定製頁面較爲複雜

https://spring.io/blog/2022/06/01/spring-security-oauth-reaches-end-of-life

參考第二種實現的源碼進行簡單實現

最終沒辦法,第二種因爲cve漏洞的問題,加上前後端不分離可能導致以後擴展困難(比如登錄要支持多因素認證等,會比較頭疼,還是做成前後端分離,交給專業前端比較好),最終決定第二種的源碼進行修改。

實際的數據流

應用A:http://10.80.121.46:8086 前後端分離,後端接口都通過http://10.80.121.46:8086 nginx轉發

授權服務器:http://10.80.121.46:8083 前後端分離,後端接口都通過http://10.80.121.46:8083 nginx轉發

應用A檢測到用戶未登錄

比如,我們這邊的前端同事是這麼判斷:

const token = localStorage.getItem('token')
if (token == null) {
    window.location.href = "/v1/oAuth2Client/redirectToAuthorizeUrl";
}

我們以前的項目也有這樣的:

if (response.status === 401) {
	window.location.href = "/oauth2/authorization/";
}

反正都是通過前端或後端知道用戶沒登錄後,調用本應用的另一個接口。

應用A組裝調用授權服務器的url

直接看下面報文,後端組裝了一個指向授權服務器(http://10.80.121.46:8083)的授權接口(v1/oauth2/authorize)的url,還帶了查詢參數,client_id代表應用A自己,redirect_uri表示授權服務器回調自己的地址,response_type=code,表示使用oauth2的授權碼流程

GET /v1/oAuth2Client/redirectToAuthorizeUrl HTTP/1.1
Host: 10.80.121.46:8086
Connection: keep-alive
...

HTTP/1.1 302 
Server: nginx/1.22.1
...
Location: http://10.80.121.46:8083/v1/oauth2/authorize?client_id=app-A&redirect_uri=http://10.80.121.46:8086/&response_type=code&scope=foo

注意,這裏是直接後端返回了302,指示瀏覽器跳轉到授權服務器,此時,如果有授權服務器這個domain下的cookie,是可以攜帶過去的;一般,我們都會在用戶在授權服務器登錄完成後,在授權服務器domain下寫個cookie,避免每次都要登錄。

授權服務器檢測到用戶未登錄

第一次流程,用戶瀏覽器肯定是沒有授權服務器domain下的cookie的,此時,我們後端就會把用戶302重定向到授權服務器這邊的統一登錄頁面:

GET /v1/oauth2/authorize?client_id=app-A
&redirect_uri=http://10.80.121.46:8086/&response_type=code&scope=foo HTTP/1.1
Host: 10.80.121.46:8083
...

HTTP/1.1 302 
Server: nginx/1.22.1
*
Location: http://10.80.121.46:8083/#/oauth-login?appCode=app-A&originUrl=aHR0cDovLzEwLjgwLjEyMS40Njo4MDgzL2FwcC1hdXRob3JpdHktYWRtaW4vdjEvb2F1dGgyL2F1dGhvcml6ZT9jbGllbnRfaWQ9YXBwLWRldi1wbGF0Zm9ybS1hZG1pbiZyZWRpcmVjdF91cmk9aHR0cDovLzEwLjgwLjEyMS40Njo4MDg2LyZyZXNwb25zZV90eXBlPWNvZGUmc2NvcGU9Zm9v

這個認證接口的後端處理也簡單,檢測請求攜帶了標識用戶已登錄的cookie沒有,沒有的話,重定向到登錄頁。

登錄頁攜帶了一些參數,這裏最主要的是originUrl,這是因爲,後端做的無狀態,在完成登錄請求後,還需要繼續請求原始接口:

/v1/oauth2/authorize?client_id=app-A
&redirect_uri=http://10.80.121.46:8086/&response_type=code&scope=foo

所以,我這邊選擇把原始接口base64編碼後,傳給前端,由前端在完成登錄後再次發起調用。

另外,這個登錄頁,大概下面這樣:

http://10.80.121.46:8083/#/oauth-login

image-20231120214146058

登錄

POST /v1/oauth2/oAuth2Login HTTP/1.1
Host: 10.80.121.46:8083
Connection: keep-alive
...

{"username":"admin","password":"f80e247e3ead3dd1f3a708b7ce4dcf54"}

這邊不重要的參數就省了,就是用戶名密碼那些,還包括驗證碼啥的。

響應:

HTTP/1.1 200 
...
Access-Control-Allow-Methods: GET, POST, PUT, OPTIONS
Access-Control-Allow-Origin: http://10.80.121.46:8083
Access-Control-Allow-Credentials: true
Set-Cookie: SSO-JSESSIONID=f09b9e61d4114a058cd6f9b6b9ce85d7; Path=/; Domain=10.80.121.46; Max-Age=43200; Expires=Tue, 21 Nov 2023 01:11:42 GMT

登錄邏輯:生成個隨機數(token),然後作爲key,用戶信息爲value,存redis,然後再就是把token寫到domain下,寫個cookie;

這塊必須用cookie,因爲瀏覽器在從應用A跳過來的時候,只有cookie才能帶的過來,我們才知道用戶是登錄了沒的。

前端在收到登錄成功的code後,就把上一步的originUrl解碼,然後重新發起調用:

/v1/oauth2/authorize?client_id=app-A
&redirect_uri=http://10.80.121.46:8086/&response_type=code&scope=foo

授權接口邏輯

主要就是各種參數校驗,如client_id是否在授權服務器註冊,各個參數的值是否合法,這塊可以參考spring的代碼實現。

一切沒問題的話,就是生成個隨機code,然後把code作爲key,其他各種用戶信息、認證請求的相關信息爲value,存儲到redis,然後就可以跳轉回應用A了。

跳到應用A的什麼地址呢,我們授權請求不是傳了個redirect_uri嗎,就重定向到哪裏,只是會給你拼個code在後面

GET /?code=eEg7t5 HTTP/1.1
Host: 10.80.121.46:8086
...

攜帶code跳轉回應用A

GET /?code=eEg7t5 HTTP/1.1
Host: 10.80.121.46:8086

我這邊是跳轉回應用A的前端的,前端拿到code,調用應用A的後端接口:利用code去請求授權服務器,獲取token。

應用A前端調用後端接口,code換token

POST /v1/oAuth2Client/fetchAccessTokenByAuthorizeCode HTTP/1.1
Host: 10.80.121.46:8086
...

{"code":"eEg7t5"}

HTTP/1.1 200 
...

{"code":"0","message":"success","data":{"scope":"all","access_token":"eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjQ3NDAsInVzZXJuYW1lIjoiYWRtaW4iLCJleHRlbmQiOm51bGwsImV4cCI6MTcwMDU3MjMwM30.HxH99Z3Mz4IZfHlC_Ai1th7jURlNs5qsHSMpiQdtGgXDqX8_XNx9GxswB","token_type":"bearer","refresh_token":null,"expires_in":86400}}

後端就是拿着code,去請求授權服務器,拿到了token,我這邊是直接把token給了前端存儲。

後續的請求,前端都會攜帶token,後端判斷token是否有效即可(大家肯定不希望每次都去授權服務器校驗token,所以可以第一次的時候,拿token去授權服務器驗證是否有效,並緩存結果;我這邊更暴力,因爲都是組內的系統,直接弄的jwt token,且token沒加密)

根據token獲取用戶信息

前端拿着token去調用應用A後端接口,獲取用戶信息;

POST /v1/oAuth2Client/queryUserInfo HTTP/1.1
Host: 10.80.121.46:8086
Connection: keep-alive
Content-Length: 0
AccessToken: eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjQ3NDAsInVzZXJuYW1lIjoiYWRtaW4iLCJleHRlbmQiOm51bGwsImV4cCI6MTcwMDU3MjMwM30.HxH99Z3Mz4IZfHlC_Ai1th7jURlNs5qsHSMpiQdtGgXDqX8_XNx9G

HTTP/1.1 200 
...

{"code":"0","message":"請求成功","data":{"id":4740,"username":"admin","departmentName":null,"status":0,"statusName":"正常","roleListInfo":[]}}

用戶信息都有了,獲取權限信息也是一樣的,這裏不展示了。

簡單的技術總結

我這邊自己實現,是沒辦法,開源的沒能滿足自己要求,其實還有一點,我們那個用戶名是可能重複的,就是說,在統一登錄頁,輸入用戶名,可能在後臺查到多個用戶,只能加上另一個內部的隱性字段才能不重,這也是我們必須自研的原因。我實現的比較簡單,不是一個圓的輪子,僅供大家參考(一些異常場景,由於對oauth2的認識也不是特別深,只能以後慢慢完善了)

大家如果自研授權服務器,肯定涉及在授權服務器域名下寫cookie,此時注意,後端接口都通過前端的nginx去轉,會減少很多跨域相關的問題,信我的沒錯,我都踩過了。

另外,有時候後端直接重定向有問題時,就可以將要重定向的地址給到前端,由前端去window.location.href跳轉也是ok的,也會減少一些跨域問題。

有問題可以留言,謝謝大家。

參考

https://www.cnblogs.com/cjsblog/p/10548022.html

https://mp.weixin.qq.com/s/AW3zkzIYR6kbQVbPlQmpMA 寫cookie遇到問題時參考本篇

https://www.springcloud.io/post/2022-04/spring-samesite/

https://www.ituring.com.cn/article/200275

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