DjangoでLDAP認証 その6 とりあえず完成形
前回はパーミッションによる認可処理のはしりを実装してみました。
今回は当初の要件だった以下の内容を全て実現できるようにしてみます。
- Active Directory上に格納されたユーザアカウントを使用してDjango上のアプリケーションを使用する際に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)
Djangoアプリケーションへの認証制限
LDAPサーバ上のアカウントは特定のセキュリティグループに所属していないとIDとパスワードがあっていたとしてもDjangoアプリケーションでの認証には通らない、というものを実装します。認証を行ったユーザ名かつ特定のセキュリティグループのメンバである場合にそのレコードを返すようなLDAPクエリを発行し、そのレコードを格納した変数(リスト)が空でなければ処理を継続するようにauthenticateメソッドを変更します。
class LdapAuthBackend(ModelBackend):
def authenticate(self, request, username=None, password=None, **kwargs):
ldap_server = Server('192.168.1.156')
print(type(ldap_server))
try:
ldap_auth_by_bind = Connection(ldap_server, user=username, password=password, auto_bind=True)
#セキュリティグループ状況の取得
ldap_query = '(&(sAMAccountName=' + username + ')(memberof=CN=django_app1_group,OU=dep1,OU=useraccount,DC=rdmac,DC=test))'
ldap_auth_by_bind.search('dc=rdmac,dc=test', ldap_query)
#セキュリティグループの所属チェック
if not ldap_auth_by_bind.entries:
return
#電話番号の取得
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
ldap3モジュールで上記のLDAPクエリを投げると、Djangoで認証したユーザ名がActive Directoryのセキュリティグループ「django_app1_group」に所属している場合、ユーザ名が返ってきます。所属していない場合、クエリを満たすオブジェクトがLDAPサーバにないということで返り値は空になります。その後のif文で空かどうかチェックしています。これにより、認証情報が正しかったとしてもセキュリティグループに所属していないとDjangoアプリケーションは使用できません。
本筋とは別の話ですが、実際にはもっとエラー処理を書かないと謎の認証エラーが頻発する可能性があります。例えば電話番号を取得するところでは、必ずtelephoneNumberから値が取れることを前提にしていますが、LDAPサーバ側に値が入っていない場合や、何らかの問題で正常に取得できなかった場合はauthenticateメソッドの処理は止まります。
パーミッションの自動付与
以下のような実装にします。
- スーパユーザadmin以外のどのユーザでもパーミッション「Custom Permission 1」は必ず割り当てる。
- Active Directoryのセキュリティグループ「django_perm2_group」に所属しているアカウントはさらにパーミッション「Custom Permission 2」を割り当てる。
- 一度「Custom Permission 2」が割り当てられてもActive Directoryのセキュリティグループ「django_perm2_group」に所属していない場合は認証時にパーミッション「Custom Permission 2」を削除する。
先程のauthenticateメソッドを以下のように変更します。
class LdapAuthBackend(ModelBackend):
def authenticate(self, request, username=None, password=None, **kwargs):
ldap_server = Server('192.168.1.156')
print(type(ldap_server))
try:
ldap_auth_by_bind = Connection(ldap_server, user=username, password=password, auto_bind=True)
#セキュリティグループ状況の取得
ldap_query = '(&(sAMAccountName=' + username + ')(memberof=CN=django_app1_group,OU=dep1,OU=useraccount,DC=rdmac,DC=test))'
ldap_auth_by_bind.search('dc=rdmac,dc=test', ldap_query)
#セキュリティグループの所属チェック
if not ldap_auth_by_bind.entries:
return
#電話番号の取得
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)
#パーミッションの付与処理
if username != 'admin': #adminには処理しない
permission_list = [Permission.objects.get(name='Custom Permission 1')]
ldap_query = '(&(sAMAccountName=' + username + ')(memberof=CN=django_perm2_group,OU=dep1,OU=useraccount,DC=rdmac,DC=test))'
ldap_auth_by_bind.search('dc=rdmac,dc=test', ldap_query)
if ldap_auth_by_bind.entries:
add_permission = Permission.objects.get(name='Custom Permission 2')
permission_list.append(add_permission)
else:
remove_permission = Permission.objects.get(name='Custom Permission 2')
user.user_permissions.remove(remove_permission)
for permission in permission_list:
user.user_permissions.add(permission)
ldap_auth_by_bind.unbind()
return user
except:
return
ワンポイント調整
先程のコードは認証時に動作するもので、パーミッションの付与状況はDjangoで認証を行った時の状態が次回の認証時まで維持されます。認証後、Active Directoryでセキュリティグループからアカウントを外してもそのセッション中のDjangoアプリケーションの操作には影響しないわけです。そこでget_userメソッドを変更して、ページ遷移時にセキュリティグループの状態を確認するように処理を変えてみました。
def get_user(self, user_id):
try:
user = UserModel._default_manager.get(pk=user_id)
ldap_server = Server('192.168.1.156')
username = user.username
print(username)
ldap_bind = Connection(ldap_server, user='ldapbinduser', password='%TGB6yhn', auto_bind=True)
if username != 'admin':
permission_list = [Permission.objects.get(name='Custom Permission 1')]
ldap_query = '(&(sAMAccountName=' + username + ')(memberof=CN=django_perm2_group,OU=dep1,OU=useraccount,DC=rdmac,DC=test))'
ldap_bind.search('dc=rdmac,dc=test', ldap_query)
if ldap_bind.entries:
add_permission = Permission.objects.get(name='Custom Permission 2')
permission_list.append(add_permission)
else:
remove_permission = Permission.objects.get(name='Custom Permission 2')
user.user_permissions.remove(remove_permission)
for permission in permission_list:
user.user_permissions.add(permission)
ldap_bind.unbind()
except UserModel.DoesNotExist:
return None
return user if self.user_can_authenticate(user) else None
LDAPバインドのあたりの書き方が超雑ですがそれはそれで。今度はLDAPバインド用アカウント「ldapbinduser」がActive Directory上にあることが前提です。その後にやっていることは先程のauthenticateメソッドと同様です。こうすることで、ページ遷移ごとにセキュリティグループのチェックが行われ、パーミッションが最新状態になります。
しかし、1ページ移動するごとにLDAP接続が行われるのは諸々の負荷を考えるとあまり実行すべきではないと思われます。実際には例えば定時で自動的にパーミッションを最新にするようなバッチ処理を行ったり、get_userメソッド内でセキュリティグループのチェックを行う頻度を減らすような判定をつけたりする方が良いように思います。
所感
記事6個に及ぶDjangoとLDAP認証の調査がようやく一区切り付きました。はじめは本当に全く理解できませんでしたが、何とかここまで来られました。インターネット上の記事を調べたりもしましたが、意外とActive DirectoryとDjangoの組み合わせは記事が少ないんですよね(Windows統合認証環境の記事はほぼありませんでした)。よくありそうな構成なのになぜだろうと考えてみましたが、Djangoと社内向けアプリケーションに使用するというモチベーションが低いためではないかと予想しています。私の所属会社と同じような情シス体制の会社は多分たくさんありますし、社内でこういうのを内製する機会はまだ少ないと思われます。外注して作る場合でも、わざわざ日本でPythonを選定しているベンダさんも少ないでしょう・・・。良い悪いはともかくとしてJVM系言語によるものがとりあえず選ばれるのではないでしょうか。
次
このauthenticateとget_userメソッドに対するテストコードを書いてみようと思います。get_userメソッドによるセキュリティグループの都度チェックのテストは行いません。アカウントの設定変更を行うにはドメイン上で強めの権限を与える必要があり、セキュリティ上のデメリットが大きいと判断しています。
と書いてみましたが実験はしてみよう・・・。
DjangoでLDAP認証 その5 パーミッションによる機能制限
前回ではようやくDjangoアプリケーションの認証をActive Directoryを使用した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)
意図せずやってたなんちゃって認可
一連のLDAP認証の動作検証を行う中で適当な掲示板ライクのCRUD操作を基本としたアプリを作りまして、その中に作成した記事の編集を行うためのビューがあります。そのビューはURLディスパッチ後に単純に記事の識別子を受け取って、その記事のモデルオブジェクトを取得します。このビューを起動した時のログインユーザが記事のモデルオブジェクトに格納されている記事作成者と一致しない場合、HTTPレスポンスとして編集ができないことを返すような処理を入れていました。これも認証と分離されたある種の認可だと思います。ただこの処理を1個ずつ書かないといけないのか?と疑問が出ました。
Djangoにはグループという概念もあるのでユーザをグループに所属させることでグループベースの制御を行うことである程度簡略化することもできると思います。しかし、私の会社では30以上の部があります。部レベルでDjangoの操作権限を変える場合、if文の条件やif文の数たるやすごいことになり、さらにそれが認証、認可が必要なビューの数分乗じられることを考えると、実装はともかくその後のメンテナンスが非常に面倒になることが想像されます。
このあたりをうまいことやる仕組みがDjango側にあるに違いない、と調べてみたものの、結論としては夢想していた良い解にはたどり着けませんでした。とはいえ後述しますがDjangoにはパーミッションという考え方があり、これを使うことでビュー単位以外のアクセス制御ができることがわかったので、これについて調べた内容をこれからの記事で書いていきたいと思います。
ところでDjangoにおいていわゆるビジネスロジックはどこに書くのが適切なのか、という疑問にぶつかりました。このあたりは一旦気にせずに認可の話だけやっていこうと思います。フレームワークの使い方という点では本質的な話の一つだと思いますので、そのうちちゃんと考えてみたいと思います。
Djangoの認可
これも公式ドキュメントを見ても具体的にどうれば良いのかよくわからなかったので、公式ドキュメントに書いてあったクラスやメソッドから調べてみました。そこで、まずDjangoが提供している認可機能の考え方は端的に言うとビジネスロジック向けのデータモデルの複雑性にあまり干渉せずにビュー実行権限付与とテンプレートの要素の表示制限行う、というもののように読めました。
先程触れたとおり、Djangoにはパーミッションという概念があります。パーミッションをユーザに割り当てることでget_userメソッドを必要とするページを表示する時に、そのユーザがそのパーミッションを持っているかどうかを判定して動作を変えるようなブール値のように機能します。
ユーザーモデルに権限を示すフィールドを作り、その値をベースに処理を作る手もあると思いますが、ちょっとした変更のたびにユーザーモデルを更新する必要があり、変更時のバグの混入やテスト漏れの発生確率がどんどん上がっていくと思われます。その点、パーミッションを使うことでユーザーモデルの変更なしでビューとテンプレートの動作を変えられるので、コードの保守性が上がると考えられます。
しかし前述の通りでビューやテンプレートでパーミッションごとに処理を分岐させるところの手間については特にフォローはなく、分岐処理がひたすら増えるというところには変わりなさそうです。勝手に夢想していたのは、データモデルから取り出し可能なデータをパーミッションで制御できないか、というものでした。データモデルに対してパーミッションでフィルタできればビューやテンプレートではパーミッションをあまり気にしなくても良いように思われるからです。
ただ、この考え方はたまたま今回単純なCRUD機能に特化したアプリを作ったからそうなったのかもしれません。ある事業におけるインプットからアウトプットのうちどの部分をDjangoに任せるかで最適な認可機能は変わると思いますし、そこまで広く語れるほど私はDjangoにもWebアプリケーションにも詳しくないので、やはりこれは一旦そういうものだと思って調査を行っておこうと思います。
基本事項の調査
パーミッションの定義
まずstartappでDjangoアプリケーションを作成した時点で標準のパーミッションが作成されます。しかしこのパーミッションはDjango管理画面でのCRUD権限のためのパーミッションのようなので、基本的にはパーミッションは追加で自作することになります。パーミッションの定義はモデルクラス単位で行います。具体的にはモデルクラス内のMetaクラスで以下のように定義します。
class Meta:
permissions = (
('perm1', 'Custom Permission 1'),
('perm2', 'Custom Permission 2')
)
パーミッションの定義は二次元タプルで表現され、左側がパーミッション、右側パーミッション名です。使い分けがよくわかりませんが。この定義自体は特に意味はなく、パーミッションが定義されているテーブルauth_permissionsにレコードがINSERTされるだけです(当然migrateが必要です)。
パーミッションとユーザの紐付け
作成したパーミッションをユーザに紐付けします。一番単純な紐付け方法はDjango管理画面で手動でユーザにパーミッションを割り当てる方法です。Django管理画面のユーザーモデルのページでユーザを選択すると先程作成したパーミッションが表示されます。
ただ、実際には手動での割当は色々と微妙なので自動的に割り当てることを考えます。今回はLDAP認証をベースとしているので、LDAPサーバ上のアカウントの状態に応じて自動的にパーミッションを割り当てる方式を取ります。具体的には以下のような実装にしようと思います。
- Django操作権限1と2を分類するためのセキュリティグループをActive Directory上に作成してユーザを所属させる。尚、今回は権限2は権限1を全て包含し、権限2のみ固有の機能を使用可能とする。
- authenticateメソッドで認証が行われる際、ユーザのセキュリティグループを調べて当てはまるパーミッションを割り当てる。
余談ですが、そもそも認証情報があっていても特定のセキュリティグループに所属していなければ認証させないという当初要件の実装を失念していました。これは次の記事で実装したいと思います。具体的なコードは一通り説明を書いてからにします。
パーミッションの判定
次は実際のアプリケーション動作時にパーミッションに応じた処理を行います。パーミッションがないと実行できない処理にパーミッション保持判定を行うif文をつけます。Djangoでは認証後にget_userメソッドにより取得されるユーザーモデルオブジェクトが、どのパーミッションを持っているか、指定したパーミッションを持っているかを評価する方法がありました。
ユーザーモデルオブジェクトが持っているパーミッションの一覧は<ユーザーモデル>.user_permissions.all()で取得できます。返り値はQueryset型でした。Djangoにおけるマネージャをまだ正しく理解していないのですが、Pythonのinspectでユーザーモデルオブジェクトを調べたらuser_permissionsという項目があり、マネージャに関係しているような出力のされぶりだったので、all()をつけてみたらうまく返ってきました、といういい加減な調査です。
ユーザーモデルオブジェクトが指定したパーミッションを持っているかはユーザーモデルのメソッドhas_perm(<パーミッション名>)で取得できます。TrueかFalseが返ってきます。尚、パーミッションを付与した後に再度ユーザーモデルオブジェクトをユーザーモデルから作成しないと実体には反映されないのでご注意を。
それぞれ実際には以下のように使います。変数userにはユーザーモデルオブジェクトが格納されています。
一覧の取得
user.user_permissions.all()
<QuerySet [<パーミッションの一覧>]>
特定のパーミッションの保持判定
user.has_perm('app1.perm1')
True
パーミッションの指定は<アプリケーション名>.<パーミッション名>で指定します。
実装
パーミッションに応じた処理の分岐はビューとテンプレートで行えます。この記事ではビューでの処理分岐を記載します。テンプレートでの分岐はテンプレートの知識整理も兼ねて別記事にて今度まとめます。
と言っても大したことはありません。
@login_required
def limited_user_function_view(request):
user = request.user
if user.has_perm('app1.perm2'):
正規処理
else:
return HttpResponseForbidden()
ここではapp1に対するパーミッションperm2が付与されていないとこのビューの正規処理は実行できません。
所感
上記の実装を作る時、元々作っていたデータモデルとは別にデータモデルを作成し、ManyToManyFieldでつなぎました。この関数は別のデータモデルを操作するための機能です。perm2は元々のデータモデルで作っていたパーミッションですが別のデータモデルに対する操作の制御にも使えます。パーミッションがモデルごとに定義可能なことを考えると、これは本来の用途とは異なる使い方なのだろうかと思う次第です。少しGoogle先生に聞いてみましたが明確な解答は見つけられず。
次
パーミッションによる制御もできたので、次で本来の要件を全て満たすコードを作成します。LDAPサーバからセキュリティグループを取り出し、それに対応したパーミッションを自動的に付与することと、特定のセキュリティグループを持っていないとDjangoの認証が成功しない、という2つの機能を追加します。
今回のLDAP調査でDjangoのそもそもの部分で全然理解ができていなかったことが結構分かってきたので中々ためになりました。