這幾天時間一直在研究怎么實現自己的OAuth2服務器,對於太了解OAuth原理以及想自己從零開始實現的,我建議可以參考《Apress.Pro ASP.NET Web API Security》里面的章節。最后發現其實微軟在這方面也已經做了實現,所以文介紹下怎么基於OWIN來實現自己的OAuth2.0授權服務器,,以及怎么使用DotNetOpenAuth作為客戶端來訪問受保護的資源。 OWIN是一套specification,微軟的Katana開源項目是基於OWIN標准開發的,所以本本文更准確的說是用katana來實現OAuth2.0授權服務器。DotNetOpenAuth也是一個非常優秀的框架,它也可以實現OAuth2.0服務器,本文是用這個框架來做客戶端。更多的關於Owin, OAuth等標准和框架,不是本文的重點,您可以自己查閱一些相關資料。本文假設您已經對OAuth,OWIN已經有一定的了解。
利用微軟的OWIN搭建OAuth非常簡單,具體步驟如下:
- 先創建一個ASP.NET項目,最簡單直接的的方式是創建一個MVC項目並且在身份驗證時選擇Individual User Accounts,這樣會自動將OWIN相關的DLL加入到項目
- 接下來找到App_Start文件夾下面的Startup.Auth.cs文件,加入如下代碼
:(不知道為什么插入代碼的時候我不能折疊:( )
//創建OAuth授權服務器 app.UseOAuthAuthorizationServer(new Microsoft.Owin.Security.OAuth.OAuthAuthorizationServerOptions() { AuthorizeEndpointPath = new PathString("/OAuth/Authorize"), TokenEndpointPath = new PathString("/OAuth/Token"), AccessTokenExpireTimeSpan=TimeSpan.FromMinutes(1), Provider = new OAuthAuthorizationServerProvider() { OnValidateClientRedirectUri = context => { context.Validated(); return Task.FromResult(0); }, OnValidateClientAuthentication = context => { string clientId; string clientSecret; if (context.TryGetBasicCredentials(out clientId, out clientSecret) || context.TryGetFormCredentials(out clientId, out clientSecret)) context.Validated(); return Task.FromResult(0); } }, AuthorizationCodeProvider = new AuthenticationTokenProvider() { OnCreate = context => { context.SetToken(DateTime.Now.Ticks.ToString()); string token = context.Token; string ticket = context.SerializeTicket(); _authenticationCodes[token] = ticket; }, OnReceive = context => { string token = context.Token; string ticket; if (_authenticationCodes.TryRemove(token, out ticket)) { context.DeserializeTicket(ticket); } }, }, RefreshTokenProvider = new AuthenticationTokenProvider() { OnCreate = context => { context.SetToken(context.SerializeTicket()); }, OnReceive = context => { context.DeserializeTicket(context.Token); }, } });
其中AuthorizeEndpointPath是授權終結點,這個需要自己去寫實現邏輯,等會會介紹。TokenEndpointPath是生成Token的終結點,這個不需要我們寫額外的邏輯了,因為Token的生成涉及到很多方面,例如序列化反序列加密解密等邏輯,所以框架默認已經幫我們做好了。 Provider里面是用來驗證客戶端跳轉地址和客戶端驗證,我這里實現比較簡單,全部都驗證通過,正確的做法應該是先判斷客戶端發送過來的ClientID是否合法,如果合法則驗證通過。AuthorizationCodeProvider是對於Authorize Code模式而言的(我等會客戶端訪問只接受這種模式,也是最負責的一種,其他三種模式Implicit,Client,Resource相對比較簡單,就不在這里介紹了,有興趣的同學可以線下聯系),里面的兩個Action委托顧名思義一個是創建Code,一個是用Code來來交換Token。最后是RefreshTokenProvider
- 接下來就看下授權終結點,這里的邏輯需要自己來處理
public ActionResult Authorize() { IAuthenticationManager authentication= HttpContext.GetOwinContext().Authentication; AuthenticateResult ticket = authentication.AuthenticateAsync(DefaultAuthenticationTypes.ApplicationCookie).Result; ClaimsIdentity identity = ticket == null ? null : ticket.Identity; if (identity == null) { //如果沒有驗證通過,則必須先通過身份驗證,跳轉到驗證方法 authentication.Challenge(DefaultAuthenticationTypes.ApplicationCookie); return new HttpUnauthorizedResult(); } identity = new ClaimsIdentity(identity.Claims, "Bearer"); //hardcode添加一些Claim,正常是從數據庫中根據用戶ID來查找添加 identity.AddClaim(new Claim(ClaimTypes.Role, "Admin")); identity.AddClaim(new Claim(ClaimTypes.Role, "Normal")); identity.AddClaim(new Claim("MyType", "MyValue")); authentication.SignIn(new AuthenticationProperties() { IsPersistent=true }, identity); return View(); }
通常來說,客戶端發送到授權終結點的請求是會被OWIN截取跳轉到第二步中注冊的 OnValidateClientRedirectUri委托,如果驗證通過再跳轉到授權終結點,這里首先看當前用戶是否已經驗證通過(Authentication),如果沒有則跳轉到驗證終結點,注意這里的方法authentication.Challenge(DefaultAuthenticationTypes.ApplicationCookie)中的參數,名稱必須要和Startup.Auth.cs中app.UseCookieAuthentication內定義的AuthenticationType一樣,否則無法跳轉到在app.UseCookieAuthentication內注冊的LoginPath終結點
- 驗證節點(Authentication)代碼
[HttpPost] [AllowAnonymous] [ValidateAntiForgeryToken] public ActionResult Login(LoginViewModel model, string returnUrl) { if (!ModelState.IsValid) { return View(model); } if (_userManagerService.VerifyUser(model.Email, model.Password)) { AppUser user = UserManager.FindByName(model.Email); //可以在這里將用戶所屬的role或者Claim添加到此 ClaimsIdentity claims = new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, user.UserName) ,new Claim(ClaimTypes.NameIdentifier,user.Id) ,new Claim("http://schemas.microsoft.com/accesscontrolservice/2010/07/claims/identityprovider",user.Id)}, DefaultAuthenticationTypes.ApplicationCookie); AuthenticationProperties properties = new AuthenticationProperties { IsPersistent = true }; ClaimsPrincipal principal = new ClaimsPrincipal(claims); //System.Threading.Thread.CurrentPrincipal = principal; this.AuthenticationManager.SignIn(properties, new[] { claims }); return RedirectToLocal(returnUrl); } else { ModelState.AddModelError("", "Invalid login attempt."); return View(model); } }
通過第三步首先會進入到Login的Get方法(這里沒有貼出來,因為只是返回試圖而已),然后用戶通過輸入正確的用戶名和密碼通過驗證后,再為用戶添加代碼中所示的Claim,注意如果你的代碼中加入了[ValidateAntiForgeryToken]特性,那么需要加入NameIdentifier,identityprovider兩個聲明,否則會報錯。最后再調用SignIn方法(我沒有仔細去看這個方法的代碼,但是我認為這里應該至少會有對System.Threading.Thread.CurrentPrincipal進行賦值,確保驗證通過)然后再次跳轉到授權節點,再次執行第三步的代碼,這是identity就不為空了,我這里重新構造了Identity,注意這里的AuthenticationType需要為“Bearer”,這是告訴OAuth生成Token的關鍵。最后再進行SignIn,此時會執行第二步中注冊的Authorize Code生成方法,生成Code后會回跳到客戶端的注冊的RedirectUrl,客戶端此時就可以拿着Code來換Token了,具體也就是執行第二步中注冊的OnReceive方法
- 好了,整個服務器授權邏輯差不多就是這樣了,當然我這里為了簡單沒有去實現Scope的Grant,如果要做可以在授權終結點里進行實現
- 下面來看看客戶端怎么實現的,首選需要說明一點如果是.NET的WEB客戶端可以使用DotNetOpenOAuth,通過Nuget就可以獲取,比較簡單,如果是其他手機設備等則可以通過ImplicitGrant模式通過javascript腳本來做,implicitgrant是authorize code的而一個簡化版,省略了code交互這些步驟,直接從服務器拿Token,而且客戶端是能直接看到Token的,但是authorize code客戶端是看不到的,只有客戶端所在的web server才能看到,所以也就更安全。
- 我在客戶端自己稍微對DNOA做了下封裝,代碼有點長,所以就只看下幾個核心方法
public string[] scopes { get; set; } public string redirectUri { get; set; } public IAuthorizationState AuthorizationState = null; private string AuthorizationEndpoint, TokenEndpoint, ClientId, ClientKey; private AuthorizationServerDescription authServer = null; private WebServerClient oauthWebClient = null; public OAuthClient() { AuthorizationEndpoint = System.Configuration.ConfigurationManager.AppSettings.Get("AuthorizeEndpoint"); TokenEndpoint = System.Configuration.ConfigurationManager.AppSettings.Get("TokenEndpoint"); ClientKey = System.Configuration.ConfigurationManager.AppSettings.Get("ClientKey"); ClientId = System.Configuration.ConfigurationManager.AppSettings.Get("ClientID"); authServer = new AuthorizationServerDescription { AuthorizationEndpoint = new Uri(AuthorizationEndpoint), TokenEndpoint = new Uri(TokenEndpoint) }; oauthWebClient = new WebServerClient(authServer, clientIdentifier: ClientId, clientSecret:ClientKey); AuthorizationState = new AuthorizationState(); }
比較重要的就是WebServerClient這個對象了,封裝了對授權服務器訪問的所有方法,對它初始化需要告訴它的授權和Token終結點,ClientID,ClientSecret(這兩個值正常做法是授權服務器頒給的,和新浪,騰訊等一樣)
- 初始化好了后就可以開始訪問了,因為我們是使用authorize code,所以這里調用的
oauthWebClient.RequestUserAuthorization(scopes, new Uri(redirectUri));
傳入你要訪問資源的范圍和回調的URL(通常就是發起的action),獲得Code后就可以服務器會把code值作為參數寫入到Request的querystring中,所以我們可以下面方式來檢查code是否已經存在
if (request.Url.AbsoluteUri.Contains(redirectUri)) { if (!string.IsNullOrEmpty(request.Params["code"])) { return true; } }
如果code存在就可以用code來換Token了,馬上就會大功告成了。。
public void GetAccessToken(HttpRequestBase Requst, out string token) { if (CheckCodeExist(Requst)) { AuthorizationState = oauthWebClient.ProcessUserAuthorization(); if (AuthorizationState != null) { if (!string.IsNullOrEmpty(AuthorizationState.AccessToken)) { token = AuthorizationState.AccessToken; return; } } } GetAuthrizationCode(); token = null; }
最關鍵的方法就是AuthorizationState = oauthWebClient.ProcessUserAuthorization();通過它我們就可以拿到token相關的信息了,例如AccessToken, RefreshToken,Token過期時間等
- 接下來就是用Token去訪問資源服務器上的資源了,訪問之前,我們先要對資源服務器同樣要加入OWIN的支持,由於我采用的IISHost,需要加入下列組件microsoft.owin,micorsoft.owin.host.systemweb, microsoft.owin.security,microsoft.owin.oauth,通過Nuget獲取既可,不做多介紹,這時應該會有Startup.cs文件自動加進來,我們在里面加上下面代碼:
public partial class Startup { public void Configuration(IAppBuilder app) { app.UseOAuthBearerAuthentication(new Microsoft.Owin.Security.OAuth.OAuthBearerAuthenticationOptions()); } }
這里就是使用Bearer驗證,具體邏輯是拿到請求頭里的Bearer驗證帶來的Token,如果token是合法的,那么OWIN對token進行解析反序列化出來對等的claim,並且通過驗證,還可以進一步根據claim來判斷當前用戶是否有權限來相應的資源,如下(用戶必須是Admin角色,否則會401):
[Authorize] [HttpGet] [Route("api/car/protect")] public string ProtectedResource() { ClaimsPrincipal principal = Thread.CurrentPrincipal as ClaimsPrincipal; var isInRole= principal.IsInRole("Admin"); return "you have the right now to access me!"; }
另外需要注意點的是,如果你需要實現單點登錄之類的功能或者是訪問多台資源服務器,那么需要保證不同的資源服務器使用的都是相同加密解密機制,這個可以通過在web.config里設置machiekey來實現,關於machine key這里就不說了
- 回到剛才9步中說到的客戶端訪問資源,很簡單,我是通過httpclient做了個簡單的訪問實現,具體如下,關鍵就是將token以Bearer方式添加到驗證頭,就不多復述了
string msg=""; using (HttpClient httpClient = new HttpClient()) { httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); httpClient.DefaultRequestHeaders.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json")); if (method.ToUpper() == "POST") { StringContent content = new StringContent(jsonStr); content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"); foreach (var address in resourceAddress) { var responseMsg = httpClient.PostAsync(address, content).Result; if (responseMsg.IsSuccessStatusCode) { msg += responseMsg.Content.ReadAsStringAsync().Result; ; } } } else { foreach (var address in resourceAddress) { var responseMsg = httpClient.GetAsync(address).Result; if (responseMsg.IsSuccessStatusCode) { msg += responseMsg.Content.ReadAsStringAsync().Result; ; } } }
現在整個授權服務器,資源服務器,客戶端基本都實現了。
第一次寫博,原以為最多一個小時可以搞定,沒想到快四個小時了,所以沒時間來檢查以及弄排版樣式啥的了,寫博客還真不是容易的事啊。