前言
這里主要總結下本人最近半個月關於搭建OAuth2.0服務器工作的經驗。至於為何需要OAuth2.0、為何是Owin、什么是Owin等問題,不再贅述。我假定讀者是使用Asp.Net,並需要搭建OAuth2.0服務器,對於涉及的Asp.Net Identity(Claims Based Authentication)、Owin、OAuth2.0等知識點已有基本了解。若不了解,請先參考以下文章:
- MVC5 - ASP.NET Identity登錄原理 - Claims-based認證和OWIN
- 下一代Asp.net開發規范OWIN(1)—— OWIN產生的背景以及簡單介紹
- 理解OAuth 2.0
- rfc6749
從何開始?
在對前言中所列的各知識點有初步了解之后,我們從何處下手呢?
這里推薦一個demo:OWIN OAuth 2.0 Authorization Server
除了demo外,還推薦准備好katanaproject的源代碼
接下來,我們主要看這個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(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配置部分
首先來看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的一套流程,但是它把幾個關鍵的事件以委托的方式暴露了出來。
- OnValidateClientRedirectUri:驗證Client的重定向Url,這個是為了安全,防釣魚
- OnValidateClientAuthentication:驗證Client的身份(ClientId以及ClientSecret)
- OnGrantResourceOwnerCredentials和OnGrantClientCredentials是這個demo中提供的另兩種授權方式,不在本文討論范圍內。
具體的這些委托的作用,我們接着看對應的方法的代碼:
//驗證重定向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類的情況下可能遇到的坑就是這個)。
- TryGetBasicCredentials:是指Client可以按照Basic身份驗證的規則提交ClientId和ClientSecret
- TryGetFormCredentials:是指Client可以把ClientId和ClientSecret放在Post請求的form表單中提交
那么什么時候需要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控制器部分。
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,這里要注意兩點:
- webapi的Authorize和mvc的Authorize不一樣(起碼截至MVC5,這還是兩個東西,vnext到時再細究;
- 如何從ResourceServer的User.Identity中挖出自定義的claims。
第一點,需要重寫的方法不是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。