0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

0
Last updated at Posted at 2026-08-17

概要

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

移行の核心は次の3点に集約される。

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

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

環境

  • 移行前: 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(User Pool / App Client / Google IdP連携を定義)

発生した問題(=なぜ移行したか)

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

  • 外部LLMサービスの連携要求: あるLLMサービスのAgent/Actions連携の利用規約が、OAuth認証経路を要求してきた。自前のメール/パスワード+独自トークンでは接続できない。
  • 利用者の即時無効化: 離脱者・退職者を運営側から即座にログイン不可にしたい。自前実装だとトークン失効の設計を自分で背負うことになる。
  • 認証経路の分散: ブラウザSPA / 外部LLM連携 / 既存の静的Bearerキークライアント と、認証の入口がバラバラに増えつつあった。1つの認証基盤に集約したい。

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

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

Cognito移行と聞くと、まず思いつくのは「auth_usercognito_sub カラムを足してカスタムUserモデルにする」だ。だがDjangoでカスタム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つ。

  • 標準モデルの不可侵化: User はDjangoのものとして完全に温存できる。ロールバックも「リンクテーブルを消すだけ」で済む。
  • 将来の拡張: 1ユーザーが複数IdP(Cognito / Google / 将来の別プロバイダ)を持つ拡張を考えると、provider を軸にした関連は最初から別テーブルが自然。OneToOneForeignKey に緩めれば1:Nへ広げられる。

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

移行時に既存ユーザーへ一括で sub を割り当てるバッチは書かない。初回ログイン時に、その場で紐付ける(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:
    """Cognito access token の検証済み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 で既存ユーザーを引き当てる(=移行前ユーザーの初回ログイン)
    #    email_verified は検証層で担保済みである前提
    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に事前登録(またはGoogle連携で初回サインイン)した後、初めてこのアプリにアクセスした瞬間に、emailで既存 User へ吸着される。既存のポートフォリオやチャット履歴は User に紐付いたままなので、1件も欠けない。

新規作成時に set_unusable_password() を呼ぶのは重要だ。これで「Cognito経由でしかログインできないユーザー」になり、旧来のパスワードログイン経路と混線しない。

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

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

認証を一晩で切り替えるのは怖い。既存の静的Bearerキークライアントが動かなくなる。そこで、DRFの DEFAULT_AUTHENTICATION_CLASSES新旧2つを並べ、順番に試す

from rest_framework.authentication import BaseAuthentication
from rest_framework import exceptions


class CognitoJWTAuthentication(BaseAuthentication):
    """Authorization: Bearer <JWT> を Cognito access token として検証する。"""

    def authenticate(self, request):
        raw = _extract_bearer(request)
        if raw is None:
            return None  # 次のAuthentication Classへ委譲

        # JWTでなさそう(サービス固有プレフィックス付きキー)なら委譲
        if raw.startswith("svc_key_"):
            return None

        claims = verify_cognito_access_token(raw)  # 検証は後述
        user = resolve_user_from_claims(claims)     # JIT解決
        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マッチングが凶器に変わる。Cognitoの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"])  # JWKSから取得(次節)

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

    # issuer 完全一致(decodeでも検証されるが明示)
    if claims.get("iss") != ISSUER:
        raise exceptions.AuthenticationFailed("bad issuer")
    # access token であること(id token を誤って受けない)
    if claims.get("token_use") != "access":
        raise exceptions.AuthenticationFailed("not an access token")
    # 許可した App Client からのトークンだけ通す
    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完全一致: 別User Poolのトークンを弾く
  • token_use=access: id tokenをaccess tokenの代わりに受けない
  • client_id許可リスト: 想定外のApp Clientを排除
  • 有効期限: 当然チェック
  • email_verified=false を拒否: これがJITの②を安全にする最後の砦。未検証メールでの吸着を許すと他人のアカウントを乗っ取れる

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()  # /.well-known/jwks.json
        _JWKS_CACHE["fetched_at"] = now

    key = _JWKS_CACHE["keys"].get(kid)
    if key is None:
        raise exceptions.AuthenticationFailed("unknown kid")
    return key

kidが未知なら即再取得するのがローテーション追従の勘所だ。Cognitoが鍵を回してもkid不一致をトリガに新しいJWKSを引き直せる。Lambdaはステートレスだが、コンテナは一定時間生存する。その生存期間中はこのローカルメモリキャッシュがヒットするので、実用上Redisのような外部キャッシュは当面不要だった。

6. App Clientを2つに分ける

Cognitoの App Client は、経路ごとに分けた。

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

分ける理由は明確だ。

  • secret漏洩防止: SPAはブラウザにコードが露出するので、secretを持てない(public + PKCE一択)。外部連携はサーバ間でsecretを安全に保持できる(confidential)。1つのApp Clientで兼ねると、どちらかが必ず不適切になる。
  • callback URL分離: SPAと外部連携で戻り先が全く異なる。同居させるとリダイレクト先の管理が緩くなり、オープンリダイレクトの温床になる。
  • ライフサイクル/監査の独立: 外部連携のsecretだけローテーションする、片方だけ失効させる、といった運用を独立に回せる。

実装例(利用者の即時無効化)

マネージド認証に寄せた最大の実利がこれだ。離脱者・退職者が出たら、Cognito側の操作だけで全経路を同時に遮断できる。

  • Disable user: 即座に新規ログイン不可になる。ただし既発行のaccess tokenは有効期限(最長1h)まで生存する。
  • Sign out user: refresh tokenも無効化され、トークンの更新が止まる。

この2つで、ブラウザSPA / 外部LLM連携 / 静的キークライアントの全経路が同時に閉じる。自前実装だと各経路ごとに失効ロジックを書く羽目になっていた。

確認方法

  • 既存ユーザー保全: 移行前ユーザーで初回ログインし、ポートフォリオ・チャット履歴が欠けずに紐付くこと。IdentityLinksub が1行作られ、User は同一idのまま(=新規作成されていない)ことを確認
  • JIT解決順序: 2回目以降のログインが①のsub検索で即ヒットし、emailマッチングまで到達しないこと
  • ハイブリッド認証: 旧クライアント(svc_key_...)が無改修で200を返し、Cognito JWTでも同一エンドポイントが通ること
  • claim検証: 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(sub検索 → emailマッチング → 新規作成)**で初回ログイン時に自動保全でき、データを1件も壊さない
  • DRFのAuthentication Classをハイブリッド化すれば、旧クライアントを動かしたまま段階移行できる
  • App Clientはpublic(SPA/PKCE)とconfidential(外部連携/secret)で分ける
  • JWKSはkid不一致時の強制再取得でローテーションに追従。Lambdaでもローカルメモリキャッシュで十分実用

「認証の入口を変えたい」だけなら、データモデルの根幹に手を入れる必要はない。壊さない境界をどこに引くかが、移行の成否を分けた。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?