著書『実践Django Pythonによる本格Webアプリケーション開発』が本日から発売されます。

Twitterでは以前から告知を行っていましたが、著書『実践Django Pythonによる本格Webアプリケーション開発』が本日から発売になります。 購入を検討している方は、Amazonもしくはお近くの書店で探してみてください(エキスパートPythonプログラミング 改訂3版も今月末発売予定です)。

f:id:nwpct1:20210717141541j:plain

初の著書であり単著ということもあり大変なこともありましたが、満足の行く一冊が出来上がってホッとしています。

追記: 単行本派データベースカテゴリでベストセラー1位、Kindle版もNPONGOカテゴリでベストセラー1位になっていました。購入してくださった方、Twitterで拡散してくださった方ありがとうございました!

本書の対象読者

本書の対象読者やおすすめの読み方については、レビューをお手伝いいただいた中川さんが素晴らしい記事を書いてくださいました。 本書がアピールしたいことや、どういうふうに読んでほしいかをまさに汲み取ってまとめていただいているのでぜひこちらを先に読んでみてください。

shinyorke.hatenablog.com

"Djangoをやる方はもちろん, Djangoを抜きにしてもWebアプリケーション開発をされる方にめちゃくちゃオススメしたい!" と思いました, レビューさせてもらったときからすごく良かったんですよ, それぐらい興奮しました ※1

※1: レビューしながら, 「そうだよ!そこが大事なのよ!!」とか家で一人声を出しながら食い気味に読ませていただきました苦笑. 発売前に原稿が読めて幸せでした(感謝)

中川さんもこうおっしゃってくださっていて嬉しいです。

例えばデータベースのインデックスチューニングやHTTPまわりの基礎知識、ユニットテストを書くときの考え方、認証認可まわりの注意点などは、Djangoに限らず必要な知識だと思います。こういったフレームワークに依存しない知識は、Django公式ドキュメントで扱っていないことも多く、初学者の方は勉強のきっかけを掴めないことも多いと思います。

でも、むしろそういったところにWeb開発者として長く役に立つ知識が詰まっているとも思っています。 他のPython Web関連の書籍に比べて高度な内容も多く扱っていると思いますが、詳しく解説しすぎるとどんどん難しくなる話もちょうどよいボリュームでまとめるよう心がけて執筆したので、ぜひ多くの方の手にとっていただきたいです。

サンプルコードや目次

サンプルコードや目次はこちらのGitHubリポジトリで公開しています。

github.com

Amazonレビューのお願い

すでに本書を読んでくださったという方もいらっしゃると思いますが、もしよければAmazonでレビューをつけていただけると嬉しいです。

(素晴らしい書籍であるにも関わらず、残念なレビューがついてしまう悲しい話 も見たことがあるので、どんな評価であれできるだけ多くの方がAmazonレビューをつけていただけると嬉しいです...!)

最後に

Djangoを触り始めてもう7年くらい経ちますが、今見てもこれだけ多くの機能を見通しよく設計し使いやすく提供できていることに驚きます。 たまにパッチを書いてコア開発者の方々からレビューをもらったときには、彼らの設計能力の高さやデータベースまわりの知識の深さに圧倒されます。 Djangoに限らないとは思いますが、世界中のWebシステムの開発に使われるフレームワークを開発している人たちのさまざまなドメインに対する知識の深さは頭がいくつも抜けていて、自分はいまだに全然届かないなと思いました。 彼らがDjangoの開発を続けてくれていることにとても感謝しています。

GitHub SponsorsこちらのページDjango Software Foundationへ寄付ができます (自分はGitHub Sponsors経由にしています)。 本書をきっかけに少しでもDjangoの採用事例が増え、寄付をする人が増えたら嬉しいなと思っています。ぜひ検討してみてください。

Django 3.1 MySQL db_flush() の高速化とTransactionTestCase利用時の注意点

追記: 翔泳社さんからDjangoの書籍を出版するので、ぜひ読んでみてください。


DjangoMySQL DatabaseOperations Backendのとある処理を最適化するためのpatchを書いていて、それがマージされたのですが、注意点があるため記事にしておこうと思います。 全部読むのが面倒な方向けに結論だけ先に書いておきます。

  • MySQLにおいて、TransactionTestCaseのteardown処理が高速になりました。
    • またほとんどのユーザーにはそれほど重要ではありませんが、 sqlflush コマンドも効率的なクエリを生成し、 flush コマンドも高速になります。
  • TransactionTestCaseを使ったテストで AUTO INCREMENT フィールドの値(デフォルトの主キーなど)に依存しているテストは、そのままだとMySQLではFAILするようになります。
    • 自分は基本的にMySQLSQLiteしか使わなくて知らなかったのですが、TransactionTestCaseにおいてAUTO INCREMENT のカウンターがテストケースごとにリセットされていたのは未定義動作です (そもそもそういうテストはアンチパターンかもしれないという話はここでは一旦置いておきます)。
    • この問題を回避するには reset_sequences オプションを明示的に True にする必要があります。

