這里主要總結(jié)下本人最近半個(gè)月關(guān)于搭建OAuth2.0服務(wù)器工作的經(jīng)驗(yàn)。至于為何需要OAuth2.0、為何是Owin、什么是Owin等問題,不再贅述。我假定讀者是使用asp.net,并需要搭建OAuth2.0服務(wù)器,對(duì)于涉及的Asp.Net Identity(Claims Based Authentication)、Owin、OAuth2.0等知識(shí)點(diǎn)已有基本了解。若不了解,請(qǐng)先參考以下文章:
在對(duì)前言中所列的各知識(shí)點(diǎn)有初步了解之后,我們從何處下手呢?
這里推薦一個(gè)demo:OWIN OAuth 2.0 Authorization Server
除了demo外,還推薦準(zhǔn)備好katanaPRoject的源代碼
接下來,我們主要看這個(gè)demo
從OAuth2.0的rfc文檔中,我們知道OAuth有多種授權(quán)模式,這里只關(guān)注授權(quán)碼方式。
首先來看Authorization Server項(xiàng)目,里面有三大塊:
以RFC6749圖示:
Clients分別對(duì)應(yīng)各種授權(quán)方式的Client,這里我們只看對(duì)應(yīng)授權(quán)碼方式的AuthorizationCodeGrant項(xiàng)目;
Authorization Server即提供OAuth服務(wù)的認(rèn)證授權(quán)服務(wù)器;
Resource Server即Client拿到accessToken后攜帶AccessToken訪問的資源服務(wù)器(這里僅簡(jiǎn)單提供一個(gè)/api/Me顯示用戶的Name)。
另外需要注意Constants項(xiàng)目,里面設(shè)置了一些關(guān)鍵數(shù)據(jù),包含接口地址以及Client的Id和Secret等。
AuthorizationCodeGrant項(xiàng)目使用了DotNetOpenAuth.OAuth2封裝的一個(gè)WebServerClient類作為和Authorization Server通信的Client。
(這里由于封裝了底層的一些細(xì)節(jié),致使不使用這個(gè)包和Authorization Server交互時(shí)可能會(huì)遇到幾個(gè)坑,這個(gè)稍后再講)
這里主要看幾個(gè)關(guān)鍵點(diǎn):
1.運(yùn)行項(xiàng)目后,出現(xiàn)頁面,點(diǎn)擊【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” } 為需要申請(qǐng)的scopes,或者說是Resource Server的接口標(biāo)識(shí),或者說是接口權(quán)限。然后Send(HttpContext)即重定向。
2.這里暫不論重定向用戶至Authorization Server后的情況,假設(shè)用戶在Authorization Server上完成了授權(quán)操作,那么Authorization Server會(huì)重定向用戶至Client,在這里,具體的回調(diào)地址即之前點(diǎn)擊【Authorize】按鈕的頁面,而url上帶有一個(gè)一次性的code參數(shù),用于Client再次從服務(wù)器端發(fā)起請(qǐng)求到Authorization Server以code交換AccessToken。關(guān)鍵代碼如下:
if (string.IsNullOrEmpty(accessToken)){ var authorizationState = _webServerClient.ProcessUserAuthorization(Request); if (authorizationState != null) { ViewBag.AccessToken = authorizationState.AccessToken; ViewBag.RefreshToken = authorizationState.RefreshToken; ViewBag.Action = Request.Path; }}我們發(fā)現(xiàn)這段代碼在之前點(diǎn)擊Authorize的時(shí)候也會(huì)觸發(fā),但是那時(shí)并沒有code參數(shù)(缺少code時(shí),可能_webServerClient.ProcessUserAuthorization(Request)并不會(huì)發(fā)起請(qǐng)求),所以拿不到AccessToken。
3.拿到AccessToken后,剩下的就是調(diào)用api,CallApi,試一下,發(fā)現(xiàn)返回的就是剛才用戶登陸Authorization Server所使用的用戶名(Resource Server的具體細(xì)節(jié)稍后再講)。
4.至此,Client端的代碼分析完畢(RefreshToken請(qǐng)自行嘗試,自行領(lǐng)會(huì))。沒有復(fù)雜的內(nèi)容,按RFC6749的設(shè)計(jì),Client所需的就只有這些步驟。對(duì)于Client部分,唯一需要再次鄭重提醒的是,一定不能把AccessToken泄露出去,比如不加密直接放在瀏覽器cookie中。
我們先把Authorization Server放一放,接著看下Resource Server。
Resource Server非常簡(jiǎn)單,App_Start中Startup.Auth配置中只有一句代碼:
app.USEOAuthBearerAuthentication(new Microsoft.Owin.Security.OAuth.OAuthBearerAuthenticationOptions());然后,唯一的控制器MeController也非常簡(jiǎn)單:
[Authorize]public class MeController : ApiController{ public string Get() { return this.User.Identity.Name; }}有效代碼就這些,就實(shí)現(xiàn)了非用戶授權(quán)下無法訪問,授權(quán)了就能獲取用戶登陸用戶名。(其實(shí)webconfig里還有一項(xiàng)關(guān)鍵配置,稍后再說)
那么,Startup.Auth中的代碼是什么意思呢?為什么Client訪問api,而User.Identity.Name卻是授權(quán)用戶的登陸名而不是Client的登陸名呢?
我們先看第一個(gè)問題,找 UseOAuthBearerAuthentication() 這個(gè)方法。具體怎么找就不廢話了,我直接說明它的源代碼位置在 Katana Project源碼中的Security目錄下的Microsoft.Owin.Security.OAuth項(xiàng)目。OAuthBearerAuthenticationExtensions.cs文件中就這么一個(gè)針對(duì)IAppBuilder的擴(kuò)展方法。而這個(gè)擴(kuò)展方法其實(shí)就是設(shè)置了一個(gè)OAuthBearerAuthenticationMiddleware,以針對(duì)AccessToken進(jìn)行解析。解析的結(jié)果就類似于Client以授權(quán)用戶的身份(即第二個(gè)問題,User.Identity.Name是授權(quán)用戶的登陸名)訪問了api接口,獲取了屬于該用戶的信息數(shù)據(jù)。
關(guān)于Resource Server,目前只需要知道這么多。
(關(guān)于接口驗(yàn)證scopes、獲取用戶主鍵、AccessToken中添加自定義標(biāo)記等,在看過Authorization Server后再進(jìn)行說明)
Authorization Server是本文的核心,也是最復(fù)雜的一部分。
首先來看Authorization Server項(xiàng)目的Startup.Auth.cs文件,關(guān)于OAuth2.0服務(wù)端的設(shè)置就在這里。
// Enable application Sign In Cookieapp.UseCookieAuthentication(new CookieAuthenticationOptions{ AuthenticationType = "Application", //這里有個(gè)坑,先提醒下 AuthenticationMode = AuthenticationMode.Passive, LoginPath = new PathString(Paths.LoginPath), LogoutPath = new PathString(Paths.LogoutPath),});既然到這里了,先提醒下這個(gè)設(shè)置:AuthenticationType是用戶登陸Authorization Server后的登陸憑證的標(biāo)記名,簡(jiǎn)單理解為cookie的鍵名就行。為什么要先提醒下呢,因?yàn)檫@和OAuth/Authorize中檢查用戶當(dāng)前是否已登陸有關(guān)系,有時(shí)候,這個(gè)值的默認(rèn)設(shè)置可能是”ApplicationCookie”。
好,正式看OAuthServer部分的設(shè)置:
// Setup Authorization Serverapp.UseOAuthAuthorizationServer(new OAuthAuthorizationServerOptions{ AuthorizeEndpointPath = new PathString(Paths.AuthorizePath), TokenEndpointPath = new PathString(Paths.TokenPath), ApplicationCanDisplayErrors = true,#if DEBUG AllowInsecureHttp = true, //重要!!這里的設(shè)置包含整個(gè)流程通信環(huán)境是否啟用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),...設(shè)置了這兩個(gè)EndpointPath,則無需重寫OAuthAuthorizationServerProvider的MatchEndpoint方法(假如你繼承了它,寫了個(gè)自己的ServerProvider,否則也可以通過設(shè)置OnMatchEndpoint達(dá)到和重寫相同的效果)。
反過來說,如果你的EndpointPath比較復(fù)雜,比如前面可能因?yàn)閲?guó)際化而攜帶culture信息,則可以通過override MatchEndpoint方法實(shí)現(xiàn)定制。
但請(qǐng)記住,重寫了MatchEndpoint(或設(shè)置了OnMatchEndpoint)后,我推薦注釋掉這兩行賦值語句。至于為什么,請(qǐng)看Katana Project源碼中的Security目錄下的Microsoft.Owin.Security.OAuth項(xiàng)目OAuthAuthorizationServerHandler.cs第38行至第46行代碼。
對(duì)了,如果項(xiàng)目使用了某些全局過濾器,請(qǐng)自行判斷是否要避開這兩個(gè)路徑(AuthorizeEndpointPath是對(duì)應(yīng)OAuth控制器中的Authorize方法,而TokenEndpointPath則是完全由這里配置的OAuthAuthorizationServer中間件接管的)。
ApplicationCanDisplayErrors = true, #if DEBUG AllowInsecureHttp = true, //重要!!這里的設(shè)置包含整個(gè)流程通信環(huán)境是否啟用ssl#endif這里第一行不多說,字面意思理解下。
重要!!AllowInsecureHttp設(shè)置整個(gè)通信環(huán)境是否啟用ssl,不僅是OAuth服務(wù)端,也包含Client端(當(dāng)設(shè)置為false時(shí),若登記的Client端重定向url未采用https,則不重定向,踩到這個(gè)坑的話,問題很難定位,親身體會(huì))。
// Authorization server provider which controls the lifecycle of Authorization ServerProvider = new OAuthAuthorizationServerProvider{ OnValidateClientRedirectUri = ValidateClientRedirectUri, OnValidateClientAuthentication = ValidateClientAuthentication, OnGrantResourceOwnerCredentials = GrantResourceOwnerCredentials, OnGrantClientCredentials = GrantClientCredetails}這里是核心Provider,凡是On開頭的,其實(shí)都是委托方法,中間件定義了OAuth2的一套流程,但是它把幾個(gè)關(guān)鍵的事件以委托的方式暴露了出來。
具體的這些委托的作用,我們接著看對(duì)應(yīng)的方法的代碼:
//驗(yàn)證重定向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項(xiàng)目中預(yù)設(shè)的測(cè)試數(shù)據(jù)。如果我們有Client的注冊(cè)機(jī)制,那么Clients.Client1.Id對(duì)應(yīng)的Clients.Client1.RedirectUrl就可能是從數(shù)據(jù)庫中讀取的。而數(shù)據(jù)庫中讀取的RedirectUrl則可以直接作為字符串參數(shù)傳給context.Validated(RedirectUrl)。這樣,這部分邏輯就算結(jié)束了。
//驗(yàn)證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);}和上面驗(yàn)證重定向URL類似,這里是驗(yàn)證Client身份的。但是特別要注意兩個(gè)TryGet方法,這兩個(gè)TryGet方法對(duì)應(yīng)了OAuth2Server如何接收Client身份認(rèn)證信息的方式(這個(gè)demo用了封裝好的客戶端,不會(huì)遇到這個(gè)問題,之前說的在不使用DotNetOpenAuth.OAuth2封裝的一個(gè)WebServerClient類的情況下可能遇到的坑就是這個(gè))。
那么什么時(shí)候需要Client提交ClientId和ClientSecret呢?是在前面說到的Client拿著一次性的code參數(shù)去OAuth服務(wù)器端交換AccessToken的時(shí)候。
Basic身份認(rèn)證,參考RFC2617
Basic簡(jiǎn)單說明下就是添加如下的一個(gè)Http Header:
Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ== //這只是個(gè)例子其中Basic后面部分是 ClientId:ClientSecret 形式的字符串進(jìn)行Base64編碼后的字符串,Authorization是Http Header 的鍵名,Basic至最后是該Header的值。
Form這種只要注意兩個(gè)鍵名是 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); } }這里是對(duì)應(yīng)之前說的用來交換AccessToken的code參數(shù)的生成和驗(yàn)證的,用ConcurrentDictionary是為了線程安全;_authenticationCodes.TryRemove就是之前一直重點(diǎn)強(qiáng)調(diào)的code是一次性的,驗(yàn)證一次后即刪除了。
private void CreateRefreshToken(AuthenticationTokenCreateContext context){ context.SetToken(context.SerializeTicket());}private void ReceiveRefreshToken(AuthenticationTokenReceiveContext context){ context.DeserializeTicket(context.Token);}這里處理RefreshToken的生成和接收,只是簡(jiǎn)單的調(diào)用Token的加密設(shè)置和解密的方法。
至此,Startup.Auth部分的基本結(jié)束,我們接下來看OAuth控制器部分。
OAuthController中只有一個(gè)Action,即Authorize。
Authorize方法并沒有區(qū)分HttpGet或者HttpPost,主要原因可能是方法簽名引起的(Action同名,除非參數(shù)不同,否則即使設(shè)置了HttpGet和HttpPost,編譯器也會(huì)認(rèn)為你定義了兩個(gè)相同的Action,我們?nèi)羰怯惨痖_,可能會(huì)稍微麻煩點(diǎn))。
if (Response.StatusCode != 200){ return View("AuthorizeError");}這段說實(shí)話,到現(xiàn)在我還沒搞懂為啥要判斷下200,可能是考慮到owin中間件會(huì)提前處理點(diǎn)什么?去掉了也沒見有什么異常,或者是我沒注意。。。這段可有可無。。
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();}這里就是判斷授權(quán)用戶是否已經(jīng)登陸,這是很簡(jiǎn)單的邏輯,登陸部分可以和AspNet.Identity那套一起使用,而關(guān)鍵就是authentication.AuthenticateAsync(“Application”)中的“Application”,還記得么,就是之前說的那個(gè)cookie名:
...AuthenticationType = "Application", //這里有個(gè)坑,先提醒下...這個(gè)里要匹配,否則用戶登陸后,到OAuth控制器這里可能依然會(huì)認(rèn)為是未登陸的。
如果用戶登陸,則這里的identity就會(huì)有值。
var scopes = (Request.QueryString.Get("scope") ?? "").Split(' ');這句只是獲取Client申請(qǐng)的scopes,或者說是權(quán)限(用空格分隔感覺有點(diǎn)奇怪,不知道是不是OAuth2.0里的標(biāo)準(zhǔn))。
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分支就是處理授權(quán)的邏輯,其實(shí)就是很直觀的向identity中添加Claims。那么Claims都去哪了?有什么用呢?
這需要再回過頭去看ResourceServer,以下是重點(diǎn)內(nèi)容:
其實(shí)Client訪問ResourceServer的api接口的時(shí)候,除了AccessToken,不需要其他任何憑據(jù)。那么ResourceServer是怎么識(shí)別出用戶登陸名的呢?關(guān)鍵就是claims-based identity 這套東西。其實(shí)所有的claims都加密存進(jìn)了AccessToken中,而ResourceServer中的OAuthBearer中間件就是解密了AccessToken,獲取了這些claims。這也是為什么之前強(qiáng)調(diào)AccessToken絕對(duì)不能泄露,對(duì)于ResourceServer來說,訪問者擁有AccessToken,那么就是受信任的,頒發(fā)AccessToken的機(jī)構(gòu)也是受信任的,所以對(duì)于AccessToken中加密的內(nèi)容也是絕對(duì)相信的,所以,ResourceServer這邊甚至不需要再去數(shù)據(jù)庫驗(yàn)證訪問者Client的身份。這里提到,頒發(fā)AccessToken的機(jī)構(gòu)也是受信任的,這是什么意思呢?我們看到AccessToken是加密過的,那么如何解密?關(guān)鍵在于AuthorizationServer項(xiàng)目和ResourceServer項(xiàng)目的web.config中配置了一致的machineKey。
(題外話,有個(gè)在線machineKey生成器:machineKey generator,這里也提一下,如果不喜歡配置machineKey,可以研究下如何重寫AccessToken和RefreshToken的加密解密過程,這里不多說了,提示:OAuthAuthorizationServerOptions中有好幾個(gè)以Format后綴的屬性)
上面說的machineKey即是系統(tǒng)默認(rèn)的AccessToken和RefreshToken的加密解密的密鑰。
submit.Login分支就不多說了,意思就是用戶換個(gè)賬號(hào)登陸。
首先,你需要一個(gè)自定義的Authorize屬性,用于在ResourceServer中驗(yàn)證Scopes,這里要注意兩點(diǎn):
第一點(diǎn),需要重寫的方法不是AuthorizeCore(具體方法名忘了,不知道有沒有寫錯(cuò)),而是OnAuthorize(同上,有空VS里驗(yàn)證下再來改),且需要調(diào)用 base.OnAuthorize 。
第二點(diǎn),如下:
var claimsIdentity = User.Identity as ClaimsIdentity;claimsIdentity.Claims.Where (c => c.Type == "urn:oauth:scope").ToList();然后,還有個(gè)ResourceServer常用的東西,就是用戶信息的主鍵,一般可以從User.Identity.GetUserId()獲取,不過這個(gè)方法是個(gè)擴(kuò)展方法,需要using Microsoft.AspNet.Identity。至于為什么這里可以用呢?就是Claims里包含了用戶信息的主鍵,不信可以調(diào)試下看看(注意觀察添加claims那段代碼,將登陸后原有的claims也累加進(jìn)去了,這里就包含了用戶登陸名Name和用戶主鍵UserId)。
這次寫的真不少,基本自己踩過的坑應(yīng)該都寫了吧,有空再回顧看下有沒有遺漏的。今天就先到這里,over。
后續(xù)實(shí)踐發(fā)現(xiàn),由于使用了owin的中間件,ResourceServer依賴Microsoft.Owin.Host.SystemWeb,發(fā)布部署的時(shí)候不要遺漏該dll。
作者:Personball's Blog
新聞熱點(diǎn)
疑難解答
圖片精選
網(wǎng)友關(guān)注