Django標準のUserモデルを壊さずにCognito OAuthへ移行する — sub↔userを別テーブルで繋ぐJITプロビジョニング設計

この記事について

個人開発のある株式分析Webアプリで、認証基盤を「Django allauth + Simple JWT のメール/パスワード認証」から「AWS Cognito User Pool ベースの OAuth 2.0 / OIDC」へ移行しました。本稿は、その設計判断と実装のうち、「Django標準の User モデルを一切壊さずにマネージド認証へ寄せる」 ことを軸にまとめた技術記録です。

既存ユーザーのデータ(ポートフォリオ・チャット履歴)を1件も壊さないことが絶対条件だったため、「一気に置き換える」選択肢は最初から捨てています。移行の核心は次の3点に集約されます。

  1. auth_user にカラムを足さず、別テーブルで Cognitoのsubuser_id を1:1で紐付ける
  2. 既存ユーザーは JIT(Just-In-Time)プロビジョニングで自動保全する
  3. DRFのAuthentication Classをハイブリッド化し、旧クライアントを動かしたまま段階移行する

環境

  • 移行前: Django + Django REST Framework、Django標準 User モデル(カスタム不採用)、allauth + Simple JWT のメール/パスワード認証(access 30分 / refresh 7日 / rotation有)
  • 移行後: AWS Cognito User Pool を単一の認証基盤に。ブラウザSPA / 外部LLM連携 / 既存の静的Bearerキークライアント の3経路を集約
  • トークン: JWT (RS256) / JWKS、実行環境は AWS Lambda(ステートレス)、IaC は Terraform

なぜ移行したか

メール/パスワード認証は動いていましたが、3つの要求に応えられなくなっていました。

  • 外部LLMサービスの連携要求: あるLLMサービスのAgent/Actions連携の利用規約が、OAuth認証経路を要求してきた
  • 利用者の即時無効化: 離脱者・退職者を運営側から即座にログイン不可にしたい
  • 認証経路の分散: 増えつつある複数の入口を1つの認証基盤に集約したい

Cognito User Pool はこの3つをまとめて解決できます。OIDC準拠でOAuthの入口を持ち、Disable user / Sign out user で即時無効化でき、複数App Clientで経路を分けられます。

設計上の落とし穴 — なぜ標準Userを壊してはいけないか

Cognito移行でまず思いつくのは「auth_usercognito_sub カラムを足してカスタムUserモデルにする」ですが、稼働中プロジェクトでのカスタムUserモデル移行は屈指のハイリスク作業です。

  • AUTH_USER_MODEL の差し替えはマイグレーション履歴に深く食い込む
  • 既存の外部キー(ポートフォリオ・チャット履歴等)が全て User を指すため差し替えが連鎖する
  • allauth / admin / サードパーティが標準 User 前提で動く

「認証の入口を変えたい」だけなのにデータモデルの根幹に手を入れることになります。ここでの設計判断は明快です。標準 User は不可侵にし、CognitoのsubとDjangoのuser_idの対応は別テーブルに逃がす。

実装1: リンクテーブルで sub ↔ user_id を1:1に紐付ける

auth_user には触れず、別テーブルを1枚立ててIdP側のアイデンティティを持たせます。

from django.conf import settings
from django.db import models


class IdentityLink(models.Model):
    """Cognito(等IdP)のアイデンティティと Django User を 1:1 で結ぶ。
    auth_user には一切カラムを足さない。"""

    user = models.OneToOneField(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="identity_link",
    )
    provider = models.CharField(max_length=32, default="cognito")
    subject = models.CharField(max_length=255, unique=True)  # Cognitoのsub
    email = models.EmailField()
    last_signed_in_at = models.DateTimeField(null=True, blank=True)

    class Meta:
        constraints = [
            models.UniqueConstraint(
                fields=["provider", "subject"],
                name="uq_provider_subject",
            )
        ]

このテーブルにした理由は2つです。標準モデルの不可侵化(ロールバックは「リンクテーブルを消すだけ」で済む)と、将来の拡張(1ユーザーが複数IdPを持つ拡張では provider を軸にした関連が最初から別テーブルが自然。OneToOneForeignKey に緩めれば1:Nへ広げられる)。

実装2: JITプロビジョニングで既存ユーザーを自動保全する

移行時の一括バッチは書かず、初回ログイン時にその場で紐付ける(JIT)。解決順序が肝で、必ず次の順で解きます。

