Django + LDAP3で、ActiveDirectoryのLDAP認証によるログインとログアウトを試してみた

DjangoでActiveDirectoryを使ったLDAP認証を試してみたので、その時に悩んだことや実装内容をメモしておきます。

 

環境

開発環境

 

ActiveDirectory環境

 

事前調査

DjangoのパッケージリストのLDAPカテゴリを見たところ、Djangoで使えそうなライブラリがいくつかあったため、それらを試してみることにしました。
Django Packages : LDAP

 

django-auth-ldap

公式サイト: psagers / django-auth-ldap / wiki / Home — Bitbucket

ChangeLogを見ると、django-auth-ldap自体はPython3に対応していました。
Change Log — django-auth-ldap 1.2.6 documentation

ドキュメントのトップには、依存しているpython-ldapがPython3に非対応とありました。ただ、併記する形でPython3対応されたforkの記載もありました。
Django Authentication Using LDAP — django-auth-ldap 1.2.6 documentation

そこで、forkをインストールしてみましたが、エラーとなりました。

# インストール
pip install git+https://github.com/rbarrois/python-ldap.git@py3

# エラー
...
  File "path\to\virtualenv\lib\site-packages\pip\util.py", line 53, in rmtree_errorhandler
    (exctype is PermissionError and value.args[3] == 5) #python3.3
IndexError: tuple index out of range

そのため、django-auth-ldapを使うのは諦めました。

 

django-python3-ldap

公式サイト: etianen/django-python3-ldap

Python3で動作するパッケージLDAP3を使っていたため期待が持てそうでした。

ただ、以下のIssueにてブランチactive-directory-bind-supportがある旨が記載されていたものの、今のところ本体にはマージされていなかったため、今回は使うのを諦めました。
ldap_sync_users completes successfully, still can't login · Issue #12 · etianen/django-python3-ldap

2015/11/21 追記

現在では上記IssueはCloseされ、ActiveDirectoryでも動作するようになりました。

現時点のREADMEにもその旨と設定内容が記載されています。

2015/11/21 追記 ここまで

 

django-auth-ldap-ad

公式サイト: susundberg/django-auth-ldap-ad

ActiveDirectoryで動きそうでしたが、内部でpython-ldapを使っていたため、使うのを諦めました。

 

自作バックエンド

上記より、現時点では既存のライブラリを使ってPython3で進めるのは厳しそうでしたので、自作バックエンドを作成する方向で作業を進めることにしました。

自分で作る方法を調べてみたところ、以下のQiitaがとても参考になりました。ありがとうございました。なお、記事にはpython3-ldapとありますが、現在はldap3へと改名されたようです。
python3-ldapを触ってみた - Qiita

 

アプリ作成

事前準備
  • Project Encodingを utf-8 にして、IntelliJ IDEAを再起動
  • <root>\apps\ad_ldap3 フォルダを作成
  • startapp ad_ldap3 ./apps/ad_ldap3
  • INSTALLED_APPS'apps.ad_ldap3',を追加
  • Pythonパッケージ ldap3をインストール

 

テンプレートに関するsettings.pyの修正

2015/09/26 追記

2015/9/26にIntelliJ IDEAやPython pluginをアップデートし、

の環境でプロジェクトを新規作成してみました。

新規プロジェクトのsettings.py

  • TEMPLATE_DIRSが存在しない
  • TEMPLATESDIRSos.path.join(BASE_DIR, 'templates')が追加

と改善され、以下の作業が不要になりました。

2015/09/26 追記 ここまで

 
IntelliJ IDEA 14.1.4のPython plugin 4.5 141.1624では、settings.pyの末尾あたりに

TEMPLATE_DIRS = (
    os.path.join(BASE_DIR,  'templates'),
)

の記載があります。

このままにしておくと、Django実行時に

WARNINGS:
?: (1_8.W001) The standalone TEMPLATE_* settings were deprecated in Django 1.8 and the TEMPLATES dictionary takes precedence. You must put the values of the following settings into your default TEMPLATES dict: TEMPLATE_DIRS.

というエラーメッセージが表示され、実行時にテンプレートフォルダが認識されませんでした。

