0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Django REST Framework APIとReact SPAでセッション認証ログイン機能をつくる

Last updated at Posted at 2025-02-22

この記事について

Django REST Frameworkで作成したバックエンドAPIとReactなどで作成したSPAからなるウェブアプリで、Djangoのセッション認証を使ったログイン機能と権限管理機能を使う手順をまとめたものです

API+SPAで認証するときの選択肢

選択肢は以下の3つが挙がるようです

OAuth2.0
セッション認証
トークン認証

セキュリティの固さでいくと
OAuth2.0 > セッション認証 > トークン認証
となるようです

トークンを使った認証

OAuth 2.0

OAuth2.0認証基盤が利用できるなら選択可能です

トークンを任意のタイミングで無効にできたり、有効期間の短いトークンを自動でリフレッシュして長期利用も可能にしたりできるので旧来の手軽な方式と比べて堅牢な方式です。認証機能としても多要素認証を選択できるものが多く、企業などで自社のOAuth2.0認証基盤があるならこれを選択すべきだと思います

トークン認証 (JWT, Django TokenAuthentication)

APIを複数のフロントエンドから利用するような場合はOriginが分かれますし、多数のクライアントがブラウザ以外から利用するようなAPIの場合はCookieを使うわけにいかなくなるのでトークンを発行して認証することになります

トークンを使うのはOAuth2.0と同様ですが、JWTなどの一般的な方式ではトークンを頻繁に更新したりできないし、CSRFを防ぐ仕組みがないのでトークンを盗まれたらそのまま利用できてしまいます

Cookieを使った認証

セッション認証

CookieにセッションIDを保存してログインを行う方式です。ブラウザからサイトにリクエストする度に自動でセッションIDが送られるので、サーバー側でセッション情報と照会してログイン済みとして処理することが出来ます

この動作を悪用して、ログイン済みのブラウザで攻撃者のサイトを閲覧しているときにログイン済みのサイトへリクエストを送られて、ログイン済みでないと見られない情報を覗き見たり設定を変更したりするCSRFという手口があり、それを防ぐために明示的にCSRFトークンを送らなければリクエストを返さないようにする実装が推奨されています。CSRFトークンを攻撃者のサイトが入手できてしまうと意味がないので、特定のOriginのみCSRFトークンを提供するように制限します。自動で送られるセッションIDとは違って、CSRFトークンはリクエストヘッダに明示的に書かないと送られませんので、これによりセッションIDによる利便性とCSRF対策を両立します

APIをSPAのみから利用する場合はリバースプロキシやDNSを使って同一Originで公開することができます。その場合は設定がシンプルになってミスが入る余地を減らせますし、CookieをSameSite=Strictに設定して同一Originからのリクエストに対してのみCookieを付けるようできますのでCSRFが困難になりますので、APIをあちこちから使う必要がなければ同一Originで公開するようにした方が良さそうです

今回の選択

今回は以下3つの理由からセッション認証を選択しました

・OAuth認証基盤がない環境である
・Django標準のセッション認証機能が使えるのでやりやすい
・セキュリティも旧来トークン認証より有利
・APIをSPAのみから利用するので同一OrijinにしてSameSite=Strictにできる
 (CSRFを受けにくい実装にできる)

仕組み

Django標準の認証機能を使っていきます

通常のDjangoアプリにおける認証機能については先日記事を書いてます
https://qiita.com/studio_haneya/items/b053b9cb144daf5c1f4e

やり方

1. 同一Originで公開する場合

以下のような手順で簡単に実現できます

  1. プロジェクトをつくる
  2. ユーザーモデルをつくる
  3. ログインするViewSetをつくる
  4. ログアウトするViewSetをつくる
  5. ログインしないと利用できないViewSetをつくる
  6. フロントエンドのコードをつくる
  7. リバースプロキシで同じOriginに公開する

完成品

完成したコードをgithubに置いています
https://github.com/haneya-studio/django_auth_sample

フォルダ構成は以下のようになりました

1-1. プロジェクトをつくる

サンプルプロジェクトをつくっていきます

terminal
django-admin startproject myapp
python manage.py startapp account

1-2. ユーザーモデルをつくる

ユーザー情報を保持するモデルを作成します

account/models.py
from django.db import models
from django.contrib.auth.models import (BaseUserManager, AbstractBaseUser, PermissionsMixin)
from django.utils.translation import gettext_lazy as _

