end0tknr's kipple - 新web写経開発

http://d.hatena.ne.jp/end0tknr/ から移転します

mod_perl2を使ってシングルサインオン認証を書いてみた

cpanにはApache::AuthTicketやApache::AuthCookie等、mod_perlによる認証モジュールはありますが、modperlを使った実装を経験したことがないので、試しに書いてみました。

概要

まず、user_id / passwordでloginすると、rsa private keyによる署名付のcookieチケットが発行され(login)、次に認証を必要とする各サーバは、認証チケットをrsa public keyによる検証(verify)を行います(authentication & authorization)。

loginと、authen&authzを行うサーバは分離できます。
未ログインの状態で認証ページへアクセスしようとすると、ログインページへリダイレクトされますが、その後、ログインが成功すると、元の認証ページでリダイレクトされます。

mod_perlの学習の為に書いたものなので、実用的でない部分も多いと思いますが、perl sourceやhttpd.conf、startup.pl等も以下に記載しておきます。

colinux環境に構築しているので、ドメインやurlが一般的ではないかもしれません。

ApacheSSO

私が書いたsigle sign onなmodule

package ApacheSSO; #Single Sign On 
use strict;
use utf8;
use APR::Table;
use Apache2::Access ();
use Apache2::Const qw(FORBIDDEN OK REDIRECT SERVER_ERROR);
use Apache2::URI ();
use CGI;
use CGI::Cookie;
use CGI::Util;
use Crypt::CBC;
use Crypt::OpenSSL::RSA;	#ticketへの署名やverifyに使用
use Crypt::OpenSSL::AES;	#ticketの可逆暗号化に使用
use DBI;
use MIME::Base64;
use Text::Xslate;
use Data::Dumper;	#for debug

my $AUTH_CONF = {};
my $AUTH_COOKIE_HEADER = __PACKAGE__;
my $LOGIN_FORM_TEMPLATE;

sub set_common_conf {
    my ($auth_name,$conf) = @_;

    for my $key (qw/login_url logout_url login_form_url default_destination/){
	$AUTH_CONF->{$auth_name}->{$key} = $conf->{$key};
    }

    local $/ = undef; #record読込のdelimiter
    my $fh;

    open($fh, '<', $conf->{crypt_key_file}) or
	die "can't open $conf->{crypt_key_file} $!";
    my $aes_key = substr(<$fh>,0,32);
    #blocksize=16bytesという制限を回避する為、Crypt::CBCを経由
    ($AUTH_CONF->{$auth_name}->{crypt_key}) =
	Crypt::CBC->new(-key=>$aes_key, -cipher=>"Crypt::OpenSSL::AES");
    close($fh) or die "can't close crypt_key_file $!";

    open($fh, '<', $conf->{public_key_file}) or
	die "can't open $conf->{public_key_file} $!";
    ($AUTH_CONF->{$auth_name}->{public_key}) =
	Crypt::OpenSSL::RSA->new_public_key(<$fh>);
    close($fh) or die "can't close public_key_file $!";
}

#login認証を行うサーバのみ、これを実行
sub set_login_conf {
    my ($auth_name,$conf) = @_;

    $AUTH_CONF->{$auth_name}->{cookie} = $conf->{cookie};

    local $/ = undef; #record読込のdelimiter
    my $fh;

    open($fh, '<', $conf->{private_key_file}) or
	die "can't open $conf->{private_key_file} $!";
    ($AUTH_CONF->{$auth_name}->{private_key}) =
	Crypt::OpenSSL::RSA->new_private_key(<$fh>);
    close($fh) or die "can't close private_key_file $!";
}

#login認証をDBで行う場合、これを実行
sub set_db_auth_conf {
    my ($auth_name,$db_conf) = @_;
    my $dbh = DBI->connect(join(';',
				"DBI:mysql:database=$db_conf->{db}->{name}",
				"host=$db_conf->{db}->{host}"),
			   $db_conf->{db}->{user},
			   $db_conf->{db}->{passwd},
			   $db_conf->{db}->{opt});
    $dbh->do("SET NAMES $db_conf->{db}->{client_encoding}") or
	die "can't set encoding";

    $AUTH_CONF->{$auth_name}->{dbh} = $dbh;
    $AUTH_CONF->{$auth_name}->{auth_type} = "db";
    $AUTH_CONF->{$auth_name}->{users} = $db_conf->{users};
}

