Rails で OpenID を使う (OpenID::Consumer#begin を読もう編)
昨日の日記の続き。 ユーザに OpenID のアカウント (URL) を入力してもらい、OpenIDの認証サーバに誘導するところの処理について。 ここは OpenID::Consumer の begin メソッドを呼び出していた。
request = consumer.begin(openid_url) url = request.redirect_url(trust_root, return_to) redirect_to(url)
ここで認証サーバと認証に必要になるデータ(共通鍵など)を交換しているはず。 なので、この中身を追ってみる。 軽く考えていたら、意外と複雑だった…。
まずは、どんなメソッドがあるかを調べてみた。
$ grep -E '^\s*(class|def|module)' consumer.rb module OpenID class Consumer def initialize(session, store, fetcher=nil) def begin(user_url) def begin_without_discovery(service) def complete(query) def get_discovery(url) class GenericConsumer def initialize(store, fetcher=nil) def ca_path=(ca_path) def begin(service) def complete(query, service_endpoint) def do_id_res(nonce, consumer_id, server_id, server_url, query) def check_auth(nonce, query, server_url) def create_nonce def get_association(server_url) def associate(server_url)
Consumer と GenericConsumer の2つのクラスがある。 ソースコードのコメントを読むと、 GenericConsumer は Consumer の内部で使うクラスみたい。 (ちなみに最新版の ruby-openid 1.9.0 では2つのクラスが統合されていた)
Consumer#begin
Consumer の begin メソッドは、エラー処理を除くと3行しかない。
- ユーザが入力した OpenID のアカウント名 (user_url) から discovery を生成
- discovery から service を生成
- service を引数に begin_without_discovery メソッドを実行
OpenID には delegate の仕組みがあって、アカウント名の URL と認証サーバは一致しているとは限らない(OpenID認証の仕組み(想像) - ただのにっき (2005-10-20)を参照)。 begin メソッドでは実際の認証サーバ (service) を見つけているんだろう。たぶん。 今は深追いしないことにする。
def begin(user_url) discovery = self.get_discovery(user_url) service = discovery.next_service return self.begin_without_discovery(service) end
Consumer#begin_without_discovery
begin_without_discovery は、実質的に @consumer.begin を呼んでいるだけ。 @consumer は GenericConsumer のインスタンス。 と言うことで、次へ。
def begin_without_discovery(service) request = @consumer.begin(service) @session[@@service_key] = service return request end
GenericConsumer#begin
ようやく処理の本質に近づいてきた。
- nonce を生成
- association を取得
- association, nonce, service から SuccessRequest のインスタンスを生成
nonce はユーザを認証するたびに生成する乱数のようなもの。 association が、はてなの認証サーバとの鍵交換などを管理しているデータ。 ってことで、association を取得する get_association を見ていく。
def begin(service) nonce = self.create_nonce assoc = self.get_association(service.server_url) return SuccessRequest.new(assoc, nonce, service) end
GenericConsumer#get_association
association は認証サーバとの間で一度生成したら、その後は同じデータを使い続けるようになっている。
- dumb モード(associationを使わないモード)の場合は何もしない
- すでにassociationがあればそれを返す
- association がなければ associate メソッドを呼び出す。
def get_association(server_url) return nil if @store.dumb? assoc = @store.get_association(server_url) return assoc unless assoc.nil? return self.associate(server_url) end
GenericConsumer#associate
ようやくたどり着いた。 ここが鍵交換の本質的な処理みたい。 メソッドも今まで以上に長いので、いくつかに分割して読んでいく。
まずは認証サーバに送るリクエストを生成する箇所。
- associate の方式は HMAC-SHA1。
- HMAC (鍵付きハッシュ) で使う共通鍵は、 DH-SHA1 で交換する。
あとは DH を使うための準備をしているっぽい。
# Make the openid.associate call to the server. def associate(server_url) dh = OpenID::DiffieHellman.new cpub = OpenID::Util.to_base64(OpenID::Util.num_to_str(dh.public)) args = { 'openid.mode' => 'associate', 'openid.assoc_type' =>'HMAC-SHA1', 'openid.session_type' =>'DH-SHA1', 'openid.dh_modulus' => OpenID::Util.to_base64(OpenID::Util.num_to_str(dh.p)), 'openid.dh_gen' => OpenID::Util.to_base64(OpenID::Util.num_to_str(dh.g)), 'openid.dh_consumer_public' => cpub } body = OpenID::Util.urlencode(args)
次にリクエストを送る箇所。 ここでは @fetcher というオブジェクトの post メソッドを呼んでいる。 @fetcher は OpenID::StandardFetcher のインスタンスで、 fetchers.rb で定義されている。 ちょっと見てみたら、 OpenID::StandardFetcher は、内部で Net::HTTP を使っていた。 とりあえず深く追う必要はなさそう。
ret = @fetcher.post(server_url, body) return nil if ret.nil? url, data = ret results = OpenID::Util.parsekv(data)
最後にサーバからの戻り値 (results) の値をセッションに格納している。 認証サーバと交換したデータ (association) は、 OpenID::Association のインスタンスとして保存されている。 OpenID::Association は association.rb で定義されているけど、今は見ないことにする。
assoc_type = results["assoc_type"] assoc_handle = results["assoc_handle"] expires_in = results.fetch("expires_in", "0").to_i session_type = results["session_type"] if session_type.nil? secret = OpenID::Util.from_base64(results["mac_key"]) else dh_server_public = results["dh_server_public"] spub = OpenID::Util.str_to_num(OpenID::Util.from_base64(dh_server_public)) dh_shared = dh.get_shared_secret(spub) enc_mac_key = results["enc_mac_key"] secret = OpenID::Util.strxor(OpenID::Util.from_base64(enc_mac_key), OpenID::Util.sha1(OpenID::Util.num_to_str(dh_shared))) end assoc = OpenID::Association.from_expires_in(expires_in, assoc_handle, secret, 'HMAC-SHA1') @store.store_association(server_url, assoc) return assoc end
まとめ
思ったよりも階層が深かった。 整理するとこんな感じかな。
- ユーザが入力した OpenID のアカウント (URL) から認証サーバを見つける (Consumer#begin)
- 特になし (Consumer#begin_without_discovery)
- 認証に必要な nonce, association を生成 (GenericConsumer#begin)
association の生成には以下の2つのメソッドを使っている。
- すでに認証サーバとの association が存在すればそれを取得 (GenericConsumer#get_association)
- association がなければ認証サーバにアクセスして生成する (GenericConsumer#associate)
次回はユーザをログイン画面に誘導するところ (SuccessRequest#redirect_url) を読むかな。