這裏主要總結下本人最近半個月關於搭建OAuth2.0服務器工做的經驗。至於爲什麼須要OAuth2.0、爲什麼是Owin、什麼是Owin等問題,再也不贅述。我假定讀者是使用Asp.Net,並須要搭建OAuth2.0服務器,對於涉及的Asp.Net Identity(Claims Based Authentication)、Owin、OAuth2.0等知識點已有基本瞭解。若不瞭解,請先參考如下文章:html
在對前言中所列的各知識點有初步瞭解以後,咱們從何處下手呢?
這裏推薦一個demo:OWIN OAuth 2.0 Authorization Server
除了demo外,還推薦準備好katanaproject的源代碼web
接下來,咱們主要看這個demo數據庫
從OAuth2.0的rfc文檔中,咱們知道OAuth有多種受權模式,這裏只關注受權碼方式。
首先來看Authorization Server項目,裏面有三大塊:api
以RFC6749圖示:
Clients分別對應各類受權方式的Client,這裏咱們只看對應受權碼方式的AuthorizationCodeGrant項目;
Authorization Server即提供OAuth服務的認證受權服務器;
Resource Server即Client拿到AccessToken後攜帶AccessToken訪問的資源服務器(這裏僅簡單提供一個/api/Me顯示用戶的Name)。
另外須要注意Constants項目,裏面設置了一些關鍵數據,包含接口地址以及Client的Id和Secret等。瀏覽器
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)即重定向。cookie
2.這裏暫不論重定向用戶至Authorization Server後的狀況,假設用戶在Authorization Server上完成了受權操做,那麼Authorization Server會重定向用戶至Client,在這裏,具體的回調地址即以前點擊【Authorize】按鈕的頁面,而url上帶有一個一次性的code參數,用於Client再次從服務器端發起請求到Authorization Server以code交換AccessToken。關鍵代碼以下:mvc
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。app
3.拿到AccessToken後,剩下的就是調用api,CallApi,試一下,發現返回的就是剛纔用戶登錄Authorization Server所使用的用戶名(Resource Server的具體細節稍後再講)。
4.至此,Client端的代碼分析完畢(RefreshToken請自行嘗試,自行領會)。沒有複雜的內容,按RFC6749的設計,Client所需的就只有這些步驟。對於Client部分,惟一須要再次鄭重提醒的是,必定不能把AccessToken泄露出去,好比不加密直接放在瀏覽器cookie中。
咱們先把Authorization Server放一放,接着看下Resource Server。
Resource Server很是簡單,App_Start中Startup.Auth配置中只有一句代碼:
app.UseOAuthBearerAuthentication(new Microsoft.Owin.Security.OAuth.OAuthBearerAuthenticationOptions());
而後,惟一的控制器MeController也很是簡單:
[Authorize] public class MeController : ApiController { public string Get() { return this.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.cs文件,關於OAuth2.0服務端的設置就在這裏。
// Enable Application Sign In Cookie app.UseCookieAuthentication(new CookieAuthenticationOptions { AuthenticationType = "Application", //這裏有個坑,先提醒下 AuthenticationMode = AuthenticationMode.Passive, LoginPath = new PathString(Paths.LoginPath), LogoutPath = new PathString(Paths.LogoutPath), });
既然到這裏了,先提醒下這個設置:AuthenticationType是用戶登錄Authorization Server後的登錄憑證的標記名,簡單理解爲cookie的鍵名就行。爲何要先提醒下呢,由於這和OAuth/Authorize中檢查用戶當前是否已登錄有關係,有時候,這個值的默認設置多是"ApplicationCookie"。
好,正式看OAuthServer部分的設置:
// Setup Authorization Server app.UseOAuthAuthorizationServer(new OAuthAuthorizationServerOptions { AuthorizeEndpointPath = new PathString(Paths.AuthorizePath), TokenEndpointPath = new PathString(Paths.TokenPath), ApplicationCanDisplayErrors = true, #if DEBUG AllowInsecureHttp = true, //重要!!這裏的設置包含整個流程通訊環境是否啓用ssl #endif // Authorization server provider which controls the lifecycle of Authorization Server Provider = new OAuthAuthorizationServerProvider { OnValidateClientRedirectUri = ValidateClientRedirectUri, OnValidateClientAuthentication = ValidateClientAuthentication, OnGrantResourceOwnerCredentials = GrantResourceOwnerCredentials, OnGrantClientCredentials = GrantClientCredetails }, // Authorization code provider which creates and receives authorization code AuthorizationCodeProvider = new AuthenticationTokenProvider { OnCreate = CreateAuthenticationCode, OnReceive = ReceiveAuthenticationCode, }, // Refresh token provider which creates and receives referesh token RefreshTokenProvider = new AuthenticationTokenProvider { OnCreate = CreateRefreshToken, OnReceive = ReceiveRefreshToken, } });
... AuthorizeEndpointPath = new PathString(Paths.AuthorizePath), TokenEndpointPath = new PathString(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 DEBUG AllowInsecureHttp = true, //重要!!這裏的設置包含整個流程通訊環境是否啓用ssl #endif
這裏第一行很少說,字面意思理解下。
重要!!AllowInsecureHttp設置整個通訊環境是否啓用ssl,不只是OAuth服務端,也包含Client端(當設置爲false時,若登記的Client端重定向url未採用https,則不重定向,踩到這個坑的話,問題很難定位,親身體會)。
// Authorization server provider which controls the lifecycle of Authorization Server Provider = new OAuthAuthorizationServerProvider { OnValidateClientRedirectUri = ValidateClientRedirectUri, OnValidateClientAuthentication = ValidateClientAuthentication, OnGrantResourceOwnerCredentials = GrantResourceOwnerCredentials, OnGrantClientCredentials = GrantClientCredetails }
這裏是核心Provider,凡是On開頭的,其實都是委託方法,中間件定義了OAuth2的一套流程,可是它把幾個關鍵的事件以委託的方式暴露了出來。
具體的這些委託的做用,咱們接着看對應的方法的代碼:
//驗證重定向url的 private Task ValidateClientRedirectUri(OAuthValidateClientRedirectUriContext context) { if (context.ClientId == Clients.Client1.Id) { context.Validated(Clients.Client1.RedirectUrl); } else if (context.ClientId == Clients.Client2.Id) { context.Validated(Clients.Client2.RedirectUrl); } return Task.FromResult(0); }
這裏context.ClientId是OAuth2處理流程上下文中獲取的ClientId,而Clients.Client1.Id是前面說的Constants項目中預設的測試數據。若是咱們有Client的註冊機制,那麼Clients.Client1.Id對應的Clients.Client1.RedirectUrl就多是從數據庫中讀取的。而數據庫中讀取的RedirectUrl則能夠直接做爲字符串參數傳給context.Validated(RedirectUrl)。這樣,這部分邏輯就算結束了。
//驗證Client身份 private Task ValidateClientAuthentication(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(); } else if (clientId == Clients.Client2.Id && clientSecret == Clients.Client2.Secret) { context.Validated(); } } return Task.FromResult(0); }
和上面驗證重定向URL相似,這裏是驗證Client身份的。可是特別要注意兩個TryGet方法,這兩個TryGet方法對應了OAuth2Server如何接收Client身份認證信息的方式(這個demo用了封裝好的客戶端,不會遇到這個問題,以前說的在不使用DotNetOpenAuth.OAuth2封裝的一個WebServerClient類的狀況下可能遇到的坑就是這個)。
那麼何時須要Client提交ClientId和ClientSecret呢?是在前面說到的Client拿着一次性的code參數去OAuth服務器端交換AccessToken的時候。
Basic身份認證,參考RFC2617
Basic簡單說明下就是添加以下的一個Http Header:
Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ== //這只是個例子
其中Basic後面部分是 ClientId:ClientSecret 形式的字符串進行Base64編碼後的字符串,Authorization是Http Header 的鍵名,Basic至最後是該Header的值。
Form這種只要注意兩個鍵名是 client_id 和 client_secret 。
private readonly ConcurrentDictionary<string, string> _authenticationCodes = new ConcurrentDictionary<string, string>(StringComparer.Ordinal); private void CreateAuthenticationCode(AuthenticationTokenCreateContext context) { context.SetToken(Guid.NewGuid().ToString("n") + Guid.NewGuid().ToString("n")); _authenticationCodes[context.Token] = context.SerializeTicket(); } private void ReceiveAuthenticationCode(AuthenticationTokenReceiveContext context) { string value; if (_authenticationCodes.TryRemove(context.Token, out value)) { context.DeserializeTicket(value); } }
這裏是對應以前說的用來交換AccessToken的code參數的生成和驗證的,用ConcurrentDictionary是爲了線程安全;_authenticationCodes.TryRemove就是以前一直重點強調的code是一次性的,驗證一次後即刪除了。
private void CreateRefreshToken(AuthenticationTokenCreateContext context) { context.SetToken(context.SerializeTicket()); } private void ReceiveRefreshToken(AuthenticationTokenReceiveContext context) { context.DeserializeTicket(context.Token); }
這裏處理RefreshToken的生成和接收,只是簡單的調用Token的加密設置和解密的方法。
至此,Startup.Auth部分的基本結束,咱們接下來看OAuth控制器部分。
OAuthController中只有一個Action,即Authorize。
Authorize方法並無區分HttpGet或者HttpPost,主要緣由多是方法簽名引發的(Action同名,除非參數不一樣,不然即便設置了HttpGet和HttpPost,編譯器也會認爲你定義了兩個相同的Action,咱們如果硬要拆開,可能會稍微麻煩點)。
if (Response.StatusCode != 200) { return View("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"); return new HttpUnauthorizedResult(); }
這裏就是判斷受權用戶是否已經登錄,這是很簡單的邏輯,登錄部分能夠和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 = new ClaimsIdentity(identity.Claims, "Bearer", identity.NameClaimType, identity.RoleClaimType); foreach (var scope in scopes) { identity.AddClaim(new Claim("urn:oauth:scope", scope)); } authentication.SignIn(identity); } if (!string.IsNullOrEmpty(Request.Form.Get("submit.Login"))) { authentication.SignOut("Application"); authentication.Challenge("Application"); return new HttpUnauthorizedResult(); } }
這裏,submit.Grant分支就是處理受權的邏輯,其實就是很直觀的向identity中添加Claims。那麼Claims都去哪了?有什麼用呢?
這須要再回過頭去看ResourceServer,如下是重點內容:
其實Client訪問ResourceServer的api接口的時候,除了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,這裏要注意兩點:
第一點,須要重寫的方法不是AuthorizeCore(具體方法名忘了,不知道有沒有寫錯),而是OnAuthorize(同上,有空VS裏驗證下再來改),且須要調用 base.OnAuthorize 。
第二點,以下:
var claimsIdentity = User.Identity as ClaimsIdentity; 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。