この記事について
Django REST Frameworkで作成したバックエンドAPIとReactなどで作成したSPAからなるウェブアプリで、Djangoのセッション認証を使ったログイン機能と権限管理機能を使う手順をまとめたものです
API+SPAで認証するときの選択肢
選択肢は以下の3つが挙がるようです
セキュリティの固さでいくと
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で公開する場合
以下のような手順で簡単に実現できます
- プロジェクトをつくる
- ユーザーモデルをつくる
- ログインするViewSetをつくる
- ログアウトするViewSetをつくる
- ログインしないと利用できないViewSetをつくる
- フロントエンドのコードをつくる
- リバースプロキシで同じOriginに公開する
完成品
完成したコードをgithubに置いています
https://github.com/haneya-studio/django_auth_sample
フォルダ構成は以下のようになりました

1-1. プロジェクトをつくる
サンプルプロジェクトをつくっていきます
django-admin startproject myapp
python manage.py startapp account
1-2. ユーザーモデルをつくる
ユーザー情報を保持するモデルを作成します
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
INSTALLED_APPS = [
...
'rest_framework', # djangorestframeworkを追加
'account' # 作成したaccountアプリを追加
]
AUTH_USER_MODEL = "account.User" # accountアプリのUserモデルをデフォルトで使用する認証ユーザーモデルとして設定する
SESSION_ENGINE = 'django.contrib.sessions.backends.db' # セッションを何で保存するかを指定する。この場合は
from django.contrib import admin
from django.contrib.sessions.models import Session
admin.site.register(Session)
python manage.py migrate
python manage.py createsuperuser
作成したユーザーモデルを確認しておきます
python manage.py runserver
問題なさそうです
http://localhost:8000/admin

1-3. ViewSetをつくる
views.pyを作成していきます
必要なパッケージをimportする
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は全部付けています
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情報を使ってログアウトしてくれます
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)である場合にのみ動作します。書き方から分かるように複数の方式を選択できますし、認証ではなくユーザーモデルに設定した他のプロパティでも権限管理できます
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でルーティングします
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 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から取得して明示的に送る必要があるのがセッション認証がない場合との違いです
<!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番ポートで閲覧できるようにします
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にリバースプロキシします
events { }
http {
server {
listen 80;
server_name localhost;
location / {
proxy_pass http://front;
}
location /api/ {
proxy_pass http://api;
}
}
}
APIはpython公式イメージで手抜き公開にします
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"]
今回必要なパッケージは以下です
django
djangorestframework
公開に必要な設定を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トークンを取れるようにする時点でかなり弱くなっているのではないかと思います
- ユーザーモデルをつくる
- CSRFトークンを取得するAPIをつくる ← 追加
- ログインするViewSetをつくる
- ログアウトするViewSetをつくる
- ログインしないと利用できないViewSetをつくる
- settings.pyに必要な項目を追加する ← 変更
- フロントエンドのコードをつくる ← 変更
完成品
完成したコードをgithubに置いています
https://github.com/haneya-studio/django_auth_sample
フォルダ構成は以下のようになりました

2-2. CSRFトークンを取得するAPIを追加する
CSRFトークンを取得するAPIを作成します
from django.http import JsonResponse
from django.middleware.csrf import get_token
def csrf_token_view(request):
return JsonResponse({"csrfToken": get_token(request)})
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を設定します
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から取得するように変更
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 に待機としたいと思います
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"
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"]
django
djangorestframework
django-cors-headers
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
参考にした記事