1. 程式人生 > >使用Owin中介軟體搭建OAuth2.0認證授權伺服器

使用Owin中介軟體搭建OAuth2.0認證授權伺服器

前言

這裡主要總結下本人最近半個月關於搭建OAuth2.0伺服器工作的經驗。至於為何需要OAuth2.0、為何是Owin、什麼是Owin等問題,不再贅述。我假定讀者是使用Asp.Net,並需要搭建OAuth2.0伺服器,對於涉及的Asp.Net Identity(Claims Based Authentication)、Owin、OAuth2.0等知識點已有基本瞭解。若不瞭解,請先參考以下文章:

從何開始?

接下來,我們主要看這個demo

Demo:Authorization Server

從OAuth2.0的rfc文件中,我們知道OAuth有多種授權模式,這裡只關注授權碼方式。
首先來看Authorization Server專案,裡面有三大塊:

  • Clients
  • Authorization Server
  • Resource Server

RFC6749圖示:
Clients分別對應各種授權方式的Client,這裡我們只看對應授權碼方式的AuthorizationCodeGrant專案;
Authorization Server即提供OAuth服務的認證授權伺服器;
Resource Server即Client拿到AccessToken後攜帶AccessToken訪問的資源伺服器(這裡僅簡單提供一個/api/Me顯示使用者的Name)。
另外需要注意Constants專案,裡面設定了一些關鍵資料,包含介面地址以及Client的Id和Secret等。

Client:AuthorizationCodeGrant

AuthorizationCodeGrant專案使用了DotNetOpenAuth.OAuth2封裝的一個WebServerClient類作為和Authorization Server通訊的Client。
(這裡由於封裝了底層的一些細節,致使不使用這個包和Authorization Server互動時可能會遇到幾個坑,這個稍後再講)
這裡主要看幾個關鍵點:

1.執行專案後,出現頁面,點選【Authorize】按鈕,第一次重定向使用者至 Authorization Server

if(!string.IsNullOrEmpty(Request
.Form.Get("submit.Authorize"))){var userAuthorization = _webServerClient.PrepareRequestUserAuthorization(new[]{"bio","notes"}); userAuthorization.Send(HttpContext);Response.End();}

這裡 new[] { “bio”, “notes” } 為需要申請的scopes,或者說是Resource Server的介面標識,或者說是介面許可權。然後Send(HttpContext)即重定向。

2.這裡暫不論重定向使用者至Authorization Server後的情況,假設使用者在Authorization Server上完成了授權操作,那麼Authorization Server會重定向使用者至Client,在這裡,具體的回撥地址即之前點選【Authorize】按鈕的頁面,而url上帶有一個一次性的code引數,用於Client再次從伺服器端發起請求到Authorization Server以code交換AccessToken。關鍵程式碼如下:

if(string.IsNullOrEmpty(accessToken)){var authorizationState = _webServerClient.ProcessUserAuthorization(Request);if(authorizationState !=null){ViewBag.AccessToken= authorizationState.AccessToken;ViewBag.RefreshToken= authorizationState.RefreshToken;ViewBag.Action=Request.Path;}}

我們發現這段程式碼在之前點選Authorize的時候也會觸發,但是那時並沒有code引數(缺少code時,可能_webServerClient.ProcessUserAuthorization(Request)並不會發起請求),所以拿不到AccessToken。

3.拿到AccessToken後,剩下的就是呼叫api,CallApi,試一下,發現返回的就是剛才使用者登陸Authorization Server所使用的使用者名稱(Resource Server的具體細節稍後再講)。

4.至此,Client端的程式碼分析完畢(RefreshToken請自行嘗試,自行領會)。沒有複雜的內容,按RFC6749的設計,Client所需的就只有這些步驟。對於Client部分,唯一需要再次鄭重提醒的是,一定不能把AccessToken洩露出去,比如不加密直接放在瀏覽器cookie中。

先易後難,接著看看Resource Server

我們先把Authorization Server放一放,接著看下Resource Server。
Resource Server非常簡單,App_Start中Startup.Auth配置中只有一句程式碼:

app.UseOAuthBearerAuthentication(newMicrosoft.Owin.Security.OAuth.OAuthBearerAuthenticationOptions());

然後,唯一的控制器MeController也非常簡單:

[Authorize]publicclassMeController:ApiController{publicstringGet(){returnthis.User.Identity.Name;}}

有效程式碼就這些,就實現了非使用者授權下無法訪問,授權了就能獲取使用者登陸使用者名稱。(其實webconfig裡還有一項關鍵配置,稍後再說)

那麼,Startup.Auth中的程式碼是什麼意思呢?為什麼Client訪問api,而User.Identity.Name卻是授權使用者的登陸名而不是Client的登陸名呢?

我們先看第一個問題,找 UseOAuthBearerAuthentication() 這個方法。具體怎麼找就不廢話了,我直接說明它的原始碼位置在 Katana Project原始碼中的Security目錄下的Microsoft.Owin.Security.OAuth專案。OAuthBearerAuthenticationExtensions.cs檔案中就這麼一個針對IAppBuilder的擴充套件方法。而這個擴充套件方法其實就是設定了一個OAuthBearerAuthenticationMiddleware,以針對AccessToken進行解析。解析的結果就類似於Client以授權使用者的身份(即第二個問題,User.Identity.Name是授權使用者的登陸名)訪問了api介面,獲取了屬於該使用者的資訊資料。

關於Resource Server,目前只需要知道這麼多。
(關於介面驗證scopes、獲取使用者主鍵、AccessToken中新增自定義標記等,在看過Authorization Server後再進行說明)

Authorization Server

Authorization Server是本文的核心,也是最複雜的一部分。

Startup.Auth配置部分

首先來看Authorization Server專案的Startup.Auth.cs檔案,關於OAuth2.0服務端的設定就在這裡。

