標準 auth_user は一切変更しない。紐付けは専用テーブルで持つ

個人開発の株式分析Webアプリで、認証基盤を全面的に入れ替えました。移行元は「Django allauth + Simple JWT のメール/パスワード認証」、移行先は「AWS Cognito User Pool ベースの OAuth 2.0 / OIDC」です。この記事では、その設計と実装の経緯を、実際のコード構造とともに残しておきます。

移行にあたって最初に引いた制約は、次の3つでした。

  • 既存ユーザーのデータ(ポートフォリオ・チャット履歴)を1件も壊さない
  • 既存クライアント(静的Bearerキーで叩く経路)を止めない
  • ブラウザSPA / 外部LLM連携 / 既存キークライアント の3経路を、1つの認証基盤に集約する

本記事の主役は派手な新機能ではなく、たった1つの地味な設計判断です。すなわち 「Django標準の auth_user には一切手を入れず、別テーブルで Cognito の sub と user_id を紐付ける」 こと。ここを起点に、JITプロビジョニングとDRFのハイブリッド認証まで、擬似コードを交えて解説していきます。

なぜ移行したのか

きっかけは技術的な理想論ではなく、外部要因の積み重ねでした。

  • 外部LLMサービスの要求:あるLLMサービスの Agent / Actions 連携を使うにあたり、その利用規約が OAuth 認証経路を要求してきました。メール/パスワード+自前JWTのままでは接続できません。
  • 離脱者の即時無効化:退職者・離脱者を運営側から即座に締め出したい。自前のトークン発行では、失効の一元管理がどうしても煩雑になります。
  • 経路の集約:ブラウザSPA・外部LLM連携・既存キークライアントを、バラバラの認証方式ではなく1つのマネージド基盤に寄せたい。

これらを1つの User Pool に集約できれば、認証の運用(無効化・監査・IdP追加)が Cognito 側で完結します。運用の手離れの良さこそ、今回の移行の本当の狙いでした。

移行前 / 移行後の構成

移行前

  • Django 標準の User モデル(カスタムUserは不採用)
  • django-allauth + Simple JWT のメール/パスワード認証
  • access 30分 / refresh 7日 / rotation あり

移行後

  • AWS Cognito User Pool を単一の認証基盤に
  • 集約した3経路:①ブラウザSPA ②外部LLM連携 ③既存の静的Bearerキークライアント
  • インフラ(User Pool / App Client / Google IdP 連携)は Terraform 管理
  • バックエンドの一部は AWS Lambda 上で稼働(ステートレス実行)

設計の核:標準Userを不可侵にする

Cognito 移行でまず悩むのが「Cognito の sub をどこに持たせるか」です。素直に思いつくのは、標準の auth_user に cognito_sub 列を追加してカスタムUserモデル化する方法でしょう。

しかし、運用中のプロジェクトで AUTH_USER_MODEL を差し替えたり、標準テーブルにマイグレーションを当てたりするのは、移行リスクが大きすぎます。allauth やサードパーティ製パッケージが標準Userを前提にしている箇所は多く、影響範囲を読み切れません。

そこで採ったのが、別テーブルで 1:1 に紐付ける 設計です。

# 標準 auth_user は一切変更しない。紐付けは専用テーブルで持つ
class ExternalIdentity(models.Model):
    user = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="external_identities",
    )
    provider = models.CharField(max_length=32, default="cognito")
    subject = models.CharField(max_length=255, db_index=True)  # Cognito の sub
    email = models.EmailField()
    last_signed_in_at = models.DateTimeField(null=True, blank=True)

    class Meta:
        # (provider, subject) で一意 = sub は基盤内で一意
        unique_together = [("provider", "subject")]

この形にした理由は2つあります。

1. 標準モデルの不可侵化

auth_user にマイグレーションを当てません。既存のポートフォリオ・チャット履歴が参照する user_id はそのまま生き続けます。移行の失敗ポイントが1つ丸ごと消えるわけです。

2. 将来の複数IdP拡張が自然