テスト時間が長く困っている方は3.1以降で改善されるかもしれません。

TransactionTestCaseのteardown処理について

厳密には flush management comandの高速化を行ったのですが、どうしてTransactionTestCaseが速くなるのかを解説します。 これにはTransactionTestCaseがtear down時に何をしているのかを理解する必要があります。

Djangoでテストを書いているという方はご存知のように、通常の TestCase では、テスト開始時にトランザクションを開始し、終了時にはロールバックすることで、テストメソッド内のDB操作を取り消します。つまり各テストケースで行った操作はロールバックにより切り戻されるため、他のテストケースの実行に影響を与えることはありません。 一方でそれだと困るケースも当然あり、そういうケースでは TransactionTestCase を使用します。 TrasactionTestCase は、各テストケースごとに全てのテーブルを初期化した状態にしておかないといけません。

どうやっているかというと、 flush management commandを call_command() 関数で発行しています。 この関数は、内部で各DatabaseOperations Backendの sql_flush() メソッドを呼び出し、そのメソッドが返すSQLを実行します。 flush management commandを普通に利用する人は、ここが少し速くなったところでそれほど嬉しくないと思いますが、何度もteardownで呼び出されるTransactionTestCase においては速度が重要になります。

sql_flush() の高速化

MySQL DatabaseOperations backendの sql_flush() メソッドは、全テーブルに対して TRUNCATE クエリを返す実装になっていました。 TRUNCATE クエリはドキュメントにも書いてあるように、 DELETE クエリや、 DROP TABLE && CREATE TABLE クエリに似ています。

TRUNCATE TABLE empties a table completely. It requires the DROP privilege. Logically, TRUNCATE TABLE is similar to a DELETE statement that deletes all rows, or a sequence of DROP TABLE and CREATE TABLE statements. https://dev.mysql.com/doc/refman/8.0/en/truncate-table.html

ただこのクエリは比較的時間がかかります。厳密にはテーブルのサイズが小さい場合に、DELETE クエリに比べ時間がかかります。 そこで DELETE クエリを使いたいのですが、 DELETE クエリで全ての情報が消えるわけではありません。 問題になるのは AUTO INCREMENT フィールドのカウンターの値です。 3つのレコードが登録され、その全てをDELETEクエリにより削除した場合、次に作成するレコードの AUTO INCREMENT フィールドは4から開始します。

そのため別途 ALTER TABLE tablename AUTO_INCREMENT = 1 などを発行してリセットする必要があります。 最初に書いたpatchではそういう処理をしていたのですが、core contributorsやmaintainerからのレビューを通して、カウンター値はリセットしないことになりました。 カウンター値がリセットされなくなることは破壊的変更のように思えますが、実はカウンター値がリセットされるかどうかは未定義動作だったようです。 Djangoのモデルの主キーは、デフォルトだとAUTO INCREMENT なINTEGERが利用されますが、この主キー値の値などをテストケースでチェックしている場合には、後述するオプションをつけていないとFailします。

TransactionTestCasereset_sequences オプション

先程の問題に対処するためには、ALTER TABLE tablename AUTO_INCREMENT = 1 を合わせて発行する必要があると説明しました。 それを有効にするのが TransactionTestCasereset_sequences オプションです。 詳細は Advanced features of TransactionTestCase  に書かれています。 Django 3.0まではMySQL利用時にこれをセットしても変わらなかったのですが、3.1からは必要に応じて reset_sequences=True を指定してください。

class TestsThatDependsOnPrimaryKeySequences(TransactionTestCase):
    reset_sequences = True

    def test_animal_pk(self):
        ...

細かく確認してないのですが、PostgreSQLのDatabase Operations backendのコードには、このreset_sequencesの処理が実装されていたので、PostgreSQLを使ってTransactionTestCase走らせていた方には常識だったのかもしれません。

ベンチマーク

ベンチマークのコードはこちらです。

GitHub - c-bata/django-fast-mysql-flush: for ticket #31275

number of records on each table before after
10 3.302 sec (+/- 0.076) 0.517 sec (+/- 0.019)
100 3.323 sec (+/- 0.047) 0.575 sec (+/- 0.025)
1000 3.577 sec (+/- 0.106) 1.046 sec (+/- 0.029)

余談 ( information_schema.tables の利用)

今回書いたpatchは、もともとヒューリスティックに1000行以下ならDELETE クエリを発行するように実装していました。 行数が多い場合には性能の改善がなく、むしろ遅くなる可能性もあるからです。 全テーブルに対して SELECT COUNT(*) で行数を調べていると余計に時間がかかる可能性があるため、 information_schema.tablestable_rows から行数を取り出して判断しました。 この値はおおよその値が返ってくるだけですが(MyISAMを除く)、今回のようにざっくり1000行以上あるかどうかを知りたいときには十分です。

TABLE_ROWS

The number of rows. Some storage engines, such as MyISAM, store the exact count. For other storage engines, such as InnoDB, this value is an approximation, and may vary from the actual value by as much as 40% to 50%. In such cases, use SELECT COUNT(*) to obtain an accurate count.