class UserManager(BaseUserManager):
    def _create_user(self, email, username, password, **extra_fields):
        email = self.normalize_email(email)
        user = self.model(email=email, username=username, **extra_fields)
        user.set_password(password)
        user.save(using=self._db)
        user.full_clean()
        return user

    def create_user(self, email, username, password=None, **extra_fields):
        extra_fields.setdefault('is_active', True)
        extra_fields.setdefault('is_staff', False)
        extra_fields.setdefault('is_superuser', False)
        return self._create_user(email=email, username=username, password=password, **extra_fields)

    def create_superuser(self, email, username, password, **extra_fields):
        extra_fields['is_active'] = True
        extra_fields['is_staff'] = True
        extra_fields['is_superuser'] = True
        return self._create_user(email=email, username=username, password=password, **extra_fields)

class User(AbstractBaseUser, PermissionsMixin):

    username = models.CharField(verbose_name=_("username"), unique=True, max_length=150)
    email = models.EmailField(verbose_name=_("Email Address"), unique=True)
    age = models.IntegerField(verbose_name=_("age"), null=True, blank=True)
    is_superuser = models.BooleanField(verbose_name=_("is_superuer"), default=False)
    is_staff = models.BooleanField(_('staff status'), default=False)
    is_active = models.BooleanField(_('active'), default=True)

    objects = UserManager()

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

    def __str__(self):
        return self.username
myapp/setting.py
INSTALLED_APPS = [
    ...
    'rest_framework', # djangorestframeworkを追加
    'account' # 作成したaccountアプリを追加
]

AUTH_USER_MODEL = "account.User" # accountアプリのUserモデルをデフォルトで使用する認証ユーザーモデルとして設定する
SESSION_ENGINE = 'django.contrib.sessions.backends.db' # セッションを何で保存するかを指定する。この場合は
account/admin.py
from django.contrib import admin
from django.contrib.sessions.models import Session

admin.site.register(Session)
terminal
python manage.py migrate
terminal
python manage.py createsuperuser

作成したユーザーモデルを確認しておきます

terminal
python manage.py runserver

問題なさそうです
http://localhost:8000/admin

1-3. ViewSetをつくる

views.pyを作成していきます

必要なパッケージをimportする

/account/views.py
from django.views.decorators.csrf import csrf_protect
from django.utils.decorators import method_decorator
from django.contrib.auth import authenticate, login, logout
from rest_framework import serializers, viewsets
from rest_framework.authentication import SessionAuthentication
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from .models import User

from django.http import JsonResponse
from django.middleware.csrf import get_token

ログイン用ViewSetをつくる

django.contrib.auth.authenticateで認証用ユーザーモデルにある情報と照会して認証を行って、認証できたらdjango.contrib.auth.loginでセッション認証済みにします。Cookieとか諸々は自動でやってくれます

@method_decorator(csrf_protect)はCSRF保護する為のもので、これを書くだけでCSRFトークンがないリクエストを拒否してくれます。変更を行うAPIや認証しないとGETできないAPIは保護すべきなので、今回のAPIは全部付けています

/account/views.py
class LoginViewSet(viewsets.ViewSet):
    @method_decorator(csrf_protect)
    def create(self, request):
        username = request.data.get('username')
        password = request.data.get('password')
        user = authenticate(username=username, password=password)

        if user is not None:
            login(request, user)
            return Response({"message": "Login successful"})
        else:
            return Response({"error": "Invalid credentials"}, status=400)

ログアウト用APIをつくる

django.contrib.auth.logoutを呼ぶだけでrequest内のuser情報を使ってログアウトしてくれます

/account/views.py
class LogoutViewSet(viewsets.ViewSet):
    @method_decorator(csrf_protect)
    def create(self, request):
        logout(request)
        return Response({"message": "Logged out successfully"})

ログインしないと見られないViewSetをつくる

ModelViewSetにauthentication_classesとpermission_classesを指定すれば認証されていなければ使えなくなります。以下の場合はセッション認証(SessionAuthentication)により認証済み(IsAuthenticated)である場合にのみ動作します。書き方から分かるように複数の方式を選択できますし、認証ではなくユーザーモデルに設定した他のプロパティでも権限管理できます

/account/views.py
class UserSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = "__all__"

class UserViewSet(viewsets.ModelViewSet):
    queryset = User.objects.all()
    serializer_class = UserSerializer
    authentication_classes = (SessionAuthentication,)
    permission_classes = (IsAuthenticated, )

1-4. urls.pyでルーティングする

作成したViewSetをDefaultRouterでルーティングします

myapp/urls.py
from django.contrib import admin
from django.urls import path
from django.conf.urls import include
from rest_framework import routers
from account.views import UserViewSet, LoginViewSet, LogoutViewSet

router = routers.DefaultRouter()
router.register(r'users', UserViewSet)
router.register(r'login', LoginViewSet, basename='login')
router.register(r'logout', LogoutViewSet, basename='logout')

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include(router.urls)),
]

ここまでの動作を確認

これでAPIがつくれたので動作を確認しておきましょう

terminal
python manage.py makemigrations
python manage.py migrate
python manage.py runserver

