Django中級 #4 ユーザー / 権限 — カスタム user model、permission、group

読了 8分

基礎 #7 で Django の ビルトイン認証 を固めました — ログイン/ログアウト、@login_requiredUser モデル。実務に入るとすぐに 2 つの問いに出会います。

  1. 「username の代わりに メール でログインしたいんだけど?」
  2. 「ユーザーに細かな権限を与えたいなら?」

この記事がその答えです。重い警告 1 つから始めます — AUTH_USER_MODEL はプロジェクト開始時点で決めなければなりません。

重い警告 — 開始時点で決めるべき決定 #

Django 公式ドキュメントが強調する一文: “Even if you’re happy with the default User model, you’ll likely want to change it later. So if you’re starting a new project, definitely set up a custom user model, even if it looks just like Django’s default user model.”

(訳: デフォルトの User モデルで満足していても、後で変更したくなる可能性が高いです。新規プロジェクトなら、デフォルトの User と全く同じ見た目でも必ずカスタム user model で始めてください。)

理由は マイグレーション後に変更するのが非常に難しいため です。すべての外部キー、すべてのマイグレーション履歴、すべてのデータを移さなければなりません。新規プロジェクトでは次の 2 行を 最初のマイグレーション前に 押さえてください。

settings.py
AUTH_USER_MODEL = "accounts.User"
accounts/models.py — 最小
from django.contrib.auth.models import AbstractUser

class User(AbstractUser):
    pass

今すぐ追加フィールドがなくても 自分の User クラスを持って始めます。後でフィールドを追加するのは簡単ですから。

AbstractUser vs AbstractBaseUser #

カスタマイズの深さに応じて 2 つの分岐があります。

AbstractUserAbstractBaseUser + PermissionsMixin
基本フィールドusername、email、first_name、last_name、…password、last_login のみ
権限システム自動含むPermissionsMixin で追加
推奨されるケースほとんど (90%) — 少し追加するだけ全く違うユーザーモデル (メールのみなど)
作業量非常に少ない多い (UserManager を自前で書く)

ほぼすべてのケースで AbstractUser が答え。 本当に username 自体をなくしてメールだけ使いたいときだけ AbstractBaseUser

AbstractUser — フィールド追加だけ #

accounts/models.py — フィールド追加
from django.contrib.auth.models import AbstractUser
from django.db import models

class User(AbstractUser):
    bio = models.TextField(blank=True)
    avatar = models.ImageField(upload_to="avatars/", blank=True, null=True)
    birth_date = models.DateField(null=True, blank=True)

    def __str__(self):
        return self.username

これで終わりです。usernameemailfirst_namelast_nameis_staffis_active などはそのまま生きています。

メールログインパターン — AbstractBaseUser #

実務でよく出会う要件 — メールを username として 使う。このときは AbstractBaseUser + カスタムマネージャの組み合わせが標準です。

accounts/models.py — メールログイン
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin
from django.db import models
from django.utils import timezone


class UserManager(BaseUserManager):
    use_in_migrations = True

    def _create_user(self, email, password, **extra):
        if not email:
            raise ValueError("メールは必須です。")
        email = self.normalize_email(email)
        user = self.model(email=email, **extra)
        user.set_password(password)
        user.save(using=self._db)
        return user

    def create_user(self, email, password=None, **extra):
        extra.setdefault("is_staff", False)
        extra.setdefault("is_superuser", False)
        return self._create_user(email, password, **extra)

    def create_superuser(self, email, password=None, **extra):
        extra.setdefault("is_staff", True)
        extra.setdefault("is_superuser", True)
        if extra.get("is_staff") is not True:
            raise ValueError("superuser は is_staff=True でなければなりません。")
        if extra.get("is_superuser") is not True:
            raise ValueError("superuser は is_superuser=True でなければなりません。")
        return self._create_user(email, password, **extra)


class User(AbstractBaseUser, PermissionsMixin):
    email = models.EmailField(unique=True)
    name = models.CharField(max_length=100, blank=True)

    is_active = models.BooleanField(default=True)
    is_staff = models.BooleanField(default=False)
    date_joined = models.DateTimeField(default=timezone.now)

    objects = UserManager()

    USERNAME_FIELD = "email"        # ログインに使うフィールド
    REQUIRED_FIELDS = []            # createsuperuser が訊ねる追加フィールド (email/password 以外)

    def __str__(self):
        return self.email

チェックポイント:

  • USERNAME_FIELD = "email" — ログイン識別子。unique=True 必須
  • REQUIRED_FIELDScreatesuperuser コマンドが追加で訊ねるフィールド。USERNAME_FIELDpassword は自動で含む のでここに書かない
  • UserManagercreate_user / create_superusermanage.py createsuperuser などから呼ばれる
  • PermissionsMixin を一緒に継承しなければグループ/パーミッションシステムが動かない

admin の登録 #

createsuperuser が動いても admin のユーザー変更フォームはデフォルトの User ベースなので壊れます。UserAdmin を直接登録します。

accounts/admin.py
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.forms import UserCreationForm, UserChangeForm
from .models import User


class UserAdmin(BaseUserAdmin):
    ordering = ("email",)
    list_display = ("email", "name", "is_staff", "is_active")
    search_fields = ("email", "name")

    fieldsets = (
        (None, {"fields": ("email", "password")}),
        ("個人情報", {"fields": ("name",)}),
        ("権限", {"fields": ("is_active", "is_staff", "is_superuser",
                          "groups", "user_permissions")}),
        ("記録", {"fields": ("last_login", "date_joined")}),
    )
    add_fieldsets = (
        (None, {"classes": ("wide",),
                "fields": ("email", "password1", "password2")}),
    )

admin.site.register(User, UserAdmin)

会員登録/ログインフォームも username ベースのビルトインフォームをそのまま使えないので、UserCreationForm / AuthenticationForm を継承してメールベースで作り直します。

Permission システム #

Django はモデルごとに自動で 4 つのパーミッションを作ります。

パーミッションコード意味
add_<model>追加
change_<model>修正
delete_<model>削除
view_<model>閲覧

blog アプリの Post モデルなら blog.add_postblog.change_postblog.delete_postblog.view_post が自動生成されます。

コードでの検査 #

has_perm の使用
if request.user.has_perm("blog.change_post"):
    # 修正権限あり
    ...

if request.user.has_perms(["blog.change_post", "blog.delete_post"]):
    # 両方あり
    ...

デコレータ / Mixin #

FBV — @permission_required
from django.contrib.auth.decorators import permission_required

@permission_required("blog.change_post", raise_exception=True)
def post_edit(request, pk):
    ...
CBV — PermissionRequiredMixin
from django.contrib.auth.mixins import PermissionRequiredMixin

class PostUpdateView(PermissionRequiredMixin, UpdateView):
    permission_required = "blog.change_post"
    raise_exception = True

#1 CBV で見たあの Mixin です。

カスタムパーミッション #

モデルの Meta に定義して自動生成に追加できます。

カスタムパーミッション
class Post(models.Model):
    ...
    class Meta:
        permissions = [
            ("publish_post", "Can publish post"),
            ("feature_post", "Can feature post on homepage"),
        ]

makemigrationsblog.publish_post で使用。admin のユーザー/グループ編集で付与できます。

Group — パーミッションの束 #

権限をユーザーに直接与えると管理が難しくなります。Group にまとめてユーザーに付与する のが標準。

グループ作成 (シェル/マイグレーション)
from django.contrib.auth.models import Group, Permission

editors, _ = Group.objects.get_or_create(name="エディター")
editors.permissions.add(
    Permission.objects.get(codename="add_post"),
    Permission.objects.get(codename="change_post"),
    Permission.objects.get(codename="publish_post"),
)

# ユーザーにグループを付与
user.groups.add(editors)

admin ページの グループ メニューでも同じことができます。普通はマイグレーションやシードコマンドでグループ/パーミッションの初期値をコードに埋め込むのが再現性の面で良いです。

data migration でグループ作成
from django.db import migrations

def create_default_groups(apps, schema_editor):
    Group = apps.get_model("auth", "Group")
    Permission = apps.get_model("auth", "Permission")
    editors, _ = Group.objects.get_or_create(name="エディター")
    perms = Permission.objects.filter(
        codename__in=["add_post", "change_post", "publish_post"]
    )
    editors.permissions.set(perms)

class Migration(migrations.Migration):
    dependencies = [("blog", "0001_initial")]
    operations = [migrations.RunPython(create_default_groups, migrations.RunPython.noop)]

テンプレートでのグループ/パーミッション検査 #

テンプレート
{% if user.is_authenticated %}
  {% if perms.blog.publish_post %}
    <a href="{% url 'post_publish' post.pk %}">公開</a>
  {% endif %}
{% endif %}

permsdjango.contrib.auth.context_processors.auth コンテキストプロセッサが渡すヘルパーです。settings.pyTEMPLATES.OPTIONS.context_processors に入っていれば自動。

Object-level permission — 一行案内 #

Django のビルトインパーミッションは モデル単位 です — 「このユーザーが Post を修正できるか」までしかありません。「このユーザーがこの特定の Post を修正できるか」 (オブジェクト単位) はありません。

オブジェクト単位の権限が必要なら:

  • UserPassesTestMixin (#1) で直接検査 — 小さなケースなら十分
  • django-guardian 外部ライブラリ — 本格的なオブジェクト権限
  • DRF の permission_classes — REST API なら (DRF #2)

小さなケースでは直接検査がきれいです。

作成者のみ修正
class PostUpdateView(LoginRequiredMixin, UserPassesTestMixin, UpdateView):
    model = Post
    form_class = PostForm

    def test_func(self):
        return self.get_object().author == self.request.user

パスワード — ハッシュ / 変更 / リセット #

Django はパスワードを PBKDF2 (デフォルト) などの安全なハッシュで自動保存します。直接触ることはほぼありません。

パスワードを扱う
user.set_password("新しいパスワード")  # ハッシュして保存
user.save()

user.check_password("入力値")          # 検証

平文パスワードを保存しないでください。user.password = "..." で直接代入すると平文が入るので 必ず set_password

ビルトインのパスワード変更/リセットビューは django.contrib.auth.urls に入っています。

urls.py
from django.urls import path, include

urlpatterns = [
    path("accounts/", include("django.contrib.auth.urls")),
    # /accounts/login/, /accounts/logout/, /accounts/password_change/,
    # /accounts/password_reset/ など
]

DRF の認証との違い — 短く #

今回の認証は セッション + クッキー (ブラウザフォームログイン) モデルです。REST API は普通 トークン / JWT / OAuth2 などの stateless 認証を使います。

Django ビルトイン (今回)DRF
認証媒体セッションクッキーToken / JWT / OAuth2
権限検査has_perm / Mixin / デコレータpermission_classes
ユーザーモデル同一 (AUTH_USER_MODEL)同一
ログインフォームビルトイン HTML別エンドポイント

同じ User モデルを使いますが 認証媒体と権限検査の位置が異なる構造 と考えるとよいでしょう。DRF の認証/権限スタックは DRF #2 で詳しく。

request.user の正体 #

AuthenticationMiddleware (#3 で見たあのミドルウェア) がすべてのリクエストに request.user を埋めます。

  • ログインしていればUser インスタンス
  • していなければAnonymousUser (偽物のオブジェクト。is_authenticated == False)
よく使う検査
if request.user.is_authenticated:
    ...

if request.user.is_staff:
    ...

if request.user.is_superuser:
    ...

is_authenticated属性 です (メソッドではありません)。request.user.is_authenticated() のように呼び出してはいけません — 古い Django 1.x 時代はメソッドでしたが今は属性。

まとめ #

今回押さえたもの:

  • AUTH_USER_MODEL はプロジェクト開始時点で決める — 後の変更は非常に高くつく
  • ほぼすべてのケースで AbstractUser が答え (フィールド追加だけ)
  • メールログイン — AbstractBaseUser + PermissionsMixin + カスタム UserManager
  • USERNAME_FIELDREQUIRED_FIELDS の意味
  • モデルごとに自動 4 つのパーミッション (add/change/delete/view_<model>)
  • has_perm@permission_requiredPermissionRequiredMixin
  • モデル Meta.permissions でカスタムパーミッション
  • Group で権限を束ねる — ユーザーにはグループを付与
  • オブジェクト単位の権限は UserPassesTestMixin または django-guardian / DRF
  • パスワードは set_password / check_password、ビルトイン変更/リセットビュー
  • request.user.is_authenticated は属性

次回 (#5 メッセージ/セッション/クッキー) では、認証されたユーザーに送る flash メッセージ、リクエスト間の状態保存のための セッション、そしてその下層の クッキー までを 1 編で解きます。

X