【Django】認証機能を使ってログインユーザーのアクセスを制限する


投稿日 2020年9月10日 >> 更新日 2023年3月1日

今回は複数の権限(スーパーユーザー、スタッフユーザー、アクティブユーザー)のユーザーが存在するサイトにおいて、Djangoの認証機能を使用して特定のビューへのアクセスを制限するといったことを行っていきます。

例えば下図のようなブログサイトがあるとしたら

もちろんスーパーユーザーは全てのビューへアクセス可能ですが、「スタッフユーザー」は「非公開リストまで、「アクティブユーザー」は記事詳細までといった内容でログインされてきたユーザーに対してそれぞれの異なる認証を与えます。

今回はパーミッション(許可)などの細かい設定は行いませんが、細かい設定を行えるようになるためにも代表的な認証機能を使ってみたいと思います。

Djangoの認証機能は、デコレータやクラスベースビューとして簡単に設定することができるのでぜひ試してみましょう。

実行環境&使用ライブラリ

実行環境
Windows Subsystem for Linux Ubuntu
Python 3.6.9
pip 9.0.1
使用ライブラリ ライセンス
Django==3.1 BSD

実装するにあたってのユーザーリスト

アクセス制限をするにあたって何を制限とするか?

それはユーザーの権限に従った制限です。

権限には3つの種類があり、上から「スーパーユーザー」「スタッフユーザー」「アクティブユーザー」となります。

チェンジリストからでは分かり兼ねる部分があるかと思いますが、それぞれ

ユーザー名 権限
admin スーパーユーザー
django スタッフユーザー
python アクティブユーザー

として保存されています。

パーミッションなどは設定していないので、スーパーユーザー以外は管理画面を扱う事が許されていません。

※自由に設定可能です

ログイン中のユーザー名をテンプレートファイルで表示する

実装するにあたりログイン中のユーザーをしっかり把握するために、ベースとなるHTMLファイルに組込みタグを使用して各権限の持ったユーザー名を出力しておきます。

<!-- app/templates/app/base.html -->

<!doctype html>
<html>
    <head>
        <title>ブログ</title>
    </head>
    <body>

        <div class="container">

            <!-- ログイン中のユーザーを表示 -->
            {% if request.user.is_authenticated %}
                <h1>
                    ブログ {{ user.username }}さんは
                    {% if request.user.is_superuser %}
                        スーパーユーザー
                    {% elif request.user.is_staff %}
                        スタッフユーザー
                    {% elif request.user.is_active %}
                        アクティブユーザー
                    {% endif %}
                    です
                </h1>
            {% else %}
                <h1>ブログ 一般ユーザーさんです</h1>
            {% endif %}
            <br>
            <!-- ここまで -->

            {% block content %}
            {% endblock %}

    </body>
</html>

下図のようにログイン中のユーザーによって「ユーザー名」「権限」が表示されます。

簡単なログイン機能の作成

ログインがされていない状況で、アクセス制限がされているページにアクセスされた時に、ログインをさせるか、それとも権限の無いユーザーには違うページにアクセスさせるか設定できますが、アクセス制限に使う認証機能とセットでログイン機能も使われることがあるので、準備をしておきます。

認証機能のモジュールとして「'django.contrib.auth'」が設定ファイル内のINSTALLED_APPSに定義されているので、インポートをすることによって様々な機能が使えます。

では、アプリディレクトリ内の「urls.py」にログイン機能のビューを定義します。

# app/urls.py

from django.urls import path
from . import views
from django.contrib.auth import views as auth_views  # インポート

app_name = 'app'

urlpatterns = [
        ...
        path('/accounts/login/', auth_views.LoginView.as_view()),  # 追加
        ...
        ...
]

URLパターンを「/accounts/login/」としているのは、認証機能を使う際にデフォルトで設定されているログインページへのURLだからです。

もちろん好きなURLパターンに変えることができますが、少し手間を省くために予め設定しています。