#ticket発行と署名
sub encode_ticket {
    my ($self,$auth_name,$user_id,$remote_ip) = @_;

    my $data = join(":",
		    $user_id,
		    $remote_ip,
		    time());

    my $rsa_pri = $AUTH_CONF->{$auth_name}->{private_key};
    my $signature = encode_base64($rsa_pri->sign($data),'');	#署名

    my $data_sign = join("\t",$data,$signature);
    my $cipher = $AUTH_CONF->{$auth_name}->{crypt_key};
    $data_sign = encode_base64( $cipher->encrypt($data_sign),'' );
    return $data_sign;
}

#ticketの署名を検証
sub decode_ticket {
    my ($self,$auth_name,$ticket) = @_;

    my $cipher = $AUTH_CONF->{$auth_name}->{crypt_key};
    my $data_sign = $cipher->decrypt(decode_base64($ticket));
    my ($data,$signature) = split(/\t/,$data_sign);

    my $signature = decode_base64($signature);

    my $rsa_pub = $AUTH_CONF->{$auth_name}->{public_key};
    unless ( $rsa_pub->verify($data, $signature) ){
        print STDERR "invalid data (can't verify)";
	return undef;
    }

    return split(/:/,$data);
}

sub authen {
    my ($self,$req) = @_;

    my $auth_name = $req->auth_name;	#Apache2::Access

    my $destination = $req->construct_url($req->unparsed_uri); #Apache2::URI
    unless ( $destination ){
	$destination = $AUTH_CONF->{$auth_name}->{default_destination};
    }

    my $cookie_name = join('_',$AUTH_COOKIE_HEADER,$auth_name);

    my $cookies_str = $req->headers_in->get("Cookie") || ""; #APR::Table
    my %cookies = CGI::Cookie->parse($cookies_str);
    my $ticket;
    if ($cookies{$cookie_name}) {
	$ticket = $cookies{$cookie_name}->value;
    } else {
	return $self->redirect_login_form($req, $destination);
    }

    my ($user,$ip) = $self->decode_ticket($auth_name, $ticket);
    if ($user) {
	$req->user($user); #これで $ENV{REMOTE_USER}によるuser_idが参照可
	return OK;
    }

    return $self->redirect_login_form($req, $destination);
}

sub authz {
    my ($self,$req) = @_;

    my $reqs_arr = $req->requires;
    return FORBIDDEN	unless $reqs_arr;
    my $user = $req->user;
    return FORBIDDEN	unless $user;

    for my $req2 (@$reqs_arr) {
	my ($requirement, $args) = split(/\s+/, $req2->{requirement});

	$args = '' unless defined $args;
	next if $requirement eq 'valid-user';
	if ($requirement eq 'user') {
	    next if $args =~ m/\b$user\b/;
	    return FORBIDDEN;
	}
	return FORBIDDEN;
    }

    $req->no_cache(1);
    $req->err_headers_out->set("Pragma" => "no-cache");
    return OK;
}

sub redirect_login_form {
    my ($self,$req,$destination) = @_;

    my $auth_name = $req->auth_name;

    my $login_form_url = $AUTH_CONF->{$auth_name}->{login_form_url};
    unless ($login_form_url) {
	print STDERR "can't get login_form_url of $auth_name";
	return SERVER_ERROR;
    }

    $req->no_cache(1);
    $req->err_headers_out->set("Pragma" => "no-cache");

    my $url = CGI::Util::escape($destination);
    $req->headers_out->set("Location" =>"$login_form_url?destination=$url");
    return REDIRECT;
}

