OpenLDAPサーバ(slapd)のperl backendを使う - たごもりすメモ

たごもりすメモ

コードとかその他の話とか。

OpenLDAPサーバ(slapd)のperl backendを使う

長いよ!

社内Webアプリケーションの認証基盤としてあちこちでデファクトになっているのではないかと思われるLDAPだが、その管理は主に人事部がやっていたりして*1中身に迂闊に手を出せず歯がゆい思いをしているエンジニアに捧げるエントリ。俺得。
社内サービスのために認証をやりたいがパスワードの管理は別途LDAPがあるのでそっちに任せたい。しかしLDAPのディレクトリ構造のままだと権限情報がうまい具合に切り分けられず、ああああパスワードは持ちたくねえが権限情報は管理したり変更したり増やしたり減らしたりしたいよおおおおおお、という思いを抱いている人は多いと思う。

そこである日にOpenLDAPオンラインドキュメントの目次を上から下までだらだらと眺めていると、なんと Perl Backend というものがあるらしい。サーバに来た問合せを任意のPerl moduleに移譲できるという。なんだって。すばらしい。なんでもできるじゃんそれ。
……と思いながらリンクをクリックしてみると、なんていうか orz な感じだ。"LATER" じゃねえよ。お前いつその LATER って書いたよ。言ってみろよ。ちなみに Perl Backend は機能としては少なくとも2005年くらいから存在するようだ。まったく知らんかった。

man 5 slapd-perl すると、ちょっと詳しく書いてある。興味がある人は読んでみよう、というか、読まないと何もできない。またOpenLDAPの tarball を展開した中の servers/slapd/back-perl/SampleLDAP.pm がサンプルなので、読んでみるとなんとなく雰囲気がわかる。(雰囲気しかわからない。)

slapd の Perl Backend サポート

Debian 6.0で用意されているパッケージは perl backend が有効のもののようだ。一方 CentOS 5 のものはダメだった。configure 時にperlのパスを特定したりするようなので、perlを入れ替えたりしている場合は自分でビルドした方がいいかもしれない。
以下のような感じでビルドする。configureオプションは詳しくは自分で調べてくれ。ください。

 $ ./configure --disable-ipv6 --disable-bdb --disable-hdb --enable-ldap --enable-perl
 $ make
 $ sudo make install

上の例では動作させる環境の問題で ipv6/dbd/hdb はOFFにしてある。また、別のLDAPサーバにパスワード認証を丸投げする場合にはLDAP Backend が必要になるので、これも有効にしてビルドする。

成功したら prefix を指定しない場合は /usr/local/libexec/slapd にインストールされる。また設定ファイルは /usr/local/etc/openldap/slapd.conf にあるので、これをいじる。
LDAPクエリの base によって、一方はバックエンドの(パスワードを持った)LDAPサーバに中継し、もう一方は Perl Backend に処理させるように、以下のように設定を書く。

# 上の方にあるpidファイルの指定とかは省略

############## pass through #############
database        ldap
suffix          "dc=ad,dc=tagomor,dc=is"
uri             ldap://ldapserver.local/
lastmod         off
binddn          "cn=Manager,ou=Users,dc=ad,dc=tagomor,dc=is"
bindpw          xxxxxx

############## perl backend #############
database        perl
suffix          "dc=tagomor,dc=wada"
rootdn          "cn=Manager,dc=tagomor,dc=wada"
rootpw          zzzzzzz
perlModulePath  /path/to/handler/module/dir
perlModule      TagomorisSlapdBackendHandler

このように slapd を設定することで ad.tagomor.is に関する問合せは ldapserver.local の内容が参照され tagomor.wada に関する問合せ*2は /path/to/handler/module/dir に置いた TagomorisSlapdBackendHandler.pm というモジュール(のインスタンス)が処理することになる。なおこのディレクトリ自体が @INC に含まれるようになるし perlModulePath は複数指定できるので、依存ライブラリなどが複数のディレクトリに分散している場合は、その数だけ書いておけばいい。

Perl Backendを組合せた認証応答