TABLE_ROWS is NULL for INFORMATION_SCHEMA tables.

For InnoDB tables, the row count is only a rough estimate used in SQL optimization. (This is also true if the InnoDB table is partitioned.) https://dev.mysql.com/doc/refman/8.0/en/tables-table.html

ただ最終的にこの方針はやめることになりました。 自分が用意したベンチマークでは2倍程度高速でしたが、 DELETE 文と ALTER TABLE tablename AUTO INCRMENT = 1 の2つのSQLを発行しているためcore contributorsの方が試したベンチマーク問題では遅くなったりもしたようです。 詳しく見てみようかとも思ったのですが、ヒューリスティックを入れるのはあまり筋がよくないのと、たかだか2倍程度の改善だったのでまぁいいかなと思い今の実装に落ち着きました。

ちなみに information_schema.tables から auto_increment を取り出して、それが1より大きい場合のみ TRUNCATE を呼ぶという実装も試してみたのですが、INSERTをしても AUTO_INCREMENT の値が更新されず1のままにいることが頻繁にありテストが落ちるため諦めました。ドキュメントを読んでもapproximationとは書かれていないので理由がよくわからないのですが、もし知ってる方いたら教えて下さい。

AUTO_INCREMENT: The next AUTO_INCREMENT value. https://dev.mysql.com/doc/refman/5.7/en/tables-table.html

自分があとから思い出すためのメモでもあったので、雑な記事でしたがこれで終わり。

Django における認証処理実装パターン

追記: 翔泳社さんからDjangoの書籍を出版するのでぜひ読んでみてください。


この資料は DjangoCongress JP 2018で話した「Djangoにおける認証処理実装パターン」 の解説記事になります。

2019/04/08 追記: GithubのコードはPython3.7 Django2.2にupdateしています)

何年か前に Djangoのユーザー認証まとめ という記事を書きました。今でもコンスタントに100PV/dayくらいアクセスのある記事なのですが内容が古く、実装時にハマりやすい注意点にもあまり触れることができておらず、おすすめできる資料ではありません。今回はDjangoCongress JPにて発表の機会をいただけたのですが、この機会に認証処理についてまとめ直すと同時にこちらの資料とソースコード(Github)を合わせて用意することにしました。何かのお役にたてば幸いです。

はじめに

フレームワークの中にユーザーモデルまで定義されていることは、Djangoの最も特徴的な点かもしれません。このおかげで認証が必要なアプリケーションを高速に開発することができ、強力な管理画面もすぐに利用できます。しかし、Flask+SQLAlchemyとかでいちからユーザーモデルを定義するケースとは違って、Djangoでは内部の仕組みを正しく把握していないとユーザーモデルを少しカスタマイズをするだけでも思わぬところでエラーが起きたり意図しない挙動となる危険があります。実装に悩んだことのある方も少なくないのではないでしょうか。本資料では認証処理をカスタマイズする際には抑えておかなければいけないポイントや注意するべき落とし穴もできるだけ解説できればと思っています。

スライド

PyCon JP 2017 のDjangoに関する発表の紹介

PyCon JP 2017でも認証や認可に関する発表がありました。それぞれ次のような内容です。

そのため今回は認証の カスタマイズ に絞った話をします。

ソースコード

github.com

Django 2.2, Python 3.7 で動作するソースコードを用意・Githubで公開しています。リポジトリ内の各PRがそれぞれのトピックのソースコードとなっています。

  1. 自作認証バックエンドを使ったEmail/Password 認証
  2. ユーザーモデルのカスタマイズ
  3. social-auth-core を使ったGithub OAuth認証
  4. social-auth-core を使わず from scratch で実装した Github OAuth認証

認証処理のカスタマイズ

Djangoの提供する認証機能

まずはDjangoが提供する認証処理についておさらいしましょう。 Djangoの認証処理に関する機能は django.contrib.auth パッケージにまとまっています。

用途 Formクラス Viewクラス
ログイン AuthenticationForm LoginView
ログアウト - LogoutView
パスワード更新 PasswordChangeForm PasswordChangeView PasswordChangeDoneView
パスワードリセット PasswordResetForm PasswordResetView PasswordResetDoneView PasswordResetConfirmView PasswordResetCompleteView
パスワード書き換え SetPasswordForm -
ユーザー登録 UserCreationForm -

参照: Built-in Auth Forms / Built-in Auth View Classes

組み込まれているFormクラスやViewクラスを列挙しましたが、パスワードリセットやパスワード更新用の画面など雑にWebサイト作るときは省略しちゃいそうなものまで標準で用意してくれています。それぞれの機能に関する関数ベースビューも提供されていますが、Django 1.11よりdeprecatedになっているので気をつけてください。また settings.py からは、 LOGIN_URLLOGIN_REDIRECT_URLLOGOUT_REDIRECT_URL を変更できます。

認証バックエンドによるカスタマイズ