#user_id,passwordを検証し、認証cookie ticketを発行
sub login {
    my ($self, $req) = @_;

    my $auth_name = $req->auth_name;

    my $args = CGI->new($req)->Vars();
    for my $arg_key (keys %$args){
	$args->{$arg_key} =~ s/^\s+//o;	#左trim
	$args->{$arg_key} =~ s/\s+$//o;	#右trim
    }

    unless ( $args->{destination} ) {
	$args->{destination} = $AUTH_CONF->{$auth_name}->{default_destination};
    }

    my $uid = $self->check_passwd($auth_name,$args->{uid},$args->{passwd});
    return $self->redirect_login_form($req, $args->{destination}) unless $uid;

    my $ticket =
	$self->encode_ticket($auth_name,$uid,$req->connection->remote_ip);

    my $cookie_name = join('_',$AUTH_COOKIE_HEADER,$auth_name);
    my $cookie_conf = $AUTH_CONF->{$auth_name}->{cookie};

    my $cookie = new CGI::Cookie(-name => $cookie_name,
				 -value=> $ticket,
				 -expires=>$cookie_conf->{expires},
				 -domain =>$cookie_conf->{domain},
				 -path   =>$cookie_conf->{path},
				 -secure =>$cookie_conf->{secure});
    $req->err_headers_out->add("Set-Cookie" => $cookie);

    $req->no_cache(1);
    $req->err_headers_out->set("Pragma" => "no-cache");
    $req->headers_out->set("Location" => $args->{destination});
    return REDIRECT;
}

#ldap認証したい場合、各自でcheck_passwd_ldap()のようなものを実装下さい
sub check_passwd {
    my ($self,$auth_name,$uid,$passwd) = @_;

    my $method = "check_passwd_$AUTH_CONF->{$auth_name}->{auth_type}";
    return $self->$method($auth_name,$uid,$passwd);
}

#dbに平文でpasswordを保存していますが、テストですので
sub check_passwd_db {
    my ($self,$auth_name,$uid,$passwd) = @_;
    my $sql =<<EOF;
select $AUTH_CONF->{$auth_name}->{users}->{uid_col}
from $AUTH_CONF->{$auth_name}->{users}->{tbl}
where $AUTH_CONF->{$auth_name}->{users}->{uid_col}=? and
      $AUTH_CONF->{$auth_name}->{users}->{passwd_col}=?
EOF
    my $sth = $AUTH_CONF->{$auth_name}->{dbh}->prepare($sql);
    $sth->execute($uid,$passwd);
    return $sth->fetchrow_array();
}

#logoutではcookieを削除し、login画面へredirect
sub logout {
    my ($self,$req) = @_;

    my $auth_name = $req->auth_name;
    my $cookie_name = join('_',$AUTH_COOKIE_HEADER,$auth_name);
    my $cookie_conf = $AUTH_CONF->{$auth_name}->{cookie};

    my $cookie = new CGI::Cookie(-name => $cookie_name,
				 -value=> '',
				 -expires=>0,
				 -domain =>$cookie_conf->{domain},
				 -path   =>$cookie_conf->{path},
				 -secure =>$cookie_conf->{secure});
    $req->err_headers_out->add("Set-Cookie" => $cookie);

    my $logout_url = $AUTH_CONF->{$auth_name}->{login_form_url};
    $req->headers_out->set("Location" => $logout_url);

    return REDIRECT;
}

#今回はlogin画面をこのclassで表示していますが、
#この程度の内容なので、全くの別サーバで実装しても良いと思います。
sub login_form {
    my ($self,$req) = @_;

    my $auth_name = $req->auth_name;

    my $args = CGI->new($req)->Vars();
    my $destination;
    if( $args->{destination} ){
	$destination = $args->{destination};
    } else {
	$destination = $AUTH_CONF->{$auth_name}->{default_destination};
    }

    {
	local $/ = undef;
	$LOGIN_FORM_TEMPLATE ||= <DATA>;
    }

    my $data = {login_url=>$AUTH_CONF->{$auth_name}->{login_url},
		destination=>$destination };
    my $tx = Text::Xslate->new();
    my $result = $tx->render_string($LOGIN_FORM_TEMPLATE,$data);

    print CGI::header(-type=>'text/html',-charset=>'UTF-8');
    print $result;
    return OK;
}

1;
__DATA__
<html>
<body>
<form method="post" action="<:$login_url:>">
<input type="hidden" name="destination" value="<:$destination:>">
USER ID:<input type="text" name="uid">
PASSWD:<input type="password" name="passwd" value="">
<input type="submit" value="LOGIN">
</form>
</body>
</html>

httpd.conf (抜粋)、startup.pl

PerlRequire "/home/endo/local/apache22/conf/startup.pl"