from django.contrib.auth import get_user_model
from django.utils import timezone

User = get_user_model()


def resolve_user_from_claims(claims: dict) -> User:
    """検証済みclaimsから Django User を解決する。
    ①sub検索 → ②emailマッチング → ③新規作成 の順。"""

    sub = claims["sub"]
    email = claims.get("email", "").lower()

    # ① 既存リンクを sub で検索(2回目以降はここで即ヒット)
    link = IdentityLink.objects.filter(
        provider="cognito", subject=sub
    ).select_related("user").first()
    if link:
        link.last_signed_in_at = timezone.now()
        link.save(update_fields=["last_signed_in_at"])
        return link.user

    # ② email で既存ユーザーを引き当てる(=移行前ユーザーの初回ログイン)
    user = User.objects.filter(email__iexact=email).first()

    # ③ どこにも居なければ新規作成(Cognito経由のみログイン可に)
    if user is None:
        user = User.objects.create(username=email, email=email)
        user.set_unusable_password()  # ローカルパスワードでのログインを封じる
        user.save()

    IdentityLink.objects.create(
        provider="cognito",
        subject=sub,
        email=email,
        user=user,
        last_signed_in_at=timezone.now(),
    )
    return user

ポイントは②のemailマッチングです。移行前ユーザーはCognitoにサインインした後、初めてアクセスした瞬間にemailで既存 User へ吸着されます。既存のポートフォリオやチャット履歴は User に紐付いたままなので1件も欠けません。新規作成時の set_unusable_password() で「Cognito経由でのみログイン可能」にし、旧パスワードログイン経路と混線させません。

なお、emailマッチングによる自動吸着は email_verified が保証されている前提でのみ安全です。未検証メールで吸着を許すと他人のアカウントを乗っ取れます。検証は次のトークン検証層で必ず担保します。

実装3: DRF認証をハイブリッド化して段階移行する

認証を一晩で切り替えると既存の静的Bearerキークライアントが止まります。そこで DEFAULT_AUTHENTICATION_CLASSES新旧2つを並べ、順番に試します

from rest_framework.authentication import BaseAuthentication
from rest_framework import exceptions


class CognitoJWTAuthentication(BaseAuthentication):
    def authenticate(self, request):
        raw = _extract_bearer(request)
        if raw is None:
            return None  # 次のAuthentication Classへ委譲
        if raw.startswith("svc_key_"):  # 静的キーなら旧方式へ委譲
            return None
        claims = verify_cognito_access_token(raw)
        user = resolve_user_from_claims(claims)
        return (user, claims)


class LegacyStaticKeyAuthentication(BaseAuthentication):
    """既存の静的Bearerキー(サービス固有プレフィックス付き)の後方互換層。"""
    def authenticate(self, request):
        raw = _extract_bearer(request)
        if raw is None or not raw.startswith("svc_key_"):
            return None
        user = lookup_user_by_static_key(raw)
        if user is None:
            raise exceptions.AuthenticationFailed("invalid key")
        return (user, None)
# settings.py
REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "myapp.auth.CognitoJWTAuthentication",       # 新方式を先に
        "myapp.auth.LegacyStaticKeyAuthentication",  # 旧方式にフォールバック
    ],
}

authenticate()None を返すとDRFは次のクラスへフォールバックします。キー形式のプレフィックスで振り分けることで、CognitoのJWTなら新方式、svc_key_... なら旧方式と自然に分岐します。旧クライアントは無改修で動き続け、旧経路の廃止は移行完了後に410 Goneで最後に落とします。

実装4: JWT検証層 — チェックすべきclaimを妥協しない

トークン検証を甘くすると②のemailマッチングが凶器に変わります。access tokenで最低限確認するclaimは以下です。

import jwt  # PyJWT

ALLOWED_CLIENT_IDS = {"<spa_client_id>", "<confidential_client_id>"}
ISSUER = "https://cognito-idp.ap-northeast-1.amazonaws.com/ap-northeast-1_XXXXXXXXX"


def verify_cognito_access_token(raw: str) -> dict:
    header = jwt.get_unverified_header(raw)
    key = get_signing_key(header["kid"])

    claims = jwt.decode(
        raw, key=key, algorithms=["RS256"], issuer=ISSUER,
        options={"require": ["exp", "iss", "token_use"]},
    )

    if claims.get("iss") != ISSUER:
        raise exceptions.AuthenticationFailed("bad issuer")
    if claims.get("token_use") != "access":       # id tokenを誤受しない
        raise exceptions.AuthenticationFailed("not an access token")
    if claims.get("client_id") not in ALLOWED_CLIENT_IDS:
        raise exceptions.AuthenticationFailed("client_id not allowed")
    if claims.get("email_verified") is False:     # メール乗っ取り防止
        raise exceptions.AuthenticationFailed("email not verified")

    return claims

issuer完全一致・token_use=access・client_id許可リスト・有効期限・email_verified=false拒否。最後のひとつが、JITのemailマッチングを安全にする最後の砦です。

実装5: JWKSキャッシュ — Lambdaでもローカルメモリで十分

RS256検証には公開鍵(JWKS)が必要です。方針は「TTL 1hのローカルメモリキャッシュ + kid不一致時の強制再取得」。

_JWKS_CACHE = {"keys": {}, "fetched_at": 0}
_TTL = 3600


def get_signing_key(kid: str):
    now = _now()
    # TTL切れ、または未知のkid(=ローテーション直後)は取り直す
    if (now - _JWKS_CACHE["fetched_at"] > _TTL) or (kid not in _JWKS_CACHE["keys"]):
        _JWKS_CACHE["keys"] = _fetch_jwks()
        _JWKS_CACHE["fetched_at"] = now
    key = _JWKS_CACHE["keys"].get(kid)
    if key is None:
        raise exceptions.AuthenticationFailed("unknown kid")
    return key

kidが未知なら即再取得するのがローテーション追従の勘所です。Lambdaはステートレスですが、コンテナの生存期間中はこのローカルメモリキャッシュがヒットするため、Redisのような外部キャッシュは当面不要でした。

実装6: App Clientを2つに分ける

用途 種別 secret PKCE callback URL
ブラウザSPA public client なし 必須 https://app.example.com/callback
外部LLM連携 confidential client あり 連携サービス側のURL

分ける理由は、secret漏洩防止(SPAはコードが露出するのでsecretを持てない)、callback URL分離(戻り先が全く異なる。同居はオープンリダイレクトの温床)、ライフサイクル/監査の独立(片方だけ失効・ローテーションできる)です。

利用者の即時無効化

マネージド認証に寄せた最大の実利です。Disable user で即座に新規ログイン不可(既発行access tokenは最長1h生存)、Sign out user でrefresh tokenも無効化。この2つで全経路が同時に閉じます。自前実装だと各経路ごとに失効ロジックを書く必要がありました。

確認方法

  • 移行前ユーザーの初回ログインでデータが欠けずに紐付き、User が新規作成されないこと
  • 2回目以降はsub検索で即ヒットすること
  • 旧クライアント(svc_key_...)が無改修で通り、Cognito JWTでも同一エンドポイントが通ること
  • token_use=id / 別issuer / 未許可client_id / email_verified=false が401になること
  • 未知kidでJWKS再取得が走ること
  • Disable user 後に新規ログイン不可、Sign out user 後にrefreshが失敗すること

注意点

  • emailマッチングは email_verified 前提でのみ安全。検証層で未検証メールを弾く実装を必ずセットにする
  • Disable user は既発行access tokenを即座には殺さない(最長1h残る)
  • ハイブリッド認証は時限措置。旧経路は410 Goneで明示的に廃止する
  • 段階移行の順序は「インフラ準備(Terraform)→ バックエンド認証層 → フロント改修(Amplify Auth)→ 旧認証廃止(410 Gone)+ 外部連携接続」。フロントを触る前にバックエンドで新旧両対応にしておくのがダウンタイムを作らないコツ

まとめ

Django標準の User を壊さずCognitoへ移行する鍵は、auth_user に触らず別テーブルで subuser_id を1:1に紐付けることでした。既存ユーザーはJITで初回ログイン時に自動保全でき、DRFのハイブリッド認証で旧クライアントを動かしたまま段階移行できます。App Clientはpublic(SPA)とconfidential(外部連携)で分け、JWKSはkid不一致時の強制再取得でローテーションに追従。「認証の入口を変えたい」だけなら、データモデルの根幹に手を入れる必要はありません。壊さない境界をどこに引くかが、移行の成否を分けました。

\ 最新情報をチェック /

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください