mod_auth_tkt を使って Single Sign On を行う

# 概要

[mod_auth_tkt](http://www.openfusion.com.au/labs/mod_auth_tkt/) を用いて、ウェブサーバ A の閲覧を、ウェブサーバ B 上の認証情報を用いて制限します。

1. mod_auth_tkt をインストールして、チケット(cookie)を持たずに、ウェブサーバ A にアクセスがあった場合には、ウェブサーバ B 上の認証用 CGI にリダイレクトするように設定します。
2. ウェブサーバ B 上の認証用 CGI を、何らかの方法(本稿では Shibboleth)で認証管理下に置いておきます。認証に成功した場合、認証用 CGI は、必要なパラメータを共通秘密鍵で暗号化して、ウェブサーバ A 上のチケット発行用 CGI にリダイレクトします。
3. ウェブサーバ A 上のチケット発行用 CGI は、認証用 CGI から受け取ったパラメータを共通秘密鍵で復号し、そのパラメータに基づいて mod_auth_tkt 用のチケット(cookie)を生成して、ブラウザに渡します。そして、本来のページにリダイレクトします。

# 背景

Shibboleth を使って Single Sign On (SSO) を実現しようとする場合、対象となるウェブサーバに SSL サーバ証明書を準備して、Shibboleth SP 化するという方法がもっとも確実かつ安全な方法であることは間違いありません。

しかし、対象となるウェブサーバが非常に多い場合には特に、Shibboleth SP化 する方法には、いくつかの短所もあります。

* 多数の SSL サーバ証明書を取得して管理しなければならない。
* 多数の Shibboleth SP メタデータを Shibboleth IdP に登録しなければならない。

SSL サーバ証明書の発行と配置・設定が完全に自動化されていれば、この問題もなんとか緩和できますが、完全な自動化はかなり難しい状況が多いと思います。

そのため、コンテンツの完全性や機密性が不完全でも良い場合には、対象となるウェブサーバ自体を Shibboleth SP 化することなく、アクセス制御できると都合が良いということがあり得ます。本稿では、そのような方法として mod_auth_tkt を用いた方法を解説します。

# mod_auth_tkt の動作

mod_auth_tkt は、チケット(cookie)を提示したクライアントに対してはアクセスを許可し、チケット(cookie)が未提示のクライアントに対してはチケットを発行してくれる CGI にリダイレクトするという動作をします。例として、http://www.example.net/ に以下のように設定されている場合を考えます。

```apache
TKTAuthSecret 0123456789
TKTAuthDigestType SHA256
<Location /resticted/>
    AuthType None
    Require valid-user
    TKTAuthLoginURL https://www.example.net/login.cgi
</Location>
```

この時、チケットを提示せずに http://www.example.net/restricted/ にアクセスすると、https://www.example.net/login.cgi?back=http://www.example.net/restricted/ にリダイレクトされます。

チケットの形式は以下の通りです。

```
cookie := digest + timstamp + uid + '!' + tokens + '!' + extdata
digest := ハッシュ関数(digest0 + secret)
digest0 := ハッシュ関数(iptstamp + secret + uid + '\0' + tokens + '\0' + extdata)
```

uid はユーザ名、timestamp はチケット発行時刻(UNIXタイム，16進表記)です。iptstamp は、チケット発行時刻(timestamp)とクライアントのIPアドレスを組み合わせて生成される文字列で、チケットが盗聴された場合でも他のIPアドレスから利用できないようになっています。

tokens はカンマで区切られた単語列で、TKTAuthToken ディレクティブと組み合わせて認可に用いることができる情報ですが、本稿では空文字列です。extdata も、本稿では空文字列です。ハッシュ関数としては、MD5 / SHA256 / SHA512 が使えますが、本稿では SHA256 を使いました。

チケットには、タイムスタンプ(timestamp)、ユーザ名(uid)などの digest を計算するために必要な情報と、それらの情報と共通秘密鍵(secret)を連結して1方向ハッシュ関数で処理した digest が含まれています。mod_auth_tkt がインストールされたウェブサーバは、TKTAuthSecret ディレクティブで設定された共通秘密鍵(secret)とチケットに含まれる情報から digest を再計算してみて、一致すればアクセスを許可します。

上記の設定例は、認証してチケットを発行する CGI が、アクセスを制限したいウェブサーバ上で動作していましたから、3rd party cookie の制限を受けません。しかし、本稿のように、認証するウェブサーバとアクセスを制限したいウェブサーバが異なる場合には、ひと工夫が必要になります。

# 詳細

## ウェブサーバ A の設定

まず、mod_auth_tkt をインストールします。Debian GNU/Linux なら以下のコマンドで簡単に入ります。

```
apt-get install libapache2-mod-auth-tkt
```

次に、保護したいコンテンツに対して、以下のように認証をかけます。

```apache
TKTAuthSecret 0123456789
TKTAuthDigestType SHA256
<Location /resticted/>
    AuthType None
    Require valid-user
    TKTAuthLoginURL https://shib.example.net/login.cgi
</Location>
```

これで、チケットを提示せずに http://www.example.net/restricted/ にアクセスすると、https://shib.example.net/login.cgi?back=http://www.example.net/restricted/ にリダイレクトされます。

なお、TKTAuthSecret ディレクティブは、共有秘密鍵を設定するディレクティブです。適切に変更してください。

## ウェブサーバ B 上の認証用 CGI

以下のような内容の認証用 CGI を、https://shib.example.net/login.cgi に配置して、認証管理下に置いておきます。なお、共有秘密鍵は、適切に変更してください。

```pl:login.cgi
#!/usr/bin/perl

use CGI;
use CGI::Carp qw/ fatalsToBrowser /;
use Crypt::CBC;
use JSON qw/ encode_json /;
use strict;
use warnings;
use constant SECRET => '0123456789';
use constant CALLBACK => '/callback.cgi';

&main();

sub main {
    my $cgi = CGI->new();
    my( %param ) = ( login  => sprintf( '%s://%s%s',
                                        $ENV{'REQUEST_SCHEME'},
                                        $ENV{'HTTP_HOST'},
                                        $ENV{'SCRIPT_NAME'} ),
                     back   => $cgi->param('back'),
                     uid    => $ENV{'REMOTE_USER'} || '',
                     ipaddr => $ENV{'REMOTE_ADDR'} || '0.0.0.0',
                     tokens => [],
                     data   => '',
                     retry  => $cgi->param('retry') || 0,
                     time   => time );
    my $cipher = Crypt::CBC->new( -key => SECRET,
                                  -cipher => 'Blowfish',
                                  -header => 'randomiv' );
    my $enc = $cipher->encrypt_hex( &encode_json( \%param ) );
    my $url = $param{back};
    $url =~ s!\A([^:]+://[^/]+)?.*\Z!$1.CALLBACK!e;
    print $cgi->redirect( -location => sprintf( '%s?param=%s', $url, $enc ) );
}
```

この CGI に必要なモジュール類は、Debian GNU/Linux では以下のコマンドでインストールできます。

```
apt-get install libcrypt-cbc-perl libcrypt-blowfish-perl libjson-perl
```

この CGI は、以下のように動作します。

1. 認証によって得られたユーザ名(REMOTE_USER 環境変数)やクライアントの IP アドレス(REMOTE_ADDR 環境変数)などのチケット発行に必要な情報を、連想配列にまとめます。
2. 連想配列を、JSON 形式の文字列にシリアライズします。
3. シリアライズした文字列を、共有秘密鍵を用いて Blowfish 方式で暗号化します。
4. ウェブサーバA上のチケット発行用 CGI に、暗号文をパラメータとして渡しながら、リダイレクトします。

この CGI は、必ず、適当な方法で認証管理下に置いてください。認証管理下にない場合、http://www.example.net/restricted/ に無制限にアクセスできてしまうことになります。本稿では、以下のように設定して Shibboleth 管理下に置きました。

```apache
ScriptAlias /login.cgi /somewhere/login.cgi
<Location /login.cgi>
	AuthType shibboleth
	ShibRequestSetting requireSession 1
	Require valid-user
</Location>
```

認証用 CGI は、REMOTE_USER 環境変数と REMOTE_ADDR 環境変数を参照しているだけですから、Shibboleth に限らず、Apache の認証モジュールなら大抵は利用することができます。例えば、[mod_authnz_ldap](http://httpd.apache.org/docs/2.4/mod/mod_authnz_ldap.html) モジュールを使って、BASIC 認証するなどの方法でも良いです。

## ウェブサーバ A 上のチケット発行用 CGI

以下のような内容のチケット発行用 CGI を、http://www.example.net/callback.cgi に配置します。なお、共有秘密鍵は、適切に変更してください。

```pl:callback.cgi
#!/usr/bin/perl

use CGI;
use CGI::Carp qw/ fatalsToBrowser /;
use Crypt::CBC;
use Digest::SHA qw/ sha256_hex /;
use JSON qw/ decode_json /;
use URI::Escape qw/ uri_escape /;
use strict;
use warnings;
use constant SECRET => '0123456789';
use constant TIMEOUT => 30;
use constant COOKIENAME => 'auth_tkt';

&main();

sub main {
    my $cgi = CGI->new();
    my $enc = $cgi->param('param') or die "No parameter\n";

    my $cipher = Crypt::CBC->new( -key => SECRET,
                                  -cipher => 'Blowfish',
                                  -header => 'randomiv' );
    my $param = eval { &decode_json( $cipher->decrypt_hex( $enc ) ) } || die "No valid parameter\n";

    my $now = time;
    if( $param->{time} + TIMEOUT < $now ){
        print $cgi->redirect( -location =>
                              sprintf( '%s?back=%s&retry=%d',
                                       $param->{login},
                                       &uri_escape( $param->{back} ),
                                       ( $param->{retry} + 1 ) ) );
        return;
    }

    my( @ts ) = ( ( ($now & 0xff000000) >> 24 ),
                  ( ($now & 0xff0000) >> 16 ),
                  ( ($now & 0xff00) >> 8 ),
                  ( ($now & 0xff) ) );
    my( @ip ) = split( /\./, $param->{ipaddr} );
    my $ipts = pack( 'C8', @ip, @ts );
    my $raw = join( '',
                    $ipts, SECRET, $param->{uid}, "\0",
                    join( ',', @{$param->{tokens}} ), "\0",
                    $param->{data} );
    my $digest = &sha256_hex( &sha256_hex($raw) . SECRET );
    my $ticket = join( '!',
                       sprintf( '%s%08x%s', $digest, $now, $param->{uid} ),
                       join( ',', @{$param->{tokens}} ),
                       $param->{data} );
    my $cookie = $cgi->cookie( -name => COOKIENAME,
                               -value => $ticket,
                               -domain => $ENV{'SERVER_NAME'},
                               -expires => '+10m' );
    print $cgi->redirect( -location => $param->{back},
                          -cookie => $cookie );                 
}
```

この CGI に必要なモジュール類は、Debian GNU/Linux では以下のコマンドでインストールできます。

```
apt-get install libcrypt-cbc-perl libcrypt-blowfish-perl libjson-perl libdigest-sha-perl liburi-perl
```

この CGI は、以下のように動作します。

1. 認証用 CGI から渡されたパラメータ(暗号文)を、共有秘密鍵を用いて復号します。
2. 復号した平文を JSON 形式として解釈して、Perl の連想配列に対するリファレンスに戻します。戻せなければ、不正なパラメータ(暗号化に用いた共有秘密鍵の不一致、パラメータの破損、などなど)であると判断して、そこで処理を終了します。
3. パラメータ(暗号文)に含まれているタイムスタンプをチェックします。タイムスタンプが古すぎる場合は replay 攻撃である可能性があるので破棄して、ウェブサーバ B 上の認証用 CGI に再度リダイレクトします。
4. 上記の2つのチェックに合格した場合は mod_auth_tkt 用のチケットを生成して、本来のページにリダイレクトします。

これで、ウェブサーバ A に対するアクセスを、別のウェブサーバ B 上での認証結果に基づいて制限(認可)できるようになっているはずです。

# セキュリティ

本稿の手法には、幾つかの既知の攻撃方法が存在します。

0. 悪意がある利用者による攻撃。利用者は、ウェブサーバ B 上の認証用 CGI から、ウェブサーバ A 上のチケット発行用 CGI にパラメータとして送られる暗号文を取得することができます。暗号文の元となっている平文に含まれる情報は、利用者にとっては既知の情報であり、かつ、何度も繰り返して認証を行うことによって、多数の暗号文を取得することができます。そのため、悪意がある利用者は既知平文攻撃が可能です。
1. CGI や apache の設定ファイル中に、共有秘密鍵が平文のままで記述されています。そのため、ウェブサーバ A または B にログインできる人は、共有秘密鍵を見て任意のチケットを偽造できます。

ウェブサーバ A との通信経路が暗号化(HTTPS 化)されていない場合には，さらに幾つかの攻撃方法が存在します。

1. mod_auth_tkt の方式そのものに対する攻撃。ウェブサーバ A との通信経路が暗号化(HTTPS 化)されていなければ、チケットを盗聴することが可能です。チケットを盗むことができ、かつ、同じ IP アドレスからのアクセスを詐称することができれば、チケットの有効期間内はウェブサーバ A に対するアクセスが可能です。
2. ウェブサーバ A との通信経路が暗号化(HTTPS 化)されていなければ、認証用 CGI からチケット発行用 CGI にパラメータとして送られる暗号文(実際には、利用者のブラウザからウェブサーバ A に送信されます)も、同様に盗聴することが可能です。暗号文を盗むことができ、かつ、同じ IP アドレスからのアクセスを詐称することができれば、暗号文の有効期間内はウェブサーバ A に対するアクセスが可能です。
3. 認証用 CGI からチケット発行用 CGI にパラメータとして送られる暗号文は JSON 形式なので、一部の平文は既知であるということになります。よって、総当り攻撃よりは効率よく共有秘密鍵を求めることができる可能性があります。

筆者としては、これらは十分にリスクが低い脅威であると判断して本稿の手法を実装しましたが、考慮の足らない点などありましたら、ご指摘をお願いします。