LoginViewクラスのデフォルト設定では

属性 デフォルト
name login
template_name registration/ login.html
form AuthenticationForm

などです。

他にもありますが、今回は簡単なログイン機能の実装なので詳しくはドキュメントをご参照ください。

ではデフォルトで設定されているテンプレート先の「registration/login.html」を作成します。

templates
    |--registration
              |--login.html
# app/templates/registration/login.html

<form method="POST">
    {% csrf_token %}
    {{ form.as_p }}
    <input type="submit" value="ログイン">
</form>

LoginViewクラスのフォームには「 AuthenticationForm」というフォームクラスがデフォルトで設定されているので、オブジェクトの名前は「form」として取得できます。

ブラウザの検索バーに「http://127.0.0.1:8000/accounts/login/?next=/」と打ち込むと、以下のようにログインフォームが表示されるはずです。

URL末尾の「?next=/」となっているのは、ログインされた際にトップページである「/」にリダイレクトされるという意味です。

ログアウトのリダイレクト先

恐らく頻繁にログアウトを実行するかと思うので、少し面倒を省くために管理画面からログアウトする際のリダイレクト先をトップページに設定します。

設定ファイル「settings.py」の最下部にLOGOUT_REDIRECT_URLを設置します。

# config/settings.py

LOGOUT_REDIRECT_URL = '/'  # トップページへリダイレクトされる

Webリクエストを使った認証

まずは単純な方法として、「request」オブジェクトを使用した権限による処理を実装します。

ビューの処理において定義された関数ビューやクラスベースビュー内で、この「request」オブジェクトのUser属性を取得し('request.user'など)、取得したUser属性からさらにユーザーの情報を取得し('request.user.is_superuser'など)条件分岐による各権限の処理を行います。

ここでは最も権限の低い「アクティブユーザー」は、「詳細ページ」のみのアクセスを許し、「スタッフユーザー」は「詳細ページ」と「非公開リスト」のアクセスを許します。

もちろんスーパーユーザーはすべての権限を有しているので、「投稿」ページのみスーパーユーザーの権限を設定します。

では関数ビューとクラスベースビューでそれぞれ確認してみます。

関数ビュー(Webリクエストの認証)

# app/views.py

from django.shortcuts import render, get_object_or_404, redirect
from .models import Blog
from .forms import BlogForm


...

def blog_private(request):
    """
    非公開リスト
    """
    # スタッフユーザーの権限がなければログインページ
    if not request.user.is_staff:
        return redirect('/accounts/login/?next=%s' % request.path)

    blog_private = Blog.objects.filter(is_publick=False).order_by('-id')
    return render(request, 'app/blog_private.html', {'blog_private': blog_private})


def detail(request, pk):
    """
    記事詳細
    """
    # アクティブユーザーの権限がなければログインページ
    if not request.user.is_active:
        return redirect('/accounts/login/?next=%s' % request.path)

    blog_detail = get_object_or_404(Blog, id=pk, is_publick=True)
    context = {
            'blog_detail': blog_detail,
    }
    return render(request, 'app/detail.html', context)


def new_blog(request):
    """
    投稿
    """
    # スーパーユーザーの権限がなければログインページ
    if not request.user.is_superuser:
        return redirect('/accounts/login/?next=%s' % request.path)

    if request.method =="POST":
        form = BlogForm(request.POST)
        if form.is_valid():
            form.save()
            return redirect('app:index')
    else:
        form =BlogForm
    return render(request, 'app/new_blog.html', {'form': form})

制限したい各アクセスポイントに「request.user...」の条件を設定するだけで、サイト訪問状態の権限によってログインページもしくわそのページにアクセスされます。

pythonユーザーは記事詳細ページにアクセスする権限があるので、URLの「/?next=/detail/id/」とされるページにリダイレクトされる

クラスベースビュー(Webリクエストの認証)

# app/views.py

