end0tknr's kipple - web写経開発

太宰府天満宮の狛犬って、妙にカワイイ

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

mod_perl2を使ってシングルサインオン認証を書いてみた - end0tknr's kipple - web写経開発

上記entryの 2021年版です。 前回は、ブラウザでのアクセスを想定し、ログイン画面を持っていましたが、 今回は、スマホアプリ等からのREST APIアクセスを想定し、画面を持っていません。

1. 構成と、書く処理の流れ

1.1 login

┌───────────────────┐    
│user                                  │
└┬──────────────────┘
  ↓①login request ↑  ④auth ticket
┌─────────┴─────────┐
│今回の ApacheSSO                      │
└┬──────────────────┘
  ↓②ip/pw check   ↑  ③check result
┌─────────┴─────────┐
│app server                            │
└───────────────────┘

1.2 認証中の処理

┌───────────────────┐    
│user                                  │
└┬──────────────────┘
  ↓①login request
┌───────────────────┐
│今回の ApacheSSO                      │
└┬──────────────────┘
  ↓②reverse proxy
┌───────────────────┐
│app server                            │
└───────────────────┘

1.3 logout

認証ticket cookieを削除するのみ

┌───────────────────┐    
│user                                  │
└┬──────────────────┘
  ↓①logout
┌───────────────────┐
│今回の ApacheSSO                      │
└───────────────────┘

2. 各種src

2.1 httpd.conf

DocumentRoot "/home/end0tknr/html_pub"

LoadModule perl_module modules/mod_perl.so

PerlRequire "/home/end0tknr/apache_sso/startup.pl"

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

#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>

2.2 startup.pl

#!/usr/local/bin/perl

BEGIN {
  use lib qw(.
             /home/end0tknr/apache_sso/lib );
}

use ApacheSSO;

my $AUTH_CONF =
    {crypt_key_file =>  '/home/end0tknr/apache_sso/key_aes.txt',
     public_key_file => '/home/end0tknr/apache_sso/public-key.txt',
     private_key_file =>'/home/end0tknr/apache_sso/private-key.txt',
     cookie =>{domain =>'cent7.a5.jp',
               path   =>'/',
               expires => undef,
#              expires => '+1d',        #refer to CGI.pm document
               secure =>0 },
     remote_login_api=> 'http://localhost/pub'
    };

ApacheSSO::set_common_auth_conf('member',$AUTH_CONF);

1;

2.3 ApacheSSO.pm

package ApacheSSO; #Single Sign On
use strict;
use warnings;
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 LWP::UserAgent;
use MIME::Base64;
use Data::Dumper;  #for debug

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

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

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

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

    # 以降は、各鍵のload.
    
    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",
                        -pbkdf=>'pbkdf2',
                        -nodeprecate=>1);
    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 $!";

    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 $!";
}

#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);

    $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);
}

# 暗号化ticketから、user情報を抽出することで、authen(認証)とします
sub authen {
    my ($self,$req) = @_;
    
    my $auth_name = $req->auth_name;    #Apache2::Access
    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 FORBIDDEN;
    }

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

    return FORBIDDEN;
}

# authen() で、暗号化ticketから、user情報抽出済の為
# authz(認可)では特に何を行いません。
sub authz {
    my ($self,$req) = @_;
    return OK;
}

#user_id,passwordを検証し、認証cookie ticketを発行
sub login {
    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 $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
    }

    my $uid = $self->check_passwd($auth_name,$args->{uid},$args->{passwd});
    if( not $uid ){
        my $cookie = new CGI::Cookie(-name => $cookie_name,
                                     -value=> '',
                                     -expires=>0,
                                     -domain =>$cookie_conf->{domain},
                                     -path   =>$cookie_conf->{path},
                                     -secure =>$cookie_conf->{secure});
        return FORBIDDEN;
    }

    # remote_ip を使用できなくなっていましたので、useragent_ip へ置換.
    my $ticket =
        $self->encode_ticket($auth_name,$uid,$req->useragent_ip);
    #   $self->encode_ticket($auth_name,$uid,$req->connection->remote_ip);

    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);
    
    # print STDERR $ticket;
    return OK;
}


sub check_passwd {
    my ($self,$auth_name,$uid,$passwd) = @_;

    my $ua = LWP::UserAgent->new;
    $ua->timeout(10);

    my $payload = {
        uid    => $uid,
        passwd => $passwd };

    my $res = $ua->post($AUTH_CONF->{remote_login_api}, $payload );

    if( ! $res->is_success ){
        #print $res->as_string;
        return $uid;
    }
    return undef;
}

# 暗号化ticketであるcookieを削除することで、logoutとします.
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);

    return OK;
}

1;
__END__

2.4 各鍵

key_aes.txt

1234567890ABCDEF1234567890ABCDEF

private-key.txt

-----BEGIN RSA PRIVATE KEY-----
MIICWwIBAAKBgQC+dlvKyOzo2XFoRD9u2CKIV/vXrDP2NbBoTp9fr9kNCkakWnZs
x19nXXl11F+p1Uc9ayO2O+ylTsJSiSoo3OGM+mK2X905lRXiX1ZlY8OgCDpBR0qf
PNFGKzd9RisssUIyCoPogF1+lR68Ghr1odLr6X2ISxdFm13POz0SSAO5GQIBAwKB
gH75kocwnfCQ9kWC1PSQFwWP/TpyzU7OdZrfFOp1O14G2cLm+Z3aP5o+UPk4P8aO
L35HbSQn8xjfLDcGHBs967IpzIiPMGdBlgZAlPaXvx2QU+GYHRS0Uta/31q2+veC
fO3ftZMVplXWdw12mNzbF1N1EEvEFYGNex2iEKmE6KKrAkEA8zPoWgQqOwt0G3oP
2rqbkS7OMrQOnhiHjTF1ekn7m/1qEjkyEi+Sc6rzxIonBOD/bHN3QE5dSyPSo9eZ
k6CngQJBAMh8AS8QdHepZOL846VqWB6NoLBq8ZA8fH42/7lovdGJYyi6Wc20a1/B
NCOrg1RO701xn2SopfwBUCRMem0GHZkCQQCiIprmrXF8sk1nprU8fGe2HzQhzV8U
EFpeIPj8MVJn/ka20MwMH7b3x00tsW9Ylf+dok+AND4yF+HCj7u3wG+rAkEAhagA
ygr4T8ZDQf3tGPGQFF5rIEdLtX2oVCSqe5spNluXcHw73nhHlSt4F8es4t9KM6EU
7cXD/VY1bYhRngQTuwJACwzEez5Hi0jDK5NY+czEyVlc08rMhkvloyU4vpHFmjiK
gxqVsgoWZYPTsDlXtRbiEIkjXr2fFsO7hHi/kNMZfw==
-----END RSA PRIVATE KEY-----

public-key.txt

-----BEGIN PUBLIC KEY-----
MIGdMA0GCSqGSIb3DQEBAQUAA4GLADCBhwKBgQC+dlvKyOzo2XFoRD9u2CKIV/vX
rDP2NbBoTp9fr9kNCkakWnZsx19nXXl11F+p1Uc9ayO2O+ylTsJSiSoo3OGM+mK2
X905lRXiX1ZlY8OgCDpBR0qfPNFGKzd9RisssUIyCoPogF1+lR68Ghr1odLr6X2I
SxdFm13POz0SSAO5GQIBAw==
-----END PUBLIC KEY-----