天天看點

使用DotNetOpenAuth搭建OAuth2.0授權架構

标題還是一如既往的難取。

我認為對于一個普遍問題,必有對應的一個簡潔優美的解決方案。當然這也許隻是我的一廂情願,因為根據宇宙法則,所有事物總歸趨于混沌,而OAuth協定就是混沌中的産物,不管是1.0、1.0a還是2.0,單看版本号就讓人神傷。

對接過各類開放平台的朋友對OAuth應該不會陌生。當年我小試了下淘寶API,各種token、key、secret、code、id,讓我眼花缭亂,不明是以,雖然最終調通,但那種照貓畫虎的感覺頗不好受。最近公司計劃,開放接口的授權協定從1.0升到2.0,這個任務不巧就落在了我的頭上。

聲明:我并沒有認真閱讀過OAuth2.0協定規範,本文對OAuth2.0的闡述或有不當之處,請諒解。本文亦不保證叙述的正确性,歡迎指正。認真的朋友可移步 http://tools.ietf.org/html/rfc6749

OAuth2.0包含四種角色:

  • 使用者,又叫資源所有者
  • 用戶端,俗稱第三方應用
  • 授權服務端,頒發AccessToken
  • 資源服務端,根據AccessToken開放相應的資源通路權限

本文涉及到三種授權模式:

  • Authorization Code模式:這是現在網際網路應用中最常見的授權模式。用戶端引導使用者在授權服務端輸入憑證擷取使用者授權(AccessToken),進而通路使用者資源。需要注意的是,在使用者授權後,授權服務端先回傳用戶端授權碼,然後用戶端再使用授權碼換取AccessToken。為什麼不直接傳回AccessToken呢?主要是由于使用者授權後,授權服務端重定向到用戶端位址(必須的,使用者可不願停留在授權服務端或者重新敲位址),此時資料隻能通過QueryString方式向用戶端傳遞,在使用者浏覽器位址欄中可見,不安全,也有被前端惡意程序截獲的風險,于是分成了兩步。第二步由用戶端主動請求擷取最終的令牌。
  • Client Credentials Flow:用戶端乃是授權服務端的信任合作方,不需要使用者參與授權,事先就約定向其開放指定資源(不特定于使用者)的通路權限。用戶端通過證書或密鑰(或其它約定形式)證明自己的身份,擷取AccessToken,用于後續通路。
  • Username and Password Flow:用戶端被使用者和授權服務端高度信任,使用者直接在用戶端中輸入使用者名密碼,然後用戶端傳遞使用者名密碼至授權服務端擷取AccessToken,便可通路相應的使用者資源。這在内部多系統資源共享、同源系統資源共享等場景下常用,比如單點登入,在登入時就擷取了其它系統的AccessToken,避免後續授權,提高了使用者體驗。

    關于第四種隐式授權模式,乃是Authorization Code模式省略擷取授權碼的步驟,直接傳回AccessToken,是以會帶來一定的安全隐患。不過在某些場景下還是合适的,比如浏覽器插件和手機app,不會顯式呈現傳回的url,如果不考慮惡意程序截獲,那一定程度上還是安全的。this flow is not recommended and deprecated in OAuth 2.1

上述模式涉及到三類憑證:

  • AuthorizationCode:授權碼,授權服務端和用戶端之間傳輸。
  • AccessToken:通路令牌,授權服務端發給用戶端,用戶端用它去到資源服務端請求資源。
  • RefreshToken:重新整理令牌,授權服務端和用戶端之間傳輸。

對用戶端來說,授權的過程就是擷取AccessToken的過程。

總的來說,OAuth并沒有新鮮玩意,仍是基于加密、證書諸如此類的技術,在OAuth出來之前,這些東東就已經被大夥玩的差不多了。OAuth給到我們的最大好處就是統一了流程标準,一定程度上促進了網際網路的繁榮。