#認証が必要な部分
<Directory /home/endo/dev/pri>
AuthType ApacheSSO
AuthName member
PerlAuthenHandler ApacheSSO->authen
PerlAuthzHandler  ApacheSSO->authz
require valid-user

<Files "*.pl">
Options ExecCGI
AddHandler cgi-script .pl
SetHandler perl-script
PerlHandler ModPerl::Registry
PerlSendHeader On
</Files>
</Directory>
Alias /pri      /home/endo/dev/pri

#login画面を表示
<Location /login_form>
AuthType ApacheSSO
AuthName member
SetHandler perl-script
PerlHandler ApacheSSO->login_form
</Location>

#login実行(ticket発行)
<Location /login>
AuthType ApacheSSO
AuthName member
SetHandler perl-script
PerlHandler ApacheSSO->login
</Location>

#logout実行(ticket無効化)
<Location /logout>
AuthType ApacheSSO
AuthName member
SetHandler perl-script
PerlHandler ApacheSSO->logout
</Location>
#/usr/local/bin/perl

BEGIN {
  use lib qw(.
	     /home/endo/dev/ApacheSSO/lib
	     );
}

use ApacheSSO;

my $AUTH_COMMON_CONF =
    {login_url =>	'http://colinux:8081/login',
     login_form_url =>	'http://colinux:8081/login_form',
     logout_url =>	'http://colinux:8081/logout',
     default_destination =>	'http://colinux:8081/pri/index.html',
     crypt_key_file =>	'/home/endo/dev/ApacheSSO/etc/key_aes.txt',
     public_key_file =>	'/home/endo/dev/ApacheSSO/etc/key_pub.txt'};
ApacheSSO::set_common_conf('member',$AUTH_COMMON_CONF);

my $AUTH_LOGIN_CONF =
    {cookie =>{domain =>'colinux',
	       path   =>'/',
	       expires => undef,
#	       expires => '+1d',	#refer to CGI.pm document
	       secure =>0,
	      },
     private_key_file =>'/home/endo/dev/ApacheSSO/etc/key_pri.txt',
    };
ApacheSSO::set_login_conf('member',$AUTH_LOGIN_CONF);

my $DB_AUTH_CONF =
    {db =>{host=> 'localhost',
	   port=> 3307,
	   name=> 'test',
	   user=> 'root',	#読取専用userにしましょう
	   passwd=>'',
	   client_encoding=> utf8,
	   opt=>{AutoCommit=> 1,
		 mysql_enable_utf8=> 1
		 }
	  },
     users=>{tbl=> 'users',
	     uid_col =>'username',
	     passwd_col=> 'passwd'
	    }
    };
ApacheSSO::set_db_auth_conf('member',$DB_AUTH_CONF);

1;

可逆暗号化用キー 32byte

1234567890ABCDEF1234567890ABCDEF

署名用rsa private key

-----BEGIN RSA PRIVATE KEY-----
MIICXAIBAAKBgQDj0BjlMxQQVupueTpk886irF2ySiYt2fNkTd1FIvMHIXnUkwgA
<略>
kN1HoE9rw/f89cUTl2HOF2rCEYDynafDrMyYuW/waws=
-----END RSA PRIVATE KEY-----

署名検証用rsa public key

-----BEGIN PUBLIC KEY-----
MIGdMA0GCSqGSIb3DQEBAQUAA4GLADCBhwKBgQDj0BjlMxQQVupueTpk886irF2y
<略>
AKFQKJxRzkNi7DDlBQIBAw==
-----END PUBLIC KEY-----

鍵の作成例

  # 秘密鍵の作成(鍵ビット長:1024)
  % openssl genrsa -3 1024 > private-key.txt

  # 公開鍵の作成
  % openssl rsa -pubout -in private-key.txt > public-key.txt

user_id / passwd 登録table

mysql> select * from users;
+----------+------------+
| username | passwd     |
+----------+------------+
| testuser | testpasswd |
+----------+------------+
1 row in set (0.00 sec)

※実験用なので平文でパスワードを登録してます

logoutのサンプル

cookie ticketを削除し、ログインページへリダイレクトします

<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML//EN">
<html>
<head></head>
<body>
<h1>private page (index.html)</h1>
this is index.html<br>
<a href="http://colinux:8081/logout">LOGOUT</a>
</body>
</html>