日向夏特殊応援部隊

俺様向けメモ

ソースから読むOpenID (1)

追記

特に暗号、認証周りの自分の理解が足りなかったので、結構加筆しました。(><)

はじめに

仕様も大事だけどやっぱりエンジニアなのでソース嫁ですよね^^
よりOpenIDの規格を理解する為に、少しずつ読んだ内容をメモって行きます。

がテキストになります。

Net::OpenID::Consumer

最初にConsumerのSYNOPSYSを見ると

  1. new
  2. claimed_identity
  3. check_url
  4. user_setup_url or user_cancel or verified_identity

って流れになってます。

今まで仕様を読んで来た限りだと認証フローそのままって感じに見えますが、百聞は一見にしかず。
とにかく読み進めてみます。

Net::OpenID::Consumer->new

まずは渡せるパラメーターですが、ソースを見ると、

sub new {
    my Net::OpenID::Consumer $self = shift;
    $self = fields::new( $self ) unless ref $self;
    my %opts = @_;

    $self->{ua}            = delete $opts{ua};
    $self->args            ( delete $opts{args}            );
    $self->cache           ( delete $opts{cache}           );
    $self->consumer_secret ( delete $opts{consumer_secret} );
    $self->required_root   ( delete $opts{required_root}   );

    $self->{debug} = delete $opts{debug};

    Carp::croak("Unknown options: " . join(", ", keys %opts)) if %opts;
    return $self;
}

とあるように、

  1. ua
  2. args
  3. cache
  4. consumer_secret
  5. required_root
  6. debug

と言うパラメータが渡せるようです。

ざっくりドキュメントに目を通してみると、

ua
省略するとLWP::UserAgentだが、LWPx::ParanoidAgent推奨。これは仕様にも書いてある。
args
URLパラメータ、渡し方は色々
cache
get($key), set($key, $value)なら何でもOK。Cache::FileとかCache::Memcachedでも。
consumer_secret
255byteを超えない鍵。LiveJournalのソースを漁るとtimestampで生成し、さらにデータベースで管理してます。*1return_urlでのspoofingを防ぐ為に生成します。
required_root
return_to URLのプリフィクス。これが含まれない場合は不正なreturn_to URLとして扱われる。
debug
デバッグ情報が表示されます。

って感じです。

例えば、

my $csr = Net::OpenID::Consumer->new(
    consumer_secret => sub {
        String::Random->new->randregex('[a-zA-Z0-9]{32}');
    },
    debug => 1
);

こんなソースになる訳です。*2

Net::OpenID::Consumer->claimed_identity()
sub claimed_identity {
    my Net::OpenID::Consumer $self = shift;
    my $url = shift;
    Carp::croak("Too many parameters") if @_;

    # trim whitespace
    $url =~ s/^\s+//;
    $url =~ s/\s+$//;
    return $self->_fail("empty_url", "Empty URL") unless $url;

    # do basic canonicalization
    $url = "http://$url" if $url && $url !~ m!^\w+://!;
    return $self->_fail("bogus_url", "Invalid URL") unless $url =~ m!^https?://!i;
    # add a slash, if none exists
    $url .= "/" unless $url =~ m!^http://.+/!i;

    my $final_url;

    my $sem_info = $self->_find_semantic_info($url, \$final_url) or
        return;

    my $id_server = $sem_info->{"openid.server"} or
        return $self->_fail("no_identity_server");

    return Net::OpenID::ClaimedIdentity->new(
                                             identity => $final_url,
                                             server   => $id_server,
                                             consumer => $self,
                                             delegate => $sem_info->{'openid.delegate'},
                                             );
}

まぁ、すぐ分かるのはNet::OpenID::ClaimedIdentityのインスタンスを生成するメソッドって事なんですけど、
_find_semantic_info()によって実際のClaimed Identifierを見に行くようですね。


それにしても前半のバリデーションはかなり力技ですなw

_find_semantic_infoですがかなり長いのですが、断片的に見ると、

my $ret = {
  'openid.server' => undef,
  'openid.delegate' => undef,
  'foaf' => undef,
  'foaf.maker' => undef,
  'rss' => undef,
  'atom' => undef,
};

って言うHASHREFを返します。ちなみにこのメソッド、そうとうゴリゴリ書いてますw
今のCPANにはそれ系のモジュールはきちんと用意されてる*3ので、そこまで頑張って書く必要は無いです。^^;

最終的にdelegateしてるかどうかまでチェックしてます。それゆえ$final_urlと言う形でdelegateしてた場合ならばその先のidentifier urlを持ってくるってイメージで、さらにopenid.serverの値も取ってくるって形になります。

return Net::OpenID::ClaimedIdentity->new(
                                          identity => $final_url,
                                          server   => $id_server,
                                          consumer => $self,
                                          delegate => $sem_info->{'openid.delegate'},
                                        );

Net::OpenID::ClaimedIdentityのインスタンス生成時には結局foafなどなどのセマンティックデータは全然反映されてませんな。

Net::OpenID::ClaimedIdentifier

