OAuthコンシューマの仕組みと実装 〜 Ruby編
前置き
前回の記事でOAuthを使ってTwitter APIにアクセスすることができるようになりましたが、ruby-oauthは内部でNet::HTTPを呼び出しているため、そのままではGoogle App Engine for Java上のJRuby(以下JRuby for GAE/J)で利用できません。
「JRuby for GAE/JでもNet::HTTPが使えるようになる」というrb-gae-supportと組み合わせればOKなのかもしれませんが*1、OAuthの仕様自体はシンプルなものですし、せっかくなので勉強がてら自分で実装してみることにします。
ちなみにタイトルにRuby編と付いていますが、他の言語編を作成する予定は特にありません。
OAuthの仕様
実装の前にOAuthの仕様や、そもそもの成り立ちについて調べました。既にわかりやすいまとめ記事がいくつも書かれていますので、ここではそれらを貼り付けるだけにとどめておきます。
OAuthの成り立ちや概要を理解するのならこちら。
「認証」と「認可」の違い、OpenIDとの比較などについてはこちら。
- OpenID や OAuth の役割と、既存のシングル・サインオンとの違い:Goodpic
- WebAPI のアクセス制御に使える OAuth という仕様 - まちゅダイアリー(2007-09-25)
- 仕様から学ぶOpenIDのキホン − @IT
オフィシャルな資料にも目を通しておきます。
2009年8月現在、OAuthの仕様は"1.0"と"1.0 Revision A"がありますが、この記事ではより新しい"1.0 Revision A"を元に実装しています。
実装する範囲
シンプルな仕様とは言え、仕様に載っているものを全て実装しようとするとそれなりに大変です。*3
ですので、あくまでも最終的な目標である「JRuby for GAE/Jで動作するTwitterのbot」を作るのに最低限必要な機能だけに絞って実装してみたいと思います。
まず、クライアントを作るわけですから、当然“コンシューマ”を実装することになりますね。
また、前回の記事でも触れたように、現時点ではTwitterのアクセストークンに期限切れはありませんので、そこは手動で頑張ることにすると、アクセストークンを取得するまでの処理の実装は必要ありません。
更に、TwitterはHMAC-SHA1形式の署名しかサポートしていませんので、それ以外の署名を実装する必要もありません。
OAuthプロトコルパラメータの渡し方もAuthorizationヘッダを使った方法だけ用意しておけば十分でしょう。
このように切り詰めていくと、最終的には
という一連の手順が実行できるだけの機能があればOK、ということになります。かなりシンプルなライブラリにできそうです。
実装の前提
最終的にはJRuby for GAE/Jで動かしたいのですが、色々試行錯誤して作っていくのならば、やはり素のRubyのほうが何かとお手軽です。
というわけで、まずは普通にNet::HTTPを使って通信するようなライブラリを書くことにします。それができれば、あとはNet::HTTP部分をGAEのURLFetchに置き換えるのはそれほど難しい作業ではないからです。
一応、1.8.7と1.9.1両方で動くようにはしているつもりです。
想定する使い方
OAuthによる認証に必要なコンシューマキー、コンシューマ秘密鍵、アクセストークン、アクセストークン秘密鍵を引数に与えてインスタンスを生成し、Net::HTTPのようにgetやpostなどのHTTPメソッド名でそのままHTTP通信を実行できるような形にします。
こんな感じのコードになりそうです。
simple_oauth = SimpleOAuth.new(CONSUMER_KEY, CONSUMER_SECRET, TOKEN, TOKEN_SECRET) response = simple_oauth.get('http://twitter.com/statuses/friends_timeline.json') response = simple_oauth.post('http://twitter.com/statuses/update.json', { :status => 'This is a test tweet.' })
各メソッドの戻り値はNet::HTTPと同じくNet::HTTPResponseのインスタンスとします。
実装
では、仕様とにらめっこしつつ、少しずつ実装を進めていってみます。
インターフェース
インターフェース部分の実装はこんな感じになります。
class SimpleOAuth def initialize(consumer_key, consumer_secret, token, token_secret) @consumer_key = consumer_key @consumer_secret = consumer_secret @token = token @token_secret = token_secret # This class supports only 'HMAC-SHA1' as signature method at present. @signature_method = 'HMAC-SHA1' end def get(url, headers = {}) request(:GET, url, nil, headers) end def head(url, headers = {}) request(:HEAD, url, nil, headers) end def post(url, body = nil, headers = {}) request(:POST, url, body, headers) end def put(url, body = nil, headers = {}) request(:PUT, url, body, headers) end def delete(url, headers = {}) request(:DELETE, url, nil, headers) end end
特に言及すべき部分はありません。
HTTPリクエストの実行
getやpostなどのメソッドから呼び出されるrequestメソッドの実装です。
def request(method, url, body = nil, headers = {}) method = method.to_s url = URI.parse(url) request = create_http_request(method, url.request_uri, body, headers) request['Authorization'] = auth_header(method, url, request.body) Net::HTTP.new(url.host, url.port).request(request) end
HTTPリクエストオブジェクトの作成は少々手がかかるので別メソッドにしています。
auth_headerメソッドでOAuth用の認証ヘッダを生成し、Authorizationヘッダにセットしています。
あとはそのままHTTPリクエストを実行しているだけです。
汎用メソッド
次のメソッドの実装に移る前に、これ以降パラメータのエンコードや連結などの処理が頻繁に必要になってきますので、共通に使うために汎用メソッドとして実装しておきます。
まずパラメータのエンコードから。関連する仕様は5.1. Parameter Encodingです。
RESERVED_CHARACTERS = /[^a-zA-Z0-9\-\.\_\~]/ def escape(value) URI.escape(value.to_s, RESERVED_CHARACTERS) end
RESERVED_CHARACTERSは仕様に明記されているものをそのまま持ってきただけです。
次にパラメータの連結を。この処理は各パラメータのエスケープも含みます。
def encode_parameters(params, delimiter = '&', quote = nil) if params.is_a?(Hash) params = params.map do |key, value| "#{escape(key)}=#{quote}#{escape(value)}#{quote}" end else params = params.map { |value| escape(value) } end delimiter ? params.join(delimiter) : params end
場所によって値を引用符で囲う必要があったり、連結する際の区切り文字が異なったりするので、全てに対応できるように柔軟な実装にしています。
HTTPリクエストオブジェクトの作成
では、先程のrequestメソッドから呼び出されていたメソッドの実装に戻ります。
VERSION = '0.1' USER_AGENT = "SimpleOAuth/#{VERSION}" def create_http_request(method, path, body, headers) method = method.capitalize.to_sym request = Net::HTTP.const_get(method).new(path, headers) request['User-Agent'] = USER_AGENT if method == :Post || method == :Put request.body = body.is_a?(Hash) ? encode_parameters(body) : body.to_s request.content_type = 'application/x-www-form-urlencoded' request.content_length = (request.body || '').length end request end
引数で受け取ったメソッド名に対応するクラスをconst_getで取得し、インスタンスを生成します。
リクエストボディの形式は"application/x-www-form-urlencoded"しか想定していません。Twitter APIで使うなら他は特に必要ないかな、と思いますので…。
せっかくなのでUser-Agentも入れるようにしました。
OAuth認証用ヘッダの生成
もう一つ、requestメソッドから呼び出されていたメソッドの実装です。Authorizationヘッダにセットする認証用データを生成します。関連する仕様は5.4. OAuth HTTP Authorization Schemeです。
def auth_header(method, url, body) parameters = oauth_parameters parameters[:oauth_signature] = signature(method, url, body, parameters) 'OAuth ' + encode_parameters(parameters, ', ', '"') end
oauth_parametersメソッドでOAuthプロトコルパラメータを取得し、そこにメソッド名、URL、リクエストボディなどの情報を混ぜて、signatureメソッドで“署名”を生成します。署名をoauth_signatureパラメータにセットしたら、まとめてエンコードしてOAuth認証用ヘッダの完成です。
OAuthプロトコルパラメータの用意
では、そのoauth_parametersメソッドの実装です。OAuth認証に必要な一連のパラメータを用意します。関連する仕様は7. Accessing Protected Resourcesです。
OAUTH_VERSION = '1.0' def oauth_parameters { :oauth_consumer_key => @consumer_key, :oauth_token => @token, :oauth_signature_method => @signature_method, :oauth_timestamp => timestamp, :oauth_nonce => nonce, :oauth_version => OAUTH_VERSION } end
必要なパラメータはコンシューマキー、アクセストークン、署名の形式、タイムスタンプ、ランダムな一意の文字列、そしてOAuthのバージョンです。
前者3つは既にインスタンス変数としてセット済みです。timestampとnonceメソッドは次に。OAuthのバージョンは現状'1.0'固定です。
タイムスタンプとNonce
timestampとnonceメソッドの実装です。関連する仕様は8. Nonce and Timestampです。
def timestamp Time.now.to_i.to_s end def nonce OpenSSL::Digest::Digest.hexdigest('MD5', "#{Time.now.to_f}#{rand}") end
タイムスタンプはそのまま、現在時刻の基準時からの経過秒です。
Nonceはランダムな一意の文字列であれば、他に細かい指定はありません。リクエストのユニーク性を維持するためだけの値です。ここではとりあえず現在時刻にランダム数値を混ぜたもののMD5ハッシュ値としています。
MD5ハッシュ値の生成にDigest::MD5ではなくOpenSSLモジュールを利用しているのは、どちらにせよ後でOpenSSLモジュールが必要になるためです。
def nonce Digest::MD5.hexdigest("#{Time.now.to_f}#{rand}") end
でも同じです。
署名
署名メソッドの実装に移ります。関連する仕様は9. Signing Requestsです。
def signature(*args) base64(digest_hmac_sha1(signature_base_string(*args))) end
前述のように、今回はHMAC-SHA1による署名しかサポートしませんので、実装もHMAC-SHA1限定となっています。
署名の手順としては、
- 受け取ったパラメータ(メソッド名、URL、リクエストボディ、OAuthプロトコルパラメータ)からSignature Base String(署名基準文字列)を作成する。(signature_base_stringメソッド)
- Signature Base Stringと秘密鍵を組み合わせてHMAC-SHA1のダイジェスト値を生成する。(digest_hmac_sha1メソッド)
- ダイジェスト値をBase64でエンコードする。(base64メソッド)
となります。
HMAC-SHA1ダイジェストとBase64エンコード
この二つは非常にシンプルですので、先に実装を済ませてしまいます。関連する仕様は9.2. HMAC-SHA1です。
def base64(value) [ value ].pack('m').gsub(/\n/, '') end def digest_hmac_sha1(value) OpenSSL::HMAC.digest(OpenSSL::Digest::SHA1.new, secret, value) end def secret escape(@consumer_secret) + '&' + escape(@token_secret) end
base64メソッドは単にBase64エンコーディングを施しているだけです。値中の改行は不要なので全部削っています。
HMACの仕組みは以下のページが参考になります。ここではOpenSSLモジュールの力を借りてサクっと。
secretメソッドは秘密鍵の生成です。秘密鍵はコンシューマ秘密鍵とアクセストークン秘密鍵をそれぞれエスケープして&記号で連結したものになります。
Signature Base Stringの作成
ではsignature_base_stringメソッドの実装に移ります。関連する仕様は9.1. Signature Base Stringです。
def signature_base_string(method, url, body, parameters) method = method.upcase base_url = signature_base_url(url) parameters = normalize_parameters(parameters, body, url.query) encode_parameters([ method, base_url, parameters ]) end
メソッド名は大文字にします。
URLは次のような処理で必要な部分だけに削ぎ落とします。
def signature_base_url(url) URI::HTTP.new(url.scheme, url.userinfo, url.host, nil, nil, url.path, nil, nil, nil) end
OAuthプロトコルパラメータ、リクエストボディ、URL中のクエリデータは次のような処理で“正規化”を施します。
def normalize_parameters(parameters, body, query) parameters = encode_parameters(parameters, nil) parameters += body.split('&') if body parameters += query.split('&') if query parameters.sort.join('&') end
これらのデータに含まれる“名前=値”のペア(それぞれエスケープ済み)をごちゃ混ぜにしてソートし、ひとかたまりに連結します。
以上のようにして得られた大文字メソッド名・基本URL・正規化パラメータをエスケープ&連結すればSignature Base Stringの完成です。
完成!
上から下へ実装していったので、どこまで実装が終わっているのかがわかりにくかったかもしれませんが、これで全ての実装が完了です。ここまでのコードをコピーしてまとめるだけで動くようになります。*4
使い方のサンプル
では実際に、今作ったライブラリ…simple-oauth.rbという名前で保存したとします、を使ってTwitterのAPIへアクセスするサンプルコードを書いてみます。
#!/usr/bin/env ruby # coding: utf-8 require 'simple-oauth' require 'rubygems' require 'json' # ここを置き換える CONSUMER_KEY = 'CONSUMER-KEY' CONSUMER_SECRET = 'CONSUMER-SECRET' TOKEN = 'ACCESS-TOKEN' TOKEN_SECRET = 'ACCESS-TOKEN-SECRET' simple_oauth = SimpleOAuth.new(CONSUMER_KEY, CONSUMER_SECRET, TOKEN, TOKEN_SECRET) # Tweetの投稿 response = simple_oauth.post('http://twitter.com/statuses/update.json', { :status => "こんにちは!この投稿はテストです。 : #{Time.now}" }) raise "Request failed: #{response.code}" unless response.code.to_i == 200 # TimeLineの取得 response = simple_oauth.get('http://twitter.com/statuses/friends_timeline.json?count=5') raise "Request failed: #{response.code}" unless response.code.to_i == 200 JSON.parse(response.body).each do |status| puts "#{status['user']['screen_name']}: #{status['text']}" end
コンシューマキーやアクセストークンは事前に取得しておいたものと置き換えてください。
これを実行すると、Tweetが一つ投稿され、またタイムライン中の最新5件のつぶやきが表示されます。
疑問点
とりあえず動くようにはなったものの、実はまだちょっと仕様の理解にあやふやな部分があるんですよね…。
それはURL中のクエリデータとリクエストボディの扱いです。
これまで見てきてわかる通り、これらはSignature Base Stringの生成のために一旦分解する必要があるのですが、この際「値の再エスケープまでする必要があるのかどうか」がわかっていません。
OAuthの仕様で定められているunreservedな文字は、Rubyで一般的にクエリデータのエスケープに使われているCGI#escapeのそれとは微妙に異なります。そのため、値をアンエスケープして再度エスケープした場合とそうでない場合とで、生成されるダイジェストが一致しないパターンが出てきます。
もちろん、再エスケープする必要があるのであれば、ダイジェストと実データに差異が出ないようにするため、リクエスト中のクエリデータとリクエストボディも再エスケープしたものに置き換える必要があります。
ダイジェストの生成に使ったデータと実際のクエリデータ・リクエストボディに差異さえなければ問題ないだろう(=再エスケープまでは要らないだろう)、とは思うのですが。この辺りご存知の方がいらっしゃいましたら是非ご教示ください。