JWT vs セッション:認証システム全体を置き換えた理由
認証は解決済みに感じられることの1つだ——そうでないコードベースを引き継ぐまでは。Leverage OJの書き換えを始めたとき、認証システムはトレンチコートを着た3つの別々の問題だった:PM2で壊れるセッションセットアップ、独自の並行認証宇宙に分岐したContestUserコンセプト、そして1つの設定漏洩で完全な認証情報ダンプになるパスワードハッシュスキーム。
これらは最初は明らかではなかった。システムは動作していた——ユーザーはログインでき、セッションは永続化し、コンテストは実行された。しかし「動作する」と「正しい」は別物であり、よく見れば見るほど、もはや有効でない仮定を蓄積したシステムが見えてきた。
セッションの何が問題だったか
PM2クラスタがメモリ内状態を壊す
オリジナルのシステムはRedisストアでexpress-sessionを使用していた——標準的なセットアップ。ただしそれは完全に標準的ではなかった。
Redisストアは理論上セッション永続性を正しく処理する:セッションはRedisに保存され、どのプロセスでも読める。しかしオリジナルのコードはセッションストアと一緒に存在するステートフルなメモリ内データを蓄積していた。ランキング再構築のpendingSetが最も悪質な例だ(コードレビュー投稿でそのバグを文書化した)が、認証モジュールにも独自のバージョンがあった:1つのプロセスがリクエストチェーンのライフサイクル全体を処理する場合にのみ正しい方法でメモリ内状態とセッションデータを混合するデバイスバインディングチェック。
PM2クラスタモードは着信リクエストをラウンドロビンでワーカーに割り当てる。認証セットアップがセッションストアと異なるメモリ内状態に触れる場合——たとえ一時的にでも——負荷がかかったときにのみ、本番でのみ現れ、開発では再現がほぼ不可能な一貫性の失敗が発生する。
2つの認証システムが並行して実行されていた
より難しい問題はContestUserだった。
Leverageには2種類のユーザーがいる:プラットフォームにアカウントを持つ通常ユーザーと、別の認証情報、IPバインディング要件、異なるアクセスルールを持つ可能性のある一時的な参加者であるコンテストユーザー。オリジナルのシステムはこれをロジックを共有しない2つの完全に別々の認証パスを持つことで解決した。
結果として、同じことをする(IDを検証し、ユーザーコンテキストをリクエストに添付する)のに完全に異なる実装を持つ2つのコードベース。あらゆるバグ修正、あらゆる新要件——更新する場所が2つ。あらゆるセキュリティ改善——間違える場所が2つ。
セッションの構造的問題
実装の詳細の下にはより深い問題がある:HTTPセッションは本質的にステートフルであり、ステートフルネスは水平スケーリングの敵だ。
Leverageの現在の規模ではこれは問題にならない。しかし書き換えは2年後に捨てる必要のないインフラストラクチャをセットアップすることでもある。ロードバランサーの後ろで複数のコンテナを実行したい場合、セッションにはスティッキーセッション(同じユーザーからのリクエストは常に同じコンテナにルーティング)か共有セッションストア(両方ともリクエスト処理を開始する前のRedisラウンドトリップ)のいずれかが必要だ。
JWTは状態をサーバーからクライアントにシフトする。トークンにはIDを検証するために必要なすべての情報が含まれている;サーバーは署名を検証するだけだ。これは実際のスケーラビリティの利点であり、実際のトレードオフがある——それについては後述する。
JWTの設計
アクセストークン + リフレッシュトークン
デュアルトークンスキームを採用した:
- アクセストークン:15分の有効期限、ステートレス、
jwt.accessSecretで署名 - リフレッシュトークン:7日の有効期限、異なる
jwt.refreshSecretで署名
2つのトークンが異なるシークレットを持つ理由:アクセスシークレットが漏洩した場合、攻撃者はアクセストークンを偽造できるが、リフレッシュトークン(新しいアクセストークンを作成できるため、より強力)は安全なまま。アクセスシークレットを独立してローテートできる。
アクセストークンは完全なJWTペイロードを運ぶ:sub(ユーザーID)、username、role。ほとんどのリクエストに必要なのはそれだけだ——データベースルックアップなしでIDと認可レベル。
ContestUser:同じシステム、異なるペイロード
これがオリジナルの2つの認証システム問題に対して設計が効果を発揮するところだ。
コンテストユーザーもJWTを取得するが、異なるペイロード形状で:
export interface ContestJwtPayload {
sub: number // userId(メインユーザーアカウントにマップ)
contestId: number
role: 'contest-user'
}contestIdはトークンに埋め込まれている。コンテストJWTは特定のコンテストにスコープされている——コンテストAのトークンをコンテストBへのアクセスに使用できない。role: 'contest-user'はガードにこれがどの種類のユーザーかを伝える。
重要なのは:コンテストユーザーは通常ユーザーと同じUserテーブルでバックアップされている。ContestUserエンティティはユーザーとコンテストの関係を表す(別のパスワード、IPバインディング、統計)。ログイン時に認証情報を検証し、コンテストスコープのJWTを生成する。その後ガードがコンテスト固有のルールを強制する。
これはオリジナルのデュアルミドルウェアセットアップを、コンテキストを運ぶ単一のトークン形式と、それをどう扱うかを知っている単一のガードに置き換える。
PBKDF2:レガシー互換性の問題
パスワードハッシュの決定は既存のデータベースによって私のために行われた。
オリジナルのシステムはグローバル固定HMACキーでHMAC-SHA256(MD5(password))を使用していた。ユーザーごとのソルトなし。コードレビュー投稿で述べたように、これは重大なセキュリティ問題だ:設定が漏洩すれば、すべてのパスワードが事前計算されたテーブルでクラック可能になる。
明らかな修正はbcryptだ。業界標準、十分にテストされ、自動的にソルトを処理し、GPU耐性。しかし落とし穴がある:平文を知らずに既存のパスワードをbcryptで後からハッシュし直すことはできない。既存のデータベースには古いハッシュ形式の4,000以上のユーザーレコードがある。bcryptに切り替えて移行を処理しなければ、すべての既存ユーザーがパスワードを失う。
PBKDF2 + 移行戦略でこれを解決した:
// crypto.util.ts
export function hashPassword(password: string): string {
const salt = randomBytes(16).toString('hex')
const hash = pbkdf2Sync(password, salt, 100000, 64, 'sha256').toString('hex')
return `pbkdf2:${salt}:${hash}`
}
export function verifyPassword(password: string, stored: string): boolean {
if (stored.startsWith('pbkdf2:')) {
// 新形式:ユーザーごとのソルト付きPBKDF2-SHA256
const [, salt, hash] = stored.split(':')
const computed = pbkdf2Sync(password, salt, 100000, 64, 'sha256').toString('hex')
return computed === hash
}
// レガシー形式:HMAC-SHA256(MD5(password))
return legacyVerify(password, stored)
}ログインフローで:
// auth.service.ts
if (!verifyPassword(password, user.passwordHash)) {
throw new UnauthorizedException('ユーザー名またはパスワードが間違っています')
}
// 成功したログインでレガシーハッシュを静かにアップグレード
await this.upgradePasswordIfNeeded(user, password)アップグレードパスが稼働した後の最初のログイン:古い形式で検証、成功、すぐにPBKDF2でハッシュしてレコードを更新。次のログイン:PBKDF2で検証。移行は透過的に、ユーザーごとに、最初のログインで行われる。
このシステムではbcryptよりPBKDF2を選択した。PBKDF2は追加の依存関係なしでNodeの組み込みcryptoモジュールで利用可能だから。CPUが貴重でbcryptのGPU耐性が主要な脅威モデルでないOJプラットフォーム(脅威はオンラインブルートフォースではなくデータベースダンプ)では、100,000イテレーションのPBKDF2は実用的な選択だ。
ガードの設計
3つのレイヤー
ガードの階層は:
JwtAuthGuard— アクセストークンを検証、期限切れまたは不正なトークンを特定のエラーメッセージで拒否ContestAuthGuard— コンテストスコープのJWTを検証、次にIPバインディングをチェックRolesGuard—@Roles()デコレータ要件に対してユーザーのロールをチェック
ロールシステムは数値の重みを使用:sa: 0, admin: 1, supervisor: 2, user: 3, contest-user: 4。@Roles('admin')は「重み ≤ 1」を意味する——adminとsuperadminはアクセスできるが、通常ユーザーはできない。これはルートハンドラー全体に散らばる文字列比較を避ける。
解決していないトレードオフ
JWTにはよく知られた問題がある:有効期限前にトークンを無効化できない。
ユーザーのアカウントが侵害された場合、または管理者がすべてのセッションを強制ログアウトしたい場合、またはコンテストが終了してすべてのコンテストトークンを無効化したい場合——15分のアクセストークンが期限切れになるのを待つ必要がある。実際には15分は十分短いのでこれは壊滅的ではないが、何でもない。
標準的な解決策はRedisのトークンブラックリスト:ログアウトまたは強制無効化時に、トークンのjti(JWT ID)をトークンと同じTTLでRedisセットに追加する。各リクエストで、トークンのjtiがブラックリストに載っているかチェックする。
現在の実装はこれを行っていない。ログアウトエンドポイントは文字通りサーバーサイドで何もしない:
// auth.controller.ts
@Post('logout')
@HttpCode(HttpStatus.NO_CONTENT)
logout(): void {
// TODO: tokenブラックリスト実装(Redis)
}これは意図的な先送りだ。Leverageの現在の脅威モデル(内部プラットフォーム、課題を行う学生、制御されたユーザー集団)では、即時無効化がないリスクは低い。ブラックリストを実装・維持する運用コスト——特にTTL/クリーンアップロジックを正しくする——は非自明だ。リスクが正当化するときに追加する。
リフレッシュトークンは現在の設計ではデータベースに保存されている。データベース保存はリフレッシュトークンを無効化できることを意味する:レコードを削除すれば、次のリフレッシュ試行は失敗する。これは「すべてからログアウト」機能のレバーだ。
正直な要約:即時無効化をアーキテクチャの簡素さと引き換えにした。セッションベースシステムではそのトレードはない——セッションは常に無効化可能だ。JWTでは、ステートレスな簡素さと失効能力の間のスペクトラム上のポイントを選ぶ。我々はステートレスに近い方を選んだが、システムが成熟するにつれて失効可能性に向かって動く意図がある。
実際に変わったこと
以前:2つの別々の認証ミドルウェア、Redisバッキング付きセッション、PM2で壊れるメモリ内状態、大量侵害から設定漏洩1つのパスワード。
以後:1つのJWTシステム、2つのトークンタイプ(通常 + コンテスト)、明確な関心分離を持つ3つのガード、ユーザーごとにソルト付きで移行安全なパスワード。
セッションセットアップはシングルプロセスデプロイメントでは動作した。水平スケーリングには大幅な手術が必要だっただろう。JWTセットアップはデフォルトで水平スケーリングに対応し、コード重複なしでContestUserのユースケースを処理する。
パスワードアップグレードは静かに行われる。ユーザーは気づかない。誰かがログインするたびにセキュリティレベルが上がる。
これらは劇的な勝利ではない——6ヶ月後にコードベースを推論しやすくする、退屈で正しい決定のようなものだ。それが書き換えの全体のポイントだ。
