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

# この記事について


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


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


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


<b>
<div>OAuth2.0</div>
<div>セッション認証</div>
<div>トークン認証</div>
</b>


セキュリティの固さでいくと
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


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


<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/277233/a9e06d34-e4b2-4495-9fb4-1acea39c86fb.png" width=30%>


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


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


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


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


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


```python: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' # セッションを何で保存するかを指定する。この場合は
```


```python:account/admin.py
from django.contrib import admin
from django.contrib.sessions.models import Session

admin.site.register(Session)
```


```python:terminal
python manage.py migrate
```


```python:terminal
python manage.py createsuperuser
```


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


```python:terminal
python manage.py runserver
```


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


<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/277233/45c1c5bb-3bf5-41f6-b8c7-715e62b35a31.png" width=70%>


### 1-3. ViewSetをつくる


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


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


```python:/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とか諸々は自動でやってくれます


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


```python:/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情報を使ってログアウトしてくれます


```python:/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)である場合にのみ動作します。書き方から分かるように複数の方式を選択できますし、認証ではなくユーザーモデルに設定した他のプロパティでも権限管理できます


```python:/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でルーティングします


```python: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がつくれたので動作を確認しておきましょう


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


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


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


<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/277233/fd8e0ee2-731e-410d-8ebb-290762bf780e.png" width=50%>


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


<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/277233/8214adc6-ced2-4d54-8b95-558356d9c5db.png" width=50%>


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


<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/277233/fc8ed960-4a36-4100-87fc-6dbc201d0db9.png" width=50%>


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


<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/277233/0ec239e2-8869-4e13-883d-c908a1f7b3da.png" width=50%>


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


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


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


```javascript: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番ポートで閲覧できるようにします


```yml: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:nginx.conf
events { }

http {
    server {
        listen 80;
        server_name localhost;

        location / {
            proxy_pass http://front;
        }

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


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


```dockerfile:/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"]
```


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


```python:/api/requirements.txt
django
djangorestframework
```


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


```python:/api/myapp/settings.py
ALLOWED_HOSTS = ["api"]

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


### 1-8. 動作を確認する


動作を確認しましょう


```docker:
docker compose up --build
```


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


<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/277233/87a3382d-c99e-4183-bb2c-7f2b28c4bec4.png" width=50%>


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


<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/277233/d0411453-e6bc-4d52-a5db-7d4f39b7b20a.png" width=60%>


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


## 2. 別Originで公開する場合


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


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


### 完成品


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


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


<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/277233/1819e3a5-0985-4c7b-a7fc-ae90926c78ff.png" width=30%>


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


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


```python:/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)})
```


```python:/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を設定します


```python:/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から取得するように変更


```javascript: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 に待機としたいと思います


```yml: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"
```


```docker:/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"]
```


```python:requirements.txt
django
djangorestframework
django-cors-headers
```


```python:terminal
docker compose up --build
```


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


<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/277233/87a3382d-c99e-4183-bb2c-7f2b28c4bec4.png" width=50%>


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


<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/277233/6414b1b9-4df4-48d4-a096-4bb9e89f5e4f.png" width=70%>


# まとめ


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


# 参考にした記事

https://qiita.com/ryo-keima/items/79c75963abc831fd255f

https://qiita.com/shun198/items/e3349860ce10e3bba15a