DjangoでLDAP認証 その4 ldap3モジュールによる認証バックエンドカスタマイズ
前回で認証バックエンドのカスタマイズに必須のメソッドを調べたので今回からは認証バックエンドのカスタマイズをやっていこうと思います。Pythonにはldap3というPythonからLDAPサーバにLDAP接続するためのモジュールがありますので今回はこれを使っていきます。以下の記事の続きです。
相変わらずの注意書きですが、この記事はWebアプリケーションとDjangoの初心者が調べたことをメモするものであり、正しい解説記事を目的としていないのでご注意ください。
環境は以下のとおりです。
OS : macOS(intel CPU) Big Sur
Python : バージョン3.8.5 (venv環境)
Django : バージョン3.2.5
Webサーバ : Djangoに組み込みのもの
ldap3 : バージョン2.9.1
LDAPサーバ : Active Directory (Windows Server2016)
方針
ldap3を使ってLDAP認証を通して認証結果の可否を判定できるようにauthenticateメソッドを新しく作ります(実はこのそのコードをどこに書けば良いのか、という疑問にあたっているのですが)。その下準備としてもう少し標準のauthenticateメソッドの内容を深堀りしておきます。その上で自前のauthenticateメソッドを準備します。
今回のLDAP認証用のコードはかなり単純なものにしますし、エラー処理もほとんど行いませんが、実際にはそもそものLDAPサーバ設計やOU設計、バインド、ベースDN、LDAPサーバの仕様に合わせた例外処理といった設計を真面目にやらないとセキュリティの問題が発生するのでご注意ください。
標準のauthenticateメソッドの処理内容
この調査を行う目的は標準のauthenticateメソッドの具体的な処理を追いかけておき、必要な処理が自前のメソッドで抜けないようにするためです。前回記事で見た限り、そこまで難しいことはしていないと思いますが念の為です。Django自体の勉強も兼ねています。
コードの要点を抜き出すと大体以下の通りかと思います。
まず1つ目。
if username is None:
~
いきなり意図がよく分かりませんが、メソッドの引数usernameとしてではなく、キーワード引数として渡されるケースを想定しているように思えます。どういうケースなのかは正直わかりませんが、これはDjangoの標準認証アプリを使わない場合なのでしょうか・・・。とりあえず、やっていることはキーワード引数として渡されたもののうち、USERNAME_FIELDとして定義されたものがあればそれを変数usernameとして格納しているようです。ちなみにUSERNAME_FIELDは標準認証アプリのmodels.pyの中で値としてusernameが指定されています。このusernameは当然ながらユーザーモデルのusernameです。authenticateメソッドのusernameとは意味合いが違います。
2つ目。
if username is None or password is None:
~
これはそのままですが、ユーザ名かパスワードがこの時点で正常に取得できなかった場合は何も返さないという処理です。
3つ目。
try:
user = UserModel._default_manager.get_by_natural_key(username)
~
実はマネージャというものを正しく理解できていないので半端な調査になります。とりあえず現在使用するマネージャを使って、get_by_natural_key関数を実行しています。get_by_natural_keyは変数USERNAME_FIELDの値を指定してユーザーモデルからインスタンスを返す動作をするようです。要するにこれが認証の実態ですね。その後のエラー処理やreturnは前記事で書いたので割愛します。
こう見てみると、1つ目が不明瞭なことを除くと特に注意すべき点はないような気がします。今回は標準認証アプリを使用するので、恐らく位置引数としてusernameが指定されるのではないかなと予想します。その場合、この処理はあってもなくても動作には影響がないでしょう。
認証バックエンドクラスの作成
まずはじめの疑問。
このコードはどこに書くのか・・・。正直良くわからなかったので適当な場所に書いてみることにします。settings.pyに認証バックエンドクラスのモジュールサーチパスを指定するので、多分どこでも良いに違いない。今回はユーザーモデルの調査のときに作成したcustomuserというDjangoアプリケーションの中にcustomauthbackend.pyというファイルを作成し、そこにコードを書いてみます。
とりあえずLdapAuthBackendというクラスを作り、ModelBackendを継承します。authenticateとget_user以外にもメソッドが諸々あり、一応使えるようにしておこうという魂胆です。
class LdapAuthBackend(ModelBackend):
ldap3を使ったauthenticateメソッドの作成
とりあえず、どストレートに実装します。
- authenticateメソッドをオーバーライドします。処理の流れは以下の通りです。
- get_user_modelで現在のユーザモデルを取得する。
- ユーザがDjangoのフォームから入力したユーザ名とパスワードを使用してLDAP Bindする。
- LDAP Bindできたかどうかで認証の成功を判断する。
- 認証に成功した場合、DjangoのユーザモデルのデータベースにユーザIDのレコードを追加し、LDAP Bindを解除した上でユーザモデルの当該ユーザIDインスタンスを返す。認証に失敗した場合は何も返さない。
具体的なコードは以下のとおりです。見辛くて申し訳ない。
また、このコードは実際にLDAP上のアカウントでDjangoアプリケーションの認証が可能になりますが、実用上は使い物にならないことが分かっているのでご注意下さい。ダメな部分は後で説明します。
class LdapAuthBackend(ModelBackend):
def authenticate(self, request, username=None, password=None, **kwargs):
ldap_server = Server("192.168.1.156")
try:
ldap_auth_by_bind = Connection(ldap_server, user=username, password=password, auto_bind=True)
user, insert_user = UserModel.objects.update_or_create(username=username, defaults={'password': password})
ldap_auth_by_bind.unbind()
return user
except:
return
尚、冒頭に以下のコードを書いています。
from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend
from ldap3 import Server, Connection, ALL
UserModel = get_user_model()
ついでにget_userメソッドも書きましたがModelBackendにあるものと同じなので必要ないかもしれません。
LDAP Bindに成功した場合、update_or_create()によってユーザーモデルのデータ更新を行っています。これでユーザーモデル上にアカウント情報がない場合はレコードが作られますし、既にアカウント情報がある場合でLDAPサーバ側でパスワードが変更された場合、そのパスワードがユーザーモデル上のアカウント情報に反映されます。
認証バックエンドの追加
global_setting.pyにAUTHENTICATION_BACKENDという配列があり、ここに先程のクラスを登録します。ただ、globalの設定を変更するのもあれなので、settings.pyに同じ配列を用意して上書きしています。
AUTHENTICATION_BACKENDS = [
'customuser.customauthbackend.LdapAuthBackend',
]
ただ、先程のクラスとこの認証バックエンドをそのまま実装すると問題がありまして、Django管理ページにアクセスできなくなります。Djangoのユーザーモデル上でスーパーユーザになっているユーザがLDAPサーバ上にいないとNGです。認証バックエンドにDjangoの標準認証アプリも指定しておけば良いのですが、何らかの問題でLDAP認証に失敗した場合、意図せず認証方式が変わる可能性があります。
このあたりはメリット・デメリット比較による決めの問題だと思っています。例えば認証方式がいつの間にか変わっていたとしても、とりあえずユーザが認証できれば良い、と考える場合、システムログからLDAP認証が失敗していることを検出できるようにしておくという手があります。ただし、この場合はLDAPサーバ側でパスワードを変更した場合、Django側ではそのパスワード変更には追従できません。他にauthenticateメソッド内でusernameが特定のユーザ名だった場合は処理を分岐させるという手もあるかと思います。
ダメな点の説明
いや実はこのコードは認証動作としてはタイポによる修正以外いきなりちゃんと動いたのでゴキゲンになっていたわけですよ。その後、update_or_create()によってupdateやcreateされた後に実際のデータベースレコードを見た後に凍りつきました。パスワードが平文で保存されている。そりゃパスワードをそのままUPDATEやINSERTしてるわけで当然ですよねといったところですが・・・。
調べたところ、パスワードを書き込むときはAbstractBaseUserクラスに定義されているset_passwordメソッドを使えとのことでした。これによりハッシュ化されるようです。しかしユーザーモデル等の前提条件を変えずにupdate_or_createの目的とset_passwordを両立させる方法が思いつかない。
少し考えまして、今回はDjangoのユーザーモデルのデータベースのパスワードには十分に複雑なランダムな文字列をハッシュ化して保存するようにします。前提として今回のDjangoアプリケーションは以下の利用法を想定しているためセキュリティ的な強度は十分と思われます。
- 外部公開のないアプリケーションであること。
- 利用者が意識するアカウントは基本的にLDAPサーバ側で作るもので、Django側で作成しない。
- 認証バックエンドからDjango標準認証アプリは外すため、Djangoデータベース内のパスワードが何であっても利用者には影響がない。
まぁそもそも仮にこれが平文じゃなかったとしてもDjango側にアカウント情報(特にパスワード)のコピーは必要なければ持たない方が良いわけで。
authenticateメソッドの修正
修正したコードは以下の通りです。randomとstringモジュールをインポートしています。
class LdapAuthBackend(ModelBackend):
def authenticate(self, request, username=None, password=None, **kwargs):
ldap_server = Server("192.168.1.156")
try:
ldap_auth_by_bind = Connection(ldap_server, user=username, password=password, auto_bind=True)
user, insert_user = UserModel.objects.update_or_create(username=username)
if insert_user == True:
hash_password = ''.join(random.choice(string.ascii_letters + string.digits + '&#!?') for _ in range(64))
user.set_password(hash_password)
ldap_auth_by_bind.unbind()
return user
except:
return
Django側にユーザが存在しない場合、update_or_createによりinsert_userがTrueになります。その場合に64文字のランダム文字列をパスワードとし、ハッシュ化します。Django側でユーザを作ることはないのでハッシュ化されていないパスワードが格納されることはありません。生成されたランダム文字列は基本的には誰にもわからないので、認証バックエンドに標準認証アプリも追加してDjangoのスーパユーザはローカル管理するのもありかもしれません。余談ですが、こちらの認証方式は通常使われることはないので、もしこちらのauthenticateメソッドが実行された場合は何らかのアラートを上げるようにしておいた方がセキュリティ上は良いと思います。
実際にユーザーモデルをカスタマイズする
ついに本当にカスタマイズする時が来ました。目的はLDAPサーバ上でアカウントが持っている属性を取り出してきてDjangoのユーザーモデルに格納してみます。まずLDAPサーバ側のアカウントのtelephoneNumber属性に電話番号として適当な値を入れます。
続いて、Django側で今回作ったユーザーモデルに電話番号フィールドを追加します。
class User(AbstractUser):
telephone_number = models.CharField('電話番号', max_length=15, blank=True)
マイグレーションしてデータベースに反映させて準備完了です。
続いてtelephoneNumber属性の値を取得できるようにauthenticateメソッドを修正します。
class LdapAuthBackend(ModelBackend):
def authenticate(self, request, username=None, password=None, **kwargs):
ldap_server = Server("192.168.1.156")
try:
ldap_auth_by_bind = Connection(ldap_server, user=username, password=password, auto_bind=True)
ldap_query = '(&(objectclass=person)(samAccountName=' + username + '))'
ldap_auth_by_bind.search('dc=rdmac,dc=test', ldap_query, attributes=['telephoneNumber'])
ldap_query_result = ldap_auth_by_bind.entries[0]
telephone_number = ldap_query_result.telephoneNumber.values[0]
user, insert_user = UserModel.objects.update_or_create(username=username, defaults={'telephone_number': telephone_number})
if insert_user == True:
hash_password = ''.join(random.choice(string.ascii_letters + string.digits + '&#!?') for _ in range(64))
user.set_password(hash_password)
ldap_auth_by_bind.unbind()
return user
except:
return
これで認証を行うとtelephoneNumberの値を取得してきます。認可の設計をLDAPサーバ側の情報をベースに行う場合にこの辺が必要になるだろうという見込みのための実装です。
次
認可について調査します。Active DirectoryのセキュリティグループをベースにDjango側の操作権限が自動的に割り当てられるような方法を考えたいと思っています。Djangoの認可機能については存在することは知っているものの、仕組みや機能を全く調べていないのでゼロスタートになります。