from django.shortcuts import render, redirect
from .models import Blog
from .forms import BlogForm
from django.views.generic import ListView, FormView, DetailView


...

class Blog_Private(ListView):
    """
    非公開リスト
    """
    queryset = Blog.objects.filter(is_publick=False).order_by('-id')
    template_name = 'app/blog_private.html'
    context_object_name = 'blog_private'

    def get(self, request):
        # スタッフユーザーでなければログインページ
        if not request.user.is_staff:
            return redirect('/accounts/login/?next=%s' % request.path)
        return super().get(request)


class Detail(DetailView):
    """
    記事詳細ページ
    """
    model = Blog
    template_name = 'app/detail.html'
    context_object_name = 'blog_detail'

    def get(self, request, **kwargs):
        # アクティブユーザーでなければログインページ
        if not request.user.is_active:
            return redirect('/accounts/login/?next=%s' % request.path)
        return super().get(request)


class New_Blog(FormView):
    """
    投稿
    """
    form_class = BlogForm
    template_name = 'app/new_blog.html'
    success_url = '/'

    def get(self, request):
        # スーパーユーザーでなければログインページ
        if not self.request.user.is_superuser:
            return redirect('/accounts/login/?next=%s' % request.path)
        return super().get(request)

    def form_valid(self, form):
        form.save()
        return super().form_valid(form)

制限したい各アクセスポイントに「request.user...」の条件を設定するだけで、サイト訪問状態の権限によってログインページもしくわそのページにアクセスされます。

user_passes_testデコレータ/UserPassesTestMixinクラス

先ほどのビューでWebリクエストを行ったような権限による認証のテストをショートカット機能として実装することができます。

関数ビューである場合は「user_passes_test」デコレータを使用し、クラスベースビューでの場合は「UserPassesTestMixin」クラスをサブクラス化して使用します。

ユーザーに対するテストは自由に決めることができますが、ここでは権限に対してのテストを実行させテストにパスできなければアクセスできないようにします。

関数ビュー(user_passes_testデコレータ)

このデコレータは「user_passes_terst(test_func, login_url=None、redirect_field_name='next')」という3つの引数を取れます。

  • test_func:第1引数は必須条件で、認証テストを行う関数を取ります。
  • login_url:ログインページにアクセスされる引数で、デフォルトでは「/accounts/login/?next='リダイレクト先'」となります。
  • redirect_field_name:ログインした際にリダイレクトされる引数です。デフォルトではそのままアクセス先に飛びますが、指定した場合は「/accounts/profile/」先のビューに飛ぶので、URLパターンを「/accounts/profile/ビュー名」とする必要があります。

※簡単なログイン機能を作成した際に、URLパターンを「/accounts/login/」とした理由はこれらの認証機能のデフォルトがそこにアクセスされるからです。

# app/views.py

from django.shortcuts import render, get_object_or_404, redirect
from django.contrib.auth.decorators import user_passes_test  # インポート
from .models import Blog
from .forms import BlogForm


...

def staff_test_func(user):
    return user.is_staff

@user_passes_test(staff_test_func)  # ログイン時スタッフユーザーであるかテスト
def blog_private(request):
    """
    非公開リスト
    """
    blog_private = Blog.objects.filter(is_publick=False).order_by('-id')
    return render(request, 'app/blog_private.html', {'blog_private': blog_private})


def active_test_func(user):
    return user.is_active

@user_passes_test(active_test_func)  # ログイン時アクティブユーザーであるかテスト
def detail(request, pk):
    """
    記事詳細ページ
    """
    blog_detail = get_object_or_404(Blog, id=pk, is_publick=True)
    context = {
            'blog_detail': blog_detail,
    }
    return render(request, 'app/detail.html', context)


def super_test_func(user):
    return user.is_superuser

@user_passes_test(super_test_func)  # ログイン時スーパーユーザーであるかテスト
def new_blog(request):
    """
    投稿
    """
    if request.method =="POST":
        form = BlogForm(request.POST)
        if form.is_valid():
            form.save()
            return redirect('app:index')
    else:
        form =BlogForm
    return render(request, 'app/new_blog.html', {'form': form})

これでWebリクエストで行った権限に従うアクセスと同じような挙動になるかと思います。

先ほどデフォルトでログインページにアクセスされると言いましたが、URLパターンで定義している「/accounts/login/」行をコメントアウトしてみると

# app/urls.py

urlpatterns = [
        ...
        # path('accounts/login/', auth_views.LoginView.as_view()),
        ...
]

以下のようにエラーとなります。

引数の「redirect_field_name」を指定して、リダイレクト先をユーザープロフィールのあるビューへ渡してみます。

まずはアプリディレクトリ内の「urls.py」を編集します。

# app/urls.py

urlpatterns = [
        ...
        path('accounts/login/', auth_views.LoginView.as_view()),
        ...
        path('accounts/profile/', views.account_profile, name='account_profile'),  # 追記
]

「views.py」にて簡単なプロフィールビューを定義します。

# app/views.py

from django.shortcuts import render, get_object_or_404, redirect
from django.contrib.auth.decorators import user_passes_test
from .models import Blog
from .forms import BlogForm
from django.http import HttpResponse  # インポート


...

@user_passes_test(staff_test_func, redirect_field_name='account_profile')
def blog_private(request):
    """
    非公開リスト
    """
    ...


@user_passes_test(active_test_func, redirect_field_name='account_profile')
def detail(request, pk):
    """
    記事詳細ページ
    """
    ...


@user_passes_test(super_test_func, redirect_field_name='account_profile')
def new_blog(request):
    """
    投稿
    """
    ...


def account_profile(request):
    """
    プロフィールページ
    """
    return HttpResponse('<h1>ようこそ%sさん' % request.user.username)

認証テストに合格したユーザーはプロフィールページへリダイレクトされ、アクティブである以上特定のページへアクセスできますが、認証されない場合は、永遠とログイン→プロフィール画面の繰り返しとなります。

クラスベースビュー(UserPassesTestMixinクラス)

UserPassesTestMixinクラスは、AccessMixinクラスを継承しているので設定次第では様々な処理を行えます。

AccessMixinはユーザーのログインでテストに失敗した際の動作を変えることができます。

代表的な属性は

  • login_url:デフォルトでは「/accounts/login/?next='アクセス先'」のログインページへ移動し、認証を行います。
  • redirect_field_name:テストに合格した際のリダイレクト先を設定できます。デフォルトでは「?next='アクセス先'」となっており、指定する場合は「/accounts/profile/」となるURLパターンでビューを設定します。
  • permission_denied_message:エラーハンドラーを出力します。テストに失敗際、コンソールにメッセージを出力します。文字列を渡せます。
  • raise_exception:デフォルトではFalse。ログインページで認証し、テストに失敗したユーザーを403 Forbiddenページに飛ばします。Trueの場合は、アクセスする権限が無い以上403 Forbiddenページに移動させます。

クラスメソッド

  • test_func():ユーザーをテストにかけるメソッド

属性とクラスメソッドの呼び出し

# 例
from django.contrib.auth.mixins import UserPassesTestMixin
from .models import Blog
from django.views.generic import ListView

class View(UserPassesTestMixin, ListView):
    model = Blog
    template_name = 'app/index'

    # 認証機能
    login_url ='/account/login/'
    redirect_filed_name = 'profile'
    permission_denied_message = 'テストに失敗しました。'
    raise_exception = True

    def test_func(self):
        # Gメールが登録されているユーザーはアクセス可能とする
        return self.request.user.email.endswith('@gmail.com')

他にもオーバーライドできるメソッドが揃っているので、興味のある方はドキュメントをご参照ください。

Django公式ドキュメント --AccessMixin

ここではデフォルトの状態で実装します。

# app/views.py

from django.contrib.auth.mixins import UserPassesTestMixin  # インポート
from .models import Blog
from .forms import BlogForm
from django.views.generic import ListView, FormView, DetailView


...

class Blog_Private(UserPassesTestMixin, ListView):
    """
    非公開リスト
    ユーザー認証機能継承
    """
    queryset = Blog.objects.filter(is_publick=False).order_by('-id')
    template_name = 'app/blog_private.html'
    context_object_name = 'blog_private'

    def test_func(self):
        # スタッフユーザーでなければ403ページ
        return self.request.user.is_staff


class Detail(UserPassesTestMixin, DetailView):
    """
    記事詳細ページ
    ユーザー認証機能継承
    """
    model = Blog
    template_name = 'app/detail.html'
    context_object_name = 'blog_detail'

    def test_func(self):
        # アクティブユーザーでなければ403ページ
        return self.request.user.is_active


class New_Blog(UserPassesTestMixin, FormView):
    """
    投稿
    ユーザー認証機能継承
    """
    form_class = BlogForm
    template_name = 'app/new_blog.html'
    success_url = '/'

    def test_func(self):
        # スーパーユーザーでなければ403ページ
        return self.request.user.is_superuser

    def form_valid(self, form):
        form.save()
        return super().form_valid(form)

これでWebリクエストで行った権限に従うアクセスと同じような挙動になるかと思います。

ただし、AccessMixinクラスを継承しているので、テスト後の動作が違います。

ログイン中のユーザーの権限が合わなければ、常に「403 Forbidden」を表示します。

login_requiredデコレータ/LoginReuiredMixin

ログインページにアクセスさせるシンプルなショートカット機能として、login_requiredがあります。

単体で使うと権限やパーミッションなどの細かい設定はできませんが、他のショートカット機能と組み合わせて使用することができます。

関数ビューである場合は「login_required」デコレータを使用し、クラスベースビューでの場合は「LoginRequiredMixin」クラスをサブクラス化して使用します。

関数ビュー(login_requiredデコレータ)

このデコレータは「login_required(login_url=None、redirect_field_name='next')」という2つの引数を取れます。

  • login_url:ログインページにアクセスされる引数で、デフォルトでは「/accounts/login/?next='リダイレクト先'」となります。
  • redirect_field_name:ログインされた際にリダイレクトされる引数です。デフォルトではそのままアクセス先に飛びますが、指定した場合は「/accounts/profile/」先のビューに飛ぶので、URLパターンを「/accounts/profile/ビュー名」とする必要があります。

※簡単なログイン機能を作成した際に、URLパターンを「/accounts/login/」とした理由はこれらの認証機能のデフォルトがそこにアクセスされるからです。

# app/views.py

from django.shortcuts import render, get_object_or_404, redirect
from django.contrib.auth.decorators import login_required  # インポート
from .models import Blog
from .forms import BlogForm
from django.http import HttpResponse  # インポート


...

@login_required
def blog_private(request):
    """
    非公開リスト
    """
    if not request.user.is_staff:
        return HttpResponse('<h1>%sさんはアクセスできません</h1>' % request.user.username)

    blog_private = Blog.objects.filter(is_publick=False).order_by('-id')
    return render(request, 'app/blog_private.html', {'blog_private': blog_private})


@login_required
def detail(request, pk):
    """
    記事詳細ページ
    """
    if not request.user.is_active:
        return HttpResponse('<h1>%sさんはアクセスできません</h1>' % request.user.username)

    blog_detail = get_object_or_404(Blog, id=pk, is_publick=True)
    context = {
            'blog_detail': blog_detail,
    }
    return render(request, 'app/detail.html', context)


@login_required
def new_blog(request):
    """
    投稿
    """
    if not request.user.is_superuser:
        return HttpResponse('<h1>%sさんはアクセスできません</h1>' % request.user.username)

    if request.method =="POST":
        form = BlogForm(request.POST)
        if form.is_valid():
            form.save()
            return redirect('app:index')
    else:
        form =BlogForm
    return render(request, 'app/new_blog.html', {'form': form})

シンプルなショートカット機能を使用する際は、各権限による処理は自分で定義します。

スタッフユーザーの権限しかアクセスできない「非公開リスト」(blog_private)にアクティブユーザーであるpythonユーザーでログインすると

クラスベースビュー(LoginRequiredMixinクラス)

LoginRequiredMixinクラスは、AccessMixinクラスを継承しているので、UserPassesTestMixinと同様設定次第では様々な処理を行えます。

代表的な属性は

  • login_url:デフォルトでは「/accounts/login/?next='アクセス先'」のログインページへ移動し、認証を行います。
  • redirect_field_name:テストに合格した際のリダイレクト先を設定できます。デフォルトでは「?next='アクセス先'」となっており、指定する場合は「/accounts/profile/」となるURLパターンでビューを設定します。
  • permission_denied_message:エラーハンドラーを出力します。テストに失敗際、コンソールにメッセージを出力します。文字列を渡せます。
  • raise_exception:デフォルトではFalse。ログインページで認証し、テストに失敗したユーザーを403 Forbiddenページに飛ばします。Trueの場合は、アクセスする権限が無い以上403 Forbiddenページに移動させます。

属性の呼び出し

# 例
from django.contrib.auth.mixins import UserPassesTestMixin
from .models import Blog
from django.views.generic import ListView

class View(UserPassesTestMixin, ListView):
    model = Blog
    template_name = 'app/index'

    # 認証機能
    login_url ='/account/login/'
    redirect_filed_name = 'profile'
    permission_denied_message = 'テストに失敗しました。'
    raise_exception = True

他にもオーバーライドできるメソッドが揃っているので、興味のある方はドキュメントをご参照ください。

ここではデフォルトの状態で実装します。

# app/views.py

from django.contrib.auth.mixins import LoginRequiredMixin  # インポート
from .models import Blog
from .forms import BlogForm
from django.http import HttpResponse  # インポート


...

class Blog_Private(LoginRequiredMixin, ListView):
    """
    非公開リスト
    認証機能継承
    """
    queryset = Blog.objects.filter(is_publick=False).order_by('-id')
    template_name = 'app/blog_private.html'
    context_object_name = 'blog_private'

    def get(self, request):
        # スタッフユーザーでなければHTMLを返す
        if not request.user.is_staff:
            return HttpResponse('<h1>%sさんはアクセスできません</h1>' % request.user.username)
        return super().get(request)


class Detail(LoginRequiredMixin, DetailView):
    """
    記事詳細ページ
    認証機能継承
    """
    model = Blog
    template_name = 'app/detail.html'
    context_object_name = 'blog_detail'

    def get(self, request, **kwags):
        # アクティブユーザーでなければHTMLを返す
        if not request.user.is_active:
            return HttpResponse('<h1>%sさんはアクセスできません</h1>' % request.user.username)
        return super().get(request)


class New_Blog(LoginRequiredMixin, FormView):
    """
    投稿
    認証機能継承
    """
    form_class = BlogForm
    template_name = 'app/new_blog.html'
    success_url = '/'

    def get(self, request):
        # スーパーユーザーでなければHTMLを返す
        if not request.user.is_superuser:
            return HttpResponse('<h1>%sさんはアクセスできません</h1>' % request.user.username)
        return super().get(request)

    def form_valid(self, form):
        form.save()
        return super().form_valid(form)

AccessMixinクラスを継承しているので、メソッドをオーバーライドすることで権限のテストなどを行うことができそうですが、ここでは権限のテストを自分で定義しました。

今回は権限による認証機能をいくつか試してみましたが、認証機能の種類、共に内容もまだまだ説明しきれていません。

余力のある方はどんどん試して行きましょう。

それでは以上となります。

最後までご覧いただきありがとうございました。

参考

Django公式ドキュメント --認証システム