ドキュメントを読むとDjango1.8ではDeprecated になっていました。
Settings - #template-dirs | Django documentation | Django

 
そのため、TEMPLATE_DIRSを削除するとともに、TEMPLATESDIRSTEMPLATE_DIRSの内容を追加します。

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [os.path.join(BASE_DIR,  'templates'),],  # ここを追加
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

 

認証バックエンドまわり

認証バックエンドの実装

以下を参考に、authenticate()get_user()を持つADBackendクラスを作成します。
Customizing authentication in Django - #writing-an-authentication-backend | Django documentation | Django

 
ADBackendクラスでは以下を実装します。

 
実装ソースコードは以下となります。  

# <root>\apps\ad_ldap3\backend.py

from django.contrib.auth import get_user_model
from django.conf import settings
from ldap3 import Server, Connection, NTLM

class ADBackend(object):
    def authenticate(self, username=None, password=None):
        server = Server(settings.AD_DOAMIN_CONTROLLER_HOST_NAME)

        try:
            # auto_bindありのConnectionの生成で例外が発生しなければ、認証成功とみなす
            c = Connection(server,
                           user="{0}\\{1}".format(settings.AD_DOMAIN_NAME, username),
                           password=password,
                           authentication=NTLM,
                           auto_bind=True)
            user = get_user_model()

            result, created = user.objects.update_or_create(
                username = username,
                password = password
            )
            c.unbind()
            return result

        except:
            return None

    def get_user(self, user_id):
        user = get_user_model()
        try:
            return user.objects.get(pk=user_id)
        except:
            return None

 

settings.pyへの追記
AUTHENTICATION_BACKENDSの設定

認証バックエンドとして自作したADBackendを使うため、追加しておきます。

AUTHENTICATION_BACKENDS = (
    'apps.ad_ldap3.backend.ADBackend',
)

 

ドメインコントローラ・ドメイン情報の設定

自作の定数AD_DOAMIN_CONTROLLER_HOST_NAMEAD_DOMAIN_NAMEをADBackendクラスにて参照するため、追加しておきます。

AD_DOAMIN_CONTROLLER_HOST_NAME = 'your_dc_host_name'
AD_DOMAIN_NAME = 'example.local'

 

Viewまわりの実装

認証バックエンドまわりはできたので、今度はViewまわりを作成します。

今回必要なViewは、

  • indexページ
    • ログインとログアウトへのリンクあり
    • 現在のログインユーザ名やセッション情報を表示
  • loginページ
    • ユーザ名とパスワードを入力してログインするページ
    • ログインに成功したら、indexページへとリダイレクト
  • logoutページ
    • ログアウトするページ

の3つとなります。

 

loginページの作成

loginページ用にdjango.contrib.auth.views.loginという便利なものがあったため、それを利用します。

そのため、<root>\apps\ad_ldap3\views.py への追加は不要になります。

 

urls.py

ルーティング設定のため、<root>\Django_AD_Ldap3_Sample\urls.pyに以下を追加します。

urlpatterns = [
    url(r'^admin/', include(admin.site.urls)),
    url(r'^login/$', 'django.contrib.auth.views.login'),
]

 

login.html

django.contrib.auth.views.loginでは、<root>\templates\registration\login.html をデフォルトのファイルとして使用しますので、そのファイルを作成します。

なお、form など、login.htmlにtemplate contextとして渡される変数は、以下にまとまっていました。
Using the Django authentication system - #django.contrib.auth.views.login | Django documentation | Django

 

{% extends "base.html" %}

{% block content %}

    <h1>login page</h1>
    {% if form.errors %}
        <p>Your username and password didn't match. Please try again.</p>
    {% endif %}

    {% if user.is_authenticated %}
        Current User: {{ user.username }}
    {% endif %}

    <form method="post" action="{% url 'django.contrib.auth.views.login' %}">
        {% csrf_token %}
        <table>
            <tr>
                <td>{{ form.username.label_tag }}</td>
                <td>{{ form.username }}</td>
            </tr>
            <tr>
                <td>{{ form.password.label_tag }}</td>
                <td>{{ form.password }}</td>
            </tr>
        </table>

        <input type="submit" value="login" />
        <input type="hidden" name="next" value="{{ next }}" />
    </form>

    <a href="/">index</a>

{% endblock %}

 

base.html

