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の認可機能については存在することは知っているものの、仕組みや機能を全く調べていないのでゼロスタートになります。
DjangoでLDAP認証 その3 標準の認証バックエンドのメソッド
今回は認証バックエンドについて調べたことを書きます。相変わらずの注意書きですが、この記事はWebアプリケーションとDjangoの初心者が調べたことをメモするものであり、正しい解説記事を目的としていないのでご注意ください。
環境は以下の通りです。
macOS(intel CPU) Big Sur
Python3.8.5 (venv環境)
Django3.2.5
WebサーバはDjangoに組み込みのもの
この記事は以前の記事の続きです。
上記の記事で書いた「認証バックエンドをカスタマイズする時、カスタマイズ用の認証用コードにauthenticateとget_userメソッドが必須」ということがよくわからない、という内容について調べたことを書いたものがこの記事です。最終的にはLDAP認証を行いたいのですが、そもそもDjangoの認証バックエンドの理解度が不足しているので今回の記事ではそこまで到達しません。Djangoが用意している標準の認証アプリケーションに関する内容のみになります。
認証バックエンド
認証バックエンドといきなり説明は始まりますが、そもそも認証バックエンドとは何かという疑問が出てくるわけです。感覚的にはイメージはつきますが、Djangoにおいては正確に何を指す言葉なのかは分かっていません。
調べた内容から結論を言うと、「Djangoがユーザから受け取ったユーザクレデンシャルでDjangoアプリケーションの認証を行うための手段と認可の方法を定義したもの」ということになります(当たり前かもです)。具体的な実装の例としてはDjangoがあらかじめ用意している認証アプリケーションもその一つです。ユーザから受け取ったクレデンシャルがユーザーモデルをベースとして生成されたデータベースにマッチするか確認して結果を返します。逆にユーザがIDとパスワードを入力する画面やフォームは認証バックエンドではありません。これらはユーザがクレデンシャルをDjangoに渡すためのUIだからです。
プロジェクト作成時の実装
Djangoが用意している認証アプリケーションを使ってとにかくユーザアカウントとパスワードによる認証を行うだけなら、公式ドキュメントに沿ってview.pyとurls.pyを準備すればすぐに実現できます。ただ、そこから1歩でも抜け出そうと思うと急に難易度が上がる印象でした。
まず、Djangoが用意している認証アプリケーション(長いのでこの一連の記事では以後、標準認証アプリと呼びます)はDjangoアプリケーションと同等のものなのか、というところを確認します。これは色々調べたところyesで、settings.pyのINSTALLED_APPSに「django.contrib.auth」というアプリケーションが書かれています。
公式ドキュメントによると認証バックエンドには複数の認証方法を指定することが可能で、ユーザから受け取ったクレデンシャルで全ての認証方法を上から試行して一つでも認証に成功したらDjango的には認証OKということになるようです。この認証バックエンドの認証方法の指定は標準ではglobal_setting.pyのAUTHENTICATION_BACKENDSというリスト変数にあります。プロジェクト作成時の値は「django.contrib.auth.backends.ModelBackend」になっています。
django > contrib > authにあるbackends.pyを見ると、ModelBackendというクラスがありました。さらにクラスの中にはauthenticateとget_userというメソッドもありました。認証バックエンドのカスタマイズにはauthenticateとget_userメソッドが必須、という前提の通りの実装になっているようです。このあたりから、標準認証アプリが認証に際して何をしているのか分かれば他の認証方法の実装方法もわかりそうな気がします。
authenticateメソッドは何をしているのか
公式ドキュメントによると、引数として例えばユーザ名とパスワードを受け取り認証バックエンドに問い合わせを行い、正しい認証情報であればユーザーオブジェクトを返すとのことです。ユーザーオブジェクトって何だ?
そのままユーザーオブジェクト(原文User object)のUserのリンクをたどるとUser Modelの記事に飛びました。言いたいことはわかるような。
具体的にauthenticateメソッドのコードを見てみると、引数として渡されたユーザ名にマッチする何かをユーザーモデルから引っ張ってuserという変数に格納しているようです。この部分のコードは以下の通りです。
user = UserModel._default_manager.get_by_natural_key(username)
細かい意味は調べていませんがメソッドの役割からして前述の通りと想像します。UserModelは変数で、backends.pyの中で以下のようにユーザーモデルを格納しているようです。
UserModel = get_user_model()
get_user_modelという関数は現在有効になっているユーザーモデルを取得する関数です。ついでにこのUserModelをそのまま出力すると何が表示されるのか試してみました。メソッド中のtry文の中にprintを書き、ログで確認しています。
print(UserModel)
print(type(UserModel)
出力は以下の通り。
<class 'customuser.models.User'>
<class 'django.db.models.base.ModelBase'>
尚、customuserというappでUserというカスタムユーザーモデル用のクラスを使っています。
また、userの内容も同様に出力してみたところ、以下の通りでした。1個目が変数、2個目が変数のtypeです。
admin
<class 'customuser.models.User'>
ユーザ名はadminを使用しました。AbstractBaseUserクラスに__str__メソッドが定義されており、戻り値がget_username()となっていたのでこれによりadminという表示になっていると思われます。
try ~ elseの最後にパスワードを評価しているような条件文があるので、これによってパスワードの評価を行っていると思われます(これは内容を確認していません)。
で、authenticateメソッド自体は上記のようにadminというユーザーモデルのクラスのインスタンスを返して終わりのようです。そもそも何がこのメソッドを呼び出して、戻り値をどのように処理しているのかはよく分かりませんが、Djangoの全てを解き明かすことを目的としているわけではないのでこれはそういうものだと思うことにします。
が、少しだけ調べてみた
先程のログが出力されるのは認証画面でユーザ情報を入力してPOSTした後でした。そのため、authenticateメソッドは認証行為が行われた時に実行されるものと思われます。今回試した認証の実装は適当に用意した関数ベースビューに@logiin_requireデコレータをつけ、標準で用意されているLoginViewをクラスベースビューとして使用する方式です。認証情報をPOSTする時にauthenticateメソッドが走るように見えるのだから、LoginViewの中に仕掛けがあるのだろうと予想しています。
LoginView
LoginViewはdjango > contrib > auth > views.pyにかかれています。コードを見ると次のような内容が気になりました。
- FormViewを継承している
- 変数form_classにAuthenticationFormを代入している
- 変数template_nameにregistration/login.htmlを代入している
1個ずつ見ていきます。
FormView
views.py内でimportされており、django > views > generic > edit.pyにあるようです。しかしFormViewクラス自体にはコメント以外何も書かれていなく、実態としては継承しているBaseFormViewが継承しているFormMixinに記述があるようです。FormMixinにはフォームのバリデーションやフォームを通した正常なHTTPリクエストに関すると思われるメソッドが並んでいます。LoginViewではこれらのメソッドをオーバーライドするような記述があります。LoginViewは標準認証アプリによるログインビューの実装例である、と言えそうです。
AuthenticationForm
views.py内でインポートされており、django > contrib > auth > forms.pyにあるようです。ファイル配置からこれも標準認証アプリによるフォームの実装例と思われます。AuthenticationFormクラス内には変数usernameとpasswordが定義されています。この記述があるから、テンプレート内で{{ form.as_p }}とかいった記述でフォームを取り出せるのですね。
さらにこのクラス内にcleanというメソッドがあり、ここでauthenticateメソッドを実行する処理がありました。usernameとpasswordがNoneやFlaseでなければauthenticateメソッドが実行されるようです。cleanメソッドがいつ実行されるのかは調べていませんが、このAuthenticationFormクラスが標準認証アプリにおける認証実行の要のように思われます。
registration/login.html
標準ではテンプレートファイルはDjangoアプリケーション内のテンプレート用ファイル内のregistrationディレクトリ内にlogin.htmlというファイルで定義することを想定しているようです。もちろんurls.pyのas_view関数内等でtemplate_nameの値をこのファイルパス以外に指定することでこの制限からは逃れられます。
つまり何だ
疑問としてはauthenticateメソッドは何をしているか、という話でしたが、これはAuthenticationFormクラスを継承しているLoginViewクラスで定義される標準認証アプリのビューによって実行され、ビューに描画されたフォームに入力されたユーザIDとパスワードが現在使用中のユーザーモデルによって構成されたデータベースレコードに存在しているかということが確認できた場合に、そのユーザーモデルのレコードをユーザーモデルクラスのオブジェクトとして返す、ということになるかと思います。
・・・これってauthenticateメソッド内の例えばパスワードを評価している条件文消したらパスワードがあっていようが間違っていようがユーザーモデルクラスのオブジェクトを返す処理になるということでしょうか。
ということで試してみました。
初期状態では以下のようなコードになっています。
try:
~
else:
if user.check_password(password) and self.user_can_authenticate(user):
user
このif文がパスワードを評価していると思われます。ここからif文を除去した上で、パスワードを適当に入力して認証してみたところ、普通に認証成功しました。
要するに、authenticateメソッドの内容はどうでも良くてとにかくユーザーモデルのオブジェクトが返されれれば良いようですね。つまりクレデンシャルがLDAPサーバ上にあったとしてもDjangoのユーザーモデル上にアカウントが必要ということでしょうか・・・。それはともかく、とりあえずauthenticateメソッドがやらないといけないことはこれで分かった気がします。
get_userメソッドは何をしているのか
このメソッドもユーザーモデルクラスのオブジェクトを返すようです。コードを見ると難しい記述はなく、どうやらユーザーモデルからユーザIDで検索した内容を変数userに確認し、user_can_authenticate()という関数がTrueだった場合にオブジェクトを返す動作でした。
authenticateメソッドと戻り値自体は同じなのでメソッドを使うタイミングが異なるのだろうと予想します。ローテクですが、authenticatedの時と同様にget_userメソッド内にprintをしかけていつこのメソッドが実行されるの確認しました。すると、どうやら認証後にDjango内でページ移動をすると実行されるようです。
よく考えるとこれは当然で、HTTPはL7ステートレスであることからDjango側がユーザを識別する機能がいるわけですね。Djangoがどうやってユーザを識別しているのかは調べていませんがWebブラウザにsessionidというcookieがあったのでこれを使用しているのでしょう。このあたりはまだ別の機会に調べてみます。セキュリティ事情は明らかにしておかないと実装時に困りそうです。
このメソッドが戻り値を返すには、user_can_authenticate(user)がif文的にTrueでないといけないようです。このメソッドは公式ドキュメントに説明があり、このユーザが認証されているかどうか、かつ有効なアカウントである場合にTrueになるようです。
少しだけ深堀り
get_userについてももう少し挙動を調べてみました。が、残念ながらほとんど分かりませんでした。get_userメソッドを実行するコードはdjangoパッケージ内では以下のファイルのようです。
- django > contrib > auth > __init__.py
- django > contrib > auth > middleware.py
- django > contrib > auth > views.py
少し見たところmiddleware.pyが怪しいのですが、Djangoのミドルウェアについてはまだ全然勉強できていません。メソッドではなく普通の関数でget_userが定義されており、位置引数としてrequestが書かれています。これはHTTPリクエストのことを指しそうです。その中でauth.get_user(request)が実行されています。このあたりでユーザを特定しているんじゃないかなと想像しますが、今回は想像で終わりにしておきます。
結論としてちょっと曖昧ですが、get_userメソッドとはページ移動時にユーザを特定するために使用され、そのユーザがDjangoのユーザーモデル上で有効であればそのユーザーモデルオブジェクトを返し、認証済みユーザとして処理するためのメソッドということになります。返り値がどちらもユーザーモデルであってもauthentiateメソッドとはそもそも役割が全く違うということになります。
次
認証バックエンドのカスタマイズに必須の2個のメソッドについて理解できた気がするので、次はauthenticateメソッドでLDAP認証できるような方法を試したいと思います。要するにPython側でLDAP認証する仕組みを作って、そのロジックの中で認証OKならユーザーモデルオブジェクトを返すようにすれば良いということですよね。