我接到任務後,本着善假于物的理念,先去網上搜了一遍,原本以為有很多資源,結果隻搜到DotNetOpenAuth這個開源元件。更讓人失望的是,官方API文檔沒找到(可能是我找的姿勢不對,有知道的兄弟告知一聲),網上其它資料也少的可憐,其間發現一篇OAuth2學習及DotNetOpenAuth部分源碼研究,欣喜若狂,粗粗浏覽一遍,有收獲,卻覺得該元件未免過于繁雜(由于時間緊迫,我并沒有深入研究,隻是目前觀點)。DotNetOpenAuth包含OpenID、OAuth1.0[a]/2.0,自帶的例子有幾處暗坑,不易(能)調通。下面介紹我在搭建基于該元件的OAuth2.0授權架構時的一些心得體會。

本文介紹的DotNetOpenAuth乃是對應.Net4.0的版本。

授權服務端

授權服務端交道打的最多的就是用戶端,于是定義一個Client類,實作DotNetOpenAuth.OAuth2.IClientDescription接口,下面我們來看IClientDescription的定義:

public interface IClientDescription {

    Uri DefaultCallback { get; }

    //0:有secret 1:沒有secret
    ClientType ClientType { get; }

    //該client的secret是否為空
    bool HasNonEmptySecret { get; }

    //檢查傳入的callback與該client的callback是否一緻
    bool IsCallbackAllowed(Uri callback);

    //檢查傳入的secret與該client的secret是否一緻
    bool IsValidClientSecret(string secret);
}      

其中隐含了許多資訊。DefaultCallback表示用戶端的預設回調位址(假如有的話),在接收用戶端請求時,使用IsCallbackAllowed判斷回調位址是否合法(比如檢視該次回調位址和預設位址是否屬于同一個域),過濾其它應用的惡意請求。若ClientType 為0,則表示用戶端需持密鑰(secret)表明自己的身份,授權服務端可以據此賦予此類用戶端相對更多的權限,是以自定義的Client類一般需要多定義一個ClientSecret屬性。DefaultCallback和ClientSecret在下文常有涉及。

相關概念:timing attacks,官方例子在IsValidClientSecret方法中涉及到。個人覺得此處不需考慮,因為沒有為給方法單獨暴露接口出來。

DotNetOpenAuth預定義了一個接口——IAuthorizationServerHost,這是個重要的接口,定義如下:

public interface IAuthorizationServerHost
{
    ICryptoKeyStore CryptoKeyStore { get; }
    INonceStore NonceStore { get; }

    AutomatedAuthorizationCheckResponse CheckAuthorizeClientCredentialsGrant(IAccessTokenRequest accessRequest);
    AutomatedUserAuthorizationCheckResponse CheckAuthorizeResourceOwnerCredentialGrant(string userName, string password, IAccessTokenRequest accessRequest);
    AccessTokenResult CreateAccessToken(IAccessTokenRequest accessTokenRequestMessage);
    IClientDescription GetClient(string clientIdentifier);
    bool IsAuthorizationValid(IAuthorizationDescription authorization);
}      

簡單地說,CryptoKeyStore用于存取對稱加密密鑰,用于授權碼和重新整理令牌的加密,由于用戶端不需要對它們進行解密,是以密鑰隻存于授權服務端;關于AccessToken的傳輸則略有不同,關于這點我們待會說。了解NonceStore 屬性需要知道Nonce和Timestamp的概念,Nonce與消息合并加密可防止重播攻擊,Timestamp是為了避免可能的Nonce重複問題,也将一同參與加密,具體參看nonce和timestamp在Http安全協定中的作用;這項技術放在這裡主要是為了確定一個授權碼隻能被使用一次。CheckAuthorizeClientCredentialsGrant方法在用戶端憑證模式下使用,CheckAuthorizeResourceOwnerCredentialGrant在使用者名密碼模式下使用,經測試,IsAuthorizationValid方法隻在授權碼模式下被調用(授權碼換取AccessToken過程),這三個方法的傳回值标示是否通過授權。

當授權通過後,通過CreateAccessToken生成AccessToken并傳回給用戶端,用戶端于是就可以用AccessToken通路資源服務端了。那當資源服務端接收到AccessToken時,需要做什麼工作呢?首先,它要确認這個AccessToken是由合法的授權服務端頒發的,否則,攻擊者就能使用DotNetOpenAuth另外建一個授權服務端,生成“合法”的AccessToken,後果可想而知。說到身份認證,最成熟的就是RSA簽名技術,即授權服務端私鑰對AccessToken簽名,資源服務端接收後使用授權服務端的公鑰驗證。我們還可以使用資源伺服器公/私鑰對來加解密AccessToken(簽名在加密後),這對于OAuth2.0來說沒任何意義,而是為OAuth1.0服務的(雖然https能保證傳輸過程加密安全性,但不保證浏覽器端的安全性——用浏覽器開發者工具一看便知——需要應用自己解決加密問題。)。

public AccessTokenResult CreateAccessToken(IAccessTokenRequest accessTokenRequestMessage)
{
    var accessToken = new AuthorizationServerAccessToken();
    int minutes = 0;
    string setting = ConfigurationManager.AppSettings["AccessTokenLifeTime"];
    minutes = int.TryParse(setting, out minutes) ? minutes : 10;//10分鐘
    accessToken.Lifetime = TimeSpan.FromMinutes(minutes);

    //這裡設定加密公鑰
    //accessToken.ResourceServerEncryptionKey = new RSACryptoServiceProvider();
    //accessToken.ResourceServerEncryptionKey.ImportParameters(ResourceServerEncryptionPublicKey);

    //簽名私鑰,這是必須的(在後續版本中可以設定accessToken.SymmetricKeyStore替代)
    accessToken.AccessTokenSigningKey = CreateRSA();

    var result = new AccessTokenResult(accessToken);
    return result;
}      

前面說了,所有授權模式都是為了擷取AccessToken,授權碼模式和使用者名密碼模式還有個RefreshToken,當然授權碼模式獨有Authorization Code。一般來說,這三個東西,對于用戶端是一個經過加密編碼的字元串,對于服務端是可序列化的對象,存儲相關授權資訊。需要注意的是用戶端證書模式沒有RefreshToken,這是為什麼呢?我們不妨想想為什麼授權碼模式和使用者名密碼模式有個RefreshToken,或者說RefreshToken的作用是什麼。以下是我個人推測:

首先要明确,AccessToken一般是不會永久有效的。因為,AccessToken并沒有承載可以驗證用戶端身份的完備資訊,并且資源服務端也不承擔驗證用戶端身份的職責,一旦AccessToken被他人擷取,那麼就有可能被惡意使用。失效機制有效減少了産生此類事故可能造成的損失。當AccessToken失效後,需要重新擷取。對于授權碼模式和使用者名密碼模式來說,假如沒有RefreshToken,就意味這需要使用者重新輸入使用者名密碼進行再次授權。如果AccessToken有效期夠長,比如幾天,倒不覺得有何不妥,有些敏感應用隻設定數分鐘,就顯得不夠人性化了。為了解決這個問題,引入RefreshToken,它會在AccessToken失效後,在不需要使用者參與的情況下,重新擷取新的AccessToken,這裡有個前提就是RefreshToken的有效期(如果有的話)要比AccessToken長,可設為永久有效。那麼,RefreshToken洩露了會帶來問題嗎?答案是不會,除非你同時洩露了用戶端身份憑證。需要同時具備RefreshToken和用戶端憑證資訊,才能擷取新的AccessToken,我們甚至可以将舊的AccessToken當作RefreshToken。同理可推,由于不需要使用者參與授權,在用戶端證書模式下,用戶端在AccessToken失效後隻需送出自己的身份憑證重新請求新AccessToken即可,根本不需要RefreshToken。

授權碼模式,使用者授權後(此時并不傳回AccessToken,而是傳回授權碼),授權服務端要儲存相關的授權資訊,為此定義一個ClientAuthorization類:

public class ClientAuthorization
{
    public int ClientId { get; set; }

    public string UserId { get; set; }

    public string Scope { get; set; }

    public DateTime? ExpirationDateUtc { get; set; }
}      

ClientId和UserId就不說了,Scope是授權範圍,可以是一串Uri,也可以是其它辨別,隻要背景代碼能通過它來判斷待通路資源是否屬于授權範圍即可。ExpirationDateUtc乃是授權過期時間,即當該時間到期後,需要使用者重新授權(有RefreshToken)也沒用,為null表示永不過期。

資源服務端

在所有的授權模式下,資源服務端都隻專注一件和OAuth相關的事情——驗證AccessToken。這個步驟相對來說就簡單很多,以Asp.net WebAPI為例。在此之前建議對Asp.net WebAPI消息攔截機制不熟悉的朋友浏覽一遍ASP.NET Web API之消息[攔截]處理。這裡我們建立一個繼承自DelegatingHandler的類作為例子:

public class BearerTokenHandler : DelegatingHandler
{
    /// <summary>
    /// 驗證通路令牌合法性,由授權伺服器私鑰簽名,資源伺服器通過對應的公鑰驗證
    /// </summary>
    private static readonly RSAParameters AuthorizationServerSigningPublicKey = new RSAParameters();//just a 例子

    private RSACryptoServiceProvider CreateAuthorizationServerSigningServiceProvider()
    {
        var authorizationServerSigningServiceProvider = new RSACryptoServiceProvider();
        authorizationServerSigningServiceProvider.ImportParameters(AuthorizationServerSigningPublicKey);
        return authorizationServerSigningServiceProvider;
    }

    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        if (request.Headers.Authorization != null)
        {
            if (request.Headers.Authorization.Scheme == "Bearer")
            {
                var resourceServer = new ResourceServer(new StandardAccessTokenAnalyzer(this.CreateAuthorizationServerSigningServiceProvider(), null));
                var principal = resourceServer.GetPrincipal(request);//可以在此傳入待通路資源辨別參與驗證
                HttpContext.Current.User = principal;
                Thread.CurrentPrincipal = principal;
            }
        }

        return base.SendAsync(request, cancellationToken);
    }
}      

需要注意,AccessToken乃是從頭資訊Authorization擷取,格式為“Bearer:AccessToken”,在下文“原生方式擷取AccessToken”中有進一步描述(OAuth2.0引入了 Bearer 和 MAC 兩種驗證機制, Bearer 使用更簡單,但需要 TLS, MAC 可以走 HTTP, 與 OAuth 1.0a 更接近)。ResourceServer.GetPrincipal方法使用授權服務端的公鑰驗證AccessToken的合法性,同時解密AccessToken,若傳入參數有scope,則還會判斷scope是否屬于授權範圍内,通過後将會話辨別賦給目前會話,該會話辨別乃是當初使用者授權時的使用者資訊,這樣就實作了使用者資訊的傳遞。一般來說若傳回的principal為null,就可以不必執行後續邏輯了。

用戶端

可以認為DotNetOpenAuth.OAuth2.Client是DotNetOpenAuth給C#用戶端提供的預設SDK。我們以授權碼模式為例。先聲明一個IAuthorizationState接口對象,IAuthorizationState接口是用來儲存最終換取AccessToken成功後授權服務端傳回的資訊,其部分定義如下:

public interface IAuthorizationState {
    Uri Callback { get; set; }
    string RefreshToken { get; set; }
    string AccessToken { get; set; }
    DateTime? AccessTokenIssueDateUtc { get; set; }
    DateTime? AccessTokenExpirationUtc { get; set; }
    HashSet<string> Scope { get; }
}       

AccessTokenExpirationUtc是AccessToken過期時間,以Utc時間為準。若該對象為null,則表示尚未授權,我們需要去授權服務端請求。

private static AuthorizationServerDescription _authServerDescription = new AuthorizationServerDescription
{
    TokenEndpoint = new Uri(MvcApplication.TokenEndpoint),
    AuthorizationEndpoint = new Uri(MvcApplication.AuthorizationEndpoint),
};

private static WebServerClient _client = new WebServerClient(_authServerDescription, "democlient", "samplesecret");

[HttpPost]
public ActionResult Index()
{
    if (Authorization == null)
    {
        return _client.PrepareRequestUserAuthorization().AsActionResult();
    }
    return View();
}      

AuthorizationServerDescription包含兩個屬性,AuthorizationEndpoint是使用者顯式授權的位址,一般即使用者輸使用者名密碼的地;TokenEndpoint是用授權碼換取AccessToken的位址,注意該位址須用POST請求。“democlient”和“samplesecret”是示例用的用戶端ID和用戶端Secret。WebServerClient.PrepareRequestUserAuthorization方法将會首先傳回code和state到目前url,以querystring的形式(若使用者授權的話)。

使用DotNetOpenAuth搭建OAuth2.0授權架構