で、Perlのバックエンドを使ってどう権限情報つきの認証をやるの? というもっともな疑問についてだが、以下のような流れになる。
まず普通にLDAPで認証をする場合にどういう処理になるかを簡単に書いておこう。

  1. search
    • LDAP Serverに対して「どのユーザで認証するか」の情報を確定するため、オブジェクトの検索を行う
      • このときはLDAP Serverに対しては binddn および bindpw で接続(bind)する
    • search には base dn とフィルタを指定する
      • base は ou=Users,dc=ad,dc=tagomor,dc=is のように指定
      • filter は (&(objectclass=*)(sAMAccountName=tagomoris)) のように指定
    • これで該当するLDAPエントリがあった場合に返ってくるデータから DN (DistinguishedName) を特定する
  2. bind
    • 前のフェーズで特定した dn とユーザから入手したパスワードをペアにして LDAP Server に対して bind を試みる
    • 成功すれば認証成功、失敗すれば失敗

さて実用上の話だが、LDAPのディレクトリ構造(ツリー構造)と権限認可のレイヤ分けとがうまく一致していれば特に問題はない。認証要求時に必要な権限に応じて search/bind 時のbase指定を変えればいいだけだからだ。

  • 部署ごとに ou が切られていて、開発部の人のみに認証を通したい場合
    • base: ou=開発部,ou=Users,dc=ad,dc=tagomor,dc=is
    • filter: (sAMAccountName=tagomoris)
  • 職位ごとに ou が切られていて、マネージャのみに認証を通したい場合
    • base: ou=Manager,ou=Users,dc=ad,dc=tagomor,dc=is
    • filter: (sAMAccountName=ikebe)

しかし、部署ごとにouが切られているが、部署を問わず特定の職位のみ認可したい、などという場合には困る。非常に困る。実際にはLDAPエントリの属性に情報を入力してインデックスを設定し、特定の属性を持つユーザのみ認証するようにフィルタを書けばいい場合もある。

  • 部署ごとにouが切られているがマネージャのみに認証を通したい場合
    • base: ou=Users,dc=ad,dc=tagomor,dc=is
    • filter: (&(title=Manager)(sAMAccountName=ikebe))

しかし、必要な情報がLDAPに登録されていない場合もある。どうしてもディレクトリのツリー構造がアテにならないかもしれない。必要な属性にインデックスが作成されておらずフィルタ条件として機能しないかもしれない。権限の種類を増やすたびにLDAPエントリの編集とか超めんどくさい。そういう理由でLDAP上の登録情報を変えようとしても人事部(以下略)ActiveDirectoryってWindows Serverで(以下略)。

したがって、以下のようにしたい。

権限情報は Perl Backend で管理する
  • つまりsearchは Perl Backend で受ける
    • このときのbaseは dc=tagomor,dc=wada とする
  • ユーザ名と権限情報の対応を適当なDBなりKVSなりに入れておいて、そいつを引いて確認する
    • 登録がない場合にデフォルト許可にするかデフォルト拒否にするかは決めておく
  • 認可したい権限ラベルを base における ou なり、適当な属性名のフィルタなりで渡す
    • こんな感じ
      • base: ou=TracTicketCreate,dc=tagomor,dc=wada
      • filter: (username=tagomoris)
    • あるいはこんな感じ
      • base: dc=tagomor,dc=wada
      • filter: (&(privilege=TracTicketCreate)(username=tagomoris))
    • Perl Backend は dc=ad,dc=tagomor,dc=is のdnを持つLDAPエントリを返す
      • 動作として、クエリ対象のbaseとは異なるdnをもつエントリでもslapdは返してくれるようだ
      • LDAP自体の動作として正しいのかどうかは知らん


パスワード認証は返ってきた dn を用いて行う
    • つまり Perl Backend から返ってきた cn=tagomoris,ou=Users,dc=ad,dc=tagomor,dc=is みたいなdnとパスワードの組合せ
    • これは LDAP backend として設定してあるbaseに一致するので、バックエンドのLDAPサーバに中継され、そちらで処理される

こうすることで、権限情報(と、必要であればアカウント名に関するゴニョゴニョした処理)をPerl Backendで行い、パスワード認証自体は既存のLDAP Serverに移譲するという構造になるのだ。べんり!


Perl Backend Module例

で、その Perl Backend Module をどう書くか。簡単に例を書く。いま手元で使ってるものとは違うコード例だけど、勘弁してほしい。
LDAPではいろいろな操作が定義されてるんだけど、上述のようなことをしたい場合、実際には search() だけ実装されてれば動く。*3

package TagomorisSlapdBackendHandler;

use strict;
use warnings;
use Carp;

use Net::LDAP;

my $ldapconfig = {
    server => 'ldapserver.local',
    binddn => 'cn=Manager,ou=Users,dc=ad,dc=tagomor,dc=is',
    bindpassword => 'xxxxxx',
    base => 'dc=ad,dc=tagomor,dc=is',
};  

