使用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,如果不考慮惡意進程截獲,那一定程度上還是安全的。

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

  • 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的形式(若用戶授權的話)。

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只是將這個過程封裝得更易用一些。下面就讓我們以授權碼模式為例,一窺究竟。

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

可以看到,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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM