1. 程式人生 > 實用技巧 >使用DotNetOpenAuth搭建OAuth2.0授權框架

使用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