LabHub

ブログ

Django認証実践ガイド — セッション、DRF + SimpleJWT、ミドルウェア、Cookieベース認証完全攻略

한국어English日本語

📚 SSO Cookie/JWT 認証シリーズ > Spring Boot 編 ← 現在: Django 編 → React 編

概要 — Django認証アーキテクチャ

Django は django.contrib.auth モジュールを通じて強力な認証フレームワークを標準で提供する。ユーザーモデル、権限システム、パスワードハッシュ、そしてセッションベースの認証が、追加インストールなしで動作する。しかし SPA フロントエンドやマイクロサービスアーキテクチャでは、セッションベースの認証だけでは足りないことが多く、JWT (JSON Web Token) ベースの認証を併用する必要がある。

Django の認証処理の流れはミドルウェアスタックによって決まる。

Request 受信
SecurityMiddlewareHTTPS リダイレクト、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 に設定した認証クラスが担当する。SessionAuthenticationTokenAuthenticationJWTAuthentication などを組み合わせて使える。

# 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/usernamepassword を送信すると、accessrefresh トークンが JSON で返る。

カスタムクレームの追加

既定の JWT payload には user_idtoken_typeexpiatjti だけが含まれる。ユーザーのロールやテナント 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 の既定の JWTAuthenticationAuthorization ヘッダーからトークンを読む。クッキーから読むにはカスタム認証クラスが必要だ。

# 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脆弱性
localStorageOX (手動でヘッダーに追加)O — 窃取可能X
sessionStorageOXO — 窃取可能X
一般CookieOO (same-origin)O — 窃取可能O
HttpOnly CookieXO (same-origin)X — JSアクセス不可O — SameSiteで緩和
HttpOnly + SameSite=LaxXO (same-origin GET)X大部分ブロック
HttpOnly + SameSite=StrictX同一サイトのみXX

推奨: 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 = TrueCORS_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

ブラックリストは OutstandingTokenBlacklistedToken という 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対策

チェックリスト

Django で認証を実装する際に確認すべき項目:

よくあるバグと誤解

1. 「SessionAuthentication と JWT を併用すると CSRF エラーになる」

SessionAuthenticationenforce_csrf() を呼び出して CSRF トークンを検証する。JWT 専用の API では、DEFAULT_AUTHENTICATION_CLASSES から SessionAuthentication を取り除くか、カスタム認証クラスで CSRF 検証を無効化する必要がある。

domain パラメータなしで設定されたクッキーは、そのドメインでのみ有効だ。api.example.com で設定したクッキーを app.example.com から読むには、domain='.example.com' に設定する必要がある。

3. 「ROTATE_REFRESH_TOKENS なしで BLACKLIST_AFTER_ROTATION を設定してしまった」

BLACKLIST_AFTER_ROTATIONROTATE_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 はトークンの署名と有効期限しか確認しない。ブラックリストに載っているトークンかどうか、ユーザーがまだ有効な状態かどうかは確認しない。本番環境では追加の検証ロジックが必要になることがある。

参考資料

コメント

まだコメントはありません。

ログインするとコメントできます