「1ユーザーに複数のIdP(Cognito直・Google・将来の別IdP)」を後から足したくなったとき、ExternalIdentity を複数行持てば済みます。ここを User の列ではなく別テーブルの行として設計しておくと、拡張時に User モデルを触らずに済みます。

紐付けは概念的には sub ↔ user_id の 1:1 ですが、その関連を「User の列」ではなく「別テーブルの行」として持つことで、User の形を変えずに認証の都合を吸収できる。これが今回の設計のいちばんの勘所です。

JITプロビジョニング:既存ユーザーを壊さず紐付ける

別テーブルを用意しただけでは、既存ユーザーはまだ Cognito の sub と結びついていません。ここで使うのが JIT(Just-In-Time)プロビジョニング です。初回ログイン時に、トークンの claim から「解決順」をあらかじめ決めておくのがポイントになります。

解決順:① sub で検索 → ② email でマッチング → ③ 無ければ新規作成

def resolve_user(claims):
    sub = claims["sub"]
    email = (claims.get("email") or "").lower()

    # ① sub で既存リンクを検索(2回目以降のログインはここで即ヒット)
    identity = ExternalIdentity.objects.filter(
        provider="cognito", subject=sub
    ).select_related("user").first()
    if identity:
        return identity.user

    # ② email で既存ユーザーとマッチング(← 移行ユーザーの保全はここ)
    user = User.objects.filter(email__iexact=email).first()

    # ③ 見つからなければ新規作成。Cognito 経由でのみログイン可能にする
    if user is None:
        user = User(username=email, email=email)
        user.set_unusable_password()  # パスワードログインを封じる
        user.save()

    # sub ↔ user のリンクを張る(次回からは①で解決される)
    ExternalIdentity.objects.create(
        provider="cognito", subject=sub, email=email, user=user,
    )
    return user

肝は ②の email マッチング です。移行前から存在するユーザーが初めて Cognito でログインすると、①ではまだリンクが無いため見つかりません。しかし②で既存の auth_user.email と突き合わせれば、既存ユーザー本人へ自動的に紐付き、そのユーザーが持つ既存データがそのまま引き継がれます。2回目以降は①でヒットするので、email マッチングが走るのは実質「移行時の1回だけ」です。

新規作成時に set_unusable_password() を呼ぶのは、Cognito 経由以外のログイン経路を塞ぐためです。パスワードハッシュが「使用不可」状態になり、旧来のパスワード認証で入られる余地を残しません。

> ⚠️ 注意:email マッチングは「同じ email = 同一人物」を前提にしています。これを安全に成立させるには、後述のJWT検証で email_verified=false のトークンを拒否することが必須です(未検証メールを使った乗っ取りを防ぐため)。

DRFハイブリッド認証:一気に置き換えない

既存の静的Bearerキークライアントを止めないために、認証を一括で切り替えるのではなく、DRF の Authentication Class を「新方式 または 旧方式」の多段構成にしました。DRF は DEFAULT_AUTHENTICATION_CLASSES を上から順に試すので、その挙動を利用します。

REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "app.auth.CognitoJWTAuthentication",     # 新方式(優先)
        "app.auth.StaticApiKeyAuthentication",   # 旧方式(フォールバック)
    ],
}

振り分けは トークンのプレフィックス で行います。サービス固有プレフィックス付きの静的キー(例:svc_key_...)はJWT検証に回さず、JWTはJWT検証に回す。こうすれば旧クライアントは無改修のまま動き続けます。

class CognitoJWTAuthentication(BaseAuthentication):
    def authenticate(self, request):
        token = _extract_bearer(request)
        if token is None:
            return None
        # 静的キーはこの認証の対象外(次のクラスに委ねる)
        if token.startswith("svc_key_"):
            return None
        claims = verify_jwt(token)          # JWKS 検証(後述)
        user = resolve_user(claims)         # JIT プロビジョニング
        return (user, claims)


