[2014-11-11]使用Owin中間件搭建OAuth2.0認證受權服務器


前言

這裏主要總結下本人最近半個月關於搭建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數據庫

Demo:Authorization Server

從OAuth2.0的rfc文檔中,咱們知道OAuth有多種受權模式,這裏只關注受權碼方式。
首先來看Authorization Server項目,裏面有三大塊:api

  • 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)即重定向。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中。

先易後難,接着看看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,這裏要注意兩點:

  1. webapi的Authorize和mvc的Authorize不同(起碼截至MVC5,這仍是兩個東西,vnext到時再細究;
  2. 如何從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。

相關文章
相關標籤/搜索