Net::OpenID::ClaimedIdentifier->check_url()

これが仕様上は何に当たるかが一番の疑問。ソースを追って行きましょう。
長いので少しずつ。

sub check_url {
    my Net::OpenID::ClaimedIdentity $self = shift;
    my (%opts) = @_;

    my $return_to   = delete $opts{'return_to'};
    my $trust_root  = delete $opts{'trust_root'};
    my $delayed_ret = delete $opts{'delayed_return'};

    Carp::croak("Unknown options: " . join(", ", keys %opts)) if %opts;
    Carp::croak("Invalid/missing return_to") unless $return_to =~ m!^https?://!;

return_to, trust_root, delayed_returnがパラメータになります。
ドキュメントはここらですけど、
一応斜め読みしておきましょう。

return_to
認証済み署名かuser_setup_urlを伴ったIdPがユーザーをリダイレクトさせるURLの事。
trust_root
信頼するURLのrootでreturn_toはその配下にあるurlになるようにしなければならない。省略するとreturn_toと同じ値になる。ワイルドカードも使える。
delayed_return
デフォルトはfalseで直ちに認証結果を伴いreturn_to URLにリダイレクトされる。*4trueにした場合は、check_urlはIdPを指し示し、ユーザーを直接IdPの実際のページに誘導し、主張が完全に確立するまでそこに置き去りにされ、最終的にreturn_toで指定したURLに戻ってくる。

って感じです。続きを読みましょう。

    my $csr = $self->{consumer};

    my $ident_server = $self->{server} or
        Carp::croak("No identity server");

    # get an assoc (or undef for dumb mode)
    my $assoc = Net::OpenID::Association::server_assoc($csr, $ident_server);

もうこの段階でassociateが行われるのが分かります。この部分は後述。
dumb mode*5だとundefと言う辺りに注目して続き。

    my $identity_arg = $self->{'delegate'} || $self->{'identity'};

    # make a note back to ourselves that we're using a delegate
    if ($self->{'delegate'}) {
        OpenID::util::push_url_arg(\$return_to,
                                   "oic.identity",  $self->{identity});
    }

このpush_url_argですがConsumer.pmの中で別にpackage宣言されてます。
url文字列のリファレンスを渡して、key-valueのペアをパラメータに追加するって言うメソッド。
delegateの時はoic.identityってキーになるんですね。*6

このようにして最終的にConsumer側に戻るべくURLであるreturn_toの値の構築を行って行きます。

    # add a HMAC-signed time so we can verify the return_to URL wasn't spoofed
    my $sig_time = time();
    my $c_secret = $csr->_get_consumer_secret($sig_time);
    my $sig = substr(OpenID::util::hmac_sha1_hex($sig_time, $c_secret), 0, 20);
    OpenID::util::push_url_arg(\$return_to,
                               "oic.time", "${sig_time}-$sig");

Consumer側の秘密鍵の生成を行います。
その秘密鍵を使って時刻と共にHMAC-SHA1で署名を作ります。実際の時間とセットの値でoic.timeとなってますね。
ここまでがreturn_toの生成。

    my $curl = $ident_server;
    OpenID::util::push_url_arg(\$curl,
                               "openid.mode",           ($delayed_ret ? "checkid_setup" : "checkid_immediate"),
                               "openid.identity",       $identity_arg,
                               "openid.return_to",      $return_to,

                               ($trust_root ?
                                ("openid.trust_root",   $trust_root) : ()),

                               ($assoc ?
                                ("openid.assoc_handle", $assoc->handle) : ()),
                               );

    $self->{consumer}->_debug("check_url for (del=$self->{delegate}, id=$self->{identity}) = $curl");
    return $curl;
}

delayed_returnの値はcheckid_setup(trueの時) or checkid_immediate(falseの時)って違いになりますね。
またsmartモードのときのみopenid.assoc_handleに値が含まれるようになるって事です。

このようにしてIdPのエンドポイントに対するパラメータを生成するのがcheck_urlメソッドの仕事です。

Net::OpenID::Assosiation

Net::OpenID::Assosiation->server_assoc()

assosiationの部分のソースを眺めておきましょう。
長いので少しずつ。。。