class StaticApiKeyAuthentication(BaseAuthentication):
    def authenticate(self, request):
        token = _extract_bearer(request)
        if token is None:
            return None
        # プレフィックスが合わなければ「このクラスは関知しない」
        if not token.startswith("svc_key_"):
            return None
        user = lookup_static_api_key(token)  # 既存のキー→ユーザー解決
        if user is None:
            raise AuthenticationFailed("invalid api key")
        return (user, None)


def _extract_bearer(request):
    header = get_authorization_header(request).decode("utf-8", "ignore")
    if not header.startswith("Bearer "):
        return None
    return header[len("Bearer "):].strip()

authenticate が None を返すと、DRF は次の認証クラスを試します。つまり「自分の担当でないトークンは None を返す」という規約を守るだけで、複数方式をきれいに共存させられます。移行が完了して旧経路を畳む段になったら、StaticApiKeyAuthentication を外すだけで済みます。この「後で外すだけ」で戻せる状態を作れることが、多段構成の一番のメリットです。

App Client は SPA用と外部連携用で分ける

Cognito の App Client は、用途ごとに分けるのが原則です。今回は少なくとも2つに分けました。

用途 クライアント種別 secret 認可フロー callback URL
ブラウザSPA public client なし Authorization Code + PKCE 必須 https://app.example.com/callback
外部LLM連携 confidential client あり Authorization Code(server-to-server) 連携サービス側のURL

分ける理由は次の通りです。

  • secret 漏洩防止:SPA はブラウザに配られるコードなので、secret を安全に隠せません。public client にして PKCE で守ります。secret を持つのは、秘匿できるサーバー側(外部連携)だけに限定します。
  • callback URL の分離:経路ごとに戻り先がまったく異なります。1つの App Client に混在させると、リダイレクト先の許可リストが緩くなり、事故のもとになります。
  • ライフサイクル/監査の独立:外部連携の secret をローテーションしたい、特定経路だけ止めたい、といった操作を互いに影響させずに行えます。

なお、App Client を後から分割するのは意外と面倒です。最初から用途別に分けておくことを強くおすすめします。

JWT検証とJWKSキャッシュ

JWT(RS256)の検証では、署名検証に加えて claim を明示的にチェックします。

  • iss(issuer)が期待値と完全一致すること
  • token_use == "access" であること
  • client_id が許可リストに含まれること
  • 有効期限(exp)が切れていないこと
  • email_verified == false のトークンは拒否すること(前述のメール乗っ取り防止)

署名鍵は Cognito の JWKS エンドポイントから取得します。鍵はローテーションされるので、TTLキャッシュ + kid不一致時の強制再取得 で追従させます。

_JWKS = {"keys": None, "fetched_at": 0}
_TTL = 3600  # 1h

def _get_key(kid, force=False):
    if force or _JWKS["keys"] is None or _now() - _JWKS["fetched_at"] > _TTL:
        _JWKS["keys"] = fetch_jwks(JWKS_URL)   # HTTP GET
        _JWKS["fetched_at"] = _now()
    key = _find_by_kid(_JWKS["keys"], kid)
    if key is None and not force:
        # kid が見つからない = 鍵ローテーションを疑い、一度だけ強制再取得
        return _get_key(kid, force=True)
    return key

def verify_jwt(token):
    kid = jwt.get_unverified_header(token)["kid"]
    key = _get_key(kid)
    if key is None:
        raise AuthenticationFailed("signing key not found")

    claims = jwt.decode(
        token, key, algorithms=["RS256"],
        options={"require": ["exp", "iss"]},
    )
    if claims.get("iss") != EXPECTED_ISSUER:
        raise AuthenticationFailed("bad issuer")
    if claims.get("token_use") != "access":
        raise AuthenticationFailed("bad token_use")
    if claims.get("client_id") not in ALLOWED_CLIENT_IDS:
        raise AuthenticationFailed("client_id not allowed")
    if claims.get("email_verified") is False:
        raise AuthenticationFailed("email not verified")
    return claims