標準では username/password によるログインが有効になっていますが、ここでは試しに email/password によるログインもできるようにしてみましょう。Djangoでは認証バックエンドというクラスを用意してあげれば認証処理を自由に拡張することができます。 django.contrib.auth.authenticate() が呼ばれると、 settings.pyAUTHENTICATION_BACKENDS により指定される認証バックエンドのリストの先頭から順に認証を試みます。1つが失敗しても次の認証バックエンドで認証を試み、全て認証に失敗すると認証失敗となります 1

それではEメールとパスワードで認証を行う認証バックエンドを定義して、 AUTHENTICATION_BACKENDS に追加してみましょう。 デフォルトでは、 認証バックエンドは2つのメソッドを定義しなければなりません 2

  • authenticate(request, **credentials): HttpRequestオブジェクトとあわせて認証に必要な情報を受け取り、ユーザーモデルのオブジェクトを返す。
  • get_user(user_id): ユーザーモデルの主キーを受け取り、ユーザーモデルのオブジェクトを返す。

一見動作するが、問題をかかえた例

それでは実際に認証バックエンドの例をみてみましょう。まずインターネットで検索したときに見かけたあまりおすすめできない例を紹介します。次のように実装してしまう気持ちはすごくわかりますし、自分も初学者のころうっかり実装してしまったことがありました。

from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend

UserModel = get_user_model()

class EmailAuthBackend(ModelBackend):
    def authenticate(self, username="", password="", **kwargs):
        if username is None:
            username = kwargs.get(UserModel.USERNAME_FIELD)
        try:
            user = UserModel.objects.get(email=username)
        except UserModel.DoesNotExist:
            return None
        else:
            if user.check_password(password) and self.user_can_authenticate(user):
                return user

usernameを入力する場所で、Eメールを入力するように変わるだけなので標準のModelBackendをベースにするのが簡単です。 authenticate メソッドだけを愚直に置き換えたこの認証バックエンドは、usernameとemailのどちらを入力しても認証に成功します。usernameとemailのフィールドの扱いが混ざっているこのコードは少しDirtyに見えますが、動作は一見問題なさそうです。 AUTHENTICATION_BACKENDS に追加して動かしてみましょう。

AUTHENTICATION_BACKENDS = [
    'django.contrib.auth.backends.ModelBackend',
    'accounts.backends.EmailAuthBackend',  # 追加
]

実際これは基本的にうまく動きます。フォームのラベルが username となってしまっていることだけ修正してあげれば、問題ないように見えますが実は1点無視できない問題があります。ログインフォーム上では username フィールドを入力する場所ですので、当然username の仕様にあわせたバリデーション処理を通ってきます。標準のusernameの仕様は、 「@」を受け入れますが twittergithub のようにそれを受け入れない仕様に変わった場合この方法は破綻します。

問題を修正した例

次のように定義するのがいいでしょう。

class EmailAuthBackend(ModelBackend):
    def authenticate(self, request, email=None, password=None, **credentials):
        try:
            user = UserModel.objects.get(email=email)
        except UserModel.DoesNotExist:
            return None
        else:
            if user.check_password(password) and self.user_can_authenticate(user):
                return user

認証バックエンドは定義できましたが、このバックエンドを設定してもまだログイン画面でEメールを入力してもログインできません。 Eメール用のログインフォームを定義して、そちらを利用する必要があります。

from django import forms
from django.contrib.auth import authenticate, get_user_model
from django.utils.text import capfirst
from django.utils.translation import gettext_lazy as _

UserModel = get_user_model()

class EmailAuthenticationForm(forms.Form):
    email = forms.EmailField(max_length=254,
                             widget=forms.TextInput(attrs={'autofocus': True}))
    password = forms.CharField(label=_("Password"), strip=False,
                               widget=forms.PasswordInput)
    error_messages = {
        'invalid_login': "Eメールアドレス または パスワードに誤りがあります。",
        'inactive': _("This account is inactive."),
    }

    def __init__(self, request=None, *args, **kwargs):
        self.request = request
        self.user_cache = None
        super().__init__(*args, **kwargs)

        # Set the label for the "email" field.
        self.email_field = UserModel._meta.get_field("email")
        if self.fields['email'].label is None:
            self.fields['email'].label = capfirst(self.email_field.verbose_name)

    def clean(self):
        email = self.cleaned_data.get('email')
        password = self.cleaned_data.get('password')

        if email is not None and password:
            self.user_cache = authenticate(self.request, email=email, password=password)
            if self.user_cache is None:
                raise forms.ValidationError(
                    self.error_messages['invalid_login'],
                    code='invalid_login',
                    params={'email': self.email_field.verbose_name})
            else:
                self.confirm_login_allowed(self.user_cache)

        return self.cleaned_data

    def confirm_login_allowed(self, user):
        if not user.is_active:
            raise forms.ValidationError(self.error_messages['inactive'], code='inactive')

    def get_user_id(self):
        if self.user_cache:
            return self.user_cache.id
        return None

    def get_user(self):
        return self.user_cache

用意できたら、ログイン用の Formクラスとして差し込みましょう。

from django.contrib.auth.views import LoginView
from django.urls import path

from accounts import views
from accounts.forms import EmailAuthenticationForm

urlpatterns = [
    path('login/', LoginView.as_view(form_class=EmailAuthenticationForm,
                                     template_name='accounts/login.html'), name='login'),
    :
]

このようにして Email/Password の実装ができます。 username と email のフィールドを混同せずしっかり別のものとして扱う点に注意してください。

ユーザーモデルのカスタマイズ

認証処理をカスタマイズしようとする際に、セットで悩むことが多いのがユーザーモデルのカスタマイズ方法です。

ユーザーモデルの拡張方法

ユーザーモデルの拡張方法はいくつかあります。

  • Userモデルに対して 1対1 の関係を持つUserProfileモデルを定義する
  • AbstractUser, AbstractBaseUser のサブクラスを定義する

それぞれ一長一短があるため、必要に応じて使い分けてください。

1対1の関係をもつモデルを定義する

UserProfileモデルを定義する方法は、次のように1対1の関係を持つモデルを用意します 3

class UserProfile(models.Model):
     user = models.OneToOneField(settings.AUTH_USER_MODEL)
     some_additional_columns1 = models.SomethingField(...)
     :

扱うデータの性質に応じてDB設計上の議論もあるかと思いますが、このやり方を採用したときの特徴は次のとおりです。

  1. カラムの追加定義のみが可能
    • first_namelast_name のようにサービスによっては不要なカラムがあるかと思いますが、これらを減らす際は後述する AbstractBaseUser クラスを継承して定義する必要があります
  2. ユーザーモデルに直接手を加える必要がない
    • 例えばサードパーティのライブラリがユーザーに紐づく情報を追加したいときは、この点が大きなメリットとなります
    • また後述するAbstractBaseUserやAbstractUserによるカスタマイズと共存可能ですので、必要に応じて使い分けたり両方使ってください。
  3. テーブルが分かれているので、SELECTする際にはクエリの数を増やすか、JOINする必要がある。
  4. 扱わなければいけないレコードの数が増える。
    1. Djangosignals という機能を使うとUserモデル作成時にトリガーしてUserProfileモデルを自動で作成したりすることもできます。この機能を使うと場合によってはUserProfileモデルの管理をあまり意識することなくコーディングができるかもしれません。

AbstractUser や AbstractBaseUser を継承したユーザーモデルの定義

AbstractUser は AbstractBaseUser を継承してカラム定義やメソッド定義を追加しています。これらは class Meta 内で abstract=True が定義されているため、 makemigrations 実行時にテーブル定義が生成されることはありません。 この2つのClassを継承する方法はどちらもほとんどやり方が変わらないので、今回は AbstractBaseUser を継承してユーザーモデルを定義、参照する方法を解説します。ユーザーモデルにどのようなカラムを定義したいのかをベースに考えてください。

標準のUserモデルは非常に多くのカラムが用意されています。 first_namelast_name などが定義されていますが、個人的につくるサービスでこれらのカラムが必要になることはありません。今回は自分がサービスを実装するときによく使うモデル定義を紹介します。

from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.auth.models import PermissionsMixin, UserManager
from django.contrib.auth.validators import ASCIIUsernameValidator
from django.core.mail import send_mail
from django.db import models
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _


class User(AbstractBaseUser, PermissionsMixin):
    username_validator = ASCIIUsernameValidator()
    username = models.CharField(
        _('username'),
        max_length=150,
        unique=True,
        help_text=_('Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.'),
        validators=[username_validator],
        error_messages={
            'unique': _("A user with that username already exists."),
        },
    )
    email = models.EmailField(_('email address'), blank=True)
    profile_icon = models.ImageField(_('profile icon'), upload_to='profile_icons', null=True, blank=True)
    self_introduction = models.CharField(_('self introduction'), max_length=512, blank=True)
    is_admin = models.BooleanField(default=False)
    is_staff = models.BooleanField(
        _('staff status'),
        default=False,
        help_text=_('Designates whether the user can log into this admin site.'),
    )
    is_active = models.BooleanField(
        _('active'),
        default=True,
        help_text=_(
            'Designates whether this user should be treated as active. '
            'Unselect this instead of deleting accounts.'
        ),
    )
    date_joined = models.DateTimeField(_('date joined'), default=timezone.now)

    objects = UserManager()

    EMAIL_FIELD = 'email'
    USERNAME_FIELD = 'username'
    REQUIRED_FIELDS = ['email']

    class Meta:
        verbose_name = _('user')
        verbose_name_plural = _('users')
        db_table = 'users'

    def clean(self):
        super().clean()
        self.email = self.__class__.objects.normalize_email(self.email)

    def email_user(self, subject, message, from_email=None, **kwargs):
        send_mail(subject, message, from_email, [self.email], **kwargs)

Djangoに詳しい方は get_short_name()get_full_name() メソッドが定義されてないじゃないかと感じるかもしれませんが、Django 2.0からは定義する必要がありません。

ユーザーモデルの差し替え

定義したモデルは settings.pyAUTH_USER_MODELapp_label.ModelName の形式で指定します。

AUTH_USER_MODEL = 'accounts.User'

注意点としては、マイグレーションを実行した後の AUTH_USER_MODEL の差し替えは、多対多や外部キーの解決が難しくが非常に複雑になります。あとからマイグレーションをする必要がないよう、 AUTH_USER_MODEL によってユーザーモデルを差し替える作業は出来るだけシステムを稼働前に行ってください。

また会員登録時に UserCreationForm を使っていますが、これは username が絡む都合上デフォルトではAbstractBaseUser を継承したカスタムモデルで利用できません(AbstractUserは使えます)。次のように、 Meta.models で自作のモデルを指定した UserCreationForm を用意して利用しましょう。 今回は使っていませんが、UserChangeFormについても同様です。

from django.contrib.auth.forms import (
    UserCreationForm as BaseUserCreationForm,
    UserChangeForm as BaseUserChangeForm,
)
from .models import User


class UserCreationForm(BaseUserCreationForm):
    class Meta(BaseUserCreationForm.Meta):
        model = User


class UserChangeForm(BaseUserChangeForm):
    class Meta(BaseUserChangeForm.Meta):
        model = User

usernameの取扱いに関する注意点

さてユーザーモデルの定義の解説は、非常に簡単で解説も非常に短いものでした。そのため今回はDjangoのユーザーモデルを定義する際に注意しておいて欲しい username の話をしようと思います。

以前のDjangoのバージョンでは、Python2を使っているときはASCII文字と数字、Python3を使っているときは unicode 文字が username に使うことができました。しかし、Django 2.0 における大きな変更としてPython 2サポートの終了があります。その上で次の質問に答えてください。

「c-bаtа」と「c-bata」、この2つは同じ username でしょうか?

$ python3
>>> "c-bata" == "c-bаtа"
False

一見同じに見えるこの2つの文字列はunicode上は別の文字です。 左辺に表示されている a という文字は U+0061 LATIN SMALL LETTER A ですが、右辺の а という文字は U+0430 CYRILLIC SMALL LETTER A です。punycode に encode することで紛らわしい文字がよくわかります。

>>> "c-bаtа".encode('punycode')
b'c-bt-73db'
>>> "c-bata".encode('punycode')
b'c-bata-'
>>>

別の文字ということは、それぞれ別のユーザーとして登録可能であることを示しています。これはなりすましといった何らかの攻撃に利用されるかもしれません。そのため先程定義したモデルのように username には ASCIIUsernameValidator を指定しておくことは、悩みごとの少なくなるいいテクニックです。仕様上問題なければぜひ付けておきましょう。

from django.contrib.auth.validators import ASCIIUsernameValidator

class User(AbstractBaseUser, PermissionsMixin):
    username_validator = ASCIIUsernameValidator()
    username = models.CharField(_('username'), validators=[username_validator], ... )
    :

またもう少し細かく制限するのもいいかもしれません。ハイフンとアンダースコアを別々としていては c-bata さんと c_bata さんが存在可能です。 個人的には次のようなvalidatorがおすすめです。

import re

from django.core import validators
from django.utils.deconstruct import deconstructible


@deconstructible
class UsernameValidator(validators.RegexValidator):
    regex = r'^[a-z0-9-]+$'
    message = (
        'Enter a valid username. This value may contain only'
        ' English small letters, numbers and hyphen.'
    )
    flags = re.ASCII

テストコード

from django.core.exceptions import ValidationError
from django.test import TestCase

from accounts.validators import UsernameValidator


class UsernameValidatorsTests(TestCase):
    def test_username_validator(self):
        valid_usernames = ['glenn', 'jean-marc001', 'c-bata']
        invalid_usernames = ['c_bata_', 'GLEnN', "o'connell", 'Éric', 'jean marc', "أحمد"]
        v = UsernameValidator()

        for valid in valid_usernames:
            with self.subTest(valid=valid):
                v(valid)

        for invalid in invalid_usernames:
            with self.subTest(invalid=invalid):
                with self.assertRaises(ValidationError):
                    v(invalid)

unicodeには他にも多くのパターンがあり複雑ですが、 unicodedata パッケージを使って次のように正規化しておくといいでしょう。 UserCreationFormでは内部でこの処理を読んでいますが、UserCreationForm を使わず自分たちでバリデーションしてるようなコードや REST Framework の Serializer を作っている例などを見るとこの処理を忘れている例がしばしばあります。気をつけておきましょう。

>>> import unicodedata
>>> unicodedata.normalize('NFKC', 'アアァ')
'アアァ'
>>> unicodedata.normalize('NFKC', '㌀')
'アパート'
>>> unicodedata.normalize('NFKC', '9⁹₉⑨')
'9999'
>>> unicodedata.normalize('NFKC', 'Hℍℌ')
'HHH'

仕様上どうしてもUnicodeを使いたいという人は、The Tripartite Identity Pattern などを参考に設計を見つめ直してみてもいいかもしれません。

