[OAuth Series] 撰寫程式,完成 OAuth 驗證與授權,並處理 OAuth 的各式參數

在前一篇文章中,已經大略的介紹過 OAuth 所使用到的各類參數,這些參數的產生與使用將會決定 OAuth 的程序是否順暢,因為在每次針對服務的 private API 呼叫,都會用到 OAuth 的認證標頭訊息,所以怎麼樣產生正確的訊息就是用戶端程式最重要的課題。

* 本文範例程式使用 C# 開發。

前一篇文章中,已經大略的介紹過 OAuth 所使用到的各類參數,這些參數的產生與使用將會決定 OAuth 的程序是否順暢,因為在每次針對服務的 private API 呼叫,都會用到 OAuth 的認證標頭訊息,所以怎麼樣產生正確的訊息就是用戶端程式最重要的課題。

我們首先可以定義一個簡單的資料結構,來存放在每次 OAuth Call 時所需要的參數資料,姑且就叫它 OAuthDataRepository 好了:

public class OAuthDataRepository
{
    [OAuthDataTag("oauth_callback", RequireAtRequestToken = true)]
    public string Callback { get; set; }
    [OAuthDataTag("oauth_consumer_key", RequireAtRequestToken = true, RequireAtAccessToken = true, RequireAtPerformRequest = true)]
    public string ConsumerKey { get; set; }
    [OAuthDataTag("oauth_nonce", RequireAtRequestToken = true, RequireAtAccessToken = true, RequireAtPerformRequest = true)]
    public string Nonce { get; set; }
    [OAuthDataTag("oauth_signature_method", RequireAtRequestToken = true, RequireAtAccessToken = true, RequireAtPerformRequest = true)]
    public string SignatureMethod { get; set; }
    public string ConsumerSecret { get; set; }
    [OAuthDataTag("oauth_signature", RequireAtRequestToken = true, RequireAtAccessToken = true, RequireAtPerformRequest = true)]
    public string Signature { get; set; }
    [OAuthDataTag("oauth_timestamp", RequireAtRequestToken = true, RequireAtAccessToken = true, RequireAtPerformRequest = true)]
    public long Timestamp { get; set; }
    [OAuthDataTag("oauth_token", RequireAtAccessToken = true, RequireAtPerformRequest = true)]
    public string Token { get; set; }
    public string TokenSecret { get; set; }
    [OAuthDataTag("oauth_verifier", RequireAtAccessToken = true)]
    public string Verifier { get; set; }
    [OAuthDataTag("oauth_version", RequireAtRequestToken = true, RequireAtAccessToken = true, RequireAtPerformRequest = true)]
    public string Version { get; set; }
}

這段程式中的 OAuthDataTag 是我另外撰寫的一個 Metadata 類別,用來標記這個參數會在何時使用 (Request Token, Access Token, PerformRequest 等階段),以及這個參數是 OAuth 的哪一個參數類型,如果對 Metadata 不了解的,可以參考本文

有了資料類別後,就可以來收集這些參數資料了,首先是 ConsumerKey 和 ConsumerSecret,這兩個值可以由 OAuth 的服務端取得,像 Twitter 可以自 http://dev.twitter.com 申請;Google 可以在 http://code.google.com/intl/zh-TW/apis/accounts/docs/RegistrationForWebAppsAuto.html 申請,而 Yahoo 可以在 http://developer.apps.yahoo.com/projects 申請,申請完成時即可取得這兩個值。再來是 Version,在 OAuth 1.0a 的服務中,這個值是固定的 "1.0";Callback 的部份,若應用程式是 Web Application,則要指定一個接收由服務回呼的網址,若是 Desktop Application,則只要填 "oob" 即可。

在開始存取 OAuth 服務前,必須要經過三個階段取得授權,接下來我會說明在各階段中要做的事以及相關的程式處理。

首先是每個階段都會有的 Nonce 以及 Timestamp 值,這兩個值在 OAuth 的設計規範上是作為防止 Replay Attack (重試攻擊法) 所設定的,也就是說,每次向服務發出的 OAuth 訊息中,這兩個值都不能一樣,所以等於每次呼叫 OAuth 服務時,這兩個值都要更新。好在 Nonce 雖然被定義為隨機字串 (random string),但我們可以與 Timestamp 一起產生。Timestamp 是自 1970/1/1 00:00:00 (UTC 時間) 起到現在的時間為止所經過的秒數,所以這兩個值我們可以寫在同一個函式中:

public void MakeNewRequestParams()
{
    // generate new request tokens.
    this.Timestamp = Convert.ToInt64((((TimeSpan)(DateTime.UtcNow - (new DateTime(1970, 1, 1, 0, 0, 0)))).TotalSeconds));

    StringBuilder nonceData = new StringBuilder();
    byte[] data = MD5.Create().ComputeHash(Encoding.UTF8.GetBytes(this.Timestamp.ToString()));

    foreach (byte d in data)
        nonceData.Append(d.ToString("x2").ToLower());

    this.Nonce = nonceData.ToString();
}

接著是 SignatureMethod,OAuth 1.0a 可支援 HMAC-SHA1, RSA-SHA1 以及 PLAINTEXT 三種,大多數的服務都會使用 HMAC-SHA1,它和 RSA-SHA1 的差別是 HMAC-SHA1 是對稱型的金鑰演算法,它會需要 consumer secret 來做簽章訊息的演算,但 RSA-SHA1 則是用 PKI 來做,實際進行簽章演算的會是公鑰 (Public Key),因此不需額外的 consumer secret。這個值也會影響到我們要使用的簽章演算法。我們目前先只實作 HMAC-SHA1,RSA-SHA1 日後再說明實作的方式。

接著就是整個 OAuth 最容易出錯的地方:Signature,它除了要使用由 Signature Method 設定的演算法外,它對參數的排列也有自己的規定,在 OAuth 服務端會以一個固定的訊息格式 (也就是所謂的 base string),再配合 Signature Method 所設定的簽章演算法,驗證用戶端傳來的訊息是不是正確的,由訊息所演算出來的簽章值必須要和用戶端送來的簽章值一致,否則就會失敗,並傳回 Signature Invalid (每個服務傳回來的不一定相同) 的錯誤,通常是 HTTP 400 (Bad Request) 或 401 (Unauthorized)。所以我們在每次發送 OAuth 要求之前,都要先建立這個 base string,再由簽章演算法來產生簽章。

base string 分為三個部份,分別是 {HTTP_METHOD}&{URL Encoded}&{Normalized Parameters},第一個是用戶端向 OAuth 服務呼叫時所用的 HTTP Method,可以是 GET/POST/PUT/DELETE 等,但一定要大寫,且方法要被服務所支援;第二個是用戶端向 OAuth 服務呼叫的 URL,URL 必須要是完整的網址,例如 http://api.twitter.com/oauth/request_token,但如果有帶 query string 的話,query string 要被移到訊息的最後方 (Normalized Parameters 的後方),且網址要用 HttpUtility.UrlEncode() 編碼,或是用特製的 UrlEncode() 編碼,這個特製的 UrlEncode() 程式碼如下 (由 LINQ to Twitter 原始碼取得):

private const string ReservedChars = @"`!@#$%^&*()_-+=.~,:;'?/|\[] ";
private const string UnReservedChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~";

public static string UrlEncode(string value)
{
    StringBuilder result = new StringBuilder();

    if (!string.IsNullOrEmpty(value))
    {
        foreach (char symbol in value)
        {
            if (UnReservedChars.IndexOf(symbol) != -1)
            {
                result.Append(symbol);
            }
            else
            {
                result.Append('%' + String.Format(CultureInfo.InvariantCulture, "{0:X2}", (int)symbol));
            }
        }
    }

    return result.ToString();
}

第三個則是正規化參數 (Normalized Parameters),這是在整個 signature 過程中很容易出錯的部份,因為它規定參數要以字典位元排序法 (Lexicographical byte order) 的方式排序,每個參數要用 "," 分隔,若兩個參數相同時,則要以值來做排序以排出正確的順序,若順序不對的話,產生的 signature 就會錯誤。許多開發人員愛用的 OAuthBase.cs (http://code.google.com/p/oauth/) 中,下面那一段程式就是在做這個排序:

protected class QueryParameterComparer : IComparer<QueryParameter>
{
    #region IComparer<QueryParameter> Members

    public int Compare(QueryParameter x, QueryParameter y)
    {
        if (x.Name == y.Name)
        {
            return string.Compare(x.Value, y.Value);
        }
        else
        {
            return string.Compare(x.Name, y.Name);
        }
    }

    #endregion
}

但我的作法是使用動態的 Reflection 自動由 OAuthDataRepository 中取得成員的資料,而且在 OAuthDataRepository 中,早已先針對各個成員做 Lexicographical byte order 順序的調整 (在 OAuth 的流程中也沒有出現過兩個參數名稱一樣的出現兩次以上),所以我不需要針對這些參數做排序,下列程式即為抽取參數產生 Dictionary 的程式碼(排序部份由 SortedDictionary 代勞即可)。你也許會注意到參數中有一個 IncludeSignature,這要做什麼的?我們後面會說:

public virtual Dictionary<string, string> GetDictionaryFromOAuthData(OAuthTagRenderPhraseEnum TagRenderPhrase, bool IncludeSiguature)
{
    Dictionary<string, string> oauthDictionary = new Dictionary<string, string>();

    PropertyInfo[] properties = this.GetType().GetProperties();

    foreach (PropertyInfo property in properties)
    {
        OAuthDataTagAttribute[] dataTagAttr = property.GetCustomAttributes(typeof(OAuthDataTagAttribute), true) as OAuthDataTagAttribute[];

        if (dataTagAttr != null && dataTagAttr.Length > 0)
        {
            switch (TagRenderPhrase)
            {
                case OAuthTagRenderPhraseEnum.RequestToken:
                    if (dataTagAttr[0].RequireAtRequestToken)
                        oauthDictionary.Add(dataTagAttr[0].TagName,
                            (property.GetValue(this, null) == null) ? string.Empty : property.GetValue(this, null).ToString());
                    break;
                case OAuthTagRenderPhraseEnum.AccessToken:
                    if (dataTagAttr[0].RequireAtAccessToken)
                        oauthDictionary.Add(dataTagAttr[0].TagName,
                            (property.GetValue(this, null) == null) ? string.Empty : property.GetValue(this, null).ToString());
                    break;
                case OAuthTagRenderPhraseEnum.PerformRequest:
                    if (dataTagAttr[0].RequireAtPerformRequest)
                        oauthDictionary.Add(dataTagAttr[0].TagName,
                            (property.GetValue(this, null) == null) ? string.Empty : property.GetValue(this, null).ToString());
                    break;
            }
        }

        dataTagAttr = null;
    }

    properties = null;

    if (!IncludeSiguature && oauthDictionary.ContainsKey("oauth_signature"))
        oauthDictionary.Remove("oauth_signature");

    SortedDictionary<string, KeyValuePair<string, string>> sortedDics = new SortedDictionary<string, KeyValuePair<string, string>>();

    foreach (KeyValuePair<string, string> oauthDicItem in oauthDictionary)
        sortedDics.Add(oauthDicItem.Key, oauthDicItem);

    oauthDictionary = new Dictionary<string, string>();

    foreach (KeyValuePair<string, KeyValuePair<string, string>> sortedDicItem in sortedDics)
        oauthDictionary.Add(sortedDicItem.Value.Key, sortedDicItem.Value.Value);

    return oauthDictionary;
}

有了 Normalized Parameters 後,我們就可以產生 base string 了:

public string GetOAuthSiguatureBase(Uri OAuthProviderOperationUrl, string HttpMethod, OAuthTagRenderPhraseEnum Phrase)
{
    Dictionary<string, string> oauthDictionary = this.GetDictionaryFromOAuthData(Phrase, false);
    StringBuilder signatureBaseBuilder = new StringBuilder();
    List<string> requestParamItems = new List<string>();

    foreach (KeyValuePair<string, string> oauthParamItem in oauthDictionary)
        requestParamItems.Add(OAuthUtility.UrlEncode(oauthParamItem.Key + "=" + oauthParamItem.Value));

    signatureBaseBuilder.AppendFormat(
        CultureInfo.InvariantCulture, "{0}&", HttpMethod.ToUpper(CultureInfo.InvariantCulture));
    signatureBaseBuilder.AppendFormat(
        CultureInfo.InvariantCulture, "{0}&", OAuthUtility.UrlEncode(
        string.Format("{0}://{1}{2}", OAuthProviderOperationUrl.Scheme, OAuthProviderOperationUrl.Host, OAuthProviderOperationUrl.AbsolutePath)));
    signatureBaseBuilder.AppendFormat(
        CultureInfo.InvariantCulture, "{0}", string.Join("%26", requestParamItems.ToArray()));

    return signatureBaseBuilder.ToString();
}

 

base string 產生後,我們就可以使用 System.Security.Cryptographics 命名空間中的 HMACSHA1 類別來進行簽章的演算,如下列程式。但這裡要注意的是,HMACSHA1 所使用的金鑰必須要是 {ConsumerSecret}&{TokenSecret} 兩個組合的字串,且兩者都要經過特製的 UrlEncode() 加密過才可以作為簽章金鑰。使用 ComputeHash() 產生的簽章必須要轉換為 Base64 字串。

public string GetOAuthSiguature(Uri OAuthProviderOperationUrl, string HttpMethod, OAuthTagRenderPhraseEnum Phrase, OAuthSignstureMethodEnum SignatureMethod)
{
    return this.GetOAuthSiguature(
       OAuthProviderOperationUrl, HttpMethod, SignatureMethod, this.GetOAuthSiguatureBase(OAuthProviderOperationUrl, HttpMethod, Phrase));
}

public string GetOAuthSiguature(Uri OAuthProviderOperationUrl, string HttpMethod, OAuthSignstureMethodEnum SignatureMethod, string SignatureBaseString)
{
    string signature = null;

    switch (SignatureMethod)
    {
        case OAuthSignstureMethodEnum.HMACSHA1:
           
HMACSHA1 hmacsha1 = new HMACSHA1(
                Encoding.UTF8.GetBytes(string.Format(
                CultureInfo.InvariantCulture, "{0}&{1}", OAuth.OAuthUtility.UrlEncode(this.ConsumerSecret),
                (string.IsNullOrEmpty(this.TokenSecret)) ? string.Empty : OAuth.OAuthUtility.UrlEncode(this.TokenSecret))));
            signature = Convert.ToBase64String(hmacsha1.ComputeHash(Encoding.UTF8.GetBytes(SignatureBaseString)));

            break;
        case OAuthSignstureMethodEnum.PLAINTEXT:
            signature = HttpUtility.UrlEncode(string.Format(CultureInfo.InvariantCulture, "{0}&{1}", this.ConsumerSecret, this.TokenSecret));
            break;
        case OAuthSignstureMethodEnum.RSASHA1:
            throw new OAuthException("ERROR_RSASHA1_IS_NOT_SUPPORTED_CURRENTLY");
        default:
            throw new OAuthException("ERROR_UNSUPPORTED_SIGNATURE_METHOD");
    }

    return signature;
}

最後,再把這些程序整合成一個函式即可:

public void PrepareRequestSignature(Uri OAuthProviderOperationUrl, string HttpMethod, OAuthTagRenderPhraseEnum Phrase)
{
    switch (this.SignatureMethod)
    {
        case "HMAC-SHA1":
            this.Signature = this.GetOAuthSiguature(OAuthProviderOperationUrl, HttpMethod, Phrase, OAuthSignstureMethodEnum.HMACSHA1);
            break;
        case "PLAINTEXT":
            this.Signature = this.GetOAuthSiguature(OAuthProviderOperationUrl, HttpMethod, Phrase, OAuthSignstureMethodEnum.PLAINTEXT);
            break;
        case "RSA-SHA1":
            this.Signature = this.GetOAuthSiguature(OAuthProviderOperationUrl, HttpMethod, Phrase, OAuthSignstureMethodEnum.RSASHA1);
            break;
        default:
            throw new OAuthException("ERROR_UNSUPPORTED_SIGNATURE_METHOD");
    }
}

有了 OAuth 訊息以及 signature 後,就可以向 OAuth 服務發出第一個要求:Request Token 階段,這裡會使用到 HttpWebRequest (用 WebClient 應該也可以),在發出要求前,先設定好 OAuth 訊息到 HTTP 的 Authorization 標頭:

HttpWebRequest request = HttpWebRequest.Create(this._requestTokenUrl) as HttpWebRequest;
HttpWebResponse response = null;
string responseData = null;
ServicePointManager.Expect100Continue = false;

request.Method = "POST";

this._oauthDataRepository.MakeNewRequestParams();
this._oauthDataRepository.PrepareRequestSignature(new Uri(this._requestTokenUrl), "POST", OAuthTagRenderPhraseEnum.RequestToken);

// build POST HTTP message.
request.Headers.Add("Authorization", this._oauthDataRepository.RenderOAuthAuthorizationHeaderForRequestToken());

請注意,先前在 PrepareRequestSignature() 中處理的 OAuth 訊息,並沒有內含 oauth_signature,因為在演算簽章時,不可以出現 oauth_signature,但在附加到 Authorization 標頭時又必須要有,因此之前在 GetDictionaryFromOAuthData() 中的 IncludeSignature 參數就派上用場了,我另外寫了針對不同階段產生 Authorization OAuth 訊息的函式,在 Request Token 時呼叫的是 RenderOAuthAuthorizationHeaderForRequestToken():

public virtual string RenderOAuthAuthorizationHeaderForRequestToken()
{
    StringBuilder headerBuilder = new StringBuilder();
    Dictionary<string, string> oauthDictionary = this.GetDictionaryFromOAuthData(OAuthTagRenderPhraseEnum.RequestToken, true);

    oauthDictionary["oauth_signature"] = OAuthUtility.UrlEncode(oauthDictionary["oauth_signature"]);

    foreach (KeyValuePair<string, string> oauthParamItem in oauthDictionary)
    {
        if (headerBuilder.Length == 0)
            headerBuilder.Append(oauthParamItem.Key + "=\"" + oauthParamItem.Value + "\"");
        else
            headerBuilder.Append("," + oauthParamItem.Key + "=\"" + oauthParamItem.Value + "\"");
    }

    return "OAuth " + headerBuilder.ToString();
}

準備完成後,就可以向 OAuth 服務發出訊息:

try
{
    response = request.GetResponse() as HttpWebResponse;

    StreamReader sr = new StreamReader(response.GetResponseStream());
   
responseData = sr.ReadToEnd();
    sr.Close();

    // parse result.
    NameValueCollection resultItems = HttpUtility.ParseQueryString(responseData);

    this._oauthDataRepository.Token = resultItems["oauth_token"];
    this._oauthDataRepository.TokenSecret = resultItems["oauth_token_secret"];

}
catch (WebException we)
{
    response = we.Response as HttpWebResponse;

    StreamReader sr = new StreamReader(response.GetResponseStream());
    responseData = sr.ReadToEnd();
    sr.Close();

    if (response.StatusCode == HttpStatusCode.Unauthorized)
        throw new OAuthUnauthorizedException("ERROR_OAUTH_UNAUTHORIZED",
            this._oauthDataRepository.GetOAuthSiguatureBase(new Uri(this._requestTokenUrl), "GET", OAuthTagRenderPhraseEnum.RequestToken),
            this._oauthDataRepository.Signature,
            responseData);
    else
        throw new OAuthNetworkException("ERROR_NETWORK_PROBLEM", response.StatusCode, responseData);

}
catch (Exception e)
{
    throw new OAuthException("ERROR_EXCEPION_OCCURRED", e);
}
finally
{
    response.Close();
}

若服務成功處理簽章時,會回傳一段訊息,內含兩個參數,一個是 token,另一個是 token secret,這兩個參數會在接下來的程序中使用到。

當取得 Token 時,就可以向 OAuth 服務發出第二個要求:Verifier 階段,這個階段在 Web Application 和 Desktop Application 會有所不同,若是 Web Application,則只要將使用者的瀏覽器導向到服務指定的 URL (ex: http://api.twitter.com/oauth/authorize) 即可,同時要附帶於 Request Token 時取得的 token 字串,以 oauth_token 附加到 Query String 中,OAuth 會將使用者帶到授權畫面中,若使用者授權時,OAuth 服務會回呼用戶端的 Callback 網址,並傳遞 oauth_verifier 的值給應用程式 (若被拒絕時則會回傳 oauth_error)。但若是 Desktop Application 時,應用程式則必須要另外彈出一個瀏覽器視窗 (可以用 WebBrowser 控制項來做),當使用者授權時,會提示 Verifier 供使用者輸入給 Desktop Application。

我在範例中使用的是 Desktop Application,所以我必須要開一個對話盒給使用者授權並輸入 Verifier:

public override void ObtainVerifier()
{
    using (ConsentUI.UserConsentDialog consentDialog = 
           new OAuth.Desktop.ConsentUI.UserConsentDialog(this._consentUrl, this._oauthDataRepository.Token))
    {
        consentDialog.DisplayConsentTitle = "...";
        consentDialog.DisplayConsentPrompt = "...";

        if (consentDialog.ShowDialog() == DialogResult.OK)
        {
            this._oauthDataRepository.Verifier = consentDialog.AuthorizationToken;
        }
        else
            throw new OAuthUnauthorizedException("ERROR_UNAUTHORIZED_BY_USER", string.Empty, string.Empty, string.Empty);
    }
}

對話盒本身的設計也不難,只是控制 WebBrowser 以及將授權碼丟回而已,它的畫面如下:

image

 

取得 Verifier 後,就可以進行第三個階段:Request Access Token 階段,在這裡會使用到的參數會有剛才所取得的 oauth_verifier 以及在第一個階段取得的 oauth_token,oauth_callback 已不再需要。所以我針對 Request Access Token 撰寫了與前面的 Request Token 類似的函式:

public virtual string RenderOAuthAuthorizationHeaderForAccessToken()
{
    StringBuilder headerBuilder = new StringBuilder();
    Dictionary<string, string> oauthDictionary = this.GetDictionaryFromOAuthData(OAuthTagRenderPhraseEnum.AccessToken, true);

    oauthDictionary["oauth_signature"] = OAuthUtility.UrlEncode(oauthDictionary["oauth_signature"]);

    foreach (KeyValuePair<string, string> oauthParamItem in oauthDictionary)
    {
        if (headerBuilder.Length == 0)
            headerBuilder.Append(oauthParamItem.Key + "=\"" + oauthParamItem.Value + "\"");
        else
            headerBuilder.Append("," + oauthParamItem.Key + "=\"" + oauthParamItem.Value + "\"");
    }

    return "OAuth " + headerBuilder.ToString();
}

呼叫的作法和 Request Token 差不多,我就不列示了,當 OAuth 服務驗證成功後,會交換 (Exchange) 一組新的 Token 和 Token Secret 給應用程式,此時,應用程式就具有可呼叫 OAuth 服務中 private API 的能力了。

Reference:

Google Code OAuthBase
LINQ to Twitter Source Code
Twitter Authentication Documentation