- 概要 — Django認証アーキテクチャ
- セッションベース認証
- JWTベース認証(DRF + SimpleJWT)
- CookieにJWTを保存する
- ミドルウェアでの認証処理
- Claimパースとユーザー情報アクセス
- ブラウザストレージ別アクセス可能性表
- CORS設定(django-cors-headers)
- ログアウトとトークン無効化
- トークンリフレッシュ戦略
- セキュリティトレードオフ
- チェックリスト
- よくあるバグと誤解
- 1. 「SessionAuthentication と JWT を併用すると CSRF エラーになる」
- 2. 「set_cookie() で domain を設定しないとサブドメインでクッキーを読めない」
- 3. 「ROTATE_REFRESH_TOKENS なしで BLACKLIST_AFTER_ROTATION を設定してしまった」
- 4. 「SameSite=Strict にしたら外部リンクからログインが切れる」
- 5. 「refresh_token クッキーの path を / にすると全リクエストに不要に送信される」
- 6. 「JWT のデコードはサーバーしかできないと思っている」
- 7. 「SimpleJWT の TokenVerifyView がトークンの有効性を完全に検証すると思っている」
- 参考資料
📚 SSO Cookie/JWT 認証シリーズ > Spring Boot 編 ← 現在: Django 編 → React 編
概要 — Django認証アーキテクチャ
Django は django.contrib.auth モジュールを通じて強力な認証フレームワークを標準で提供する。ユーザーモデル、権限システム、パスワードハッシュ、そしてセッションベースの認証が、追加インストールなしで動作する。しかし SPA フロントエンドやマイクロサービスアーキテクチャでは、セッションベースの認証だけでは足りないことが多く、JWT (JSON Web Token) ベースの認証を併用する必要がある。
Django の認証処理の流れはミドルウェアスタックによって決まる。
Request 受信
↓
SecurityMiddleware ← HTTPS リダイレクト、HSTS 設定
↓
SessionMiddleware ← session_key クッキーでセッションをロード → request.session にバインド
↓
AuthenticationMiddleware ← request.session から user_id を抽出 → request.user にバインド
↓
View または DRF APIView ← request.user が利用可能
DRF (Django REST Framework) を使う場合、認証は DEFAULT_AUTHENTICATION_CLASSES に設定した認証クラスが担当する。SessionAuthentication、TokenAuthentication、JWTAuthentication などを組み合わせて使える。
# settings.py
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework',
'rest_framework_simplejwt',
'rest_framework_simplejwt.token_blacklist',
'corsheaders',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'corsheaders.middleware.CorsMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
セッションベース認証
Django のセッション認証は、サーバー側にセッションデータを保存し、クライアントに sessionid クッキーを発行する伝統的な方式だ。
セッションバックエンドの種類
# settings.py — セッションバックエンドの設定
# 1. DB ベース (既定値)
SESSION_ENGINE = 'django.contrib.sessions.backends.db'
# 2. キャッシュベース (Redis/Memcached)
SESSION_ENGINE = 'django.contrib.sessions.backends.cache'
SESSION_CACHE_ALIAS = 'default'
# 3. キャッシュ + DB の併用 (書き込みは DB、読み取りはキャッシュ優先)
SESSION_ENGINE = 'django.contrib.sessions.backends.cached_db'
# 4. クッキーベース (サーバー保存なし — 署名付きクッキーにセッションデータを含める)
SESSION_ENGINE = 'django.contrib.sessions.backends.signed_cookies'
# 共通設定
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SECURE = True # HTTPS 専用
SESSION_COOKIE_SAMESITE = 'Lax'
SESSION_COOKIE_AGE = 1209600 # 2 週間 (秒単位)
SESSION_COOKIE_DOMAIN = '.example.com' # サブドメイン間で共有
ログイン/ログアウトの実装
# views.py
from django.contrib.auth import authenticate, login, logout
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
import json
@require_POST
def session_login_view(request):
"""セッションベースのログイン"""
data = json.loads(request.body)
username = data.get('username')
password = data.get('password')
user = authenticate(request, username=username, password=password)
if user is not None:
login(request, user) # セッションに user_id を保存 + sessionid クッキーを発行
return JsonResponse({
'message': 'ログイン成功',
'user': {
'id': user.id,
'username': user.username,
'email': user.email,
}
})
return JsonResponse({'error': '認証失敗'}, status=401)
@require_POST
def session_logout_view(request):
"""セッションベースのログアウト"""
logout(request) # セッションデータを削除 + sessionid クッキーを無効化
return JsonResponse({'message': 'ログアウト完了'})
def profile_view(request):
"""request.user へのアクセス"""
if request.user.is_authenticated:
return JsonResponse({
'id': request.user.id,
'username': request.user.username,
'email': request.user.email,
'is_staff': request.user.is_staff,
})
return JsonResponse({'error': '認証されていないユーザー'}, status=401)
authenticate() 関数は AUTHENTICATION_BACKENDS に登録されたバックエンドを順に回りながら資格情報を検証する。login() 関数はセッションに _auth_user_id、_auth_user_backend、_auth_user_hash を保存し、レスポンスに sessionid クッキーを設定する。
JWTベース認証(DRF + SimpleJWT)
SPA フロントエンド、モバイルアプリ、マイクロサービス間の通信では、ステートレスな JWT 認証のほうが適している。Django では djangorestframework-simplejwt ライブラリが事実上の標準だ。
SimpleJWT設定
pip install djangorestframework-simplejwt
# settings.py
from datetime import timedelta
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
'rest_framework.authentication.SessionAuthentication',
),
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.IsAuthenticated',
),
}
SIMPLE_JWT = {
# トークンの寿命
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=15),
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
# リフレッシュトークンのローテーション — True なら refresh のたびに新しい refresh トークンを発行
'ROTATE_REFRESH_TOKENS': True,
'BLACKLIST_AFTER_ROTATION': True,
# アルゴリズムと鍵
'ALGORITHM': 'HS256',
'SIGNING_KEY': SECRET_KEY,
# RS256 を使う場合:
# 'ALGORITHM': 'RS256',
# 'SIGNING_KEY': open('/path/to/private.pem').read(),
# 'VERIFYING_KEY': open('/path/to/public.pem').read(),
# ヘッダー
'AUTH_HEADER_TYPES': ('Bearer',),
'AUTH_HEADER_NAME': 'HTTP_AUTHORIZATION',
# ユーザー識別子
'USER_ID_FIELD': 'id',
'USER_ID_CLAIM': 'user_id',
# トークンタイプ
'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',),
'TOKEN_TYPE_CLAIM': 'token_type',
# JTI (JWT ID) — トークンの一意識別子
'JTI_CLAIM': 'jti',
# スライディングトークン (任意)
'SLIDING_TOKEN_REFRESH_EXP_CLAIM': 'refresh_exp',
'SLIDING_TOKEN_LIFETIME': timedelta(minutes=5),
'SLIDING_TOKEN_REFRESH_LIFETIME': timedelta(days=1),
}
URL設定
# urls.py
from django.urls import path
from rest_framework_simplejwt.views import (
TokenObtainPairView,
TokenRefreshView,
TokenVerifyView,
)
urlpatterns = [
path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
path('api/token/verify/', TokenVerifyView.as_view(), name='token_verify'),
]
POST /api/token/ に username と password を送信すると、access と refresh トークンが JSON で返る。
カスタムクレームの追加
既定の JWT payload には user_id、token_type、exp、iat、jti だけが含まれる。ユーザーのロールやテナント ID など追加情報を含めたい場合は、シリアライザをカスタマイズする。
# serializers.py
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
from rest_framework_simplejwt.views import TokenObtainPairView
class CustomTokenObtainPairSerializer(TokenObtainPairSerializer):
@classmethod
def get_token(cls, user):
token = super().get_token(user)
# カスタムクレームの追加
token['username'] = user.username
token['email'] = user.email
token['is_staff'] = user.is_staff
# ユーザーのロール (ManyToMany 関係を想定)
token['roles'] = list(user.groups.values_list('name', flat=True))
# マルチテナント環境 — ユーザープロフィールから tenant_id を取得
if hasattr(user, 'profile'):
token['tenant_id'] = str(user.profile.tenant_id)
token['organization'] = user.profile.organization_name
return token
def validate(self, attrs):
data = super().validate(attrs)
# レスポンス JSON に追加情報を含める
data['user'] = {
'id': self.user.id,
'username': self.user.username,
'email': self.user.email,
'roles': list(self.user.groups.values_list('name', flat=True)),
}
return data
class CustomTokenObtainPairView(TokenObtainPairView):
serializer_class = CustomTokenObtainPairSerializer
生成される JWT payload の例:
{
"token_type": "access",
"exp": 1741500000,
"iat": 1741499100,
"jti": "a1b2c3d4e5f6...",
"user_id": 42,
"username": "youngju",
"email": "youngju@example.com",
"is_staff": false,
"roles": ["editor", "reviewer"],
"tenant_id": "550e8400-e29b-41d4-a716-446655440000"
}
CookieにJWTを保存する
SPA 環境で JWT を localStorage に保存すると XSS 攻撃に弱くなる。セキュリティを高めるには、JWT を HttpOnly クッキーに保存して JavaScript からアクセスできないようにする必要がある。
Cookieベースのログインビュー
# views.py
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework import status
from django.contrib.auth import authenticate
from rest_framework_simplejwt.tokens import RefreshToken
from django.conf import settings
@api_view(['POST'])
@permission_classes([AllowAny])
def cookie_login_view(request):
"""JWT を HttpOnly クッキーに保存するログインビュー"""
username = request.data.get('username')
password = request.data.get('password')
user = authenticate(username=username, password=password)
if user is None:
return Response(
{'error': '有効でない資格情報です。'},
status=status.HTTP_401_UNAUTHORIZED
)
# トークンの生成
refresh = RefreshToken.for_user(user)
# カスタムクレームの追加
refresh['username'] = user.username
refresh['roles'] = list(user.groups.values_list('name', flat=True))
access_token = str(refresh.access_token)
refresh_token = str(refresh)
response = Response({
'message': 'ログイン成功',
'user': {
'id': user.id,
'username': user.username,
'email': user.email,
}
})
# Access Token クッキーの設定
response.set_cookie(
key='access_token',
value=access_token,
max_age=settings.SIMPLE_JWT['ACCESS_TOKEN_LIFETIME'].total_seconds(),
httponly=True, # JavaScript からアクセス不可
secure=True, # HTTPS でのみ送信
samesite='Lax', # CSRF 保護 (cross-site POST を遮断)
domain='.example.com', # サブドメイン間で共有
path='/',
)
# Refresh Token クッキーの設定
response.set_cookie(
key='refresh_token',
value=refresh_token,
max_age=settings.SIMPLE_JWT['REFRESH_TOKEN_LIFETIME'].total_seconds(),
httponly=True,
secure=True,
samesite='Lax',
domain='.example.com',
path='/api/token/refresh/', # refresh エンドポイントでのみ送信
)
return response
CookieベースのDRF認証クラス
DRF の既定の JWTAuthentication は Authorization ヘッダーからトークンを読む。クッキーから読むにはカスタム認証クラスが必要だ。
# authentication.py
from rest_framework_simplejwt.authentication import JWTAuthentication
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
from rest_framework_simplejwt.tokens import UntypedToken
from django.conf import settings
class CookieJWTAuthentication(JWTAuthentication):
"""クッキーから JWT を読む DRF 認証クラス"""
def authenticate(self, request):
# 1. クッキーから access_token を読む
raw_token = request.COOKIES.get('access_token')
if raw_token is None:
# クッキーになければ既定のヘッダー方式を試す (fallback)
return super().authenticate(request)
# 2. トークンの検証
try:
validated_token = self.get_validated_token(raw_token)
except (InvalidToken, TokenError) as e:
return None # 認証失敗 — AnonymousUser として扱う
# 3. トークンからユーザーオブジェクトを取得
user = self.get_user(validated_token)
return (user, validated_token)
# settings.py に登録
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'myapp.authentication.CookieJWTAuthentication',
'rest_framework.authentication.SessionAuthentication',
),
}
ミドルウェアでの認証処理
DRF の APIView だけでなく通常の Django View でもクッキーベースの JWT 認証を使うには、ミドルウェアで処理するのが効果的だ。
# middleware.py
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser
from rest_framework_simplejwt.tokens import AccessToken
from rest_framework_simplejwt.exceptions import TokenError, InvalidToken
import logging
User = get_user_model()
logger = logging.getLogger(__name__)
class JWTCookieAuthMiddleware:
"""
クッキーから JWT を読んで request.user を設定するミドルウェア。
AuthenticationMiddleware の後ろに配置する必要がある。
セッション認証ですでに認証済みのユーザーはスキップする。
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
# セッションですでに認証済みならスキップ
if hasattr(request, 'user') and request.user.is_authenticated:
return self.get_response(request)
# クッキーから access_token を抽出
access_token = request.COOKIES.get('access_token')
if access_token:
try:
# トークンの検証
validated_token = AccessToken(access_token)
# ユーザーの取得
user_id = validated_token.get('user_id')
user = User.objects.get(id=user_id)
# request にユーザーおよびトークン情報をバインド
request.user = user
request.jwt_token = validated_token
request.jwt_claims = validated_token.payload
except (TokenError, InvalidToken) as e:
logger.warning(f'JWT 認証失敗: {e}')
request.user = AnonymousUser()
except User.DoesNotExist:
logger.warning(f'JWT user_id に該当するユーザーなし: {user_id}')
request.user = AnonymousUser()
response = self.get_response(request)
return response
# settings.py — ミドルウェアの登録順序が重要!
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'corsheaders.middleware.CorsMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'myapp.middleware.JWTCookieAuthMiddleware', # AuthenticationMiddleware の後ろに!
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
Claimパースとユーザー情報アクセス
requestからのJWT情報アクセス
ミドルウェアまたは認証クラスを通じて JWT 認証が完了すれば、View からさまざまな方法でユーザー情報にアクセスできる。
# views.py
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework_simplejwt.tokens import AccessToken
import jwt
from django.conf import settings
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def user_info_view(request):
"""request.user から情報にアクセス"""
user = request.user
return Response({
'id': user.id,
'username': user.username,
'email': user.email,
'is_staff': user.is_staff,
'groups': list(user.groups.values_list('name', flat=True)),
})
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def jwt_claims_view(request):
"""JWT claim を直接デコード"""
# 方法 1: ミドルウェアでバインドされた claims を使う
if hasattr(request, 'jwt_claims'):
claims = request.jwt_claims
return Response({
'user_id': claims.get('user_id'),
'username': claims.get('username'),
'roles': claims.get('roles', []),
'tenant_id': claims.get('tenant_id'),
'exp': claims.get('exp'),
})
# 方法 2: DRF auth でバインドされたトークンを使う
if request.auth:
return Response({
'user_id': request.auth.get('user_id'),
'token_type': request.auth.get('token_type'),
'exp': request.auth.get('exp'),
})
# 方法 3: クッキーから直接デコード (PyJWT を使用)
raw_token = request.COOKIES.get('access_token')
if raw_token:
decoded = jwt.decode(
raw_token,
settings.SECRET_KEY,
algorithms=['HS256'],
)
return Response(decoded)
return Response({'error': 'No token found'}, status=400)
カスタムPermissionクラス
# permissions.py
from rest_framework.permissions import BasePermission
class HasRole(BasePermission):
"""JWT claim からロールを確認する権限クラス"""
def __init__(self, required_role=None):
self.required_role = required_role
def has_permission(self, request, view):
if not request.user or not request.user.is_authenticated:
return False
# view に required_role 属性が定義されている場合はそれを使う
required = getattr(view, 'required_role', self.required_role)
if required is None:
return True
# JWT claims からロールを確認
if hasattr(request, 'jwt_claims'):
roles = request.jwt_claims.get('roles', [])
return required in roles
# DB からロールを確認 (fallback)
return request.user.groups.filter(name=required).exists()
class IsSameTenant(BasePermission):
"""リクエストしたユーザーとリソースの tenant_id が一致するかを確認"""
def has_object_permission(self, request, view, obj):
if not hasattr(request, 'jwt_claims'):
return False
user_tenant = request.jwt_claims.get('tenant_id')
obj_tenant = getattr(obj, 'tenant_id', None)
return user_tenant is not None and str(user_tenant) == str(obj_tenant)
# views.py — Permission の使用例
from rest_framework.views import APIView
from rest_framework.response import Response
from myapp.permissions import HasRole, IsSameTenant
class AdminDashboardView(APIView):
permission_classes = [HasRole]
required_role = 'admin'
def get(self, request):
return Response({'message': '管理者ダッシュボード'})
class TenantResourceView(APIView):
permission_classes = [HasRole, IsSameTenant]
required_role = 'editor'
def get(self, request, pk):
resource = Resource.objects.get(pk=pk)
self.check_object_permissions(request, resource)
return Response({'resource': resource.name})
ブラウザストレージ別アクセス可能性表
JWT をどこに保存するかによって、セキュリティ特性は変わる。
| ストレージ | JSアクセス | サーバー自動送信 | XSS脆弱性 | CSRF脆弱性 |
|---|---|---|---|---|
localStorage | O | X (手動でヘッダーに追加) | O — 窃取可能 | X |
sessionStorage | O | X | O — 窃取可能 | X |
| 一般Cookie | O | O (same-origin) | O — 窃取可能 | O |
| HttpOnly Cookie | X | O (same-origin) | X — JSアクセス不可 | O — SameSiteで緩和 |
| HttpOnly + SameSite=Lax | X | O (same-origin GET) | X | 大部分ブロック |
| HttpOnly + SameSite=Strict | X | 同一サイトのみ | X | X |
推奨: HttpOnly + Secure + SameSite=Lax の組み合わせでクッキーに保存し、状態を変更するリクエスト (POST、PUT、DELETE) には CSRF トークンまたはカスタムヘッダーを追加する。
CORS設定(django-cors-headers)
SPA フロントエンドが別ドメインから API を呼び出すには、CORS の設定が必須だ。
pip install django-cors-headers
# settings.py
INSTALLED_APPS = [
# ...
'corsheaders',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'corsheaders.middleware.CorsMiddleware', # CommonMiddleware の前に配置
'django.middleware.common.CommonMiddleware',
# ...
]
# === CORS 設定 ===
# 許可するオリジン (ワイルドカード使用時は credentials 不可)
CORS_ALLOWED_ORIGINS = [
'https://app.example.com',
'https://admin.example.com',
'http://localhost:3000', # 開発環境
]
# クッキーを含む cross-origin リクエストを許可 (withCredentials: true)
CORS_ALLOW_CREDENTIALS = True
# 許可するヘッダー
CORS_ALLOW_HEADERS = [
'accept',
'accept-encoding',
'authorization',
'content-type',
'dnt',
'origin',
'user-agent',
'x-csrftoken',
'x-requested-with',
]
# プリフライトレスポンスのキャッシュ時間
CORS_PREFLIGHT_MAX_AGE = 86400 # 24 時間
# === CSRF 設定 ===
# CORS_ALLOWED_ORIGINS と同じに設定する
CSRF_TRUSTED_ORIGINS = [
'https://app.example.com',
'https://admin.example.com',
]
# CSRF クッキーの設定
CSRF_COOKIE_HTTPONLY = False # JS から読む必要があるので False
CSRF_COOKIE_SECURE = True
CSRF_COOKIE_SAMESITE = 'Lax'
CSRF_COOKIE_DOMAIN = '.example.com'
注意:
CORS_ALLOW_ALL_ORIGINS = TrueとCORS_ALLOW_CREDENTIALS = Trueを同時に設定するとセキュリティ上の危険がある。ブラウザはAccess-Control-Allow-Origin: *とAccess-Control-Allow-Credentials: trueの組み合わせを拒否する。
ログアウトとトークン無効化
JWT は本質的にステートレスであるため、サーバー側から強制的に無効化するのが難しい。SimpleJWT のトークンブラックリスト機能を使えば、この問題を解決できる。
ブラックリスト設定
# settings.py
INSTALLED_APPS = [
# ...
'rest_framework_simplejwt.token_blacklist',
]
# マイグレーションの実行が必要
# python manage.py migrate
ブラックリストは OutstandingToken と BlacklistedToken という 2 つのモデルを作る。OutstandingToken は発行されたリフレッシュトークンを、BlacklistedToken は無効化されたトークンを保存する。
ログアウトビュー
# views.py
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework import status
from rest_framework_simplejwt.tokens import RefreshToken
from rest_framework_simplejwt.exceptions import TokenError
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def cookie_logout_view(request):
"""クッキーベース JWT のログアウト — ブラックリスト + クッキー削除"""
refresh_token = request.COOKIES.get('refresh_token')
if refresh_token:
try:
token = RefreshToken(refresh_token)
token.blacklist() # トークンをブラックリストに追加
except TokenError:
pass # すでに期限切れか無効なトークン
response = Response({'message': 'ログアウト完了'}, status=status.HTTP_200_OK)
# クッキーの削除
response.delete_cookie(
key='access_token',
domain='.example.com',
path='/',
)
response.delete_cookie(
key='refresh_token',
domain='.example.com',
path='/api/token/refresh/',
)
return response
期限切れトークンの整理(cron/celery)
# management/commands/flush_expired_tokens.py
# SimpleJWT が標準提供するコマンドを使う:
# python manage.py flushexpiredtokens
# Celery beat のスケジュール設定
CELERY_BEAT_SCHEDULE = {
'flush-expired-tokens': {
'task': 'myapp.tasks.flush_expired_tokens',
'schedule': 86400, # 毎日
},
}
# tasks.py
from celery import shared_task
from django.core.management import call_command
@shared_task
def flush_expired_tokens():
call_command('flushexpiredtokens')
トークンリフレッシュ戦略
Access Token が期限切れになったら、Refresh Token で新しい Access Token を発行してもらう必要がある。クッキーベースの環境では、専用のリフレッシュビューを実装する。
# views.py
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework import status
from rest_framework_simplejwt.tokens import RefreshToken
from rest_framework_simplejwt.exceptions import TokenError
from django.conf import settings
@api_view(['POST'])
@permission_classes([AllowAny])
def cookie_token_refresh_view(request):
"""クッキーから refresh_token を読んで新しい access_token + refresh_token を発行"""
refresh_token = request.COOKIES.get('refresh_token')
if not refresh_token:
return Response(
{'error': 'Refresh token がありません。'},
status=status.HTTP_401_UNAUTHORIZED
)
try:
old_refresh = RefreshToken(refresh_token)
# 新しい access token の発行
new_access = str(old_refresh.access_token)
# ROTATE_REFRESH_TOKENS=True の場合は新しい refresh token を発行
if settings.SIMPLE_JWT.get('ROTATE_REFRESH_TOKENS', False):
# 既存の refresh token をブラックリスト処理
if settings.SIMPLE_JWT.get('BLACKLIST_AFTER_ROTATION', False):
old_refresh.blacklist()
# 新しい refresh token を生成
new_refresh = RefreshToken.for_user(old_refresh.payload.get('user_id'))
# 既存のカスタムクレームをコピー
for key in ['username', 'roles', 'tenant_id']:
if key in old_refresh.payload:
new_refresh[key] = old_refresh.payload[key]
new_refresh_str = str(new_refresh)
else:
new_refresh_str = refresh_token # 既存トークンを再利用
response = Response({'message': 'トークン更新成功'})
# 新しい access token クッキーの設定
response.set_cookie(
key='access_token',
value=new_access,
max_age=settings.SIMPLE_JWT['ACCESS_TOKEN_LIFETIME'].total_seconds(),
httponly=True,
secure=True,
samesite='Lax',
domain='.example.com',
path='/',
)
# 新しい refresh token クッキーの設定 (ローテーションした場合)
if settings.SIMPLE_JWT.get('ROTATE_REFRESH_TOKENS', False):
response.set_cookie(
key='refresh_token',
value=new_refresh_str,
max_age=settings.SIMPLE_JWT['REFRESH_TOKEN_LIFETIME'].total_seconds(),
httponly=True,
secure=True,
samesite='Lax',
domain='.example.com',
path='/api/token/refresh/',
)
return response
except TokenError as e:
return Response(
{'error': f'有効でない refresh token: {str(e)}'},
status=status.HTTP_401_UNAUTHORIZED
)
リフレッシュの流れの整理:
1. クライアント: API リクエスト → 401 Unauthorized (access_token 期限切れ)
2. クライアント: POST /api/token/refresh/ (refresh_token クッキーが自動送信される)
3. サーバー: refresh_token を検証 → 新しい access_token + refresh_token を発行 → クッキーを設定
4. クライアント: 元の API リクエストを再試行 → 200 OK
セキュリティトレードオフ
XSS対策
HttpOnly クッキーに JWT を保存すると、JavaScript からトークンにアクセスできないため、XSS 攻撃によるトークンの窃取を防げる。ただし XSS の脆弱性があれば攻撃者がユーザーになりすまして API リクエストを送れるため、入力の検証と出力のエスケープは依然として必須だ。
CSRF対策
クッキーベースの認証は CSRF (Cross-Site Request Forgery) 攻撃に弱い。Django は CsrfViewMiddleware で CSRF 保護を提供するが、JWT 認証と併用すると衝突が起きることがある。
# CSRF と JWT クッキーを併用する戦略
# 戦略 1: CSRF 保護を維持する (推奨)
# — SessionAuthentication は CSRF 検証を強制するので取り除くか、
# CookieJWTAuthentication で enforce_csrf() をオーバーライドする
class CookieJWTAuthentication(JWTAuthentication):
def authenticate(self, request):
raw_token = request.COOKIES.get('access_token')
if raw_token is None:
return None
validated_token = self.get_validated_token(raw_token)
user = self.get_user(validated_token)
return (user, validated_token)
def enforce_csrf(self, request):
"""クッキーベース JWT では CSRF 検証を無効化する
(SameSite クッキーがすでに CSRF を防いでいるため)"""
return # CSRF 検証をスキップ
# 戦略 2: Double Submit Cookie パターン
# — フロントエンドから CSRF トークンをヘッダーに含めて送信する
# settings.py:
CSRF_COOKIE_HTTPONLY = False # JS から CSRF トークンを読めるようにする
CSRF_HEADER_NAME = 'HTTP_X_CSRFTOKEN'
# フロントエンド: X-CSRFToken ヘッダーに csrftoken クッキーの値を入れて送信
@csrf_exempt使用時の注意点
# 危険なパターン — 理由のない csrf_exempt
@csrf_exempt # 危険! 明確な理由なしに使わないこと
def my_view(request):
pass
# 許容できるパターン — 外部 Webhook の受信、API 専用エンドポイント
@csrf_exempt
def stripe_webhook(request):
"""Stripe の Webhook はサーバー間の呼び出しなので CSRF は不要"""
# 代わりに Stripe の署名検証でリクエストを認証する
sig = request.headers.get('Stripe-Signature')
stripe.Webhook.construct_event(request.body, sig, endpoint_secret)
トークン窃取とReplay Attack対策
- Access Token の寿命を最小化: 15 分以下を推奨
- Refresh Token のローテーション:
ROTATE_REFRESH_TOKENS = Trueで更新のたびに新しい refresh トークンを発行 - Refresh Token の再利用検知: すでにブラックリスト入りしたトークンで更新を試みたら、そのユーザーの全トークンを無効化
- IP バインディング: クレームに発行時の IP を含め、使用時に比較する (モバイル環境では注意)
チェックリスト
Django で認証を実装する際に確認すべき項目:
-
SECRET_KEYが環境変数で管理されているか -
ACCESS_TOKEN_LIFETIMEが 15 分以下に設定されているか -
REFRESH_TOKEN_LIFETIMEが適切な期間 (7 日以下) に設定されているか -
ROTATE_REFRESH_TOKENS = Trueに設定されているか -
BLACKLIST_AFTER_ROTATION = Trueに設定されているか - クッキーに
HttpOnly、Secure、SameSiteフラグが設定されているか -
CORS_ALLOW_CREDENTIALS = Trueに設定されているか -
CORS_ALLOWED_ORIGINSに信頼できるオリジンだけが登録されているか -
CSRF_TRUSTED_ORIGINSが CORS の設定と一致しているか - ログアウト時にクッキー削除とトークンのブラックリスト登録が両方とも処理されるか
-
flushexpiredtokensコマンドが定期的に実行されているか (cron/Celery) - RS256 を使う場合、公開鍵/秘密鍵が安全に管理されているか
- 本番環境で
DEBUG = Falseであり、ALLOWED_HOSTSが設定されているか - パスワードのハッシュが既定 (PBKDF2) 以上のアルゴリズム (Argon2、bcrypt) を使っているか
- カスタムクレームに機微な情報 (パスワード、個人情報) が含まれていないか
よくあるバグと誤解
1. 「SessionAuthentication と JWT を併用すると CSRF エラーになる」
SessionAuthentication は enforce_csrf() を呼び出して CSRF トークンを検証する。JWT 専用の API では、DEFAULT_AUTHENTICATION_CLASSES から SessionAuthentication を取り除くか、カスタム認証クラスで CSRF 検証を無効化する必要がある。
2. 「set_cookie() で domain を設定しないとサブドメインでクッキーを読めない」
domain パラメータなしで設定されたクッキーは、そのドメインでのみ有効だ。api.example.com で設定したクッキーを app.example.com から読むには、domain='.example.com' に設定する必要がある。
3. 「ROTATE_REFRESH_TOKENS なしで BLACKLIST_AFTER_ROTATION を設定してしまった」
BLACKLIST_AFTER_ROTATION は ROTATE_REFRESH_TOKENS = True のときだけ機能する。ローテーションなしでブラックリストだけを設定しても、何の効果もない。
4. 「SameSite=Strict にしたら外部リンクからログインが切れる」
SameSite=Strict は、外部サイトからのすべてのリクエストにクッキーを含めない。メールのリンクや SNS の共有リンクなどからサイトに入るとクッキーが送られず、ログインが切れたように見える。ほとんどの場合は SameSite=Lax が適切だ。
5. 「refresh_token クッキーの path を / にすると全リクエストに不要に送信される」
Refresh Token は更新エンドポイントでしか必要ない。path='/api/token/refresh/' に設定すれば、そのパスへのリクエストでのみクッキーが送られ、不要な露出を減らせる。
6. 「JWT のデコードはサーバーしかできないと思っている」
JWT の header と payload は Base64URL エンコードにすぎず、暗号化はされていない。署名 (signature) だけがサーバーの秘密鍵で検証される。したがって機微な情報をクレームに含めてはならない。HttpOnly クッキーに保存しても、ネットワークのスニッフィング (HTTPS 未使用時) やサーバーログから漏れることがある。
7. 「SimpleJWT の TokenVerifyView がトークンの有効性を完全に検証すると思っている」
TokenVerifyView はトークンの署名と有効期限しか確認しない。ブラックリストに載っているトークンかどうか、ユーザーがまだ有効な状態かどうかは確認しない。本番環境では追加の検証ロジックが必要になることがある。
参考資料
- Django Authentication System — Django 公式の認証ドキュメント
- Django REST Framework - Authentication — DRF 認証ガイド
- djangorestframework-simplejwt Documentation — SimpleJWT 公式ドキュメント
- RFC 7519 - JSON Web Token (JWT) — JWT の標準仕様
- PyJWT Documentation — Python の JWT ライブラリのドキュメント
- django-cors-headers — CORS ヘッダーのミドルウェア
- OWASP - JSON Web Token Cheat Sheet — OWASP の JWT セキュリティガイド
- OWASP - Cross-Site Request Forgery Prevention — CSRF 防御ガイド
- Django Security - CSRF Protection — Django の CSRF 保護ドキュメント
- MDN - Set-Cookie — Set-Cookie ヘッダーのリファレンス
- MDN - SameSite cookies — SameSite 属性の説明
- RFC 6749 - The OAuth 2.0 Authorization Framework — OAuth 2.0 の仕様