GithubによるOAuth認証

追記: 自分の勉強不足だったのですが、django-allauthはあらためて実装読んでみると複雑性を抑えつつよくできている印象です。自分が次実装するならこちらを使いそうです。あまりこれより下の内容は参考にしないほうがいいかもしれません。

python-social-auth(social-auth-core) の紹介

1つだけならまだいいですが、複数のOAuthプロバイダーをサポートしたい場合、twittergithubFacebook全てのAPIを調べて自分で実装するのは少し面倒です。python-social-auth(social-auth-core)という人気のライブラリがあり、 social-auth-app-django というDjangoアプリケーションまで公開しています。今回はこちらを使ったOAuth認証の実装を解説します。

social-auth-core 4 は、様々なORM、フレームワーク、OAuthプロバイダーに対応するため抽象化のためのStorageやStrategy、Pipelineという独自の概念があります。今回紹介するproviderでは必要のないNonceなどのモデルも同時に作成されてしまいます。これらの概念とあわせて実装を理解するのは、Djangoに少し慣れたプログラマーであったとしても少々苦労するでしょう。データベースの状態をシンプルに保つことはアプリケーションの保守性を高める上で非常に重要です。Python界の巨匠 石本さんも次のようにおっしゃっています。

f:id:nwpct1:20180519132330p:plain

対応したいOAuthプロバイダーの数が少なく、social-auth-coreの理解に時間をかけたくない場合は、自前で実装するという選択も検討してみるといいかもしれません。social-auth-coreを使わずにGithub OAuth認証をする例を用意しました。これから紹介する認証フローを理解していれば、読めると思いますので今回は詳しく解説しませんが自分で実装が必要なときは参考にしてください。

https://github.com/c-bata/django-auth-example/pull/4

OAuth認証の流れ

Githubを例にOAuth認証のおおまかな流れを簡単に解説します。

f:id:nwpct1:20180519132358p:plain

データベースの定義

まずはデータベースの構成について考えます。方法は1つではありませんが、基本的には次のようにデータベースをわけるでしょう。

f:id:nwpct1:20180519132417p:plain

  • id: 主キー
  • user_id: ユーザーモデルとのひもづける外部キー
  • provider: 'github' や 'facebook' などOAuthプロバイダーの識別子
  • uid: プロバイダーのシステム上でユーザーの識別に使われている一意な値

今回は同じSocialアカウントが別々のレコードで登録されないように、providerとuidでunique togetherの制約を付与しています。 これをDjangoのModel定義に落とし込むと次のようになります。

from django.conf import settings
from django.db import models


class Social(models.Model):
    """Social Auth association model"""
    user = models.ForeignKey(settings.AUTH_USER_MODEL,
                             related_name='socials',
                             on_delete=models.CASCADE)
    provider = models.CharField(max_length=32)
    uid = models.CharField(max_length=255)

    class Meta:
        unique_together = ('provider', 'uid')
        db_table = 'socials'

social-auth-core を使った実装

次は social-auth-core をインストールして設定していきましょう。 今回はGithub OAuthを実装していきます。

$ pip install social-auth-core==1.7.0 social-auth-app-django==2.1.0

インストールが出来たら settings.py を変更していきます。 social_app_django が、Djangoのアプリケーションであると説明しましたが social-core の方もDjangoを意識したデザインになっていて、social_core のbackendsと呼ばれる概念はDjangoの認証バックエンドとしての仕様を満たしています。 今回はGithubなので、次のように AUTHENTICATION_BACKENDS を設定してください。

INSTALLED_APPS = [
    :
    'social_django',
    'socials.apps.SocialsConfig',
]


AUTHENTICATION_BACKENDS = (
    'django.contrib.auth.backends.ModelBackend',
    'social_core.backends.github.GithubOAuth2',
)

TEMPLATES = [
    {
        'OPTIONS': {
            'context_processors': [
                :
                'socials.context_processors.backends',
                'socials.context_processors.login_redirect',
            ],
        },
    },
]

SOCIAL_AUTH_GITHUB_KEY = os.getenv("SOCIAL_AUTH_GITHUB_KEY", "")
SOCIAL_AUTH_GITHUB_SECRET = os.getenv("SOCIAL_AUTH_GITHUB_SECRET", "")

GithubのApplication KeyとApplication Secretが必要です。 Github Settingsから登録を行いましょう。コールバックURLは次のように http://127.0.0.1:8000/social/complete/github とします。

f:id:nwpct1:20180519132437p:plain

次はCallback等のエンドポイントを追加していきます。URLは social-app-django に習って次のようにしました。

from django.urls import path

from socials import views

app_name = 'social'

urlpatterns = [
    path("login/<provider>", views.auth, name="begin"),
    path("complete/<provider>", views.complete, name="complete"),
    path("disconnect/<provider>", views.disconnect, name="disconnect"),
    path("disconnect/<provider>/<int:association_id>", views.disconnect,
         name="disconnect_individual"),
]

プロジェクトの urls.py でincludeもしておきます。