sub new {
    my $class = shift;
    return bless {}, $class;
}

sub init {
    return 0;
}

sub search {
    my $this = shift;
    my ($base, $scope, $deref, $sizeLim, $timeLim, $filterStr, $attrOnly, @attrs ) = @_;

    my ($priv) = ($base =~ m/ou=(\w+)/); # baseの一番左のouを権限ラベルとして扱う

    $filterStr =~ s/\(\?/(/g; # スキーマに定義されてない属性名が ?hoge のようにマーキングされてしまうので除去

    my $filter = Net::LDAP::Filter->new($filterStr);

    # フィルタが単一の等号条件 (hoge=pos) だとして、posをユーザ名として扱い @tagomor.is をつけたメールアドレスで検索する
    my $username = $filter->{equalityMatch}->{assertionValue};
    my $searchfilter = '(MAIL=' . $username . '@tagomor.is)';

    # 権限確認 …… ここでDBを引くなりKVSを引くなりして $username と $priv の組合せを認可していいかチェックする
    # 良ければ進めばいいし、権限が無かった場合は return (0); する

    # バックエンドのLDAP Serverに接続し $username を用いてエントリを検索
    my $ldap = Net::LDAP->new($ldapconfig->{server});
    
    my $mesg = $ldap->bind($ldapconfig->{binddn}, password => $ldapconfig->{bindpassword});
    $mesg->code && croak "Dictionary entry[bind]: " . $mesg->error;
    
    $mesg = $ldap->search(base => $config->{base}, filter => $searchfilter);
    $mesg->code && croak "Dictionary entry[search]: " . $mesg->error;
    
    my @entries = $mesg->entries;
    if (scalar(@entries) > 1) {
        # 2件以上の検索結果がある場合はどちらにしろ認証には使えない
        # レスポンスを件数ゼロにしてしまっていいかは考える必要がある、かも
        return (0); 
    }
    if (scalar(@entries) < 1) {
        return (0);
    }
    my $entry = $entries[0];
    my $entryString = "dn: " . $entry->dn . "\n" . join("\n", map {$_ . ": " . $entry->get_value($_)} $entry->attributes) . "\n";

    # レスポンスコード 0 (正常)、 およびLDAPエントリ実体(テキスト表現)を返して正常に応答完了
    return (0, $entryString);
}

sub bind {
    my $this = shift;
    # you cannot bind with virtual entries.
    return 0;
}

sub compare {
    croak "not implemented";
}

sub modify {
    my $this = shift;
    # you cannot do any modifications to virtual entries.
    return 0;
}

sub add {
    my $this = shift;
    # you cannot add entries to virtual entries.
    return 0;
}   

sub modrdn {
    my $this = shift;
    # you cannot do any modifications to virtual entries.
    return 0;
}   

sub delete {
    my $this = shift;
    # you cannot do any modifications to virtual entries.
}   

sub config {
    my $this = shift;
    # you cannot do any modifications to virtual entries.
    return 0;
}   

1;

search時の引数には $scope, $deref, $sizeLim, $timeLim, $attrOnly, @attrs などなどがあるが、とりあえず $base と $filterStr だけ見ておけばいい(ひどい)。まあ、それで使いものにはなる。
プロトコルを遵守したい方々はこれらの引数も正しく解釈していただきたいが、そもそもLDAP自体を良く知らないと、これらの引数が厳密に何を意味するのかもよくわからんのだよね。そのうちどうにか学んでおきたい。

Wadaの話

LDAP ServerにPerlスクリプトをかませることができるのは分かったけど、そうは言ってもこれじゃ扱いづらいよねえ、だいたいLDAPだけじゃなくて他の認証プロトコルとも共通で権限情報とか扱いたいよ!

というアナタというより自分のため、いま Wada という名前の認証基盤アプリケーションを作ってる。slapd perl backend handler も持っていて、LDAPやOAuthやOpenIDで共通の権限情報DBを参照して認証応答できるようになるはず。まあコードは既にgithubにあるんだけど、まだLDAPになんとなく全許可で応答できるようになった程度です。
しばらくたったら使いものになるものが出てくると思うので、それまで待つか、俺がWadaを捨てて乗り換えたくなるような何かを誰か書いてください。

*1:正体がActiveDirectoryだったりすることによる

*2:なんで .wada なのかは置いておこう。実在しないドメインならなんでもOK。

*3:他のメソッドも定義は存在する必要がある。でないとslapdに呼び出しが届いた瞬間にslapdごと落ちる。