1.簡介
在這篇文章中,從頭開始實施OAuth 2.0和OpenID Connect服務器的開發人員(我)討論了調查結果。基本上,實施的考慮點是在討論中寫出來的。因此,對于那些正在尋找“如何及時設置OAuth 2.0和OpenID Connect服務器”等信息的人來說,這不是一個文檔。如果您正在尋找此類信息,請訪問GitHub上的java-oauth-server和java-resource-server。使用這些,您可以在10分鍾內啓動授權服務器和資源服務器,發出訪問令牌並使用訪問令牌調用Web API,而無需設置數據庫服務器。
偏見
我是Authlete,Inc。的聯合創始人,該公司是一家在雲端提供OAuth 2.0和OpenID Connect實施的公司,因此本文檔可能會受到這種偏見的影響。因此,請在腦海中閱讀本文檔。但是,基本上,我將從純工程師的角度來寫這篇文章。
2.OAuth是否必要?
“我們希望在我們的公司網站上這樣做。我們應該實施OAuth嗎?“ – 這經常被問到。從本質上講,這個問題是詢問OAuth是什麽。
我經常用來解釋OAuth的一句話答案如下。
OAuth 2.0是一種框架,其中服務的用戶可以允許第三方應用程序訪問他/她在服務中托管的數據,而無需向應用程序透露他/她的憑據。
重要的一點是“不向第三方應用程序透露憑據”。 OAuth就是爲此而存在的。一旦理解了這一點,您可以通過檢查是否滿足以下條件來判斷您是否應該爲公司的服務准備OAuth服務器。
- 您的服務管理用戶的數據。
- 您希望第三方爲您的服務用戶開發應用程序。
- 您不希望向第三方開發的應用程序透露用戶憑據。
即使上述條件不滿足且貴公司服務的應用程序僅爲自制服務,如果您可能希望第三方在將來開發應用程序和/或建議應用程序,建議您實施OAuth服務器如果您想遵循Web API開發的最佳實踐。
但是,混淆可能無法解決。當您想要讓用戶使用他們的外部服務帳戶(如Facebook和Twitter)登錄您的網站時。由于“OAuth身份驗證”這一術語經常在此上下文中使用,因此您可能認爲必須爲您的服務實施OAuth。但是,在這種情況下,由于您的服務是使用外部服務實施的OAuth的客戶端,因此您的服務本身不必實施OAuth。確切地說,您的服務必須編寫代碼以使用其他公司的OAuth。換句話說,從外部服務的角度來看,您的服務必須表現爲OAuth客戶端。但是,在此用例中,您的服務不必像OAuth服務器那樣運行。也就是說,您不必實現OAuth服務器。
3.認證和授權
我解釋了讓人們感到困惑的術語 – “OAuth身份驗證”。
每個解釋都說“OAuth是授權規範,而不是身份驗證規範。”這是因爲RFC 6749(OAuth 2.0授權框架)明確指出認證“超出了本規範的範圍。”以下段落摘自“ 3.1。 RFC 6749中的“授權端點”。
授權端點用于與資源所有者交互並獲得授權授權。授權服務器必須首先驗證資源所有者的身份。授權服務器驗證資源所有者的方式(例如,用戶名和密碼登錄,會話cookie)超出了本規範的範圍。
盡管如此,“OAuth身份驗證”一詞泛濫並使人們感到困惑。這種混淆不僅在商業方面,而且在工程師中也是如此。例如,“OAuth授權與身份驗證”之類的問題有時會發布到Stack Overflow(我對問題的回答是這個)。
由術語,認證和授權(在OAuth的上下文中)處理的信息可以描述如下。
- 身份驗證 – 誰是誰。
- 授權 – 誰授予誰誰的權限。
身份驗證是一個簡單的概念換句話說,它是對身份的確認。在網站上識別人的最流行方式是請求該人提供一對ID和密碼,但還有其他方式,如使用指紋或虹膜的生物識別身份驗證,一次性密碼,隨機數字表等。無論如何,無論使用何種方式,身份驗證都是識別身份的過程。使用開發人員的話,可以表示爲“身份驗證是識別用戶唯一標識符的過程”。
另一方面,授權是複雜的,因爲涉及三個元素,即“誰”,“什麽權限”和“對誰”。另外,令人困惑的是,在這三個要素中,識別“誰”是認證的過程。換句話說,授權過程包括認證過程作爲一個部分的事實使事情變得混亂。
如果三個元素應該被開發人員使用的單詞替換,“who”可以替換爲“user”,“who”替換爲“client application”。因此,OAuth上下文中的授權可以說是用戶向客戶端應用程序授予權限的過程。
下圖描繪了到目前爲止所解釋的概念。
此圖說明了授權頁面(用戶授予客戶端應用程序權限的頁面)中的哪些部分用于身份驗證和授權。身份驗證和授權之間的區別很明顯。
現在,是時候談論“OAuth身份驗證”了。
因爲授權過程包括認證過程作爲一部分,所以授權意味著認證。因此,有些人開始使用OAuth進行身份驗證。這是“OAuth身份驗證”,並且由于“管理用戶憑據的任務可以委托給外部服務”以及“新用戶開始使用該服務的障礙因爲用戶而變得更低”等優點而迅速占據主導地位注冊過程可以省略。“
OpenID的人對這種情況抱有怨恨。 – 抱歉,我不知道他們是否真的有這種感覺,但至少我可以想象他們認爲OAuth身份驗證遠遠超出他們之前定義的規範級別,如OpenID 2.0和SAML。然而,不可否認的是,他們的規範並沒有占上風,世界各地的開發人員都選擇了OAuth身份驗證的簡易性。因此,他們在OAuth之上定義了一個新的身份驗證規範OpenID Connect。 OpenID Connect常見問題解答將關系描述爲如下所示的等式。
(Identity, Authentication) + OAuth 2.0 = OpenID Connect
由于這一點,OpenID Connect的身份驗證可以在OAuth授權過程中同時執行。
由于業界的主要參與者一直致力于規範創建和主動實施(FAQ),OpenID Connect肯定會占上風。因此,OmniAuth等OAuth身份驗證庫將逐漸完成其角色。
但是,人們肯定會變得更加困惑,因爲用于身份驗證的OpenID Connect建立在用于授權的OAuth之上。很難解釋,特別是在我的情況下,因爲Authlete專注于授權,雖然它支持OpenID Connect,但它不會對身份驗證做任何事情。在開始向客戶解釋産品本身之前,我總是要解釋身份驗證和授權之間的區別。
關于OAuth身份驗證的問題,請閱讀John Bradley先生的文章“OAuth for Authentication的問題”。在文章中他說:“這是一個安全漏洞,你可以開車穿過。”
“再說OAuth是一種認證標准。”Nat Sakimura先生和John Bradley先生。 (來自https://twitter.com/ve7jtb/status/740650395735871488)
4. OAuth 2.0和OpenID Connect之間的關系
然而,到目前爲止,所有內容只是這篇文章的序言。開發人員的技術內容從這裏開始。第一個主題是OAuth 2.0和OpenID Connect之間的關系。
在我完成RFC 6749(OAuth 2.0授權框架)的實施之後,我注意到了OpenID Connect的存在。當我收集有關OpenID Connect的信息時,我認爲我應該實現該功能,因此請閱讀OpenID Connect Core 1.0和其他相關規範。在閱讀之後,我得出的結論是“所有人都應該從頭開始重寫”。
OpenID Connect網站稱“OpenID Connect 1.0是一個基于OAuth 2.0協議的簡單身份層。”這給人的印象是OpenID Connect可以在現有的OAuth 2.0實現之上輕松無縫地實現。然而,事實卻完全不同。恕我直言,OpenID Connect實際上是OAuth 3.0。
有許多與OpenID Connect相關的規範,它們令人費解,難以破譯它們。在我能夠掌握整個畫面之前,我幾乎瘋了,不得不讀了三遍。與OpenID Connect規範相比,RFC 6749可以說很容易。
5.響應類型
特別是,與現有實現沖突的是處理請求參數response_type的方法。可以肯定的是,RFC 6749聲明請求參數可能需要多個值,但這是將來的可能性。如果我們直接讀取RFC 6749,則response_type是代碼或令牌。幾乎不可能想象這兩個是同時設置的。這是因爲該參數用于確定處理來自客戶端應用程序的請求的流程。具體而言,當response_type的值是代碼時使用授權代碼流,並且當值是token時使用隱式流。誰能想象這些流量是混合的?即使可以想象它,我們應該如何解決流量之間存在的沖突?例如,授權代碼流要求將響應參數嵌入到重定向URI(4.1.2。授權響應)的查詢部分中,而隱式流要求將響應參數嵌入到片段部分中(4.2.2。訪問令牌)響應),並不能同時滿足這些要求。
但是,OpenID Connect已將id_token添加爲response_type的新值,並明確允許將code,token和id_token的任意組合作爲response_type的值。此外,也沒有添加。詳情見“3。身份驗證“OpenID Connect Core 1.0和OAuth 2.0多響應類型編碼實踐”。
它需要進行重大更改才能修改在假定選擇或選擇的情況下編寫的現有代碼,以便它可以處理可能值和混合流的任意組合。因此,如果將來有可能支持OpenID Connect,OAuth庫的實現者應該從頭開始用OpenID Connect編寫它。換句話說,現有的OAuth庫無法在不進行重大修改的情況下支持OpenID Connect。
例如,Spring Security OAuth。此庫尚未支持OpenID Connect(截至2016年6月)。對于要支持OpenID Connect的庫,首先,請求參數response_type必須能夠采用除代碼和令牌之外的其他值。對它的請求被列爲“問題#619處理其他response_types”,但它尚未得到解決,並且該主題的最後一條評論是“任何評論都非常受歡迎,因爲事實證明(正如我預測的那樣)a大型重構練習。“我閱讀了一些相關的源文件,發現支持OpenID Connect需要進行大的修改才是真的。除非一些公司在財務上支持該項目,否則我擔心該項目需要很長時間才能支持OpenID Connect。
順便說一句,我也想提到Apache Oltu。該項目聲稱它支持OpenID Connect,但我的猜測是初始實現僅支持OAuth 2.0,並且在稍後階段添加了OpenID Connect支持。我認爲這樣的原因是OAuth 2.0(org.apache.oltu.oauth2)的包和OpenID Connect(org.apache.oltu.openidconnect)的包是隔離的。但是,這種方法會破壞架構。例如,OpenIdConnectResponse類是OAuthAccessTokenResponse的後代是不合適的,因爲包含ID令牌的響應不一定包含訪問令牌。其他示例是存在名爲OAuthClientRequest.AuthenticationRequestBuilder的類(由于某些原因而不是“授權”但是“身份驗證”)以及存在GitHub特定的類GitHubTokenResponse。 Apache Oltu的架構至少給我帶來了問題。我不知道有關該項目的細節,但在我個人看來,它注定要縮小。
6.客戶端應用程序的元數據
正如在RFC 6749的客戶端注冊中明確寫出的那樣,客戶端應用程序必須在發出授權請求之前提前注冊到目標授權服務器。因此,在典型情況下,授權服務器的實現者定義數據庫表以存儲關于客戶端應用程序的信息。
要確定表應該具有哪些列,實現者通過閱讀規範來列出項目。例如,閱讀RFC 6749將使您意識到至少需要以下項目。
- Client ID
- Client Secret
- Client Type
- Redirect URIs
除此之外,實現者可以添加更多屬性。例如,“應用程序名稱”。
即使您通過RFC 6749進行搜索,客戶端應用程序的屬性也沒有那麽多,因此存儲客戶端應用程序屬性的數據庫表的列數不會變大 – 這樣的好日子已經因爲出現了OpenID Connect。客戶端應用程序應具有的許多屬性列在2. OpenID Connect動態客戶端注冊1.0的客戶端元數據中。以下是清單。
- redirect_uris – 客戶端使用的重定向URI值。
- response_types – 客戶端聲明它將自己限制爲使用的response_type值。
- grant_types – 授權客戶端聲明它將限制自己使用的類型。
- application_type – 應用程序的種類。
- 聯系人 – 負責此客戶的人員的電子郵件地址。
- client_name – 要呈現給最終用戶的客戶端的名稱。
- logo_uri – 引用客戶端應用程序徽標的URL。
- client_uri – 客戶端主頁的URL。
- policy_uri-依賴方客戶端向最終用戶提供的URL,以了解如何使用配置文件數據。
- tos_uri-依賴方客戶提供給最終用戶的URL,以了解依賴方的服務條款。
- jwks_uri-客戶端的JSON Web密鑰集文檔的URL。
- jwks – 客戶端的JSON Web Key Set文檔,按值傳遞。
- sector_identifier_uri – 使用https方案的URL,用于由OP計算僞名標識符。
- subject_type – 要求對此客戶的響應的subject_type。
- id_token_signed_response_alg – 簽署發給此客戶端的ID令牌所需的JWS alg算法。
- id_token_encrypted_response_alg – 加密發給此客戶端的ID令牌所需的JWE alg算法。
- id_token_encrypted_response_enc-加密發給該客戶端的ID令牌所需的JWE enc算法。
- userinfo_signed_response_alg-簽署UserInfo響應所需的JWS alg算法。
- userinfo_encrypted_response_alg – 加密UserInfo響應所需的JWE alg算法。
- userinfo_encrypted_response_enc – 加密UserInfo響應所需的JWE enc算法。
- request_object_signing_response_alg – 必須用于簽署發送給OP的請求對象的JWS alg算法。
- request_object_encryption_alg – RP聲明它可以用于加密發送給OP的請求對象的JWE alg算法。
- request_object_encryption_enc – JWE enc算法,RP聲明它可以用于加密發送給OP的請求對象。
- token_endpoint_auth_method-請求端點的請求客戶端身份驗證方法。
- token_endpoint_auth_signing_alg – 必須用于對JWT進行簽名的JWS alg算法,該JWT用于在令牌端點對private_key_jwt和client_secret_jwt身份驗證方法的客戶端進行身份驗證。
- default_max_age – 默認最大認證年齡。
- require_auth_time – 布爾值,指定是否需要ID令牌中的auth_time聲明。
- default_acr_values – 默認請求的身份驗證上下文類參考值。
- initiate_login_uri – 使用https方案的URI,第三方可以使用該方案來啓動RP的登錄。
- request_uris – 由RP預先注冊以在OP上使用的request_uri值。
因此,客戶端應用程序的數據庫表應該能夠存儲這些信息。此外,應該注意的是,允許本地化某些屬性(例如client_name,tos_uri,policy_uri,logo_uri和client_uri)(2.1。元數據語言和腳本)。需要額外考慮數據庫表設計來存儲本地化屬性值。
以下小節是我對客戶應用程序屬性的個人意見。
6.1 客戶類型
我擔心定義規範是一種錯誤2. OpenID Connect動態客戶端注冊1.0的客戶端元數據不包含“客戶端類型”。我認爲這樣做的原因是,當我們實現授權服務器時,必須考慮兩種客戶端類型之間的區別,“機密”和“公共”(在2.1。客戶端類型的RFC 6749中定義)。事實上,“客戶端類型”被列爲要在2.注冊RFC 6749的客戶端注冊的客戶端屬性的示例如下。
…注冊可以依賴于其他方式來建立信任並獲得所需的客戶端屬性(例如,重定向URI,客戶端類型)。
如果這不是錯誤,則必須就動態客戶端注冊注冊的客戶端應用程序的客戶端類型達成共識。但是,我無法在相關規範中找到此類信息。
無論如何,我認爲在爲客戶端應用程序定義數據庫表時,應該存在客戶端類型的列。
您可以在問題991中找到關于此的一些討論。
6.2。申請類型
根據規範,application_type是可選屬性。 application_type的預定義值是native和web。如果省略,則將web用作默認值。
如果省略時使用默認值,則自然結果是客戶端應用程序的應用程序類型必須是本機和Web。因此,您可能希望在application_type的列中添加NOT NULL。但是,Authlete的實現不敢添加NOT NULL並允許NULL。
原因是我不確定應用于每個OAuth 2.0客戶端的OpenID Connect動態客戶端注冊1.0中定義的application_type所施加的重定向URI值的限制。
使用OAuth隱式授權類型的Web客戶端必須僅使用https方案注冊URL作爲redirect_uris;他們不能使用localhost作爲主機名。本機客戶端必須僅使用自定義URI方案或URL使用http:scheme注冊redirect_uris,並使用localhost作爲主機名。
2年前,我發布了一個問題“應用程序類型(OpenID Connect)是否與客戶端類型(OAuth 2.0)對應?”到Stack Overflow,但我無法得到任何答案。所以我自己調查和回答。如果有興趣請看。
6.3。客戶秘密
客戶秘密的長度應該是多長時間?
例如,“OpenAM管理指南”使用密碼作爲客戶端機密值的示例。下面是12.4.1的截圖。將OpenAM配置爲授權服務器和客戶端。
似乎OpenAM允許用戶使用短字符串作爲客戶端密鑰。
另一方面,在Authlete的實現中,客戶端機密自動生成並變得像下面那樣長。
GBAyfVL7YWtP6gudLIjbRZV_N0dW4f3xETiIxqtokEAZ6FAsBtgyIq0MpU1uQ7J08xOTO2zwP0OuO3pMVAUTid
這個長度的原因是我想支持512位的對稱簽名和加密算法。例如,我想支持HS512作爲JWS的簽名算法。因爲客戶機密碼必須具有512位或更多的熵以支持HS512,所以上述示例的長度是86,這是使用base64url編碼512位數據的結果。
關于對稱簽名和加密算法的熵,OpenID Connect Core 1.0中的16.19對稱密鑰熵如下所述。
在10.1節和10.2節中,密鑰是從client_secret值派生的。因此,當與對稱簽名或加密操作一起使用時,client_secret值必須包含足夠的熵以生成加密強密鑰。此外,client_secret值還必須至少包含所使用的特定算法的MAC密鑰所需的最小八位字節數。因此,例如,對于HS256,client_secret值必須包含至少32個八位字節(並且幾乎可以肯定應該包含更多,因爲client_secret值可能使用受限制的字母表)。
並且,3.1。 RFC 7518(JSON Web算法)中的JWS的“alg”(算法)頭部參數值指出必須支持HS256(使用SHA-256的HMAC)作爲JWS的簽名算法。作爲合乎邏輯的結果,任何聲稱符合OpenID Connect的實現都需要生成具有256位或更多熵的客戶機密鑰。
6.4。簽名算法
id_token_signed_response_alg列在“2。 “OpenID Connect動態客戶端注冊1.0的客戶端元數據”。它表示客戶端應用程序要求授權服務器用作ID令牌的簽名算法的算法。如上所述,有效值列在RFC 7518中,應注意不允許任何值。如果在注冊時省略id_token_signed_response_alg的值,則使用RS256。
userinfo_signed_response_alg也是客戶端應用程序要求授權服務器使用的簽名算法。該算法用于簽署從UserI返回的信息
這是偏離主題的,但是爲nv-websocket-client(日語信息)創建了一個問題,這是一個用于Java的WebSocket客戶端庫我在GitHub上向公衆開放。問題是一個功能改進的提議,表明當開發人員同時調用setSSLContext()方法和setSSLSocketFactory()方法時,庫有一個警告機制。之所以提出這個提案,是因爲記者對這兩種方法的不正當行爲感到不安。我的回答是它在JavaDoc中明確寫出了當調用這兩個方法時哪個設置優先,並且這樣的插入檢查會使WebSocketFactory類難以使用。然後,反應是“在調用這兩種方法之前,先沒有詳細閱讀文檔,這是我的錯。但是,您認爲有多少其他開發人員會在犯同樣錯誤之前先詳細閱讀文檔?“
哦,如果開發人員由于他/她沒有閱讀文件的原因而浪費時間在自制錯誤上,這只是一個當之無愧的懲罰……
幫助那些不閱讀文件的人的試驗將是無止境的。即使庫阻止了alg = none的簽名,這些工程師也會毫不猶豫地將私鑰包含在通過授權服務器的JWK Set端點發布的JWK集中。爲什麽?你認爲那些不讀文件的人可以注意到JWKSet類的toPublicJWKSet()方法的存在(在Nimbus JOSE + JWT庫中)並理解方法的含義嗎?可能,他們天真地說,“是的,我可以創建一個JWKSet類的實例。我們發布吧!我已經完成了JWK Set端點的實現!“
不參考RFC等主要來源的工程師無法發現他們找到的答案中的錯誤,並毫無疑問地相信答案。但是,工程師必須避免閱讀RFC以成爲真正的工程師。
要成爲真正的工程師,請不要避免閱讀RFC。只搜索技術博客和Stack Overflow尋找答案永遠不會把你帶到正確的地方。
6.5。 Client Application Developer
一些開源授權服務器提供了一種機制,可以動態注冊客戶端應用程序,如HTML表單(ForgeRock的OpenAM)和Web API(MITRE的MITREid Connect)。但是,似乎只有授權服務器的管理員才能注冊客戶端應用程序。但是,理想的方法是創建類似于Twitter的應用程序管理控制台,讓開發人員登錄,並提供一個環境,讓每個開發人員都可以注冊和管理他/她自己的客戶端應用程序。爲此,客戶端應用程序的數據庫表應該有一個包含開發人員唯一標識符的列。
它經常被遺忘,因爲實現授權服務器本身很麻煩,但是還需要提供管理客戶端應用程序的機制,以便向公衆開放Web API。如果Web API的預期用戶僅限于封閉組,則授權服務器的管理員可以在每次請求他/她時注冊客戶端應用程序。事實上,有一家公司的管理員爲每個注冊請求手動鍵入SQL語句。但是,如果要向公衆開放Web API,此類操作將無法運行,您將意識到必須爲客戶端應用程序提供合適的管理控制台。如果您成功確保了開發授權服務器和Web API的預算,但忘記了爲客戶端應用程序確保管理控制台的預算,則會導致“已實現Web API但無法向公衆開放”。
作爲此類管理控制台的示例,Authlete爲上述用例提供了開發者控制台。 Authlete本身不管理開發人員帳戶,但通過名爲“開發人員身份驗證回調”的機制,其帳戶由Authlete客戶管理的開發人員可以使用開發人員控制台。因此,Authlete客戶不必爲客戶端應用程序開發管理控制台。
7.訪問令牌
7.1。訪問令牌表示
如何表示訪問令牌?有兩種主要方式。
作爲無意義的隨機字符串。與訪問令牌相關聯的信息存儲在授權服務器後面的數據庫表中。
作爲一個自包含的字符串,它是通過base64url或類似的東西對訪問令牌信息進行編碼的結果。
在這些方式之間進行選擇將導致後續差異,如下表所述。
如果訪問令牌是隨機字符串,則每次都需要查詢授權服務器以獲取有關訪問令牌的信息。相反,如果訪問令牌本身包含信息,則無需查詢授權服務器。這使得自包含樣式聽起來更好,但是因爲必須對授權服務器進行查詢以檢查訪問令牌是否已被撤銷,即使采用自包含樣式,在任何情況下,網絡通信也是如此。每次客戶端應用程序呈現訪問令牌時都需要。
自包含樣式中的繁瑣之處在于,每次請求訪問令牌撤銷時,我們必須添加表示“已撤銷”的記錄,並且必須保留此類記錄,直到訪問令牌到期爲止。否則,如果刪除了記錄,則撤銷的訪問令牌將被複活並再次生效(如果尚未達到原始到期日期)。
相反,在隨機字符串樣式的情況下,可以簡單地通過刪除訪問令牌記錄本身來實現訪問令牌撤銷。因此,由于任何意外,撤銷訪問令牌無法複活。此外,不會發生在獨立風格中觀察到的負面影響“撤銷增加記錄”。
要啓用訪問令牌吊銷,即使在自包含樣式的情況下,也必須爲訪問令牌分配唯一標識符。否則,無法分辨哪個訪問令牌已被撤銷。換句話說,授權服務器采用自包含樣式但不爲訪問令牌分配唯一標識符是授權服務器,它不能撤銷訪問令牌。它可能是實現策略之一,但是這樣的授權服務器不應該發出長期訪問令牌,也不應該發出刷新令牌。
“無法撤銷訪問令牌的授權服務器?!”,您可能想知道。但是,這種授權確實存在。某個全球大型系統集成商收購了一家公司,並正在使用被收購公司的産品開發授權服務器,但在後期階段,系統集成商及其客戶注意到授權服務器無法撤銷訪問令牌。當我聽到這個故事時,我猜想授權服務器會發出沒有唯一標識符的自包含樣式的訪問令牌。
自包含的樣式看起來很好,因爲有一些優點,例如“無需查詢授權服務器來提取訪問令牌的信息”和“無需在授權服務器端維護訪問令牌記錄”,但是當你考慮訪問令牌撤銷,有討論的余地。
7.2。訪問令牌刪除
爲防止數據庫無限增長,應定期從數據庫中刪除過期的訪問令牌。
請求授權服務器不必要地發出訪問令牌的客戶端應用程序是麻煩制造者。雖然他們已經有一個尚未過期的訪問令牌,但他們會重複丟棄這樣一個有效的訪問令牌並請求新的令牌。如果發生這種情況,則會在數據庫中累積未使用但無法刪除的訪問令牌(因爲它們尚未過期)。
要防止出現這種情況,請將訪問令牌最後一次使用的時間戳保存到數據庫中,以及訪問令牌到期的時間戳,並定期運行程序,以便長時間刪除未使用的訪問令牌。當然,它取決于服務的特性是否可以在未過期時刪除未使用的訪問令牌。
在此之前,我遇到了一位工程師,他在某個大公司的OAuth實施項目中工作,而他卻屬于該公司。他告訴我,系統的構建沒有考慮訪問令牌的刪除,因此系統的數據庫可能擁有數以億計的訪問令牌。嚇人,可怕。當開發生成某個東西的系統時,應該同時考慮刪除生成的東西的時間。
8.重定向URI
8.1。重定向URI驗證
2014年5月,獲博士學位。新加坡的學生發表了一篇文章,它引起了人們對“OAuth中的漏洞?”的討論,這是一個關于所謂的Covert Redirect的問題。那些正確理解OAuth 2.0的人很快意識到這不是由于規範中的漏洞而是由于不正確的實現。然而,該主題讓很多人感到不安,OAuth領域的專家無法幫助編寫解釋性文檔。約翰布拉德利先生的“隱蔽重定向及其對OAuth和OpenID Connect的真正影響”就是其中一個文件。
如果未正確處理重定向URI,則會出現安全問題。相關規範中描述了如何處理重定向URI,但很難正確實現它,因爲有許多事情要關注,例如,(a)RFC 6749的要求和OpenID Connect的要求是不同的(b) )必須考慮客戶端應用程序的application_type屬性的值。
如何正確處理重定向URI的部分取決于實現者如何仔細和詳盡地閱讀相關規範。因此,讀取部件的實現代碼可以很好地猜測整個授權服務器的實現質量。所以,每個人都盡最大努力實施它!
……如果我冷冷地抛棄了你,到目前爲止我讀過我的長篇文章,我會感到很遺憾,所以我向你展示了Authlete的實施訣竅。以下是處理授權請求中包含的redirect_uri參數的僞代碼。請注意,僞代碼不必分解爲可浏覽性的方法,但在實際的Authlete實現中,代碼流很好地分解爲方法。因此,出于性能目的,實際代碼流與僞代碼不同。 (如果實際的實現包含如此多的嵌套if和for僞像,那將是一種恥辱。)
// Extract the value of the ‘redirect_uri’ parameter from// the authorization request.redirectUri = …// Remember whether a redirect URI was explicitly given.// It must be checked later in the implementation of the// token endpoint because RFC 6749 states as follows.//// redirect_uri// REQUIRED, if the “redirect_uri” parameter was// included in the authorization request as described// in Section 4.1.1, and their values MUST be identical.//explicit = (redirectUri != null);// Extract registered redirect URIs from the database.registeredRedirectUris = …// Requirements by RFC 6749 (OAuth 2.0) and those by// OpenID Connect are different. Therefore, the code flow// branches according to whether the request is an OpenID// Connect request or not. This is judged by whether the// ‘scope’ request parameter contains ‘openid’ as a value.if ( ‘openid’ is included in ‘scope’ ){ // Check requirements by OpenID Connect. // If the ‘redirect_uri’ is not contained in the request. if ( redirectUri == null ) { // The ‘redirect_uri’ parameter is mandatory in // OpenID Connect. It’s optional in RFC 6749. throw new Exception( “The ‘redirect_uri’ parameter is missing.”); } // For each registered redirect URI. for ( registeredRedirectUri : registeredRedirectUris ) { // ‘Simple String Comparison’ is required by the // specification. if ( registeredRedirectUri.equals( redirectUri ) ) { // OK. The redirect URI specified by the // authorization request is registered. registered = true; break; } } // If the redirect URI specified by the authorization // request matches none of the registered redirect URIs. if ( registered == false ) { throw new Exception( “The redirect URI is not registered.”); }}else{ // Check requirements by RFC 6749. // If redirect URIs are not registered at all. if ( registeredRedirectUris.size() == 0 ) { // RFC 6749, 3.1.2.2. Registration Requirements says // as follows: // // The authorization server MUST require the // following clients to register their // redirection endpoint: // // o Public clients. // o Confidential clients utilizing the // implicit grant type. // If the type of the client application which made // the authorization request is ‘public’. if ( client.getClientType() == PUBLIC ) { throw new Exception( “A redirect URI must be registered.”); } // If the client type is ‘confidential’ and if the // authorization flow is ‘Implicit Flow’. If the // ‘response_type’ request parameter contains either // or both of ‘token’ and ‘id_token’, the flow should // be treated as a kind of ‘Implicit Flow’. else if ( responseType.requiresImplicitFlow() ) { throw new Exception( “A redirect URI must be registered.”); } } // If the authorization request does not contain the // ‘redirect_uri’ request parameter. if ( redirectUri == null ) { // If redirect URIs are not registered at all, // or if multiple redirect URIs are registered. if ( registeredRedirectUris.size() != 1 ) { // A redirect URI must be explicitly specified // by the ‘redirect_uri’ parameter. throw new Exception( “The ‘redirect_uri’ parameter is missing.”); } // One redirect URI is registered. Use it as the // default value of redirect URI. redirectUri = registeredRedirectUris[0]; } // The authorization request contains the ‘redirect_uri’ // parameter, but redirect URIs are not registered. else if ( registeredRedirectUris.size() == 0 ) { // The code flow reaches here if and only if the // client type is ‘confidential’ and the authorization // flow is not ‘Implicit Flow’. In this case, the // redirect URI specified by the ‘redirect_uri’ // parameter of the authorization request is used // although it is not registered. However, // requirements written in RFC 6749, 3.1.2. // Redirection Endpoint are checked. // If the specified redirect URI is not an absolute one. if ( redirectUri.isAbsolute() == false ) { throw new Exception( “The ‘redirect_uri’ is not an absolute URI.”); } // If the specified redirect URI has a fragment part. if ( redirectUri.getFragment() != null ) { throw new Exception( “The ‘redirect_uri’ has a fragment part.”); } } else { // If the specified redirect URI is not an absolute one. if ( redirectUri.isAbsolute() == false ) { throw new Exception( “The ‘redirect_uri’ is not an absolute URI.”); } // If the specified redirect URI has a fragment part. if ( redirectUri.getFragment() != null ) { throw new Exception( “The ‘redirect_uri’ has a fragment part.”); } // For each registered redirect URI. for (registeredRedirectUri : registeredRedirectUris ) { // If the registered redirect URI is a full URI. if ( registeredRedirectUri.getQuery() != null ) { // ‘Simple String Comparison’ if ( registeredRedirectUri.equals( redirectUri ) ) { // The specified redirect URI is registered. registered = true; break; } // This registered redirect URI does not match. continue; } // Compare the scheme parts. if ( registeredRedirectUri.getScheme().equals( redirectUri.getScheme() ) == false ) { // This registered redirect URI does not match. continue; } // Compare the user information parts. Here I use // an imaginary method ‘equalsSafely()’ because // the code would become too long if I inlined it. // The method compares arguments without throwing // any exception even if either or both of the // arguments are null. if ( equalsSafely( registeredRedirectUri.getUserInfo(), redirectUri.getUserInfo() ) == false ) { // This registered redirect URI does not match. continue; } // Compare the host parts. Ignore case sensitivity. if ( registeredRedirectUri.getHost().equalsIgnoreCase( redirectUri.getHost() ) == false ) { // This registered redirect URI does not match. continue; } // Compare the port parts. Here I use an imaginary // method ‘getPortOrDefaultPort()’ because the // code would become too long if I inlined it. The // method returns the default port number of the // scheme when ‘getPort()’ returns -1. The last // resort is ‘URI.toURL().getDefaultPort()’. -1 is // returned If ‘getDefaultPort()’ throws an exception. if ( getPortOrDefaultPort( registeredRedirectUri ) != getPortOrDefaultPort( redirectUri ) ) { // This registered redirect URI does not match. continue; } // Compare the path parts. Here I use the imaginary // method ‘equalsSafely()’ again. if ( equalsSafely( registeredRedirectUri.getPath(), redirectUri.getPath() ) == false ) { // This registered redirect URI does not match. continue; } // The specified redirect URI is registered. registered = true; break; } // If none of the registered redirect URI matches. if ( registered == false ) { throw new Exception( “The redirect URI is not registered.”); } }}// Check requirements by the ‘application_type’ of the client.// If the value of the ‘application_type’ attribute is ‘web’.if ( client.getApplicationType() == WEB ){ // If the authorization flow is ‘Implicit Flow’. When the // ‘response_type’ request parameter of the authorization // request contains either or both of ‘token’ and ‘id_token’, // it should be treated as a kind of ‘Implicit Flow’. if ( responseType.requiresImplicitFlow() ) { // If the scheme of the redirect URI is not ‘https’. if ( “https”.equals( redirectUri.getScheme() ) == false ) { // The scheme part of the redirect URI must be // ‘https’ when a client application whose // ‘application_type’ is ‘web’ uses ‘Implicit Flow’. throw new Exception( “The scheme of the redirect URI is not ‘https’.”); } // If the host of the redirect URI is ‘localhost’. if ( “localhost”.equals( redirectUri.getHost() ) ) { // The host of the redirect URI must not be // ‘localhost’ when a client application whose // ‘application_type’ is ‘web’ uses ‘Implicit Flow’. throw new Exception( “The host of the redirect URI is ‘localhost’.”); } }}// If the value of the ‘application_type’ attribute is ‘native’.else if ( client.getApplicationType() == NATIVE ){ // If the scheme of the redirect URI is ‘https’. if ( “https”.equals( redirectUri.getScheme() ) ) { // The scheme of the redirect URI must not be ‘https’ // when the ‘application_type’ of the client is ‘native’. throw new Exception( “The scheme of the redirect URI is ‘https’.”); } // If the scheme of the redirect URI is ‘http’. if ( “http”.equals( redirectUri.getScheme() ) ) { // If the host of the redirect URI is not ‘localhost’. if ( “localhost”.equals( redirectUri.getHost() ) == false ) { // When a client application whose ‘application_type’ // is ‘native’ uses a redirect URI whose scheme is // ‘http’, the host port of the URI must be // ‘localhost’. throw new Exception( “The host of the redirect URI is not ‘localhost’.”); } }}// If the value of the ‘application_type’ attribute is neither// ‘web’ or ‘native’.else{ // As mentioned above, Authlete allows ‘unspecified’ as a // value of the ‘application_type’ attribute. Therefore, // no exception is thrown here.}
8.2。其他的實施
在OpenID Connect中,redirect_uri參數是必需的,關于如何檢查呈現的重定向URI是否已注冊的要求只是“簡單字符串比較”。因此,如果您需要關注的只是OpenID Connect,那麽實現將非常簡單。例如,在2016年10月在GitHub上贏得大約1,700顆星並且已通過OpenID認證計劃認證的IdentityServer3中,檢查重定向URI的實現如下(摘自DefaultRedirectUriValidator.cs以及其他格式化新行)。
public virtual Task<bool> IsRedirectUriValidAsync(
string requestedUri, Client client)
{
return Task.FromResult(
StringCollectionContainsString(
client.RedirectUris, requestedUri));
}
OpenID Connect只關心手段,換句話說,授權服務器不接受傳統授權代碼流和範圍請求參數中不包含openid的隱式流。也就是說,這樣的授權服務器無法響應任何現有的OAuth 2.0客戶端應用程序。
那麽,IdentityServer3是否拒絕傳統的授權請求?看看AuthorizeRequestValidator.cs,你會發現這個(格式化已經調整):
if (request.RequestedScopes.Contains(
Constants.StandardScopes.OpenId))
{
request.IsOpenIdRequest = true;
}
//////////////////////////////////////////////////////////
// check scope vs response_type plausability
//////////////////////////////////////////////////////////
var requirement =
Constants.ResponseTypeToScopeRequirement[request.ResponseType];
if (requirement == Constants.ScopeRequirement.Identity
requirement == Constants.ScopeRequirement.IdentityOnly)
{
if (request.IsOpenIdRequest == false)
{
LogError(“response_type requires the openid scope”, request);
return Invalid(request, ErrorTypes.Client);
}
}
您無需了解此代碼的詳細信息。關鍵是有一些路徑允許在scope參數中不包含openid的情況。也就是說,接受傳統的授權請求。如果是這樣,IdentityServer3的實現是不正確的。但是,另一方面,在AuthorizeRequestValidator.cs中的另一個位置,實現拒絕所有不包含redirect_uri參數的授權請求,如下所示(格式化已調整)。
//////////////////////////////////////////////////////////
// redirect_uri must be present, and a valid uri
//////////////////////////////////////////////////////////
var redirectUri = request.Raw.Get(Constants.AuthorizeRequest.RedirectUri);
if (redirectUri.IsMissingOrTooLong(
_options.InputLengthRestrictions.RedirectUri))
{
LogError(“redirect_uri is missing or too long”, request);
return Invalid(request);
}
因此,實現不必關心省略redirect_uri參數的情況。但是,因爲redirect_uri參數在RFC 6749中是可選的,所以行爲 – 沒有redirect_uri參數的授權請求被無條件拒絕,盡管傳統的授權請求被接受 – 違反了規範。此外,IdentityServer3不會對application_type屬性進行驗證。要實現驗證,作爲第一步,必須將application_type屬性的屬性添加到表示客戶端應用程序(Client.cs)的模型類中,因爲當前實現錯過了它。
9.違反規範
細微違反規範的行爲有時被稱爲“方言”。 “方言”一詞可能給人一種“可接受”的印象,但違法行爲是違法行爲。如果沒有方言,則爲每種計算機語言提供一個通用OAuth 2.0 / OpenID Connect庫就足夠了。但是,在現實世界中,違反規範的授權服務器需要自定義客戶端庫。
Facebook的OAuth流程需要其自定義客戶端庫的原因是Facebook的OAuth實現中存在許多違反規範的行爲。例如,(1)逗號用作範圍列表的分隔符(它應該是空格),(2)來自令牌端點的響應的格式是application / x-www-form-urlencoded(它應該是JSON) ,以及(3)訪問令牌的到期日期參數的名稱是過期的(應該是expires_in)。
Facebook和其他大牌公司不僅違反了規範。以下是其他示例。
9.1。範圍清單的分隔符
範圍名稱列在授權端點和令牌端點的請求的範圍參數中。 RFC 6749,3.3。訪問令牌範圍要求將空格用作分隔符,但以下OAuth實現使用逗號:
- GitHub
- Spotify
- Discus
- Todoist
9.2 令牌端點的響應格式
RFC 6749,5.1。成功響應要求來自令牌端點的成功響應的格式爲JSON,但以下OAuth實現使用application / x-www-form-urlencoded:
- Bitly
- GitHub
默認格式爲application / x-www-form-urlencoded,但GitHub提供了一種請求JSON的方法。
9.3 來自令牌端點的響應中的token_type
RFC 6749,5.1。成功響應要求token_type參數包含在來自令牌端點的成功響應中,但以下OAuth實現不包含它:
松弛
Salesforce也遇到過這個問題(OAuth訪問令牌響應丟失token_type),但它已被修複。
9.4 token_type不一致
以下OAuth實現聲稱令牌類型爲“Bearer”,但其資源端點不接受通過RFC 6750(OAuth 2.0授權框架:承載令牌使用)中定義的方式訪問令牌:
GitHub(它通過授權格式接受訪問令牌:令牌OAUTH-TOKEN)
9.5 grant_type不是必需的
grant_type參數在令牌端點是必需的,但以下OAuth實現不需要它:
- GitHub
- Slack
- Todoist
9.6 錯誤參數的非官方值
規範已爲錯誤參數定義了一些值,這些值包含在授權服務器的錯誤響應中,但以下OAuth實現定義了自己的值:
GitHub(例如application_suspended)
Todoist(例如bad_authorization_code)
9.7。錯誤時參數名稱錯誤
以下OAuth實現在返回錯誤代碼時使用errorCode而不是error:
線
10.代碼交換的證明密鑰
10.1。 PKCE是必須的
你知道PKCE嗎?它是一個定義爲RFC 7636(OAuth公共客戶端的代碼交換證明密鑰)的規範,于2015年9月發布。它是針對授權代碼攔截攻擊的對策。
攻擊成功需要一些條件,但如果您考慮發布智能手機應用程序,強烈建議客戶端應用程序和授權服務器都支持PKCE。否則,惡意應用程序可能攔截授權服務器發出的授權代碼,並將其與授權服務器的令牌端點處的有效訪問令牌交換。
在2012年10月發布了RFC 6749(OAuth 2.0授權框架),因此即使熟悉OAuth 2.0的開發人員也可能不知道2015年9月最近發布的RFC 7636。但是,應該注意的是“OAuth 2.0 for Native Apps”草案表明,在某些情況下,它的支持是必須的。
客戶端和授權服務器都必須支持PKCE [RFC7636]使用自定義URI方案或環回IP重定向。授權服務器應該使用自定義方案拒絕授權請求,或者如果不存在所需的PKCE參數,則將環回IP作爲重定向URI的一部分,返回PKCE [RFC7636]第4.4.1節中定義的錯誤消息。建議將PKCE [RFC7636]用于應用程序聲明的HTTPS重定向URI,即使這些URI通常不會被攔截,以防止對應用程序間通信的攻擊。
支持RFC 7636的授權服務器的授權端點接受兩個請求參數:code_challenge和code_challenge_method,令牌端點接受code_verifier。並且在令牌端點的實現中,授權服務器使用(a)客戶端應用程序呈現的代碼驗證器和(b)客戶端應用程序在授權端點處指定的代碼質詢方法來計算代碼質詢的值。如果計算的代碼質詢和客戶端應用程序在授權端點處呈現的code_challenge參數的值相等,則可以說發出授權請求的實體和發出令牌請求的實體是相同的。因此,授權服務器可以避免向惡意應用程序發出訪問令牌,該惡意應用程序與發出授權請求的實體不同。
RFC 7636的整個流程在Authlete的網站上進行了說明:代碼交換的證明密鑰(RFC 7636)。如果您有興趣,請閱讀。
10.2 服務器端實現
在授權端點的實現中,授權服務器必須做的是將授權請求中包含的code_challenge參數和code_challenge_method參數的值保存到數據庫中。因此,實現代碼中沒有任何有趣的內容。需要注意的是,想要支持PKCE的授權服務器必須將code_challenge和code_challenge_method的列添加到存儲授權碼的數據庫表中。
Authlete的完整源代碼是保密的,但是爲了您的興趣,我在這裏向您展示了實際的Authlete實現,它驗證了令牌端點處code_verifier參數的值。
private void validatePKCE(AuthorizationCodeEntity acEntity)
{
// See RFC 7636 (Proof Key for Code Exchange) for details.
// Get the value of ‘code_challenge’ which was contained in
// the authorization request.
String challenge = acEntity.getCodeChallenge();
if (challenge == null)
{
// The authorization request did not contain
// ‘code_challenge’.
return;
}
// If the authorization request contained ‘code_challenge’,
// the token request must contain ‘code_verifier’. Extract
// the value of ‘code_verifier’ from the token request.
String verifier = extractFromParameters(
“code_verifier”, invalid_grant, A050312, A050313, A050314);
// Compute the challenge using the verifier
String computedChallenge = computeChallenge(acEntity, verifier);
if (challenge.equals(computedChallenge))
{
// OK. The presented code_verifier is valid.
return;
}
// The code challenge value computed with ‘code_verifier’
// is different from ‘code_challenge’ contained in the
// authorization request.
throw toException(invalid_grant, A050315);
}
private String computeChallenge(
AuthorizationCodeEntity acEntity, String verifier)
{
CodeChallengeMethod method = acEntity.getCodeChallengeMethod();
// This should not happen, but just in case.
if (method == null)
{
// Use ‘plain’ as the default value required by RFC 7636.
method = CodeChallengeMethod.PLAIN;
}
switch (method)
{
case PLAIN:
// code_verifier
return verifier;
case S256:
// BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))
return computeChallengeS256(verifier);
default:
// The value of code_challenge_method extracted
// from the database is not supported.
throw toException(server_error, A050102);
}
}
private String computeChallengeS256(String verifier)
{
// BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))
// SHA256
byte[] hash =
Digest.getInstanceSHA256().update(verifier).digest();
// BASE64URL
return SecurityUtils.encode(hash);
}
用于實現computeChallengeS256(String)方法的Digest類包含在我的開源庫nv-digest中。它是一個實用程序庫,可以輕松進行摘要計算。使用此庫,計算SHA-256摘要值可以寫成一行,如下所示。
byte[] hash = Digest.getInstanceSHA256().update(verifier).digest();
10.3。客戶端實施
客戶端應用程序必須爲PKCE做些什麽。一種是生成一個由43-128個字母組成的隨機碼驗證器,使用代碼驗證器和代碼質詢方法(plain或S256)計算代碼質詢,並包括計算出的代碼質詢和代碼質詢方法作爲值授權請求中的code_challenge參數和code_challenge_method參數。另一種是在令牌請求中包含代碼驗證器。
作爲客戶端實現的示例,我將介紹以下兩個。
- AppAuth for Android
- AppAuth for iOS
它們是用于與OAuth 2.0和OpenID Connect服務器通信的SDK。他們聲稱他們包括最佳實踐並支持PKCE。
如果爲code_challenge_method = S256實現計算邏輯,則可以通過在代碼驗證器的值爲dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk時檢查代碼質詢的值是否變爲E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM來測試它。這些值在RFC 7636的“附錄B. S256 code_challenge_method的示例”中作爲示例值找到。
11.最後
有些人可能會說實施OAuth和OpenID Connect很容易,其他人可能會說不是。在任何一種情況下,事實上,即使是擁有足夠預算和人力資源的Facebook和GitHub等大型科技公司也未能正確實施OAuth和OpenID Connect。著名的開源項目如Apache Oltu和Spring Security也存在問題。因此,如果您自己實施OAuth和OpenID Connect,請認真對待並准備一個體面的開發團隊。否則,安全風險將會增加。
僅僅實現RFC 6749並不困難,但是從頭開始實施OpenID Connect會讓您發瘋。因此,建議使用現有實現作爲起點。第一步是在OpenID Connect網站中搜索與OAuth和OpenID Connect相關的軟件的“庫,産品和工具”頁面(盡管未列出Authlete)。當然,作爲Authlete,Inc。的聯合創始人,如果您選擇Authlete,我將很高興。
感謝您閱讀這篇長篇文章。
原文:https://medium.com/@darutk/full-scratch-implementor-of-oauth-and-openid-connect-talks-about-findings-55015f36d1c3
本文:http://pub.intelligentx.net/node/510
討論:請加入知識星球或者小紅圈【首席架構師圈】