因項目需要,需要和三方的oauth2服務器進行集成。網上關于spring cloud security oauth2的相關資料,一般都是講如何配置,而能把這塊原理講透徹的比較少,這邊自己做一下總結和整理,順帶介紹一下JWT的使用場景。
什么是OAuth2?
OAuth2是一個關于授權的開放標準,核心思路是通過各類認證手段(具體什么手段OAuth2不關心)認證用戶身份,并頒發token(令牌),使得第三方應用可以使用該令牌在限定時間、限定范圍訪問指定資源。主要涉及的RFC規范有RFC6749
(整體授權框架),RFC6750
(令牌使用),RFC6819
(威脅模型)這幾個,一般我們需要了解的就是RFC6749
。獲取令牌的方式主要有四種,分別是授權碼模式
,簡單模式
,密碼模式
和客戶端模式
,如何獲取token不在本篇文章的討論范圍,我們這里假定客戶端已經通過某種方式獲取到了access_token,想了解具體的oauth2授權步驟可以移步阮一峰老師的理解OAuth 2.0,里面有非常詳細的說明。
這里要先明確幾個OAuth2中的幾個重要概念:
-
resource owner
: 擁有被訪問資源的用戶 -
user-agent
: 一般來說就是瀏覽器 -
client
: 第三方應用 -
Authorization server
: 認證服務器,用來進行用戶認證并頒發token -
Resource server
:資源服務器,擁有被訪問資源的服務器,需要通過token來確定是否有權限訪問
明確概念后,就可以看OAuth2的協議握手流程,摘自RFC6749
什么是Spring Security?
Spring Security是一套安全框架,可以基于RBAC(基于角色的權限控制)對用戶的訪問權限進行控制,核心思想是通過一系列的filter chain來進行攔截過濾,以下是ss中默認的內置過濾器列表,當然你也可以通過custom-filter
來自定義擴展filter chain列表
Alias | Filter Class | Namespace Element or Attribute |
---|---|---|
CHANNEL_FILTER | ChannelProcessingFilter | http/intercept-url@requires-channel |
SECURITY_CONTEXT_FILTER | SecurityContextPersistenceFilter | http |
CONCURRENT_SESSION_FILTER | ConcurrentSessionFilter | session-management/concurrency-control |
HEADERS_FILTER | HeaderWriterFilter | http/headers |
CSRF_FILTER | CsrfFilter | http/csrf |
LOGOUT_FILTER | LogoutFilter | http/logout |
X509_FILTER | X509AuthenticationFilter | http/x509 |
PRE_AUTH_FILTER | AbstractPreAuthenticatedProcessingFilter | N/A |
CAS_FILTER | CasAuthenticationFilter | N/A |
FORM_LOGIN_FILTER | UsernamePasswordAuthenticationFilter | http/form-login |
BASIC_AUTH_FILTER | BasicAuthenticationFilter | http/http-basic |
SERVLET_API_SUPPORT_FILTER | SecurityContextHolderAwareRequestFilter | http/@servlet-api-provision |
JAAS_API_SUPPORT_FILTER | JaasApiIntegrationFilter | http/@jaas-api-provision |
REMEMBER_ME_FILTER | RememberMeAuthenticationFilter | http/remember-me |
ANONYMOUS_FILTER | AnonymousAuthenticationFilter | http/anonymous |
SESSION_MANAGEMENT_FILTER | SessionManagementFilter | session-management |
EXCEPTION_TRANSLATION_FILTER | ExceptionTranslationFilter | http |
FILTER_SECURITY_INTERCEPTOR | FilterSecurityInterceptor | http |
SWITCH_USER_FILTER | SwitchUserFilter | N/A |
這里面最核心的就是FILTER_SECURITY_INTERCEPTOR
,通過FilterInvocationSecurityMetadataSource
來進行資源權限的匹配,AccessDecisionManager
來執行訪問策略。
認證與授權(Authentication and Authorization)
一般意義來說的應用訪問安全性,都是圍繞認證(Authentication)和授權(Authorization)這兩個核心概念來展開的。即首先需要確定用戶身份,在確定這個用戶是否有訪問指定資源的權限。認證這塊的解決方案很多,主流的有CAS
、SAML2
、OAUTH2
等(不巧這幾個都用過-_-),我們常說的單點登錄方案(SSO)說的就是這塊,授權的話主流的就是spring security和shiro。shiro我沒用過,據說是比較輕量級,相比較而言spring security確實架構比較復雜。
Spring Cloud Security Oauth2認證流程
將OAuth2和Spring Security集成,就可以得到一套完整的安全解決方案。
為了便于理解,現在假設有一個名叫“臉盆網”的社交網站,用戶在首次登陸時會要求導入用戶在facebook的好友列表,以便于快速建立社交關系。具體的授權流程如下:
- 用戶登陸臉盆網,臉盆網試圖訪問facebook上的好友列表
- 臉盆網發現該資源是facebook的受保護資源,于是返回302將用戶重定向至facebook登陸頁面
- 用戶完成認證后,facebook提示用戶是否將好友列表資源授權給臉盆網使用(如果本來就是已登陸facebook狀態則直接顯示是否授權的頁面)
- 用戶確認后,臉盆網通過
授權碼模式
獲取了facebook頒發的access_token - 臉盆網攜帶該token訪問facebook的獲取用戶接口
https://api.facebook.com/user
,facebook驗證token無誤后返回了與該token綁定的用戶信息 - 臉盆網的spring security安全框架根據返回的用戶信息構造出了principal對象并保存在session中
- 臉盆網再次攜帶該token訪問好友列表,facebook根據該token對應的用戶返回該用戶的好友列表信息
- 該用戶后續在臉盆網發起的訪問facebook上的資源,只要在token有效期及權限范圍內均可以正常獲取(比如想訪問一下保存在facebook里的相冊)
不難看出,這個假設的場景中,臉盆網就是第三方應用(client),而facebook既充當了認證服務器,又充當了資源服務器。這個流程里面有幾個比較重要的關鍵點,我需要重點說一下,而這也是其他的涉及spring security與OAuth2整合的文章中很少提及的,很容易云里霧里的地方。
細心的同學應該發現了,其實在標準的OAuth2授權過程中,5、6、8這幾步都不是必須的,從上面貼的RFC6749
規范來看,只要有1、2、3、4、7這幾步,就完成了被保護資源訪問的整個過程。事實上,RFC6749
協議規范本身也并不關心用戶身份的部分,它只關心token如何頒發,如何續簽,如何用token訪問被保護資源(facebook只要保證返回給臉盆網的就是當前用戶的好友,至于當前用戶是誰臉盆網不需要關心)。那為什么spring security還要做5、6這兩步呢?這是因為spring security是一套完整的安全框架,它必須關心用戶身份!在實際的使用場景中,OAuth2一般不僅僅用來進行被保護資源的訪問,還會被用來做單點登陸(SSO)。在SSO的場景中,用戶身份無疑就是核心,而token本身是不攜帶用戶信息的,這樣client就沒法知道認證服務器發的token到底對應的是哪個用戶。設想一下這個場景,臉盆網不想自建用戶體系了,想直接用facebook的用戶體系,facebook的用戶和臉盆網的用戶一一對應(其實在很多中小網站現在都是這種模式,可以選擇使用微信、QQ、微博等網站的用戶直接登陸),這種情況下,臉盆網在通過OAuth2的認證后,就希望拿到用戶信息了。所以現在一般主流的OAuth2認證實現,都會預留一個用戶信息獲取接口,就是上面提到的https://api.facebook.com/user
(雖然這不是OAuth2授權流程中必須的),這樣client在拿到token后,就可以攜帶token通過這個接口獲取用戶信息,完成SSO的整個過程。另外從用戶體驗的角度來說,如果獲取不到用戶信息,則意味者每次要從臉盆網訪問facebook的資源,都需要重定向一次進行認證,用戶體驗也不好。
OAuth2與SSO
首先要明確一點,OAuth2并不是一個SSO框架,但可以實現SSO功能。以下是一個使用github作為OAuth2認證服務器的配置文件
server:
port: 11001
security:
user:
password: user # 直接登錄時的密碼
ignored: /
sessions: never # session策略
oauth2:
sso:
loginPath: /login # 登錄路徑
client:
clientId: c40fb56cb4sdsdsdsd
clientSecret: c910ec22981daa28e1b59c778sdfjh73j3
accessTokenUri: https://github.com/login/oauth/access_token
userAuthorizationUri: https://github.com/login/oauth/authorize
resource:
userInfoUri: https://api.github.com/user
preferTokenInfo: false
可以看到accessTokenUri
和userAuthorizationUri
都是為了完成OAuth2的授權流程所必須的配置,而userInfoUri
則是spring security框架為了完成SSO所必須要的。所以總結一下就是:通過將用戶信息這個資源設置為被保護資源,可以使用OAuth2技術實現單點登陸(SSO),而Spring Security OAuth2就是這種OAuth2 SSO方案的一個實現。
Spring Security在調用user接口成功后,會構造一個OAuth2Authentication
對象,這個對象是我們通常使用的UsernamePasswordAuthenticationToken
對象的一個超集,里面封裝了一個標準的UsernamePasswordAuthenticationToken
,同時在detail
中還攜帶了OAuth2認證中需要用到的一些關鍵信息(比如tokenValue
,tokenType
等),這時候就完成了SSO的登陸認證過程。后續用戶如果再想訪問被保護資源,spring security只需要從principal中取出這個用戶的token,再去訪問資源服務器就行了,而不需要每次進行用戶授權。這里要注意的一點是此時瀏覽器與client之間仍然是通過傳統的cookie-session機制來保持會話,而非通過token。實際上在SSO的過程中,使用到token訪問的只有client與resource server之間獲取user信息那一次,token的信息是保存在client的session中的,而不是在用戶本地。這也是之前我沒搞清楚的地方,以為瀏覽器和client之間也是使用token,繞了不少彎路,對于Spring Security來說,不管是用cas、saml2還是Oauth2來實現SSO,最后和用戶建立會話保持的方式都是一樣的。
OAuth2 SSO與CAS、SAML2的比較
根據前面所說,大家不難看出,OAuth2的SSO方案和CAS、SAML2這樣的純SSO框架是有本質區別的。在CAS和SAML2中,沒有資源服務器的概念,只有認證客戶端(需要驗證客戶信息的應用)和認證服務器(提供認證服務的應用)的概念。在CAS中這叫做cas-client
和cas-server
,SAML2中這叫做Service Providers
和Identity Provider
,可以看出CAS、SAML2規范天生就是為SSO設計的,在報文結構上都考慮到了用戶信息的問題(SAML2規范甚至還帶了權限信息),而OAuth2本身不是專門為SSO設計的,主要是為了解決資源第三方授權訪問的問題,所以在用戶信息方面,還需要額外提供一個接口。
Authorization Server與Resource Server分離
臉盆網的這個例子中,我們看到資源服務器和認證服務器是在一起的(都是facebook),在互聯網場景下一般你很難找到一個獨立的、權威的、第三方的認證中心(你很難想像騰訊的QQ空間通過支付寶的認證中心去授權,也很難想像使用谷歌服務要通過亞馬遜去授權)。但是如果是在公司內部,這種場景其實是很多的,尤其在微服務架構下,有大量服務會對外提供資源訪問,他們都需要做權限控制。那么最合理的當然就是建立一個統一的認證中心,而不是每個服務都做一個認證中心。我們前面也介紹了,token本身是不攜帶用戶信息的,在分離后resouce server在收到請求后,如何檢驗token的真實性?又如何從token中獲取對應的用戶信息?這部分的介紹網上其實非常少,幸好我們可以直接從官方文檔獲取相關的蛛絲馬跡,官方文檔對于resouce server的配置是這樣描述的:
security:
oauth2:
resource:
userInfoUri: https://api.github.com/user
preferTokenInfo: false
寥寥數語,但已經足夠我們分析了。從這個配置可以看出,client在訪問resource server的被保護資源時,如果沒有攜帶token,則資源服務器直接返回一個401未認證的錯誤
<oauth>
<error_description>
Full authentication is required to access this resource
</error_description>
<error>unauthorized</error>
</oauth>
如果攜帶了token,則資源服務器會使用這個token向認證服務器發起一個用戶查詢的請求,若token錯誤或已經失效,則會返回
<oauth>
<error_description>49e2c7d8720738cfb75f6b675d62e5ecd66</error_description>
<error>invalid_token</error>
</oauth>
若token驗證成功,則認證服務器向資源服務器返回對應的用戶信息,此時resource server的spring security安全框架就可以按照標準的授權流程進行訪問權限控制了。
認證與授權的解耦
從這個流程中我們可以看出,通過OAuth2進行SSO認證,有一個好處是做到了認證與授權的解耦。從日常的使用場景來說,認證比較容易做到統一和抽象,畢竟你就是你,走到哪里都是你,但是你在不同系統里面的角色,卻可能千差萬別(家里你是父親,單位里你是員工,父母那里你是子女)。同時角色的設計,又是和資源服務器的設計強相關的。從前面的配置中不難發現,如果希望獲得為不同資源服務器設計的角色,你只需要替換https://api.facebook.com/user
這個配置就行了,這為我們的權限控制帶來了更大的靈活性,而這是傳統的比如SAML2這樣的SSO框架做不到的。
JWT介紹
終于來到了著名的JWT部分了,JWT全稱為Json Web Token,最近隨著微服務架構的流行而越來越火,號稱新一代的認證技術。今天我們就來看一下,jwt的本質到底是什么。
我們先來看一下OAuth2的token技術有沒有什么痛點,相信從之前的介紹中你也發現了,token技術最大的問題是不攜帶用戶信息,且資源服務器無法進行本地驗證,每次對于資源的訪問,資源服務器都需要向認證服務器發起請求,一是驗證token的有效性,二是獲取token對應的用戶信息。如果有大量的此類請求,無疑處理效率是很低的,且認證服務器會變成一個中心節點,對于SLA和處理性能等均有很高的要求,這在分布式架構下是很要命的。
JWT就是在這樣的背景下誕生的,從本質上來說,jwt就是一種特殊格式的token。普通的oauth2頒發的就是一串隨機hash字符串,本身無意義,而jwt格式的token是有特定含義的,分為三部分:
- 頭部
Header
- 載荷
Payload
- 簽名
Signature
這三部分均用base64進行編碼,當中用.
進行分隔,一個典型的jwt格式的token類似xxxxx.yyyyy.zzzzz
。關于jwt格式的更多具體說明,不是本文討論的重點,大家可以直接去官網查看官方文檔,這里不過多贅述。
相信看到簽名大家都很熟悉了,沒錯,jwt其實并不是什么高深莫測的技術,相反非常簡單。認證服務器通過對稱或非對稱的加密方式利用payload
生成signature
,并在header
中申明簽名方式,僅此而已。通過這種本質上極其傳統的方式,jwt可以實現分布式的token驗證功能,即資源服務器通過事先維護好的對稱或者非對稱密鑰(非對稱的話就是認證服務器提供的公鑰),直接在本地驗證token,這種去中心化的驗證機制無疑很對現在分布式架構的胃口。jwt相對于傳統的token來說,解決以下兩個痛點:
- 通過驗證簽名,token的驗證可以直接在本地完成,不需要連接認證服務器
- 在payload中可以定義用戶相關信息,這樣就輕松實現了token和用戶信息的綁定
在上面的那個資源服務器和認證服務器分離的例子中,如果認證服務器頒發的是jwt格式的token,那么資源服務器就可以直接自己驗證token的有效性并綁定用戶,這無疑大大提升了處理效率且減少了單點隱患。
JWT適用場景與不適用場景
就像布魯克斯在《人月神話》中所說的名言一樣:“沒有銀彈”。JWT的使用上現在也有一種誤區,認為傳統的認證方式都應該被jwt取代。事實上,jwt也不能解決一切問題,它也有適用場景和不適用場景。
適用場景:
- 一次性的身份認證
- api的鑒權
這些場景能充分發揮jwt無狀態以及分布式驗證的優勢
不適用的場景:
- 傳統的基于session的用戶會話保持
不要試圖用jwt去代替session。這種模式下其實傳統的session+cookie機制工作的更好,jwt因為其無狀態和分布式,事實上只要在有效期內,是無法作廢的,用戶的簽退更多是一個客戶端的簽退,服務端token仍然有效,你只要使用這個token,仍然可以登陸系統。另外一個問題是續簽問題,使用token,無疑令續簽變得十分麻煩,當然你也可以通過redis去記錄token狀態,并在用戶訪問后更新這個狀態,但這就是硬生生把jwt的無狀態搞成有狀態了,而這些在傳統的session+cookie機制中都是不需要去考慮的。這種場景下,考慮高可用,我更加推薦采用分布式的session機制,現在已經有很多的成熟框架可供選擇了(比如spring session)。