code即是授權碼,state參數不好了解,這涉及到CSRF,可參看淺談CSRF攻擊方式,state就是為了預防CSRF而引入的随機數。用戶端生成該值,将其附加到state參數的同時,存入使用者Cookie中,使用者授權完畢後,該參數會同授權碼一起傳回到用戶端,然後用戶端将其值同Cookie中的值比較,若一樣則表示該次授權為目前使用者操作,視為有效。由于不同域的cookie無法共享,是以其它站點并不能知道state的确切的值,CSRF攻擊也就無從談起了。簡單地說,state參數起到一個标示消息是否合法的作用。結合擷取授權碼這步來說,授權服務端傳回的url為http://localhost:22187/?code=xxxxxxxxx&state=_PzGpfJzyQI9DkdoyWeWr格式,若忽略state,那麼攻擊方将code替換成自己的授權碼,引誘使用者點選,最終用戶端擷取的AccessToken是攻擊方的AccessToken,由于AccessToken同使用者關聯,也就是說,後續用戶端做的其實是另一個使用者資源(也許是攻擊方注冊的虛拟使用者),如果操作中包括新增或更新,那麼錄入的真實使用者資訊就會被攻擊方擷取到。現在很多用戶端使用服務端的賬号進行自身的登入(類似于OpenID),即賬号綁定,那麼攻擊方即可用自己在服務端的賬号管理受害者在用戶端的賬号資訊。可參看OAuth2 Cross Site Request Forgery, and state parameter、小議OAuth 2.0的state參數。

有了code就可以去換取AccessToken了:

public ActionResult Index(string code,string state)
{
    if (!string.IsNullOrEmpty(code) && !string.IsNullOrEmpty(state))
    {
        var authorization = _client.ProcessUserAuthorization(Request);
        Authorization = authorization;
        return View(authorization);
    }
    return View();
}      

如前所述,Authorization不為null即表示整個授權流程成功完成。然後就可以用它來請求資源了。

public ActionResult Invoke()
{
    var request = new HttpRequestMessage(new HttpMethod("GET"), "http://demo.openapi.cn/bookcates");
    using (var httpClient = new HttpClient(_client.CreateAuthorizingHandler(Authorization)))
    {
        using (var resourceResponse = httpClient.SendAsync(request))
        {
            ViewBag.Result = resourceResponse.Result.Content.ReadAsStringAsync().Result;
        }
    }
    return View(Authorization);
}      

WebServerClient.CreateAuthorizingHandler方法傳回一個DelegatingHandler,主要用來當AccessToken過期時,使用RefreshToken重新整理換取新的AccessToken;并設定Authorization頭資訊,下文有進一步說明。

原生方式擷取AccessToken

既然是開放平台,面對的用戶端種類自然多種多樣,DotNetOpenAuth.OAuth2.Client顯然就不夠用了,我也不打算為了這個學遍所有程式語言。所幸OAuth基于http,不管任何語言開發的用戶端,擷取AccessToken的步驟本質上就是送出http請求和接收http響應的過程,用戶端SDK隻是将這個過程封裝得更易用一些。下面就讓我們以授權碼模式為例,一窺究竟。

參照前述事例,當我們第一次(新的浏覽器會話)在用戶端點選“請求授權”按鈕後,會跳轉到授權服務端的授權界面。

使用DotNetOpenAuth搭建OAuth2.0授權架構

可以看到,url中帶了client_id、redirect_uri、state、response_type四個參數,若要請求限定的授權範圍,還可以傳入scope參數。其中response_type設為code表示請求的是授權碼。

以下為請求授權碼:

1 private string GetNonCryptoRandomDataAsBase64(int binaryLength)
 2 {
 3     byte[] buffer = new byte[binaryLength];
 4     _random.NextBytes(buffer);
 5     string uniq = Convert.ToBase64String(buffer);
 6     return uniq;
 7 }
 8 
 9 public ActionResult DemoRequestCode()
10 {
11     string xsrfKey = this.GetNonCryptoRandomDataAsBase64(16);//生成随機數
12     string url = MvcApplication.AuthorizationEndpoint + "?" + 
13         string.Format("client_id={0}&redirect_uri={1}&response_type={2}&state={3}",
14         "democlient", "http://localhost:22187/", "code", xsrfKey);
15     HttpCookie xsrfKeyCookie = new HttpCookie(XsrfCookieName, xsrfKey);
16     xsrfKeyCookie.HttpOnly = true;
17     xsrfKeyCookie.Secure = FormsAuthentication.RequireSSL;
18     Response.Cookies.Add(xsrfKeyCookie);
19 
20     return Redirect(url);
21 }      

授權碼傳回後,先檢查state參數,若通過則換取AccessToken:

private bool VerifyState(string state)
{
    var cookie = Request.Cookies[XsrfCookieName];
    if (cookie == null)
        return false;

    var xsrfCookieValue = cookie.Value;
    return xsrfCookieValue == state;
}

private AuthenticationHeaderValue SetAuthorizationHeader()
{
    string concat = "democlient:samplesecret";
    byte[] bits = Encoding.UTF8.GetBytes(concat);
    string base64 = Convert.ToBase64String(bits);
    return new AuthenticationHeaderValue("Basic", base64);
}

public ActionResult Demo(string code, string state)
{
    if (!string.IsNullOrEmpty(code) && !string.IsNullOrEmpty(state) && VerifyState(state))
    {
        var httpClient = new HttpClient();
        var httpContent = new FormUrlEncodedContent(new Dictionary<string, string>()
    {
        {"code", code},
        {"redirect_uri", "http://localhost:22187/"},
        {"grant_type","authorization_code"}
    });
        httpClient.DefaultRequestHeaders.Authorization = this.SetAuthorizationHeader();

        var response = httpClient.PostAsync(MvcApplication.TokenEndpoint, httpContent).Result;
        Authorization = response.Content.ReadAsAsync<AuthorizationState>().Result;
        return View(Authorization);
    }
    return View();
}      

如上所示,以Post方式送出,三個參數,code即是授權碼,redirect_uri和擷取授權碼時傳遞的redirect_uri要保持一緻,grant_type設定為“authorization_code”。注意SetAuthorizationHeader方法,需要設定請求頭的Authorization屬性,Scheme為“Basic”,Parameter為以Base64編碼的“用戶端ID:用戶端Secret”字元串。成功後傳回的資訊可以轉為前面說的IAuthorizationState接口對象。 

如前所述,當AccessToken過期後,需要用RefreshToken重新整理。

private void RefreshAccessToken()
{
    var httpClient = new HttpClient();
    var httpContent = new FormUrlEncodedContent(new Dictionary<string, string>()
    {
        {"refresh_token", Authorization.RefreshToken},
        {"grant_type","refresh_token"}
    });
    httpClient.DefaultRequestHeaders.Authorization = this.SetAuthorizationHeader();

    var response = httpClient.PostAsync(MvcApplication.TokenEndpoint, httpContent).Result;
    Authorization = response.Content.ReadAsAsync<AuthorizationState>().Result;
}      

其中grant_type須設定為”refresh_token”,請求頭資訊設定同前。

擷取AccessToken後,就可以用于通路使用者資源了。

public ActionResult DemoInvoke()
{
    var httpClient = new HttpClient();
    if (this.Authorization.AccessTokenExpirationUtc.HasValue && this.Authorization.AccessTokenExpirationUtc.Value < DateTime.UtcNow)
    {
        this.RefreshAccessToken();
    }
    var bearerToken = this.Authorization.AccessToken;

    httpClient = new HttpClient();
    httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken);
    var request = new HttpRequestMessage(new HttpMethod("GET"), "http://demo.openapi.cn/bookcates");
    using (var resourceResponse = httpClient.SendAsync(request))
    {
        ViewBag.Result = resourceResponse.Result.Content.ReadAsStringAsync().Result;
    }
    return View(Authorization);
}      

用法很簡單,Authorization請求頭,Scheme設為“Bearer”,Parameter為AccessToken即可。

斷斷續續寫了大半個月,到此終于可以舒一口氣了。需要完整代碼的朋友,我會過段時間補上。

代碼連結在評論24#,有疑問可參看我的後續随筆:使用DotNetOpenAuth搭建OAuth2.0授權架構——Demo代碼簡單說明。

其它參考資料:

OAuth 1.0 簡介

轉載請注明本文出處:http://www.cnblogs.com/newton/p/3409984.html

繼續閱讀