Django標準のUserモデルを壊さずにCognito OAuthへ移行する — sub↔userを別テーブルで繋ぐJITプロビジョニング設計
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のsub` ↔ `user_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_user` に `cognito_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側のアイデンティティを持たせます。
```python
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` を軸にした関連が最初から別テーブルが自然。`OneToOne` を `ForeignKey` に緩めれば1:Nへ広げられる)。
実装2: JITプロビジョニングで既存ユーザーを自動保全する
移行時の一括バッチは書かず、初回ログイン時にその場で紐付ける(JIT)。解決順序が肝で、必ず次の順で解きます。
```python
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つを並べ、順番に試します。
```python
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)
```
```python
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は以下です。
```python
import jwt # PyJWT
ALLOWED_CLIENT_IDS = {"
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不一致時の強制再取得」。
```python
_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` に触らず別テーブルで `sub` ↔ `user_id` を1:1に紐付けることでした。既存ユーザーはJITで初回ログイン時に自動保全でき、DRFのハイブリッド認証で旧クライアントを動かしたまま段階移行できます。App Clientはpublic(SPA)とconfidential(外部連携)で分け、JWKSはkid不一致時の強制再取得でローテーションに追従。「認証の入口を変えたい」だけなら、データモデルの根幹に手を入れる必要はありません。壊さない境界をどこに引くかが、移行の成否を分けました。