sub server_assoc {
    my ($csr, $server) = @_;

    # closure to return undef (dumb consumer mode) and log why
    my $dumb = sub {
        $csr->_debug("server_assoc: dumb mode: $_[0]");
        return undef;
    };

    my $cache = $csr->cache;
    return $dumb->("no_cache") unless $cache;

仮にsmartモードにしてあってもcache用のオブジェクトが設定されてないと強制的にundefが返ってきます。
この理由は後で述べます。

    # try first from cached association handle
    if (my $handle = $cache->get("shandle:$server")) {
        my $assoc = handle_assoc($csr, $server, $handle);

        if ($assoc && $assoc->usable) {
            $csr->_debug("Found association from cache (handle=$handle)");
            return $assoc;
        }
    }

cacheが存在し、かつexpireを過ぎていなければそのまま採用しassociateとして利用します。

    # make a new association
    my $dh = _default_dh();

    my %post = (
                "openid.mode" => "associate",
                "openid.assoc_type" => "HMAC-SHA1",
                "openid.session_type" => "DH-SHA1",
                "openid.dh_consumer_public" => OpenID::util::bi2arg($dh->pub_key),
                );

    my $req = HTTP::Request->new(POST => $server);
    $req->header("Content-Type" => "application/x-www-form-urlencoded");
    $req->content(join("&", map { "$_=" . OpenID::util::eurl($post{$_}) } keys %post));

    $csr->_debug("Associate mode request: " . $req->content);

    my $ua  = $csr->ua;
    my $res = $ua->request($req);

    # uh, some failure, let's go into dumb mode?
    return $dumb->("http_failure_no_associate") unless $res && $res->is_success;

DH共通鍵共有の為の秘密鍵、公開鍵のペアを生成し、IdP側に公開鍵を通知します。この際は普通のPOSTとなっています。
dh_consumer_publicについてはいつか書きます。

    my $recv_time = time();
    my $content = $res->content;
    my %args = OpenID::util::parse_keyvalue($content);
    $csr->_debug("Response to associate mode: [$content] parsed = " . join(",", %args));

    return $dumb->("unknown_assoc_type") unless $args{'assoc_type'} eq "HMAC-SHA1";

    my $stype = $args{'session_type'};
    return $dumb->("unknown_session_type") if $stype && $stype ne "DH-SHA1";

DH共通鍵共有に使っている暗号化タイプとセッションの暗号化タイプの確認を行います。

    # protocol version 1.1
    my $expires_in = $args{'expires_in'};

    # protocol version 1.0 (DEPRECATED)
    if (! $expires_in) {
        if (my $issued = OpenID::util::w3c_to_time($args{'issued'})) {
            my $expiry = OpenID::util::w3c_to_time($args{'expiry'});
            my $replace_after = OpenID::util::w3c_to_time($args{'replace_after'});

            # seconds ahead (positive) or behind (negative) the server is
            $expires_in = ($replace_after || $expiry) - $issued;
        }
    }

    # between 1 second and 2 years
    return $dumb->("bogus_expires_in") unless $expires_in > 0 && $expires_in < 63072000;

有効期間のチェックを行います。さらに後方互換も保っているようです。

    my $ahandle = $args{'assoc_handle'};

    my $secret;
    if ($stype ne "DH-SHA1") {
        $secret = OpenID::util::d64($args{'mac_key'});
    } else {
        my $server_pub = OpenID::util::arg2bi($args{'dh_server_public'});
        my $dh_sec = $dh->compute_secret($server_pub);
        $secret = OpenID::util::d64($args{'enc_mac_key'}) ^ sha1(OpenID::util::bi2bytes($dh_sec));
    }
    return $dumb->("secret_not_20_bytes") unless length($secret) == 20;

暗号化形式がDH-SHA1で無い場合は、Base64デコードを行って共通鍵を取得し、
DH-SHA1の場合は、IdP側の公開鍵を用いて共通鍵を取得します。

ここは強くDH-SHA1で暗号化する事が望まれてるので、DH-SHA1での暗号化を使用するべきでしょう。

    my %assoc = (
                 handle => $ahandle,
                 server => $server,
                 secret => $secret,
                 type   => $args{'assoc_type'},
                 expiry => $recv_time + $expires_in,
                 );

    my $assoc = Net::OpenID::Association->new( %assoc );
    return $dumb->("assoc_undef") unless $assoc;

    $cache->set("hassoc:$server:$ahandle", Storable::freeze(\%assoc));
    $cache->set("shandle:$server", $ahandle);

    # now we test that the cache object given to us actually works.  if it
    # doesn't, it'll also fail later, making the verify fail, so let's
    # go into stateless (dumb mode) earlier if we can detect this.
    $cache->get("shandle:$server")
        or return $dumb->("cache_broken");

    return $assoc;
}
  1. assoc_handle
  2. server
  3. assoc_type(HMAC_SHA1)
  4. expire

をひとかたまりとして、Consumer側でキャッシュ化して、また特定のIdPとどのようなassoc_handleで共通鍵を共有したかの対応もキャッシュ化します。
最後にインスタンス生成して、念入りにキャッシュのチェックを行って終了となります。

まとめ

ソース嫁って事だ。。。仕様よりよっぽど分かりやすい罠。

  • ConsumerがEnd Userのclaimed identityの取得を行い
  • ConsumerがIdPとassosiationを行い
  • ConsumerがEnd Userにcheck_urlを用意する

と言う手続きまで簡単に追いました。
次回はこの続きからさらに追って行こうと思います。

*1:LJ::OpenID, ljlib.pl辺り

*2:consumer_secretは貧弱過ぎなので真似しないように!w

*3:http://search.cpan.org/search?query=WWW%3A%3ABlog%3A%3AMetadata&mode=dist 辺り

*4:AJAXスタイルもこれ

*5:共通鍵共有を行って無いモード

*6:仕様にない気がするけど。。。