以下にAPI一覧が出る筈です
http://localhost:8000/

ログイン用APIは以下に待機しています
http://localhost:8000/login/

usernameとpasswordをJSONでPOSTするとログインできます

ログインするとUsersViewSetのAPIがGETできるようになります
http://localhost:8000/users/

ログアウトは以下でPOSTするだけです
http://localhost:8000/logout/

1-5. フロントエンドのコードをつくる

ボタンを押すとJavaScriptで各APIをコールするhtmlファイルを作成します

サンプルコードなので、手抜きしてパスワードはべた書き、APIからの応答はconsole.logするだけにしています。Cookieを送るようにcredentials: "include"として、CSRFトークンをCookieから取得して明示的に送る必要があるのがセッション認証がない場合との違いです

index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Fetch Buttons</title>
</head>
<body>
    <div>APIからの応答をconsoleに出力します</div>
    <button id="loginButton">Login</button>
    <button id="getUsersButton">Get Users</button>
    <button id="logoutButton">Logout</button>

    <script>
        const baseURL = 'http://localhost/api/'

        async function getCSRF() {
            name = 'csrftoken'
            let token = null
            if (document.cookie && document.cookie !== '') {
                const cookies = document.cookie.split(';')
                for (let i = 0; i < cookies.length; i++) {
                    const cookie = cookies[i].trim()
                    if (cookie.startsWith(name + '=')) {
                        token = decodeURIComponent(cookie.substring(name.length + 1))
                        break
                    }
                }
            }
            return token
        }
       
        document.getElementById("loginButton").addEventListener("click", async () => {
            try {
                const url = `${baseURL}login/`
                console.log(url)
                const token = await getCSRF()
                const response = await fetch(url, {
                    method: "POST",
                    headers: {
                        "Content-Type": "application/json",
                        'X-CSRFToken': token,
                    },
                    body: JSON.stringify({
                        username: "hoge",
                        password: "hogehoge"
                    }),
                    credentials: "include",
                });
                const data = await response.json();
                console.log("Login Response:", data);
            } catch (error) {
                console.error("Login Error:", error);
            }
        });

        document.getElementById("getUsersButton").addEventListener("click", async () => {
            try {
                const url = `${baseURL}users/`
                console.log(url)
                const token = await getCSRF()
                const response = await fetch(url, {
                    method: "GET",
                    headers: {
                      "Content-Type": "application/json",
                      'X-CSRFToken': token,
                    },
                    credentials: "include",
                });
                const data = await response.json();
                console.log("Users Response:", data);
            } catch (error) {
                console.error("Users Fetch Error:", error);
            }
        });

        document.getElementById("logoutButton").addEventListener("click", async () => {
            try {
                const url = `${baseURL}logout/`
                console.log(url)
                const token = await getCSRF()
                const response = await fetch(url, {
                    method: "POST",
                    headers: {
                        "Content-Type": "application/json",
                        'X-CSRFToken': token,
                    },
                    credentials: "include",
                });
                const data = await response.json();
                console.log("Logout Response:", data);
            } catch (error) {
                console.error("Logout Error:", error);
            }
        });
    </script>
</body>
</html>

1-6. リバースプロキシ環境をdockerで構築する

nginxを使うと簡単にリバースプロキシができますので、dockerで適当な環境をつくってみます

以下ではフロントエンドのhtmlファイルをコンテナ名「front」でhttpdにより公開、APIをコンテナ名「api」で公開して、これらをnginxの公式イメージによりリバースプロキシして80番ポートで閲覧できるようにします

docker-compose.yml
services: 
  nginx:
    image: nginx:latest
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf
    depends_on:
      - api
      - front

  front:
    image: httpd:latest
    container_name: auth-front-same-origin
    volumes:
      - ./spa:/usr/local/apache2/htdocs/

  api:
    build: ./api
    container_name: auth-api-same-origin
    volumes:
      - ./api/myapp:/app

frontをlocalhostに、apiをlocalhost/apiにリバースプロキシします

nginx.conf
events { }

http {
    server {
        listen 80;
        server_name localhost;

        location / {
            proxy_pass http://front;
        }

        location /api/ {
            proxy_pass http://api;
        }
    }
}

APIはpython公式イメージで手抜き公開にします

/api/Dockerfile
FROM python:3.11

RUN pip install --upgrade pip
WORKDIR /app
COPY requirements.txt /app/
RUN pip install -r /app/requirements.txt
CMD ["python", "manage.py", "runserver", "0.0.0.0:80"]

今回必要なパッケージは以下です

/api/requirements.txt
django
djangorestframework

公開に必要な設定をsettings.pyに追加します

/api/myapp/settings.py
ALLOWED_HOSTS = ["api"]

CSRF_TRUSTED_ORIGINS = [
    "http://127.0.0.1",
    "http://localhost",
]

