OpenIDをハックする

OpenIDの業界団体、4月に日本支部設立へ - ITmedia エンタープライズ」とかで何かとOpenIDが話題になっているので、今更ながらOpenID Consumerのサンプルコードを書いてみた(しかもさらに今更ながら OpenID 1.1 対応)。

Perlには Net::OpenID::Consumer とかライブラリがあるので、本来はそれを使えばいいんだろうけど、OpenIDの動作原理を確認したかったので、完全に手作り。

#!/usr/bin/perl -T

use strict;
use CGI;
use CGI::Carp qw(fatalsToBrowser);
use LWP::UserAgent;
use HTTP::Request::Common qw(GET POST);
use vars qw($UA);

sub get_idp {
    my ($openid_url) = @_;

    $openid_url = "http://$openid_url"    if ($openid_url !~ m|^http://|);

    my $req = GET $openid_url;
    my $res = $UA->request($req);
    return if (! $res->is_success);

    my $idp = { identity => $openid_url };
    my @link = $res->header('Link');
    foreach my $link (@link) {
        $link =~ /^<(.*?)>;.* rel="openid\.server"/ and $idp->{server} = $1;
        $link =~ /^<(.*?)>;.* rel="openid\.delegate"/
                                                    and $idp->{identity} = $1;
    }
    return $idp if ($idp->{server});
}

sub checkid_immediate {
    my ($server, $identity, $return_to) = @_;

    my %param = ( 'openid.mode'      => 'checkid_immediate',
                  'openid.identity'  => $identity,
                  'openid.return_to' => $return_to        );
    my $query;
    foreach my $key (keys %param) {
        $query .= $query ? '&' : '?';
        $query .= "$key=$param{$key}";
    }

    return $server.$query;
}

sub check_authentication {
    my ($server, $openid) = @_;

    my $req = POST $server,
                [ 'openid.mode'         => 'check_authentication',
                  'openid.assoc_handle' => $openid->{assoc_handle},
                  'openid.sig'          => $openid->{sig},
                  'openid.signed'       => $openid->{signed},
                  'openid.identity'     => $openid->{identity},
                  'openid.return_to'    => $openid->{return_to},
                ];
    my $res = $UA->request($req);
    return if (! $res->is_success);

    my $authentication = {};
    my $content = $res->content;
    foreach my $line (split(/\n/, $content)) {
        my ($key, $value) = split(/:/, $line, 2);
        $authentication->{$key} = $value;
    }

    return $authentication;
}

sub openid_form {
    my ($cgi, $message) = @_;

    return $cgi->start_html(-title=>'Test OpenID')
            . $cgi->h1($cgi->a({href=>$cgi->url},'Test OpenID'))
            . $cgi->p($message)
            . $cgi->start_form(-method=>'post', -action=>$cgi->url)
            . $cgi->textfield(-name=>'openid_url')
            . $cgi->submit(-value=>'LOGIN')
            . $cgi->end_form
            . $cgi->end_html;
}

my $cgi = new CGI;

$UA = new LWP::UserAgent;

my $message;

if ($cgi->request_method eq 'POST') {

    my ($openid_url, $idp, $url);
    $openid_url = $cgi->param('openid_url')
        and $idp = get_idp($openid_url)
        and $url = checkid_immediate(
                       $idp->{server}, $idp->{identity}, $cgi->url)
        and print $cgi->redirect($url)
        and exit;

    $message = 'Identity Provider Not Found.';
}
elsif ($cgi->param('openid.mode') eq 'id_res'){

    if (my $url = $cgi->param('openid.user_setup_url')) {
        print $cgi->redirect($url);
        exit;
    }

    my $openid;
    foreach my $key ($cgi->param) {
        my $value = $cgi->param($key);
        $key =~ s/^openid\.//    or next;
        $openid->{$key} = $value;
    }

    my ($idp, $auth);
    $idp = get_idp($openid->{identity})
        and $auth = check_authentication($idp->{server}, $openid)
        and $message = ($auth->{is_valid} eq 'true')
                           ? 'Login!'
                           : 'Login Failed.'
        or  $message = 'Identity Provider Not Found.';
}

print $cgi->header,
      openid_form($cgi, $message);

上記のコードはdumbモードの実装。dumbモードのシーケンスは以下のような感じ。

End User    User-Agent    Consumer     Identifier   Identity Provider
    |            |            |            |            |
    | openid_url |            |            |            |
    |----------->| openid_url |            |            |
    |            |----------->*            |            |
    |            |            |  get_idp   |            |
    |            |            |----------->|            |
    |            |  redirect  |<-----------|            |
    |            |<-----------|            |            |
    |            |            |            |            |
    |            |           checkid_immediate          |
    |            |------------------------------------->|
    |            |            |            |            | login
    |            |<-------------------------------------|
    |            |----------->*            |            |
    |            |            |            |            |
    |            |  redirect  |            |            |
    |            |<-----------|            |            |
    |            |            checkid_setup             |
    |            |------------------------------------->|
    |            |            |            |            | setup
    |            |<-------------------------------------|
    |            |----------->*            |            |
    |            |            |  get_idp   |            |
    |            |            |------------------------>|
    |            |            |<------------------------|
    |            |            |  check_authentication   |
    |            |            |------------------------>|
    |            |            |<------------------------|
    |            |<-----------|            |            |
    |<-----------|            |            |            |
    |            |            |            |            |

Consumerには * のポイントで処理のトリガがかかる。IdP側がsetup済みの場合は、checkid_setupのシーケンスは発生しない。

dumbモードの場合、associate をしない代わりに最後に check_authenticationが必要。これをやらないと偽装したassoc_handleを使ってログインされてしまうということなんだと理解。

ちゃんとセッション管理していれば、2回目のget_idpは不要。サンプルコードは手抜きなので、2回get_idpしてます。しかも1回目と2回目で問い合わせ先が違う場合があるけれど、本質じゃないからまあいいか。