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等も以下に記載しておきます。
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>