login.htmlbase.htmlをextendsしているため、<root>\templates\base.htmlも用意します。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title></title>
</head>
<body>
    {% block content %}
    {% endblock %}
</body>
</html>

 

LOGIN_REDIRECT_URLの設定

ログイン成功時のリダイレクト先URLは、デフォルトでは/accounts/profile/となります。

ただ、今回は/を使用するため、settings.pyに設定を追加します。

LOGIN_REDIRECT_URL = '/'

 

logoutページの作成

こちらも、django.contrib.auth.views.logoutが用意されています。
Using the Django authentication system - #django.contrib.auth.views.logout | Django documentation | Django

loginページとほぼ同様の作りとなるため、以下に箇条書きでまとめます。

  • urls.pyに、 url(r'^logout/$', 'django.contrib.auth.views.logout'), を追加
  • <root>\templates\registration\logged_out.html がデフォルトのログアウト後ページなので、以下の内容で作成

 

{% extends "base.html" %}

{% block content %}
    <h1>logout page</h1>

    {{ title }}

    <p><a href="/">index</a></p>

{% endblock %}

 

indexページの作成

デフォルトのindexページは用意されていないため、自分で用意します。

views.pyへの追加

現在のセッション状態をindexページに表示させたいため、以下を参考にしてセッションの内容をHTMLテンプレート側に渡します。

# <root>\apps\ad_ldap3\views.py

from django.shortcuts import render_to_response
from django.template.context import RequestContext
from django.contrib.sessions.models import Session

def index(request):
    session = Session.objects.all().first()

    return render_to_response(
        'index.html',
        {'session_info': None if session is None else session.get_decoded()},
        context_instance=RequestContext(request)
    )

 

urls.pyへの追加

indexページのルーティングとして、url(r'^$', 'apps.ad_ldap3.views.index'),を追加します。

 

HTMLテンプレートの作成

<root>\templates\index.htmlとして、テンプレートを作成します。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title></title>
</head>
<body>
    <h1>index page</h1>

    {# ユーザ認証されていれば、ユーザ名を表示 #}
    {% if user.is_authenticated %}
        <p>Current User:{{ user.username }}</p>
    {% endif %}

    {# セッション情報の中身を表示 #}
    {% if session_info %}
        <p>session_info: {{ session_info }}</p>
    {% else %}
        <p>no session.</p>
    {% endif %}

    <div>
        <a href="/login">login</a>
        <a href="/logout">logout</a>
    </div>
</body>
</html>

 

manage.pyによる、マイグレーションの実行

念のためmakemigrationsを実行した後、migrateを実行します。

ここまでで実装は完了です。

 

動作確認

以下の流れで動作を確認します。

1. Indexを表示

この時点ではCookieへの設定はありません。

f:id:thinkAmi:20150917183329p:plain

 
また、PupSQLiteでDBを確認しますが、django_sessionにはエントリがありません。

f:id:thinkAmi:20150917183631p:plain

 

2. ログインページへ移動

Cookieに変化はありません。

f:id:thinkAmi:20150917183357p:plain

 

3. ログイン後にIndexページへリダイレクト

Cookieにセッション情報が登録されています。また、バックエンドから取得したUserオブジェクトの内容も表示できています。よく見ると、Cookiecsrftokenも変更されています。

f:id:thinkAmi:20150917183412p:plain

 
また、DBにもセッション情報が追加されています。

f:id:thinkAmi:20150917183618p:plain

他に、ログインに使用したユーザも登録されています。

f:id:thinkAmi:20150917183604p:plain

 

4. ログアウトページヘ移動

CookieやDBにセッション情報がなくなりました。

f:id:thinkAmi:20150917184922p:plain

 

5. indexページへ移動

バックエンドからUserオブジェクトの情報は取得できなくなりました。

f:id:thinkAmi:20150917183653p:plain

 

ソースコード

今回使用したソースコードは、GitHubに上げておきました。
thinkAmi-sandbox/Django_AD_Ldap3_Sample

 

2015/11/21追記:自作バックエンドをパッケージ化しました

pipでインストールできるよう、GitHubにて公開しています。
thinkAmi/django-auth-ldap3-ad-backend

上記のサンプルコードはtest_projectへと移動していますが、READMEのようにして設定すれば、同じように使えると思います。