DELOGs
[管理画面フォーマット開発編 #2] JWT +Cookie+middlewareで実装するログイン機能

管理画面フォーマット開発編 #2
JWT +Cookie+middlewareで実装するログイン機能

httpOnly Cookie と middleware を組み合わせ、JWTはjtiのみを運ぶ“鍵”として使用。法人ユースに耐える堅牢なログインを実装

初回公開日

最終更新日

0. はじめに

本章では、今回の記事の背景と目的を整理します。前回の記事 【管理画面フォーマット開発編 #1】 Prisma × PostgreSQLで進めるDB設計 では、Prisma × PostgreSQL を用いた DB 設計を行い、ログインに必要なテーブル構造を整備しました。今回はその続編として、 実際に動作するログイン機能 を実装していきます。

前回までの振り返り

前回の記事で構築したポイントをまとめると、以下のようになります。
項目内容
ログイン境界Department.code を利用し、部署ごとにユーザーを管理
ユーザー管理User.email を部署内で一意に制約
パスワードUser.hashedPassword に argon2 でハッシュ化して保存
ロール管理Role.codepriority を持ち、RBAC を実現できる構成
初期データSeed スクリプトで管理者ユーザー・ロール・契約を投入済み
これにより、認証に必要な土台はすでに完成しています。

今回の実装範囲

今回の記事では、以下の内容を実装対象とします。
  • ログインフォームとサーバアクションの接続
  • JWT 発行(ただしペイロードは jti と exp のみ)
  • httpOnly Cookie に保存して middleware で保護
  • 失敗時のエラーメッセージ(項目別+列挙検知時の曖昧化)
  • 5回連続失敗による一時ロック
このように、セキュリティ要件を満たしつつ UX にも配慮したログイン機能を「実践記録」として構築していきます。

今後の流れ

本記事(管理画面フォーマット開発編 #2)はログインにフォーカスし、ログイン成立後に最低限ページを表示できる状態までを目指します。次の記事(管理画面フォーマット開発編 #3)では、ログイン済みユーザー情報を Context(AuthProvider) としてアプリ全体で共通利用できるように拡張し、メニューや各画面の RBAC 制御を体系化していく予定です。

技術スタック

Tool / LibVersionPurpose
React19.xUIの土台。コンポーネント/フックで状態と表示を組み立てる
Next.js15.xフルスタックFW。App Router/SSR/SSG、動的ルーティング、メタデータ管理
TypeScript5.x型安全・補完・リファクタリング
shadcn/uilatestRadixベースのUIキット
Tailwind CSS4.xユーティリティファーストCSSで素早くスタイリング
Zod4.xスキーマ定義と実行時バリデーション

1. ログイン仕様の概要

本章では、今回実装するログイン機能の仕様を整理します。ログインに必要な認証要素やセッション管理、そして成功・失敗時の挙動を明確にすることで、実装に向けた全体像を固めます。

認証要素

ログインに必要な入力と検証対象は以下の通りです。
アカウントIDは部署コード、メールは部署内一意、パスワードは argon2 での照合を行います。
項目入力値DB側の検証対象備考
アカウントIDDepartment.codeDepartment.code15文字以上、大小英数字を各1以上含む
メールアドレスUser.emailUser.email部署内で一意制約 @@unique([departmentId, email])
パスワード平文入力User.hashedPasswordargon2.verify で照合

セッション管理

セッションは JWT と Session テーブルを組み合わせて管理します。JWT は「鍵」としてのみ機能させ、実体のユーザー情報はサーバ側で参照します。
項目内容
JWT ペイロードjti(Session.id)、exp のみ
Cookie 属性httpOnly, secure, sameSite=lax, path=/
middlewareJWT 検証(署名・期限)+ Session 存在確認
Session テーブルjti に紐づくセッションを保持、失効や期限切れを管理

成功・失敗時の挙動

ログインの成否に応じて、リダイレクトやエラーメッセージ表示を制御します。UX とセキュリティの両立を図るため、通常は項目別エラーを返しつつ、列挙攻撃が疑われる場合は一時的に曖昧メッセージへ切り替えます。
状況挙動
成功時Cookie に JWT を保存し、/dashboard にリダイレクト
失敗時(通常)項目別にエラーメッセージ表示(アカウント / メール / パスワード)
失敗時(列挙検知中)曖昧メッセージ「アカウントまたは認証情報が正しくありません」を表示
5回連続失敗ユーザーを 15 分間ロック(解除は時間経過または成功時リセット)
このように、仕様全体を通じて「安全性」と「使いやすさ」の両立を意識した構成としています。

2. セッションストア設計と作成

ここでは、JWT にユーザー情報を含めず「鍵」としてのみ使うための仕組みとして、サーバ側に用意するセッションストアの設計を説明します。これにより、ログイン状態を一元管理し、即時失効や監査ログの基盤を整えられます。

設計の目的

セッションストアを設けることで、以下のような利点が得られます。
目的説明
PII の最小化JWT に個人情報を含めないため、漏洩リスクを低減できる
即時失効サーバ側の Session 行を無効化すれば、次リクエストから強制ログアウト可能
権限変更の即時反映ユーザーのロール変更を DB 側に反映するだけで次アクセスに適用される
監査対応認証イベントの履歴を保持し、法人利用で求められる監査性を高められる

セッションテーブルの概要

実装する Session テーブルの構造を整理すると以下の通りです。
カラム名説明
idString (UUID)JWT の jti として利用
userIdString対応するユーザー ID
expiresAtDateTimeセッションの有効期限
createdAtDateTime作成日時
revokedAtDateTime?任意、手動失効やログアウト時に記録
ipString?任意、監査用(接続元IP)
userAgentString?任意、監査用(UA文字列)

セッションのライフサイクル

セッションの生成から失効までの流れを整理します。
txt
1[loginAction] 2 ├─ User 認証(Department.code + email + password) 3 ├─ Session 行を INSERT(id = jti, expiresAt = 1h) 4 ├─ JWT を発行(payload = { jti, exp }) 5 └─ JWT を httpOnly Cookie に保存 → /dashboard へ redirect 6 7[middleware] 8 ├─ Cookie から JWT を取得 9 ├─ JWT の署名・exp 検証 10 ├─ jti を使って Session を DB で参照 11 └─ 有効なら通過 / 無効なら /login へ redirect 12 13[logoutAction] 14 ├─ 現在の jti を特定 15 ├─ Session.revokedAt = now()(または行を削除) 16 └─ Cookie 削除 → /login へ redirect
このように、JWT はあくまで「セッション ID を運ぶトークン」として機能し、ユーザーの文脈はすべてサーバ側のストアを参照して解決します。

Prisma スキーマへの追記

ここから実際に prisma/schema.prismaSession モデルを追加します。既存データを保持するため、マイグレーションは追加のみ行います。
ts
1// prisma/schema.prisma(抜粋:User に逆側のリレーションを追加/明示的な名前を付与) 2 3model User { 4 id String @id @default(uuid()) 5 displayId String @unique @default(dbgenerated("generate_display_id('user_display_id_seq','US')")) @db.VarChar(10) 6 isActive Boolean @default(true) 7 createdAt DateTime @default(now()) @db.Timestamptz 8 updatedAt DateTime @updatedAt @db.Timestamptz 9 deletedAt DateTime? @db.Timestamptz 10 11 departmentId String 12 roleId String 13 email String // login & notice 14 hashedPassword String 15 name String 16 phone String? 17 remarks String? 18 19 // ログイン失敗ロック 20 failedLoginCount Int @default(0) 21 lockedUntil DateTime? @db.Timestamptz 22 23 // リレーション 24 department Department @relation(fields: [departmentId], references: [id], onDelete: Restrict) 25 role Role @relation(fields: [roleId], references: [id], onDelete: Restrict) 26 27 // Session への逆側(双方向)リレーション 28 sessions Session[] @relation("UserSessions") 29 30 // 部署内メール一意(ログインキー) 31 @@unique([departmentId, email]) 32 @@index([departmentId]) 33 @@index([roleId]) 34 @@index([isActive]) 35 @@index([createdAt]) 36} 37 38// Session 定義 39model Session { 40 id String @id @default(uuid()) 41 userId String 42 user User @relation("UserSessions", fields: [userId], references: [id], onDelete: Cascade) 43 44 expiresAt DateTime @db.Timestamptz 45 createdAt DateTime @default(now()) @db.Timestamptz 46 revokedAt DateTime? @db.Timestamptz 47 48 ip String? 49 userAgent String? 50 51 @@index([userId]) 52 @@index([expiresAt]) 53 @@index([revokedAt]) 54}
このコードでは、Session と User をリレーションで結び、userId が削除された場合は関連するセッションも削除されるよう onDelete: Cascade を設定しています。また、期限切れやユーザー別の検索が高速に行えるよう、インデックスを付与しています。

マイグレーションの実行

新しいモデルを追加したら、既存データを保持したままマイグレーションを実行します。
zsh
1npx prisma migrate dev --name add_session_and_user_sessions 2 3// クライアント再生成 4npx prisma generate
このコマンドで Session テーブルが追加され、既存のユーザーやロールのデータは保持されます。これで、ログイン機能に必要なセッションストアが完成しました。

3. ログイン処理の実装(Server Actions 編)

本章では、実装に踏み込みます。サーバアクションを src/app/_actions 配下にまとめ、JWT(jtiのみ)発行・Cookie設定・セッション生成・ロック判定・エラーメッセージの曖昧化切替までを実装します。フロントは既存の login-form.tsx を最小差分でサーバアクションに接続します。
txt
1┌───────────────────────────────────────────────┐ 2│ LoginForm (client) │ 3│ └─ onSubmit -> loginAction(...) ───────────┼─▶ サーバ側Zod検証 4│ │ │ 5│ │ ├─ ロック確認/失敗カウント更新 6│ │ ├─ ユーザー認証 (Department.code + email + argon2) 7│ │ ├─ Session作成 (DB) 8│ │ ├─ JWT発行 (payload={ jti, exp }) 9│ │ ├─ Cookie設定 (httpOnly/secure/sameSite=lax) 10│ │ └─ redirect("/dashboard") 11└───────────────────────────────────────────────┘

ディレクトリ構成と方針

サーバアクションと共通ユーティリティを以下のように分割します。アプリ本体の import が読みやすく、テストもしやすい構成です。
種別役割提案パス
サーバアクションログイン/ログアウトsrc/app/_actions/login.ts, src/app/_actions/logout.ts
JWTユーティリティ署名・検証(jti/expのみ)src/lib/auth/jwt.ts
Cookieユーティリティset/clear などsrc/lib/auth/cookies.ts
セッション解決jti→Session→User解決src/lib/auth/session.ts
サーバ側Zod入力検証(同等以上)src/lib/login/server-schema.ts
以降、順番にファイルを作っていきます。
txt
1src/ 2├─ app/ 3│ └─ _actions/ 4│ ├─ login.ts 5│ └─ logout.ts 6├─ components/ 7│ └─ login/ 8│ └─ login-form.tsx # 既存:最小差分で接続 9└─ lib/ 10 └─ auth/ 11 ├─ jwt.ts 12 ├─ cookies.ts 13 └─ session.ts 14 └─ login/ 15 └─ server-schema.ts 16 └─ database.ts # DB接続用のライブラリ

JWTユーティリティ(src/lib/auth/jwt.ts

JWT は jtiexp のみを持つ署名付きトークンとして扱います。アルゴリズムは固定、JWT_SECRET は環境変数から取得します。

joseのインストール

下記のコマンドでインストールを実行します。
zsh
1npm install jose

src/lib/auth/jwt.tsの作成

ts
1// src/lib/auth/jwt.ts 2import { SignJWT, jwtVerify } from "jose"; 3 4const alg = "HS256"; 5const encoder = () => new TextEncoder().encode(process.env.JWT_SECRET ?? ""); 6 7if (!process.env.JWT_SECRET || process.env.JWT_SECRET.length < 32) { 8 // 起動時に早期発見したいため例外化(本番では適宜ハンドリング) 9 throw new Error( 10 "JWT_SECRET is missing or too short (>=32 chars recommended).", 11 ); 12} 13 14export type SessionJwtPayload = { 15 jti: string; // Session.id 16 // exp は SignJWT 側で設定 17}; 18 19export async function signSessionJwt( 20 payload: SessionJwtPayload, 21 ttlSeconds: number, 22): Promise<string> { 23 const now = Math.floor(Date.now() / 1000); 24 const token = await new SignJWT({ jti: payload.jti }) 25 .setProtectedHeader({ alg }) 26 .setIssuedAt(now) 27 .setExpirationTime(now + ttlSeconds) 28 .sign(encoder()); 29 return token; 30} 31 32export async function verifySessionJwt( 33 token: string, 34): Promise<{ jti: string }> { 35 const { payload, protectedHeader } = await jwtVerify(token, encoder(), { 36 algorithms: [alg], 37 }); 38 if (protectedHeader.alg !== alg) { 39 throw new Error("Invalid JWT alg"); 40 } 41 const jti = (payload as unknown as { jti?: string }).jti; 42 if (!jti) throw new Error("JWT missing jti"); 43 return { jti }; 44}
解説
  • SignJWTjti のみをクレームに入れ、expsetExpirationTime で付与します。
  • verifySessionJwt は署名・期限を検証し、jti を取り出すだけの最小実装です。
  • alg を固定し、none 等を拒否することで安全側に寄せています。
  • JWT_SECRET は 32 文字以上を推奨(十分長い乱数文字列)。

JWT_SECRET を.envに設定

env
1JWT_SECRET=BIKGag4(以下省略)

Cookieユーティリティ(src/lib/auth/cookies.ts

httpOnly Cookie の設定・削除を共通化します。SESSION_COOKIE_NAME, SESSION_TTL_SECONDS は環境変数で調整できるようにします。

src/lib/auth/cookies.tsの作成

ts
1// src/lib/auth/cookies.ts 2import { cookies } from "next/headers"; 3 4const COOKIE_NAME = process.env.SESSION_COOKIE_NAME ?? "session"; 5const TTL_SECONDS = Number(process.env.SESSION_TTL_SECONDS ?? "3600"); 6const isProd = process.env.NODE_ENV === "production"; 7 8export function getSessionCookieName(): string { 9 return COOKIE_NAME; 10} 11export function getSessionTtlSeconds(): number { 12 return TTL_SECONDS; 13} 14 15export async function setSessionCookie(token: string) { 16 const jar = await cookies(); 17 jar.set(COOKIE_NAME, token, { 18 httpOnly: true, 19 sameSite: "lax", 20 secure: isProd, // ★ 本番のみ true 21 path: "/", 22 maxAge: TTL_SECONDS, 23 }); 24} 25 26export async function clearSessionCookie() { 27 const jar = await cookies(); 28 jar.delete(COOKIE_NAME); 29}
解説
  • Cookieは httpOnly/secure/sameSite=lax/path=/ を付与。
  • maxAge はセッションの TTL と揃えます。
  • 削除は cookies().delete(name) で即時に反映されます。

環境変数の設定

.envSESSION_COOKIE_NAME, SESSION_TTL_SECONDSを設定しておきます。とりあえず、上記のcookies.tsのデフォルト設定のまま設定します。
env
1# セッションCookieの名前(任意。省略時は "session") 2SESSION_COOKIE_NAME=session 3 4# Cookie と JWT の有効期限(秒) 5# 例: 3600 = 1時間 6SESSION_TTL_SECONDS=3600

サーバ側Zodスキーマ(src/lib/login/server-schema.ts

クライアントの Zod(既存)と 同等以上 の検証をサーバでも行います。DB問い合わせ前の早期排除が狙いです。とはいえ、スキーマを別途記述するのは2度手間ですので、src/lib/login/schema.tsですでに設定した内容を流用します。
ts
1// src/lib/login/server-schema.ts 2import { z } from "zod"; 3import { loginSchema } from "@/lib/login/schema"; 4 5/** 6 * サーバ側では、(1) 正規化(trim/lowercase)と (2) サーバ限定ルール があれば“後付け”する。 7 * - 形式チェックは loginSchema に委譲(DRY) 8 * - パスワードは加工しない(ハッシュ照合に影響するため) 9 */ 10export const loginServerSchema = loginSchema.transform((v) => ({ 11 ...v, 12 accountId: v.accountId.trim(), 13 email: v.email.trim().toLowerCase(), 14 // password は加工しない 15})); 16 17export type LoginServerInput = z.infer<typeof loginServerSchema>;
解説
  • アカウントID/パスワードは15文字以上+大小英字・数字を各1以上を強制。
  • 形式不正はDBアクセス前に弾き、辞書的攻撃の負荷を軽減します。

データベース接続用のライブラリ(src/lib/database.ts

Prismaを扱いやすくするためにデータベース接続ライブラリを作成します。DELOGsの環境ではリードレプリカDBを導入していますので、その設定もここで行います。
ts
1// src/lib/database.ts 2import { PrismaClient, Prisma } from "@prisma/client"; 3import { readReplicas } from "@prisma/extension-read-replicas"; 4 5const globalForPrisma = global as typeof globalThis & { prisma?: PrismaClient }; 6 7const basePrisma = new PrismaClient({ 8 log: 9 process.env.NODE_ENV === "development" 10 ? ["query", "info", "warn", "error"] 11 : ["error"], 12}); 13 14export const prisma = 15 globalForPrisma.prisma || 16 (basePrisma.$extends( 17 readReplicas({ 18 url: process.env.DATABASE_URL_REPLICA!, 19 }), 20 ) as unknown as PrismaClient); 21 22if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma; 23 24// Prismaの型もエクスポート 25export { Prisma };
上記については、 Prisma × PostgreSQLで始めるユーザー・ロール管理 でも詳しく記載していますので参照ください。 今回は、リードレプリカDBを扱うための設定をしています。リードレプリカの用の設定については、 【自サーバでNext.jsアプリを動かす#5】リードレプリカ(スレーブ)DBの利用 を参照ください。

セッション解決ユーティリティ(src/lib/auth/session.ts

jti から Session を引き、期限/失効を確認します。次回記事で Context 注入に使う getSessionUser の土台にもなります(ここでは最小限)。
ts
1// src/lib/auth/session.ts 2import { verifySessionJwt } from "./jwt"; 3import { cookies } from "next/headers"; 4import { prisma } from "@/lib/database"; 5 6const COOKIE_NAME = process.env.SESSION_COOKIE_NAME ?? "session"; 7 8export type SessionLookupResult = 9 | { ok: true; sessionId: string; userId: string } 10 | { ok: false }; 11 12export async function lookupSessionFromCookie(): Promise<SessionLookupResult> { 13 const jar = await cookies(); 14 const token = jar.get(COOKIE_NAME)?.value; 15 if (!token) return { ok: false }; 16 17 try { 18 const { jti } = await verifySessionJwt(token); 19 const session = await prisma.session.findUnique({ 20 where: { id: jti }, 21 select: { id: true, userId: true, expiresAt: true, revokedAt: true }, 22 }); 23 if (!session) return { ok: false }; 24 if (session.revokedAt) return { ok: false }; 25 if (session.expiresAt.getTime() <= Date.now()) return { ok: false }; 26 return { ok: true, sessionId: session.id, userId: session.userId }; 27 } catch { 28 return { ok: false }; 29 } 30}
解説
  • Cookie → JWT 検証 → Session 存在/期限/失効を確認して結果を返します。
  • 次回記事(Context注入)で、この userId から User/Role/Department を JOIN 取得する実装へ拡張します。

loginAction の実装(src/app/_actions/login.ts

サーバアクションでログイン成立までを実装します。 ロック判定 (5回/15分)、 項目別エラー列挙検知時の曖昧化 に対応します。
ts
1// src/app/_actions/login.ts 2"use server"; 3 4import { prisma } from "@/lib/database"; 5import argon2 from "argon2"; 6import { redirect } from "next/navigation"; 7import { 8 loginServerSchema, 9 type LoginServerInput, 10} from "@/lib/login/server-schema"; 11import { signSessionJwt } from "@/lib/auth/jwt"; 12import { setSessionCookie, getSessionTtlSeconds } from "@/lib/auth/cookies"; 13 14const LOCK_THRESHOLD = Number(process.env.LOCK_THRESHOLD ?? "5"); 15const LOCK_MINUTES = Number(process.env.LOCK_MINUTES ?? "15"); 16const AUTH_ERROR_MODE = process.env.AUTH_ERROR_MODE ?? "detailed"; // "ambiguous" | "detailed" 17 18type FieldErrors = Partial<Record<keyof LoginServerInput, string>>; 19 20function ambiguousMessage(): string { 21 return "アカウントまたは認証情報が正しくありません"; 22} 23 24function maybeAmbiguous(fieldErrors?: FieldErrors, fallback?: string) { 25 if (AUTH_ERROR_MODE === "ambiguous") { 26 return { ok: false as const, message: ambiguousMessage() }; 27 } 28 if (fieldErrors) return { ok: false as const, fieldErrors }; 29 return { ok: false as const, message: fallback ?? ambiguousMessage() }; 30} 31 32export type LoginActionResult = 33 | { ok: true } 34 | { ok: false; fieldErrors?: FieldErrors; message?: string }; 35 36export async function loginAction( 37 input: LoginServerInput, 38): Promise<LoginActionResult> { 39 // 1) サーバ側バリデーション 40 const parsed = loginServerSchema.safeParse(input); 41 if (!parsed.success) { 42 const fe: FieldErrors = {}; 43 for (const issue of parsed.error.issues) { 44 const path = issue.path[0] as keyof LoginServerInput; 45 fe[path] = issue.message; 46 } 47 return { ok: false, fieldErrors: fe }; 48 } 49 const { accountId, email, password } = parsed.data; 50 51 // 2) 部署・ユーザー特定(メール正規化) 52 const normEmail = email.trim().toLowerCase(); 53 const department = await prisma.department.findUnique({ 54 where: { code: accountId }, 55 }); 56 if (!department) { 57 // ユーザーが特定できないためカウントはできない。メッセージは項目別 or 曖昧化。 58 return maybeAmbiguous({ accountId: "アカウントIDが見つかりません" }); 59 } 60 61 const user = await prisma.user.findFirst({ 62 where: { departmentId: department.id, email: normEmail }, 63 select: { 64 id: true, 65 hashedPassword: true, 66 failedLoginCount: true, 67 lockedUntil: true, 68 isActive: true, 69 }, 70 }); 71 if (!user) { 72 // ここもユーザー未特定のためカウント不可。メッセージは項目別 or 曖昧化。 73 return maybeAmbiguous({ email: "メールアドレスが見つかりません" }); 74 } 75 76 // 3) ロック/無効チェック(ここではカウントは増やさない) 77 if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) { 78 return { 79 ok: false, 80 message: `アカウントがロックされています。時間をおいて再試行してください。`, 81 }; 82 } 83 if (!user.isActive) { 84 return maybeAmbiguous(undefined, "このアカウントは無効化されています。"); 85 } 86 87 // 4) パスワード検証 88 const passwordOk = await argon2.verify(user.hashedPassword, password); 89 if (!passwordOk) { 90 // 失敗:ユーザー特定済みなので +1(原子的) 91 const updated = await prisma.user.update({ 92 where: { id: user.id }, 93 data: { failedLoginCount: { increment: 1 } }, 94 select: { failedLoginCount: true }, 95 }); 96 97 if (updated.failedLoginCount >= LOCK_THRESHOLD) { 98 await prisma.user.update({ 99 where: { id: user.id }, 100 data: { 101 failedLoginCount: 0, // ロック発火時にリセット 102 lockedUntil: new Date(Date.now() + LOCK_MINUTES * 60 * 1000), 103 }, 104 }); 105 return { 106 ok: false, 107 message: `一定回数以上の失敗によりロックされました。${LOCK_MINUTES}分後に再試行してください。`, 108 }; 109 } 110 111 // 閾値に近づいたら曖昧化(もしくはモードが ambiguous なら常に曖昧) 112 if (updated.failedLoginCount >= Math.max(1, LOCK_THRESHOLD - 2)) { 113 return { ok: false, message: ambiguousMessage() }; 114 } 115 return maybeAmbiguous({ password: "パスワードが違います" }); 116 } 117 118 // 5) 成功:カウンタ/ロック解除 119 await prisma.user.update({ 120 where: { id: user.id }, 121 data: { failedLoginCount: 0, lockedUntil: null }, 122 }); 123 124 // 6) セッション発行(DB + JWT + Cookie) 125 const ttl = getSessionTtlSeconds(); 126 const session = await prisma.session.create({ 127 data: { userId: user.id, expiresAt: new Date(Date.now() + ttl * 1000) }, 128 select: { id: true }, 129 }); 130 const token = await signSessionJwt({ jti: session.id }, ttl); 131 await setSessionCookie(token); 132 133 // 7) ダッシュボードへ 134 redirect("/dashboard"); 135}
解説
  • バリデーション → 部署/ユーザ特定 → ロック検査 → パス検証 → カウンタ更新 → セッション発行の順で実装。
  • 失敗時は原則「項目別エラー」を返しますが、閾値接近時は曖昧メッセージに切替(列挙耐性)。
  • 成功時Session を作成し、jti を入れた JWT を Cookie に保存して /dashboardredirect
  • failedLoginCount / lockedUntil は 2章 で追加したカラムを使用します。

.envへの関連パラメータ追加

env
1# 失敗ロック関連 2LOCK_THRESHOLD=5 3LOCK_MINUTES=15 4 5# 曖昧化モード(任意) 6# detailed: 項目別エラー / ambiguous: 常に曖昧メッセージ 7AUTH_ERROR_MODE=ambiguous
認証まで進んだ段階で項目別にエラーを返すのは、UXとしては良いと思うのですが、あまり具体的にエラー内容がわかるのもセキュリティ面では良くないと思い、曖昧なメッセージを表示するようにしました。AUTH_ERROR_MODE=detailedにすれば、項目別のエラーが表示されるように変更可能です。

logoutAction の実装(src/app/_actions/logout.ts

現在のセッションを失効(revokedAt 更新)し、Cookie を削除して /login に戻します。
ts
1// src/app/_actions/logout.ts 2"use server"; 3 4import { prisma } from "@/lib/database"; 5import { cookies } from "next/headers"; 6import { clearSessionCookie } from "@/lib/auth/cookies"; 7import { verifySessionJwt } from "@/lib/auth/jwt"; 8import { redirect } from "next/navigation"; 9 10const COOKIE_NAME = process.env.SESSION_COOKIE_NAME ?? "session"; 11 12export async function logoutAction() { 13 const jar = await cookies(); 14 const token = jar.get(COOKIE_NAME)?.value; 15 if (token) { 16 try { 17 const { jti } = await verifySessionJwt(token); 18 await prisma.session.update({ 19 where: { id: jti }, 20 data: { revokedAt: new Date() }, 21 }); 22 } catch { 23 // 署名エラーや期限切れは無視してCookieだけ消す 24 } 25 } 26 await clearSessionCookie(); 27 redirect("/"); 28}
解説
  • JWT が検証可能なら Session.revokedAt を更新して即時失効します。
  • 期限切れや壊れたトークンでも、Cookie を消せばログアウト動作としては十分です。

ログインフォーム をサーバアクションに接続

既存の src/components/login/login-form.tsxonSubmit を、loginAction 呼び出しに差し替えます。フォーム側で項目別エラーを受け取り、画面に反映します(成功時はサーバ側で redirect されるため、クライアント側の遷移は不要)。
tsx
1// src/components/login/login-form.tsx 2"use client"; 3 4import { useState, useTransition } from "react"; 5import { useForm } from "react-hook-form"; 6import { z } from "zod"; 7import { zodResolver } from "@hookform/resolvers/zod"; 8import { loginSchema } from "@/lib/login/schema"; 9import { loginAction } from "@/app/_actions/login"; 10import { 11 Form, 12 FormField, 13 FormItem, 14 FormLabel, 15 FormControl, 16 FormMessage, 17} from "@/components/ui/form"; 18import { Input } from "@/components/ui/input"; 19import { Button } from "@/components/ui/button"; 20import { Eye, EyeOff } from "lucide-react"; 21 22type LoginValues = z.infer<typeof loginSchema>; 23 24export default function LoginForm() { 25 const [pending, startTransition] = useTransition(); 26 const [globalError, setGlobalError] = useState<string | null>(null); 27 28 const form = useForm<LoginValues>({ 29 resolver: zodResolver(loginSchema), 30 defaultValues: { accountId: "", email: "", password: "" }, 31 mode: "onSubmit", 32 }); 33 34 const [showPassword, setShowPassword] = useState(false); 35 36 const onSubmit = (values: LoginValues) => { 37 setGlobalError(null); 38 39 // 送信中の2重送信防止(任意) 40 if (pending) return; 41 42 startTransition(async () => { 43 // サーバアクション呼び出し 44 const res = await loginAction({ 45 accountId: values.accountId, 46 email: values.email, 47 password: values.password, 48 }); 49 50 if (!res || !("ok" in res)) { 51 setGlobalError( 52 "予期せぬエラーが発生しました。時間をおいて再試行してください。", 53 ); 54 return; 55 } 56 57 if (!res.ok) { 58 // 項目別エラーを formState に注入(既存の <FormMessage /> が拾う) 59 if (res.fieldErrors) { 60 for (const [field, message] of Object.entries(res.fieldErrors)) { 61 // message が undefined の場合でも空文字は避ける 62 if (message) { 63 form.setError(field as keyof LoginValues, { 64 type: "server", 65 message, 66 }); 67 } 68 } 69 } 70 // 全体エラー(ロック/曖昧メッセージなど)はグローバルに表示 71 if (res.message) setGlobalError(res.message); 72 return; 73 } 74 75 // 成功時:サーバ側で redirect("/dashboard") 済みのため、ここには戻らない想定 76 }); 77 }; 78 79 return ( 80 <Form {...form}> 81 <form 82 onSubmit={form.handleSubmit(onSubmit)} 83 noValidate 84 className="mx-auto max-w-md space-y-4 pt-4 pb-10" 85 > 86 <FormField 87 control={form.control} 88 name="accountId" 89 render={({ field }) => ( 90 <FormItem> 91 <FormLabel>アカウントID</FormLabel> 92 <FormControl> 93 <Input 94 data-testid="accountId" 95 placeholder="CoRP000123456" 96 autoFocus 97 aria-invalid={!!form.formState.errors.accountId} 98 disabled={pending} 99 {...field} 100 /> 101 </FormControl> 102 <FormMessage data-testid="accountId-error" /> 103 </FormItem> 104 )} 105 /> 106 <FormField 107 control={form.control} 108 name="email" 109 render={({ field }) => ( 110 <FormItem> 111 <FormLabel>メールアドレス</FormLabel> 112 <FormControl> 113 <Input 114 data-testid="email" 115 type="email" 116 autoComplete="email" 117 placeholder="your@email.com" 118 aria-invalid={!!form.formState.errors.email} 119 disabled={pending} 120 {...field} 121 /> 122 </FormControl> 123 <FormMessage data-testid="email-error" /> 124 </FormItem> 125 )} 126 /> 127 <FormField 128 control={form.control} 129 name="password" 130 render={({ field }) => ( 131 <FormItem> 132 <FormLabel>パスワード</FormLabel> 133 <div className="flex items-start gap-2"> 134 <FormControl> 135 <Input 136 {...field} 137 data-testid="password" 138 type={showPassword ? "text" : "password"} 139 autoComplete="current-password" 140 placeholder="半角英数字15文字以上" 141 aria-invalid={!!form.formState.errors.password} 142 disabled={pending} 143 /> 144 </FormControl> 145 <Button 146 data-testid="password-toggle" 147 type="button" 148 size="icon" 149 variant="outline" 150 onClick={() => setShowPassword((prev) => !prev)} 151 aria-label={ 152 showPassword 153 ? "パスワードを非表示にする" 154 : "パスワードを表示する" 155 } 156 className="shrink-0 cursor-pointer" 157 > 158 {showPassword ? <EyeOff size={18} /> : <Eye size={18} />} 159 </Button> 160 </div> 161 <FormMessage data-testid="password-error" /> 162 </FormItem> 163 )} 164 /> 165 {/* グローバルメッセージ(ロック/曖昧メッセージなど) */} 166 {globalError && ( 167 <p className="mt-2 text-sm text-red-500" data-testid="global-error"> 168 {globalError} 169 </p> 170 )} 171 172 <Button 173 data-testid="submit" 174 type="submit" 175 className="mt-4 w-full cursor-pointer" 176 disabled={pending} 177 > 178 {pending ? "ログイン中..." : "ログイン"} 179 </Button> 180 </form> 181 </Form> 182 ); 183}
解説
  • loginAction の戻りを見て、フィールド別エラーform.setError曖昧メッセージglobalError として表示。
  • 成功時はサーバ側がリダイレクトするため、クライアント側での router.push は不要です。
  • useTransition()pending を使い、送信ボタンを無効化しつつラベルを「ログイン中…」に切替。
  • サーバアクションの戻りで fieldErrorsform.setError へ流し込み、既存の <FormMessage /> がそのまま表示。
  • 項目に紐づかないメッセージは globalError に格納して <p data-testid="global-error"> に表示。
  • フォームは noValidate を付けてブラウザネイティブのバリデーションポップアップを抑止(UIを統一)。
  • aria-invalid を正しく付与(アクセシビリティ配慮)。

ログアウト をサーバアクションに接続

既存の src/components/sidebar/nav-user.tsxにログアウト導線があります。NavUser の「ログアウト」を サーバアクション logoutAction に直結 します。useTransition で二重押下を防ぎつつ、処理完了時はサーバ側の redirect("/login") が発火します。Link は使いません。
tsx
1// src/components/sidebar/nav-user.tsx 2"use client"; 3import { useTransition } from "react"; // ★ 追加 4import { logoutAction } from "@/app/_actions/logout"; // ★ 追加 5import Link from "next/link"; 6import { 7 Bell, 8 ChevronsUpDown, 9 KeyRound, 10 LogOut, 11 User as UserIcon, 12} from "lucide-react"; 13 14import type { User } from "@/lib/sidebar/mock-user"; 15import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 16import { 17 DropdownMenu, 18 DropdownMenuContent, 19 DropdownMenuGroup, 20 DropdownMenuItem, 21 DropdownMenuLabel, 22 DropdownMenuSeparator, 23 DropdownMenuTrigger, 24} from "@/components/ui/dropdown-menu"; 25import { 26 SidebarMenu, 27 SidebarMenuButton, 28 SidebarMenuItem, 29 useSidebar, 30} from "@/components/ui/sidebar"; 31 32export function NavUser({ user }: { user: User }) { 33 const { isMobile } = useSidebar(); 34 const [pending, startTransition] = useTransition(); // ★ 追加 35 36 const handleLogout = () => { 37 if (pending) return; // ★ 二重実行防止 38 startTransition(async () => { 39 await logoutAction(); // サーバ側で Cookie 削除 + Session 失効 + redirect("/login") 40 }); 41 }; 42 43 return ( 44 <SidebarMenu> 45 <SidebarMenuItem> 46 <DropdownMenu> 47 <DropdownMenuTrigger asChild> 48 <SidebarMenuButton 49 size="lg" 50 className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" 51 aria-label="ユーザーメニューを開く" 52 > 53 <Avatar className="h-8 w-8 rounded-lg"> 54 {/* 画像は next/image でもOKだが AvatarImage で十分 */} 55 <AvatarImage src={user.avatar} alt={user.name} /> 56 <AvatarFallback className="rounded-lg"> 57 {user.name.slice(0, 1)} 58 </AvatarFallback> 59 </Avatar> 60 <div className="grid flex-1 text-left text-sm leading-tight"> 61 <span className="truncate font-medium">{user.name}</span> 62 <span className="text-muted-foreground truncate text-xs"> 63 {user.email} 64 </span> 65 </div> 66 <ChevronsUpDown className="ml-auto size-4" /> 67 </SidebarMenuButton> 68 </DropdownMenuTrigger> 69 70 <DropdownMenuContent 71 className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg" 72 side={isMobile ? "bottom" : "right"} 73 align="end" 74 sideOffset={4} 75 > 76 {/* ヘッダー(ユーザー情報の再掲) */} 77 <DropdownMenuLabel className="p-0 font-normal"> 78 <div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm"> 79 <Avatar className="h-8 w-8 rounded-lg"> 80 <AvatarImage src={user.avatar} alt={user.name} /> 81 <AvatarFallback className="rounded-lg"> 82 {user.name.slice(0, 1)} 83 </AvatarFallback> 84 </Avatar> 85 <div className="grid flex-1 text-left text-sm leading-tight"> 86 <span className="truncate font-medium">{user.name}</span> 87 <span className="text-muted-foreground truncate text-xs"> 88 {user.email} 89 </span> 90 </div> 91 </div> 92 </DropdownMenuLabel> 93 94 <DropdownMenuSeparator /> 95 96 <DropdownMenuGroup> 97 <DropdownMenuItem asChild> 98 {/* 変更: /profile に差し替え */} 99 <Link href="/profile" className="flex items-center gap-2"> 100 <UserIcon className="size-4" /> 101 ユーザー情報確認 102 </Link> 103 </DropdownMenuItem> 104 105 <DropdownMenuItem asChild> 106 {/* 変更: /profile/password に差し替え */} 107 <Link 108 href="/profile/password" 109 className="flex items-center gap-2" 110 > 111 <KeyRound className="size-4" /> 112 パスワード変更 113 </Link> 114 </DropdownMenuItem> 115 116 <DropdownMenuItem asChild> 117 {/* TODO: /notifications に差し替え */} 118 <Link href="#" className="flex items-center gap-2"> 119 <Bell className="size-4" /> 120 通知 121 </Link> 122 </DropdownMenuItem> 123 </DropdownMenuGroup> 124 125 <DropdownMenuSeparator /> 126 127 {/* ★ ここを Link ではなく onClick で Action 直結 */} 128 <DropdownMenuItem 129 onClick={handleLogout} 130 disabled={pending} 131 className="text-destructive flex cursor-pointer items-center gap-2" 132 aria-disabled={pending} 133 > 134 <LogOut className="size-4" /> 135 {pending ? "ログアウト中..." : "ログアウト"} 136 </DropdownMenuItem> 137 </DropdownMenuContent> 138 </DropdownMenu> 139 </SidebarMenuItem> 140 </SidebarMenu> 141 ); 142}

補足

  • logoutAction はサーバ側で Session.revokedAt 更新 → Cookie削除 → redirect("/login") まで行うため、クライアント側で router.push は不要です。
  • disabled={pending}aria-disabled を付けて、連打防止とA11y を確保しています。
  • もしゼロJSで運用したい場合は、<form action={logoutAction}> + <button type="submit">ログアウト</button> でもOKですが、今回の UI(DropdownMenuItem)に合わせて onClick 方式にしています。

動作確認のポイント

以下の観点で手動確認します(E2E は今回は対象外)。
観点確認内容
正常系正しい Department.code + email + password/dashboard へ遷移する
形式不正アカウントID/パスが形式外のとき、DBアクセス前にフォームエラーになる
項目別エラー部署不一致/メール不一致/パス不一致でそれぞれエラー表示
ロック連続5回失敗でロック→ロック中メッセージが表示される
曖昧化閾値接近時に曖昧メッセージへ切替される
Cookiesession Cookie(httpOnly/secure/sameSite=lax)が設定される
セッションSession テーブルにレコード作成・ログアウトで revokedAt が入る
以上で、 サーバアクション中心のログイン実装 が完了です。次章では middleware とルート保護の実装を行い、未ログイン時のアクセス制御を仕上げます。

4. middleware とルート保護の実装

この章では、ログイン済みユーザーだけが管理画面にアクセスできるようにする仕組みを構築します。
方針は以下の通りです。
  • middleware は「早期蹴り(未認証ユーザーを即座に / にリダイレクトする)」のみに利用
  • セッションの実体確認やユーザー情報の解決は Server Action で行う
  • ログインページは /、ログイン後の画面は src/app/(protected) 配下にまとめて保護する
これにより、APIを用いずにシンプルかつ安全 な保護が実現できます。

ディレクトリ構成の確認

まず、ルート構造と保護対象を整理します。
txt
1/project-root 2├─ src/ 3│ ├─ app/ 4│ │ ├─ (protected)/ 5│ │ │ ├─ dashboard/ 6│ │ │ ├─ users/ 7│ │ │ └─ profile/ 8│ │ │ └─ password/ 9│ │ └─ page.tsx # ログインページ (/) 10│ └─ lib/ 11│ └─ auth/ 12│ ├─ jwt.ts 13│ ├─ cookies.ts 14│ └─ session.ts 15└─ middleware.ts
つまり、(protected) 配下はすべて「認証必須」とし、middleware で早期判定を行います。

middleware での早期蹴り

middleware.ts では Cookie 内の JWT を検証し、期限切れ or 無効な場合は / にリダイレクト します。
ここでは 署名と exp のみ を確認し、DB照会は行いません。これにより「明らかに無効なリクエスト」を早期に排除し、DB負荷を下げます。
ts
1// middleware.ts(プロジェクト直下) 2import { NextResponse } from "next/server"; 3import type { NextRequest } from "next/server"; 4import { verifySessionJwt } from "@/lib/auth/jwt"; 5 6const LOGIN_PATH = "/"; 7 8// 実際の公開パス((protected)配下の各ルートのパスを列挙) 9const PROTECTED_PATHS = [ 10 "/changelog", 11 "/dashboard", 12 "/masters", 13 "/tutorial", 14 "/users", 15 "/profile", 16]; 17 18export async function middleware(req: NextRequest) { 19 const { pathname } = req.nextUrl; 20 21 // いずれかの保護パスで始まる場合のみチェック 22 if (PROTECTED_PATHS.some((p) => pathname.startsWith(p))) { 23 const token = req.cookies.get( 24 process.env.SESSION_COOKIE_NAME ?? "session", 25 )?.value; 26 if (!token) return NextResponse.redirect(new URL(LOGIN_PATH, req.url)); 27 28 try { 29 await verifySessionJwt(token); // 署名とexpのみ 30 return NextResponse.next(); 31 } catch { 32 return NextResponse.redirect(new URL(LOGIN_PATH, req.url)); 33 } 34 } 35 return NextResponse.next(); 36} 37 38export const config = { 39 matcher: [ 40 "/changelog/:path*", 41 "/dashboard/:path*", 42 "/masters/:path*", 43 "/tutorial/:path*", 44 "/users/:path*", 45 "/profile/:path*", 46 ], 47}; 48

上記コードのポイント:

処理内容
pathname.startsWith(PROTECTED_PREFIX)(protected) 配下のみに適用
Cookie チェックSESSION_COOKIE_NAME(デフォルト: "session")を取得
JWT 検証署名と期限のみ確認(DBアクセスなし)
失敗時即座に / へリダイレクト
これにより「明らかに期限切れ・偽造されたトークン」はDBを叩かずに早期拒否されます。

Server Action 側での完全検証

middleware はあくまで「早期蹴り」。実際の処理では DBに存在するセッションかどうか を必ず確認します。
そのため、3章で作成したsrc/lib/auth/session.tslookupSessionFromCookie を用いて、User 情報解決まで を行います。
ts
1// src/lib/auth/session.ts(3章で作成済み) 2import { verifySessionJwt } from "./jwt"; 3import { cookies } from "next/headers"; 4import { PrismaClient } from "@prisma/client"; 5 6const prisma = new PrismaClient(); 7const COOKIE_NAME = process.env.SESSION_COOKIE_NAME ?? "session"; 8 9export type SessionLookupResult = 10 | { ok: true; sessionId: string; userId: string } 11 | { ok: false }; 12 13export async function lookupSessionFromCookie(): Promise<SessionLookupResult> { 14 const jar = await cookies(); 15 const token = jar.get(COOKIE_NAME)?.value; 16 if (!token) return { ok: false }; 17 18 try { 19 const { jti } = await verifySessionJwt(token); 20 const session = await prisma.session.findUnique({ 21 where: { id: jti }, 22 select: { id: true, userId: true, expiresAt: true, revokedAt: true }, 23 }); 24 if (!session) return { ok: false }; 25 if (session.revokedAt) return { ok: false }; 26 if (session.expiresAt.getTime() <= Date.now()) return { ok: false }; 27 return { ok: true, sessionId: session.id, userId: session.userId }; 28 } catch { 29 return { ok: false }; 30 } 31}
この関数を Server Action や Page Server Component 内で呼び出すことで、常に有効なユーザーだけ が操作対象になります。
まとめると次のように役割分担します。
役割
middleware期限切れ・不正トークンの早期拒否
lookupSessionFromCookie() (Server Action)DBと照合し、セッション失効やユーザー無効を確認
これにより、UX とセキュリティを両立できます。

実際の利用例

ダッシュボードページを例に、ログイン済みユーザーを必須とするコードは次のようになります。
tsx
1// src/app/(protected)/dashboard/page.tsx 2import { redirect } from "next/navigation"; // 追加 3import { lookupSessionFromCookie } from "@/lib/auth/session"; // 追加 4 5import type { Metadata } from "next"; 6import Link from "next/link"; 7 8// ──省略 9 10// function に async を追加 11export default async function DashboardPage() { 12 13// 下記を追記 14 const session = await lookupSessionFromCookie(); 15 if (!session.ok) { 16 redirect("/"); // 未ログインはログインページ(/)へ 17 } 18 19// ──省略 20
  • このように page.tsx の先頭lookupSessionFromCookie() を呼びます。
  • セッションが不正ならすぐ redirect("/")
  • 有効なら session.userId を使って必要なデータをフェッチしたり、UI に渡せます。
少し面倒ですが、src/app/(protected)配下のpage.tsxnot-found.tsxに上記のように追記しておきます。
ここまでで、今回の目標は達成です。

5. まとめと次回予告

本記事では、ログイン機能の完成形として「サーバアクションによるログイン/ログアウト」「セッション管理」「middlewareでの早期蹴り」「ルート保護」を一通り実装しました。ここで一度全体を整理し、次回以降の展開につなげます。

本記事で実現したこと

今回の実装で達成したポイントを表にまとめます。
項目実装内容ポイント
ログイン処理loginActionでDB照合・JWT発行・Cookie保存5回連続失敗でロック、曖昧エラーメッセージ切替
セッション管理Sessionテーブルを導入、JWTはjtiのみ即時失効・権限変更の即時反映が可能
ログアウト処理logoutActionrevokedAt更新+Cookie削除即時失効とUXの両立
middlewareJWT署名とexpのみを検証DBを叩かず不正トークンを早期排除
ルート保護(protected)配下を一括保護、page.tsxで最終確認Server Actionで完全なDB検証

実装アーキテクチャの整理

ここまでの構成を、役割ごとに簡易図で示します。
txt
1[LoginForm] --(server action)--> [loginAction] 2 ├─ 部署/ユーザ特定 3 ├─ パスワード照合 4 ├─ ロック判定・カウント更新 5 ├─ Session作成 6 └─ JWT発行 → Cookie保存 7 8[middleware] 9 ├─ CookieのJWT署名・期限確認 10 └─ NGなら即 ログイン画面 へredirect 11 12[(protected)/page.tsx] 13 ├─ lookupSessionFromCookie() 14 ├─ Session有効性(DB)検証 15 └─ OKなら画面表示 / NGなら ログイン画面

次回予告

次回の記事では、ログイン済みユーザー情報をアプリ全体で共有できるようにします。
具体的には、AuthProviderを導入し、Context経由でユーザー情報・ロール情報をコンポーネント全体から利用可能にします。
テーマ内容
Context注入lookupSessionFromCookieを用いてUser/Role情報を解決し、Contextに格納
メニュー制御Sidebarのメニューをロールに応じて動的表示
画面制御ページ単位のRBACを可能にし、認可レベルを整理

参考文献

今回の記事執筆にあたり、以下のドキュメントや記事を参考にしました。
リンクはすべて公式または信頼性の高い技術情報源です。
この記事の執筆・編集担当
DE

松本 孝太郎

DELOGs編集部/中年新米プログラマー

ここ数年はReact&MUIのフロントエンドエンジニアって感じでしたが、Next.jsを学んで少しずつできることが広がりつつあります。その実践記録をできるだけ共有していければと思っています。

この記事の更新履歴
  • 2025/9/12

    DB接続用のライブラリ(src/lib/database.ts)を作成したので、3章に記述を追記して、サーバアクションとセッションのDBアクセス部分を変更

  • 2025/9/12

    初回公開