// Enable Application Sign In Cookie
app.UseCookieAuthentication(newCookieAuthenticationOptions{AuthenticationType="Application",//這裡有個坑,先提醒下AuthenticationMode=AuthenticationMode.Passive,LoginPath=newPathString(Paths.LoginPath),LogoutPath=newPathString(Paths.LogoutPath),});

既然到這裡了,先提醒下這個設定:AuthenticationType是使用者登陸Authorization Server後的登陸憑證的標記名,簡單理解為cookie的鍵名就行。為什麼要先提醒下呢,因為這和OAuth/Authorize中檢查使用者當前是否已登陸有關係,有時候,這個值的預設設定可能是”ApplicationCookie”。

好,正式看OAuthServer部分的設定:

// Setup Authorization Server
app.UseOAuthAuthorizationServer(newOAuthAuthorizationServerOptions{AuthorizeEndpointPath=newPathString(Paths.AuthorizePath),TokenEndpointPath=newPathString(Paths.TokenPath),ApplicationCanDisplayErrors=true,#if DEBUGAllowInsecureHttp=true,//重要!!這裡的設定包含整個流程通訊環境是否啟用ssl#endif// Authorization server provider which controls the lifecycle of Authorization ServerProvider=newOAuthAuthorizationServerProvider{OnValidateClientRedirectUri=ValidateClientRedirectUri,OnValidateClientAuthentication=ValidateClientAuthentication,OnGrantResourceOwnerCredentials=GrantResourceOwnerCredentials,OnGrantClientCredentials=GrantClientCredetails},// Authorization code provider which creates and receives authorization codeAuthorizationCodeProvider=newAuthenticationTokenProvider{OnCreate=CreateAuthenticationCode,OnReceive=ReceiveAuthenticationCode,},// Refresh token provider which creates and receives referesh tokenRefreshTokenProvider=newAuthenticationTokenProvider{OnCreate=CreateRefreshToken,OnReceive=ReceiveRefreshToken,}});
我們一段段來看:
...AuthorizeEndpointPath=newPathString(Paths.AuthorizePath),TokenEndpointPath=newPathString(Paths.TokenPath),...

設定了這兩個EndpointPath,則無需重寫OAuthAuthorizationServerProvider的MatchEndpoint方法(假如你繼承了它,寫了個自己的ServerProvider,否則也可以通過設定OnMatchEndpoint達到和重寫相同的效果)。
反過來說,如果你的EndpointPath比較複雜,比如前面可能因為國際化而攜帶culture資訊,則可以通過override MatchEndpoint方法實現定製。
但請記住,重寫了MatchEndpoint(或設定了OnMatchEndpoint)後,我推薦註釋掉這兩行賦值語句。至於為什麼,請看Katana Project原始碼中的Security目錄下的Microsoft.Owin.Security.OAuth專案OAuthAuthorizationServerHandler.cs第38行至第46行程式碼。
對了,如果專案使用了某些全域性過濾器,請自行判斷是否要避開這兩個路徑(AuthorizeEndpointPath是對應OAuth控制器中的Authorize方法,而TokenEndpointPath則是完全由這裡配置的OAuthAuthorizationServer中介軟體接管的)。

ApplicationCanDisplayErrors=true,#if DEBUGAllowInsecureHttp=true,//重要!!這裡的設定包含整個流程通訊環境是否啟用ssl#endif

這裡第一行不多說,字面意思理解下。
重要!!AllowInsecureHttp設定整個通訊環境是否啟用ssl,不僅是OAuth服務端,也包含Client端(當設定為false時,若登記的Client端重定向url未採用https,則不重定向,踩到這個坑的話,問題很難定位,親身體會)

// Authorization server provider which controls the lifecycle of Authorization ServerProvider=newOAuthAuthorizationServerProvider{OnValidateClientRedirectUri=ValidateClientRedirectUri,OnValidateClientAuthentication=ValidateClientAuthentication,OnGrantResourceOwnerCredentials=GrantResourceOwnerCredentials,OnGrantClientCredentials=GrantClientCredetails}

這裡是核心Provider,凡是On開頭的,其實都是委託方法,中介軟體定義了OAuth2的一套流程,但是它把幾個關鍵的事件以委託的方式暴露了出來。

  • OnValidateClientRedirectUri:驗證Client的重定向Url,這個是為了安全,防釣魚
  • OnValidateClientAuthentication:驗證Client的身份(ClientId以及ClientSecret)
  • OnGrantResourceOwnerCredentials和OnGrantClientCredentials是這個demo中提供的另兩種授權方式,不在本文討論範圍內。

具體的這些委託的作用,我們接著看對應的方法的程式碼:

//驗證重定向url的privateTaskValidateClientRedirectUri(OAuthValidateClientRedirectUriContext context){if(context.ClientId==Clients.Client1.Id){
        context.Validated(Clients.Client1.RedirectUrl);}elseif(context.ClientId==Clients.Client2.Id){
        context.Validated(Clients.Client2.RedirectUrl);}returnTask.FromResult(0);}

這裡context.ClientId是OAuth2處理流程上下文中獲取的ClientId,而Clients.Client1.Id是前面說的Constants專案中預設的測試資料。如果我們有Client的註冊機制,那麼Clients.Client1.Id對應的Clients.Client1.RedirectUrl就可能是從資料庫中讀取的。而資料庫中讀取的RedirectUrl則可以直接作為字串引數傳給context.Validated(RedirectUrl)。這樣,這部分邏輯就算結束了。

//驗證Client身份privateTaskValidateClientAuthentication(OAuthValidateClientAuthenticationContext context){string clientId;string clientSecret;if(context.TryGetBasicCredentials(out clientId,out clientSecret)||
        context.TryGetFormCredentials(out clientId,out clientSecret)){if(clientId ==Clients.Client1.Id&& clientSecret ==Clients.Client1.Secret){
            context.Validated();}elseif(clientId ==Clients.Client2.Id&& clientSecret ==Clients.Client2.Secret){
            context.Validated();}}returnTask.FromResult(0);}

