這里主要總結下本人最近半個月關于搭建OAuth2.0服務器工作的經驗。至于為何需要OAuth2.0、為何是Owin、什么是Owin等問題,不再贅述。我假定讀者是使用asp.net,并需要搭建OAuth2.0服務器,對于涉及的Asp.Net Identity(Claims Based Authentication)、Owin、OAuth2.0等知識點已有基本了解。若不了解,請先參考以下文章:
在對前言中所列的各知識點有初步了解之后,我們從何處下手呢?
這里推薦一個demo:OWIN OAuth 2.0 Authorization Server
除了demo外,還推薦準備好katanaPRoject的源代碼
接下來,我們主要看這個demo
從OAuth2.0的rfc文檔中,我們知道OAuth有多種授權模式,這里只關注授權碼方式。
首先來看Authorization Server項目,里面有三大塊:
以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)即重定向。
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中。
我們先把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 Cookieapp.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 Serverapp.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 ServerProvider = 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。
作者:Personball's Blog
新聞熱點
疑難解答