Lambda のようなステートレス実行でも、このキャッシュはきちんと効きます。 Lambda はリクエストごとにゼロから起動するわけではなく、実行コンテナが一定時間再利用されます。その生存期間中はモジュールスコープの _JWKS が生き残るため、2回目以降のリクエストはローカルメモリキャッシュにヒットします。鍵ローテーションは kid 不一致をトリガに再取得されるので、Redis 等の外部キャッシュは当面不要、という判断にしました。

離脱者の即時無効化

集約の恩恵が最もわかりやすく出るのが、この無効化のシーンです。

  • Cognito 側でユーザーを Disable user → 新規ログインが即座に不可になります(ただし既発行の access token は最長1h残ります)
  • Sign out user → refresh token も無効化され、トークン更新が止まります
  • これらは 全利用経路(SPA / 外部連携 / Cognito発行トークン経路)に同時に効きます

自前でトークンを発行していた頃は、失効をアプリ側で管理する必要がありました。基盤に寄せたことで、「Cognito側の1操作で締め出す」がそのまま成立します。

段階移行フェーズ

一気にやらず、次の順で進めました。各段で「壊れていないこと」を確認してから次へ進む、これが最大の要点です。

  1. インフラ準備 — Terraform で User Pool / App Client(×2) / Google IdP 連携を定義
  2. バックエンド認証層 — JWT検証・JITプロビジョニング・DRFハイブリッド認証を実装(この時点では旧認証も生きている)
  3. フロント改修 — SPA を Amplify Auth に載せ替え、PKCE の Authorization Code フローへ
  4. 旧認証の廃止 — 旧エンドポイントを 410 Gone で明示的に畳み、外部LLM連携を confidential client で接続

旧認証を単に消すのではなく 410 Gone を返すのは、旧クライアントが「意図的に廃止された」と判別できるようにするためです。404 だと「一時的な障害」と区別がつきません。

確認方法

移行後は、次の観点で1つずつ動作を確認しました。

  • 既存ユーザーの保全:移行前ユーザーで Cognito ログイン → ExternalIdentity に行が1件作られ、既存のポートフォリオ/履歴がそのまま見えることを確認(②の email マッチングが働いた証拠)
  • 2回目ログイン:同じユーザーで再ログイン → 新規行が増えず、①の sub 検索でヒットすることを確認
  • 旧クライアント:svc_key_... の静的キーで従来どおり叩けること(無改修で200が返る)
  • 鍵ローテーション:古い kid のトークンで叩き、JWKS強制再取得が走って検証が通ること
  • 無効化:Disable user 直後に新規ログインが弾かれ、Sign out user で refresh が止まること

注意点

最後に、実装しながら「ここは事故りやすい」と感じたポイントを挙げておきます。

  • email マッチングは email_verified 検証とセットで。未検証メールを許すと、他人の email を騙ったアカウントで既存ユーザーに紐付いてしまいます。
  • Cognito の access token に email claim が載るかは設定次第。載らない構成なら id token / userinfo で解決するなど、経路を明示的に設計する必要があります。
  • 静的キーのプレフィックス分岐は「暫定の橋」。移行完了後は旧認証クラスを外し、単一方式に戻すことをゴールに置きます。
  • App Client を後から分割するのは面倒。繰り返しになりますが、最初から用途別に分けておきましょう。

まとめ

マネージド認証への移行で一番怖いのは、「認証を変えたつもりが、既存ユーザーのデータごと壊す」ことです。今回の設計は、それを次の3点で回避しました。

  1. 標準Userを不可侵化 — sub ↔ user_id を別テーブルに逃がし、auth_user にマイグレーションを当てない
  2. JITプロビジョニング — sub → email → 新規作成 の解決順で、既存ユーザーを自動保全
  3. DRFハイブリッド認証 — プレフィックス分岐で新旧を共存させ、無停止で段階移行

「認証基盤を差し替える」という一見大がかりな変更も、壊してはいけない境界(標準User・既存データ・既存クライアント)を先に決めてから設計すれば、地味な別テーブル1枚で安全に着地できる。これが今回いちばんの学びでした。同じように運用中のプロジェクトで認証を載せ替える方の参考になれば幸いです。

\ 最新情報をチェック /

コメントを残す

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