urlpatterns = [
    :
    path("social/", include("socials.urls")),
    path('admin/', admin.site.urls),
]

次は view関数を定義します。

from django.conf import settings
from django.contrib.auth import REDIRECT_FIELD_NAME, login
from django.http import Http404
from django.urls import reverse
from django.views.decorators.cache import never_cache
from social_core.actions import do_auth, do_complete, do_disconnect
from social_core.backends.utils import get_backend
from social_core.exceptions import MissingBackend
from social_django.strategy import DjangoStrategy
from social_django.models import DjangoStorage
from social_django.views import _do_login as login_func

BACKENDS = settings.AUTHENTICATION_BACKENDS


@never_cache
def auth(request, provider):
    redirect_uri = reverse("social:complete", args=(provider,))
    request.social_strategy = DjangoStrategy(DjangoStorage, request)
    try:
        backend_cls = get_backend(BACKENDS, provider)
        backend_obj = backend_cls(request.social_strategy, redirect_uri)
    except MissingBackend:
        raise Http404('Backend not found')

    return do_auth(backend_obj, redirect_name=REDIRECT_FIELD_NAME)


@never_cache
def complete(request, provider):
    redirect_uri = reverse("social:complete", args=(provider,))
    request.social_strategy = DjangoStrategy(DjangoStorage, request)
    try:
        backend_cls = get_backend(BACKENDS, provider)
        backend_obj = backend_cls(request.social_strategy, redirect_uri)
    except MissingBackend:
        raise Http404('Backend not found')

    return do_complete(backend_obj, login_func, request.user,
                       redirect_name=REDIRECT_FIELD_NAME, request=request)


@never_cache
def disconnect(request, provider, association_id=None):
    request.social_strategy = DjangoStrategy(DjangoStorage, request)
    try:
        backend_cls = get_backend(BACKENDS, provider)
        backend_obj = backend_cls(request.social_strategy, "")
    except MissingBackend:
        raise Http404('Backend not found')

    return do_disconnect(backend_obj, request.user, association_id,
                         redirect_name=REDIRECT_FIELD_NAME)

ログイン画面に Github によるログインボタンを追加。

<a href="{% url 'social:begin' 'github' %}">Github でログイン</a>

Pipeline の仕組み

Pipeline は python-social-core の最も優れた概念です。Pipelineは、OAuthの流れの中でいくつかのポイントに処理を差し込めるフックポイントを提供してくれます。

f:id:nwpct1:20180519132452p:plain

例えば↑のポイントにフックして処理を記述することができますが、これは何が嬉しいのでしょうか? OAuthプロバイダーのシステム上での表現と自分たちのサービスの表現にはいくつか違いがあります。 例えば、FacebookがGenderを50種類以上用意しているのに対して、自分たちのサービスでは 2-4 種類しか定義したくないこともあるでしょう。プロフィール画像URLのスキームが http の場合、Mixed Contentを避けるために画像をダウンロードしてhttpsのエンドポイントで自前でホストする必要があるケースもあるかもしれません。

# pipeline.py
def save_profile(backend, user, response, *args, **kwargs):
    if backend.name == 'facebook':
        user.gender = sanitize_gender(response.get('gender'))
        :
        profile.save()

定義したPipelineは次のように設定します。

SOCIAL_AUTH_PIPELINE = (
    'app_label.pipeline.save_profile',
    :
)

こういった外部のサービスから取得するユーザー情報を自分たちのサービスに合わせて加工することができます。簡単に拡張できるようになっているので、ぜひドキュメント を参考に利用してみてください。

まとめ

Djangoにおけるユーザー認証のカスタマイズにフォーカスして解説を行いました。 誰も教えてくれないはまりどころもありますので、この資料を参考に進めてください。


  1. ユーザは一度認証されると、Djangoはどのバックエンドで認証されたのかをユーザーセッションに保存します。セッションが有効な場合は、同じバックエンドを利用する時にキャッシュとしてそのユーザーが認証済みかどうかチェックします。強制的に別の方法で再度認証させたい場合は、セッションデータをクリアしてください。クッキーを削除するか、 Session.objects.all().delete() で消すことができます。

  2. 認証バックエンドには、 has_permget_all_permissions といったユーザーオブジェクトの権限確認(認可)のためのメソッドを定義することもできます。ただし、話が大きくなりすぎるため今回は扱いません。

  3. ユーザーモデルを外部キーとするときに get_user_model でもできそうに見えますが、こちらはimport loopが発生する問題があるので避けてください。 settings.AUTH_USER_MODEL を使いましょう。

  4. 特にpython-social-authの実装は social-core の意味のないラッパーのようになっていて、一見あまり綺麗でないように感じました。これは python-social-auth が悪いというわけではなく、人気のあるライブラリが後方互換を保ったまま初期の設計の負債を修正するにはこのようにならざるを得なかったことも想像できます。実際omabさんも2016年移行 python-social-auth にはcommitしていなくて、django-social-appもsocial-coreだけに依存しています。そこで今回の実装も social-auth-core にのみ依存するように実装しました。