[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 = @"`[email protected]#$%^&*()_-+=.~,:;'?/|[] ";
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
{
   #region IComparer 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 GetDictionaryFromOAuthData(OAuthTagRenderPhraseEnum TagRenderPhrase, bool IncludeSiguature)
{
   Dictionary oauthDictionary = new Dictionary();

    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> sortedDics = new SortedDictionary>();

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

    oauthDictionary = new Dictionary();

    foreach (KeyValuePair> 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 oauthDictionary = this.GetDictionaryFromOAuthData(Phrase, false);
   StringBuilder signatureBaseBuilder = new StringBuilder();
   List requestParamItems = new List();

    foreach (KeyValuePair 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 oauthDictionary = this.GetDictionaryFromOAuthData(OAuthTagRenderPhraseEnum.RequestToken, true);

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

    foreach (KeyValuePair 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 oauthDictionary = this.GetDictionaryFromOAuthData(OAuthTagRenderPhraseEnum.AccessToken, true);

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

    foreach (KeyValuePair 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