at posts/single.html

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

まとめ

思ったよりも階層が深かった。 整理するとこんな感じかな。

  1. ユーザが入力した OpenID のアカウント (URL) から認証サーバを見つける (Consumer#begin)
  2. 特になし (Consumer#begin_without_discovery)
  3. 認証に必要な nonce, association を生成 (GenericConsumer#begin)

association の生成には以下の2つのメソッドを使っている。

  1. すでに認証サーバとの association が存在すればそれを取得 (GenericConsumer#get_association)
  2. association がなければ認証サーバにアクセスして生成する (GenericConsumer#associate)

次回はユーザをログイン画面に誘導するところ (SuccessRequest#redirect_url) を読むかな。

関連する日記