和上面驗證重定向URL類似,這裡是驗證Client身份的。但是特別要注意兩個TryGet方法,這兩個TryGet方法對應了OAuth2Server如何接收Client身份認證資訊的方式(這個demo用了封裝好的客戶端,不會遇到這個問題,之前說的在不使用DotNetOpenAuth.OAuth2封裝的一個WebServerClient類的情況下可能遇到的坑就是這個)。

  • TryGetBasicCredentials:是指Client可以按照Basic身份驗證的規則提交ClientId和ClientSecret
  • TryGetFormCredentials:是指Client可以把ClientId和ClientSecret放在Post請求的form表單中提交

那麼什麼時候需要Client提交ClientId和ClientSecret呢?是在前面說到的Client拿著一次性的code引數去OAuth伺服器端交換AccessToken的時候。
Basic身份認證,參考RFC2617
Basic簡單說明下就是新增如下的一個Http Header:

Authorization:BasicQWxhZGRpbjpvcGVuIHNlc2FtZQ==//這只是個例子

其中Basic後面部分是 ClientId:ClientSecret 形式的字串進行Base64編碼後的字串,Authorization是Http Header 的鍵名,Basic至最後是該Header的值。
Form這種只要注意兩個鍵名是 client_id 和 client_secret 。

privatereadonlyConcurrentDictionary<string,string> _authenticationCodes =newConcurrentDictionary<string,string>(StringComparer.Ordinal);privatevoidCreateAuthenticationCode(AuthenticationTokenCreateContext context){
        context.SetToken(Guid.NewGuid().ToString("n")+Guid.NewGuid().ToString("n"));
        _authenticationCodes[context.Token]= context.SerializeTicket();}privatevoidReceiveAuthenticationCode(AuthenticationTokenReceiveContext context){string value;if(_authenticationCodes.TryRemove(context.Token,out value)){
            context.DeserializeTicket(value);}}

這裡是對應之前說的用來交換AccessToken的code引數的生成和驗證的,用ConcurrentDictionary是為了執行緒安全;_authenticationCodes.TryRemove就是之前一直重點強調的code是一次性的,驗證一次後即刪除了。

privatevoidCreateRefreshToken(AuthenticationTokenCreateContext context){
    context.SetToken(context.SerializeTicket());}privatevoidReceiveRefreshToken(AuthenticationTokenReceiveContext context){
    context.DeserializeTicket(context.Token);}

這裡處理RefreshToken的生成和接收,只是簡單的呼叫Token的加密設定和解密的方法。

至此,Startup.Auth部分的基本結束,我們接下來看OAuth控制器部分。

OAuth控制器

OAuthController中只有一個Action,即Authorize。
Authorize方法並沒有區分HttpGet或者HttpPost,主要原因可能是方法簽名引起的(Action同名,除非引數不同,否則即使設定了HttpGet和HttpPost,編譯器也會認為你定義了兩個相同的Action,我們若是硬要拆開,可能會稍微麻煩點)。

還是一段段來看
if(Response.StatusCode!=200){returnView("AuthorizeError");}

這段說實話,到現在我還沒搞懂為啥要判斷下200,可能是考慮到owin中介軟體會提前處理點什麼?去掉了也沒見有什麼異常,或者是我沒注意。。。這段可有可無。。

var authentication =HttpContext.GetOwinContext().Authentication;var ticket = authentication.AuthenticateAsync("Application").Result;var identity = ticket !=null? ticket.Identity:null;if(identity ==null){
    authentication.Challenge("Application");returnnewHttpUnauthorizedResult();}

這裡就是判斷授權使用者是否已經登陸,這是很簡單的邏輯,登陸部分可以和AspNet.Identity那套一起使用,而關鍵就是authentication.AuthenticateAsync(“Application”)中的“Application”,還記得麼,就是之前說的那個cookie名:

...AuthenticationType="Application",//這裡有個坑,先提醒下...

這個裡要匹配,否則使用者登陸後,到OAuth控制器這裡可能依然會認為是未登陸的。
如果使用者登陸,則這裡的identity就會有值。

var scopes =(Request.QueryString.Get("scope")??"").Split(' ');

這句只是獲取Client申請的scopes,或者說是許可權(用空格分隔感覺有點奇怪,不知道是不是OAuth2.0裡的標準)。

