ITの窓辺から

三流IT技術者の日常

DjangoでLDAP認証 その3 標準の認証バックエンドのメソッド

今回は認証バックエンドについて調べたことを書きます。相変わらずの注意書きですが、この記事はWebアプリケーションとDjangoの初心者が調べたことをメモするものであり、正しい解説記事を目的としていないのでご注意ください。

環境は以下の通りです。
macOS(intel CPU) Big Sur
Python3.8.5 (venv環境)
Django3.2.5
WebサーバはDjangoに組み込みのもの

この記事は以前の記事の続きです。

realizeznsg.hatenablog.com

上記の記事で書いた「認証バックエンドをカスタマイズする時、カスタマイズ用の認証用コードに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ならユーザーモデルオブジェクトを返すようにすれば良いということですよね。