1-8. 動作を確認する

動作を確認しましょう

docker compose up --build

以下にindex.htmlが待機している筈です
http://localhost/

ボタンを左から順に押していくと、ログイン、UsersViewSetをGET、ログアウトの順に実行して、APIの応答をConsoleに表示します

上手く動いてくれているようです

2. 別Originで公開する場合

APIとSPAのOriginが異なる場合はCORSとCSRF_TRUSTED_ORIGINSを設定して、CSRFトークンをAPIから取得して動作するようにします。APIからCSRFトークンを取れるようにする時点でかなり弱くなっているのではないかと思います

  1. ユーザーモデルをつくる
  2. CSRFトークンを取得するAPIをつくる ← 追加
  3. ログインするViewSetをつくる
  4. ログアウトするViewSetをつくる
  5. ログインしないと利用できないViewSetをつくる
  6. settings.pyに必要な項目を追加する ← 変更
  7. フロントエンドのコードをつくる ← 変更

完成品

完成したコードをgithubに置いています
https://github.com/haneya-studio/django_auth_sample

フォルダ構成は以下のようになりました

2-2. CSRFトークンを取得するAPIを追加する

CSRFトークンを取得するAPIを作成します

/acount/views.py
from django.http import JsonResponse
from django.middleware.csrf import get_token

def csrf_token_view(request):
    return JsonResponse({"csrfToken": get_token(request)})
/myapp/urls.py
from account.views import csrf_token_view

urlpatterns = [
    ...
    path("csrf-token/", csrf_token_view, name="csrf-token"),  # 追加
]

2-6. settings.pyに必要な項目を追加する

CORSとCSRF_TRUSTED_ORIGINSを設定します

/myapp/settings.py
INSTALLED_APPS = [
    ...
    'corsheaders',  # 追加
]

MIDDLEWARE = [
    ...
    'corsheaders.middleware.CorsMiddleware',  # 追加
]

CORS_ALLOW_CREDENTIALS = True  # 認証情報(セッションやクッキー)を送受信可能にする
CORS_ALLOWED_ORIGINS = [
    "http://127.0.0.1:3000",
    "http://localhost:3000",
    "http://127.0.0.1:8000",
    "http://localhost:8000",
]
CSRF_TRUSTED_ORIGINS = [
    "http://127.0.0.1:3000",
    "http://localhost:3000",
    "http://127.0.0.1:8000",
    "http://localhost:8000",
]

2-7. フロントエンドのコードを変更する

CSRFトークンをAPIから取得するように変更

index.htmlを変更
async function getCSRF() {
    const url = `${baseURL}csrf-token/`
    const response = await fetch(url, {
        method: "GET",
        headers: {
        "Content-Type": "application/json",
        },
        credentials: "include",
    });
    const data = await response.json()
    const token = data.csrfToken
    return token
}

動作を確認する

Originを揃える必要がないので、APIを http://localhost:8000 に待機、index.htmlをhttpdで http://localhost:3000 に待機としたいと思います

docker-compose.yml
services: 
  spa:
    image: httpd:latest
    container_name: auth-front
    ports:
      - '3000:80'
    volumes:
      - ./spa:/usr/local/apache2/htdocs/

  api:
    build: ./api
    container_name: auth-api
    volumes:
      - ./api/myapp:/app
    ports:
      - "8000:8000"
/api/Dockerfile
FROM python:3.11

RUN pip install --upgrade pip
WORKDIR /app
COPY requirements.txt /app/
RUN pip install -r /app/requirements.txt
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
requirements.txt
django
djangorestframework
django-cors-headers
terminal
docker compose up --build

以下で確認できます
http://localhost:3000

ボタンを左から順に押していくと、ログイン、UsersViewSetをGET、ログアウトの順に実行して、APIの応答をConsoleに表示します

まとめ

Django単独で使うときよりもかなりややこしくなりますが、ちゃんと理解すれば比較的簡単に実装できると思います

レッツトライ

おまけ

何故だか127.0.0.1からlocalhostのAPIを呼んだり、localhostから127.0.0.1のAPIを呼ぶとCORSエラーが出てしまうようです。両方許可してあるので同じ組み合わせならどちらも正常に動くのですが、何故なんでしょうか?

(正常に動作する組み合わせ)
http://127.0.0.1 から http://127.0.0.1/api
http://localhost から http://localhost/api
http://127.0.0.1:3000 から http://127.0.0.1:8000
http://localhost:3000 から http://localhost:8000

(CORSが原因としてAPIからエラーが返る組み合わせ)
http://127.0.0.1 から http://localhost/api
http://localhost から http://127.0.0.1/api
http://127.0.0.1:3000 から http://localhost:8000
http://localhost:3000 から http://127.0.0.1:8000

参考にした記事

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?