if(Request.HttpMethod=="POST"){if(!string.IsNullOrEmpty(Request.Form.Get("submit.Grant"))){
        identity =newClaimsIdentity(identity.Claims,"Bearer", identity.NameClaimType, identity.RoleClaimType);foreach(var scope in scopes){
            identity.AddClaim(newClaim("urn:oauth:scope", scope));}
        authentication.SignIn(identity);}if(!string.IsNullOrEmpty(Request.Form.Get("submit.Login"))){
        authentication.SignOut("Application");
        authentication.Challenge("Application");returnnewHttpUnauthorizedResult();}}

這裡,submit.Grant分支就是處理授權的邏輯,其實就是很直觀的向identity中新增Claims。那麼Claims都去哪了?有什麼用呢?
這需要再回過頭去看ResourceServer,以下是重點內容:

其實Client訪問ResourceServerapi介面的時候,除了AccessToken,不需要其他任何憑據。那麼ResourceServer是怎麼識別出使用者登陸名的呢?關鍵就是claims-based identity 這套東西。其實所有的claims都加密存進了AccessToken中,而ResourceServer中的OAuthBearer中介軟體就是解密了AccessToken,獲取了這些claims。這也是為什麼之前強調AccessToken絕對不能洩露,對於ResourceServer來說,訪問者擁有AccessToken,那麼就是受信任的,頒發AccessToken的機構也是受信任的,所以對於AccessToken中加密的內容也是絕對相信的,所以,ResourceServer這邊甚至不需要再去資料庫驗證訪問者Client的身份。

這裡提到,頒發AccessToken的機構也是受信任的,這是什麼意思呢?我們看到AccessToken是加密過的,那麼如何解密?關鍵在於AuthorizationServer專案和ResourceServer專案的web.config中配置了一致的machineKey
(題外話,有個線上machineKey生成器:machineKey generator,這裡也提一下,如果不喜歡配置machineKey,可以研究下如何重寫AccessToken和RefreshToken的加密解密過程,這裡不多說了,提示:OAuthAuthorizationServerOptions中有好幾個以Format字尾的屬性)
上面說的machineKey即是系統預設的AccessToken和RefreshToken的加密解密的金鑰。

submit.Login分支就不多說了,意思就是使用者換個賬號登陸。

寫了這麼多,基本分析已經結束,我們來看看還需要什麼

首先,你需要一個自定義的Authorize屬性,用於在ResourceServer中驗證Scopes,這裡要注意兩點:

  1. webapi的Authorize和mvc的Authorize不一樣(起碼截至MVC5,這還是兩個東西,vnext到時再細究;
  2. 如何從ResourceServer的User.Identity中挖出自定義的claims。

第一點,需要重寫的方法不是AuthorizeCore(具體方法名忘了,不知道有沒有寫錯),而是OnAuthorize(同上,有空VS裡驗證下再來改),且需要呼叫 base.OnAuthorize 。
第二點,如下:

var claimsIdentity =User.IdentityasClaimsIdentity;
claimsIdentity.Claims.Where(c => c.Type=="urn:oauth:scope").ToList();

然後,還有個ResourceServer常用的東西,就是使用者資訊的主鍵,一般可以從User.Identity.GetUserId()獲取,不過這個方法是個擴充套件方法,需要using Microsoft.AspNet.Identity。至於為什麼這裡可以用呢?就是Claims裡包含了使用者資訊的主鍵,不信可以除錯下看看(注意觀察新增claims那段程式碼,將登陸後原有的claims也累加進去了,這裡就包含了使用者登陸名Name和使用者主鍵UserId)。

實踐才會真的進步

這次寫的真不少,基本自己踩過的坑應該都寫了吧,有空再回顧看下有沒有遺漏的。今天就先到這裡,over。

追加

後續實踐發現,由於使用了owin的中介軟體,ResourceServer依賴Microsoft.Owin.Host.SystemWeb,釋出部署的時候不要遺漏該dll

文章轉載: http://ju.outofmemory.cn/entry/99935