![[管理画面フォーマット開発編 #2] JWT +Cookie+middlewareで実装するログイン機能](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-login%2Fhero.jpg&w=3840&q=75)
管理画面フォーマット開発編 #2JWT +Cookie+middlewareで実装するログイン機能
httpOnly Cookie と middleware を組み合わせ、JWTはjtiのみを運ぶ“鍵”として使用。法人ユースに耐える堅牢なログインを実装
初回公開日
最終更新日
0. はじめに
前回までの振り返り
| 項目 | 内容 |
|---|---|
| ログイン境界 | Department.code を利用し、部署ごとにユーザーを管理 |
| ユーザー管理 | User.email を部署内で一意に制約 |
| パスワード | User.hashedPassword に argon2 でハッシュ化して保存 |
| ロール管理 | Role.code と priority を持ち、RBAC を実現できる構成 |
| 初期データ | Seed スクリプトで管理者ユーザー・ロール・契約を投入済み |
今回の実装範囲
- ログインフォームとサーバアクションの接続
- JWT 発行(ただしペイロードは jti と exp のみ)
- httpOnly Cookie に保存して middleware で保護
- 失敗時のエラーメッセージ(項目別+列挙検知時の曖昧化)
- 5回連続失敗による一時ロック
今後の流れ
管理画面フォーマット開発編の完成形(デモとGithubリポジトリ)

管理画面フォーマット開発編の完成版デモ: https://delogs.jp/demo/dashboard-format
管理画面フォーマット開発編のGihubリポジトリ: https://github.com/delogs-jp/dashboard-format-fullstack
技術スタック
| Tool / Lib | Version | Purpose |
|---|---|---|
| React | 19.x | UIの土台。コンポーネント/フックで状態と表示を組み立てる |
| Next.js | 15.x | フルスタックFW。App Router/SSR/SSG、動的ルーティング、メタデータ管理 |
| TypeScript | 5.x | 型安全・補完・リファクタリング |
| shadcn/ui | latest | RadixベースのUIキット |
| Tailwind CSS | 4.x | ユーティリティファーストCSSで素早くスタイリング |
| Zod | 4.x | スキーマ定義と実行時バリデーション |
1. ログイン仕様の概要
認証要素
アカウントIDは部署コード、メールは部署内一意、パスワードは argon2 での照合を行います。
| 項目 | 入力値 | DB側の検証対象 | 備考 |
|---|---|---|---|
| アカウントID | Department.code | Department.code | 15文字以上、大小英数字を各1以上含む |
| メールアドレス | User.email | User.email | 部署内で一意制約 @@unique([departmentId, email]) |
| パスワード | 平文入力 | User.hashedPassword | argon2.verify で照合 |
セッション管理
| 項目 | 内容 |
|---|---|
| JWT ペイロード | jti(Session.id)、exp のみ |
| Cookie 属性 | httpOnly, secure, sameSite=lax, path=/ |
| middleware | JWT 検証(署名・期限)+ Session 存在確認 |
| Session テーブル | jti に紐づくセッションを保持、失効や期限切れを管理 |
成功・失敗時の挙動
| 状況 | 挙動 |
|---|---|
| 成功時 | Cookie に JWT を保存し、/dashboard にリダイレクト |
| 失敗時(通常) | 項目別にエラーメッセージ表示(アカウント / メール / パスワード) |
| 失敗時(列挙検知中) | 曖昧メッセージ「アカウントまたは認証情報が正しくありません」を表示 |
| 5回連続失敗 | ユーザーを 15 分間ロック(解除は時間経過または成功時リセット) |
2. セッションストア設計と作成
設計の目的
| 目的 | 説明 |
|---|---|
| PII の最小化 | JWT に個人情報を含めないため、漏洩リスクを低減できる |
| 即時失効 | サーバ側の Session 行を無効化すれば、次リクエストから強制ログアウト可能 |
| 権限変更の即時反映 | ユーザーのロール変更を DB 側に反映するだけで次アクセスに適用される |
| 監査対応 | 認証イベントの履歴を保持し、法人利用で求められる監査性を高められる |
セッションテーブルの概要
Session テーブルの構造を整理すると以下の通りです。| カラム名 | 型 | 説明 |
|---|---|---|
| id | String (UUID) | JWT の jti として利用 |
| userId | String | 対応するユーザー ID |
| expiresAt | DateTime | セッションの有効期限 |
| createdAt | DateTime | 作成日時 |
| revokedAt | DateTime? | 任意、手動失効やログアウト時に記録 |
| ip | String? | 任意、監査用(接続元IP) |
| userAgent | String? | 任意、監査用(UA文字列) |
セッションのライフサイクル
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 へ redirectPrisma スキーマへの追記
prisma/schema.prisma に Session モデルを追加します。既存データを保持するため、マイグレーションは追加のみ行います。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}マイグレーションの実行
1npx prisma migrate dev --name add_session_and_user_sessions
2
3// クライアント再生成
4npx prisma generate3. ログイン処理の実装(Server Actions 編)
src/app/_actions 配下にまとめ、JWT(jtiのみ)発行・Cookie設定・セッション生成・ロック判定・エラーメッセージの曖昧化切替までを実装します。フロントは既存の login-form.tsx を最小差分でサーバアクションに接続します。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└───────────────────────────────────────────────┘ディレクトリ構成と方針
| 種別 | 役割 | 提案パス |
|---|---|---|
| サーバアクション | ログイン/ログアウト | src/app/_actions/auth/login.ts, src/app/_actions/auth/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 |
1src/
2├─ app/
3│ └─ _actions/
4│ └─ auth/
5│ ├─ login.ts
6│ └─ logout.ts
7├─ components/
8│ └─ login/
9│ └─ login-form.tsx # 既存:最小差分で接続
10└─ lib/
11 └─ auth/
12 ├─ jwt.ts
13 ├─ cookies.ts
14 └─ session.ts
15 └─ login/
16 └─ server-schema.ts
17 └─ database.ts # DB接続用のライブラリJWTユーティリティ(src/lib/auth/jwt.ts)
jti と exp のみを持つ署名付きトークンとして扱います。アルゴリズムは固定、JWT_SECRET は環境変数から取得します。joseのインストール
1npm install josesrc/lib/auth/jwt.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}SignJWTでjtiのみをクレームに入れ、expはsetExpirationTimeで付与します。verifySessionJwtは署名・期限を検証し、jtiを取り出すだけの最小実装です。algを固定し、none等を拒否することで安全側に寄せています。JWT_SECRETは 32 文字以上を推奨(十分長い乱数文字列)。
JWT_SECRET を.envに設定
1JWT_SECRET=BIKGag4(以下省略)Cookieユーティリティ(src/lib/auth/cookies.ts)
SESSION_COOKIE_NAME, SESSION_TTL_SECONDS は環境変数で調整できるようにします。src/lib/auth/cookies.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)で即時に反映されます。
環境変数の設定
.envにSESSION_COOKIE_NAME, SESSION_TTL_SECONDSを設定しておきます。とりあえず、上記のcookies.tsのデフォルト設定のまま設定します。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)
src/lib/login/schema.tsですでに設定した内容を流用します。ただ、ここで、メールアドレスについて、日本語ドメイン対策も追加しておきます。メールアドレスのドメインで日本語ドメインを利用しているのレアケースだと思いますが、念の為です。punycodeをインストールします。1# 送信周りの依存を追加
2npm i punycode
3
4# 型(TS)を使う場合
5npm i -D @types/punycodesrc/lib/login/schema.tsのメールアドレスのzodスキーマを変更します。1// src/lib/login/schema.ts
2import { z } from "zod";
3import * as punycode from "punycode/";
4
5function toAsciiEmailSafe(input: string) {
6 const s = input.trim();
7 const at = s.lastIndexOf("@");
8 if (at === -1) return s; // 後段の z.email で弾かせる
9 const local = s.slice(0, at);
10 const domain = s.slice(at + 1);
11 if (!local || !domain) return s;
12 try {
13 return `${local}@${punycode.toASCII(domain)}`;
14 } catch {
15 // 不正なIDNなどはそのまま返して z.email で invalid に
16 return s;
17 }
18}
19
20export const loginSchema = z.object({
21 accountId: z
22 .string()
23 .min(15, "アカウントIDは15文字以上で入力してください。")
24 .regex(/[A-Z]/, "大文字を1文字以上含めてください。")
25 .regex(/[a-z]/, "小文字を1文字以上含めてください。")
26 .regex(/[0-9]/, "数字を1文字以上含めてください。"),
27 email: z
28 .string()
29 .transform((s) => toAsciiEmailSafe(s))
30 .pipe(z.email("メールアドレスの形式が正しくありません")),
31 password: z
32 .string()
33 .min(15, "パスワードは15文字以上で入力してください。")
34 .regex(/[A-Z]/, "大文字を1文字以上含めてください。")
35 .regex(/[a-z]/, "小文字を1文字以上含めてください。")
36 .regex(/[0-9]/, "数字を1文字以上含めてください。"),
37});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(),
14 // password は加工しない
15}));
16
17export type LoginServerInput = z.infer<typeof loginServerSchema>;- アカウントID/パスワードは15文字以上+大小英字・数字を各1以上を強制。
- 形式不正はDBアクセス前に弾き、辞書的攻撃の負荷を軽減します。
データベース接続用のライブラリ(src/lib/database.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 };セッション解決ユーティリティ(src/lib/auth/session.ts)
jti から Session を引き、期限/失効を確認します。次回記事で Context 注入に使う getSessionUser の土台にもなります(ここでは最小限)。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/auth/login.ts)
1// src/app/_actions/auth/login.ts
2"use server";
3
4import { prisma } from "@/lib/database";
5import argon2 from "argon2";
6import {
7 loginServerSchema,
8 type LoginServerInput,
9} from "@/lib/login/server-schema";
10import { signSessionJwt } from "@/lib/auth/jwt";
11import { setSessionCookie, getSessionTtlSeconds } from "@/lib/auth/cookies";
12
13const LOCK_THRESHOLD = Number(process.env.LOCK_THRESHOLD ?? "5");
14const LOCK_MINUTES = Number(process.env.LOCK_MINUTES ?? "15");
15const AUTH_ERROR_MODE = process.env.AUTH_ERROR_MODE ?? "detailed"; // "ambiguous" | "detailed"
16
17type FieldErrors = Partial<Record<keyof LoginServerInput, string>>;
18
19function ambiguousMessage(): string {
20 return "アカウントまたは認証情報が正しくありません";
21}
22
23function maybeAmbiguous(fieldErrors?: FieldErrors, fallback?: string) {
24 if (AUTH_ERROR_MODE === "ambiguous") {
25 return { ok: false as const, message: ambiguousMessage() };
26 }
27 if (fieldErrors) return { ok: false as const, fieldErrors };
28 return { ok: false as const, message: fallback ?? ambiguousMessage() };
29}
30
31export type LoginActionResult =
32 | { ok: true; user: AuthUserSnapshot }
33 | { ok: false; fieldErrors?: FieldErrors; message?: string };
34
35export async function loginAction(
36 input: LoginServerInput,
37): Promise<LoginActionResult> {
38 // 1) サーバ側バリデーション(※ loginSchema 側で punycode 正規化済み)
39 const parsed = loginServerSchema.safeParse(input);
40 if (!parsed.success) {
41 const fe: FieldErrors = {};
42 for (const issue of parsed.error.issues) {
43 const path = issue.path[0] as keyof LoginServerInput;
44 fe[path] = issue.message;
45 }
46 return { ok: false, fieldErrors: fe };
47 }
48 // ★ ここで得られる email は「punycode ASCII(大小保持)」、accountId は trim 済み
49 const { accountId, email, password } = parsed.data;
50
51 // 2) 部署を特定
52 const department = await prisma.department.findUnique({
53 where: { code: accountId },
54 });
55 if (!department) {
56 // ユーザーが特定できないためカウントはできない。メッセージは項目別 or 曖昧化。
57 return maybeAmbiguous({ accountId: "アカウントIDが見つかりません" });
58 }
59
60 // 3) ユーザー特定
61 const user = await prisma.user.findFirst({
62 where: { departmentId: department.id, email },
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 // 4) ロック/無効チェック(ここではカウントは増やさない)
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 // 5) パスワード検証
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 // 6) 成功:カウンタ/ロック解除
119 await prisma.user.update({
120 where: { id: user.id },
121 data: { failedLoginCount: 0, lockedUntil: null },
122 });
123
124 // 7) セッション発行(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, userId: true },
129 });
130 const token = await signSessionJwt({ jti: session.id }, ttl);
131 await setSessionCookie(token);
132
133 // 8) ダッシュボードへ
134 redirect("/dashboard");
135}- バリデーション → 部署/ユーザ特定 → ロック検査 → パス検証 → カウンタ更新 → セッション発行の順で実装。
- 失敗時は原則「項目別エラー」を返しますが、閾値接近時は曖昧メッセージに切替(列挙耐性)。
- 成功時は
Sessionを作成し、jtiを入れた JWT を Cookie に保存して/dashboardへredirect。 failedLoginCount / lockedUntilは 2章 で追加したカラムを使用します。
.envへの関連パラメータ追加
1# 失敗ロック関連
2LOCK_THRESHOLD=5
3LOCK_MINUTES=15
4
5# 曖昧化モード(任意)
6# detailed: 項目別エラー / ambiguous: 常に曖昧メッセージ
7AUTH_ERROR_MODE=ambiguousAUTH_ERROR_MODE=detailedにすれば、項目別のエラーが表示されるように変更可能です。logoutAction の実装(src/app/_actions/auth/logout.ts)
revokedAt 更新)し、Cookie を削除して /login に戻します。1// src/app/_actions/auth/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.tsx の onSubmit を、loginAction 呼び出しに差し替えます。フォーム側で項目別エラーを受け取り、画面に反映します(成功時はサーバ側で redirect されるため、クライアント側の遷移は不要)。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/auth/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を使い、送信ボタンを無効化しつつラベルを「ログイン中…」に切替。- サーバアクションの戻りで
fieldErrorsをform.setErrorへ流し込み、既存の<FormMessage />がそのまま表示。 - 項目に紐づかないメッセージは
globalErrorに格納して<p data-testid="global-error">に表示。 - フォームは
noValidateを付けてブラウザネイティブのバリデーションポップアップを抑止(UIを統一)。 aria-invalidを正しく付与(アクセシビリティ配慮)。
ログアウト をサーバアクションに接続
src/components/sidebar/nav-user.tsxにログアウト導線があります。NavUser の「ログアウト」を サーバアクション logoutAction に直結 します。useTransition で二重押下を防ぎつつ、処理完了時はサーバ側の redirect("/login") が発火します。Link は使いません。1// src/components/sidebar/nav-user.tsx
2"use client";
3import { useTransition } from "react"; // ★ 追加
4import { logoutAction } from "@/app/_actions/auth/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方式にしています。
動作確認のポイント
| 観点 | 確認内容 |
|---|---|
| 正常系 | 正しい Department.code + email + password で /dashboard へ遷移する |
| 形式不正 | アカウントID/パスが形式外のとき、DBアクセス前にフォームエラーになる |
| 項目別エラー | 部署不一致/メール不一致/パス不一致でそれぞれエラー表示 |
| ロック | 連続5回失敗でロック→ロック中メッセージが表示される |
| 曖昧化 | 閾値接近時に曖昧メッセージへ切替される |
| Cookie | session Cookie(httpOnly/secure/sameSite=lax)が設定される |
| セッション | Session テーブルにレコード作成・ログアウトで revokedAt が入る |
4. middleware とルート保護の実装
方針は以下の通りです。
- middleware は「早期蹴り(未認証ユーザーを即座に
/にリダイレクトする)」のみに利用 - セッションの実体確認やユーザー情報の解決は Server Action で行う
- ログインページは
/、ログイン後の画面はsrc/app/(protected)配下にまとめて保護する
ディレクトリ構成の確認
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負荷を下げます。
middleware.ts は srcディレクトリ を採用しているときは プロジェクト直下ではなく 、 srcディレクトリ直下 に配置しないと動かないので注意する必要があります。1// src/middleware.ts(src/直下)
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アクセスなし) |
| 失敗時 | 即座に / へリダイレクト |
Server Action 側での完全検証
src/lib/auth/session.ts の lookupSessionFromCookie を用いて、User 情報解決まで を行います。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}| 層 | 役割 |
|---|---|
| middleware | 期限切れ・不正トークンの早期拒否 |
| lookupSessionFromCookie() (Server Action) | DBと照合し、セッション失効やユーザー無効を確認 |
実際の利用例
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.tsxとnot-found.tsxに上記のように追記しておきます。5. まとめと次回予告
本記事で実現したこと
| 項目 | 実装内容 | ポイント |
|---|---|---|
| ログイン処理 | loginActionでDB照合・JWT発行・Cookie保存 | 5回連続失敗でロック、曖昧エラーメッセージ切替 |
| セッション管理 | Sessionテーブルを導入、JWTはjtiのみ | 即時失効・権限変更の即時反映が可能 |
| ログアウト処理 | logoutActionでrevokedAt更新+Cookie削除 | 即時失効とUXの両立 |
| middleware | JWT署名とexpのみを検証 | DBを叩かず不正トークンを早期排除 |
| ルート保護 | (protected)配下を一括保護、page.tsxで最終確認 | Server Actionで完全なDB検証 |
実装アーキテクチャの整理
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を可能にし、認可レベルを整理 |
参考文献
リンクはすべて公式または信頼性の高い技術情報源です。
- Next.js Documentation – Middleware
- Next.js Documentation – Server Actions
- Prisma Documentation – Relations
- Prisma Documentation – Migrate
- jose – Universal "JSON Web Almost Everything"
- argon2 – Password Hashing Library
- MDN Web Docs – HTTP Cookies
- OWASP Cheat Sheet – Authentication
松本 孝太郎
DELOGs編集部/中年新米プログラマー
ここ数年はReact&MUIのフロントエンドエンジニアって感じでしたが、Next.jsを学んで少しずつできることが広がりつつあります。その実践記録をできるだけ共有していければと思っています。
2025/11/6
デモ環境とGithubリポジトリについての記述を追加
2025/9/25
src/lib/login/schema.tsのメールアドレスのバリデーション部分を preprocess からtransform + pipe へ変更
2025/9/21
メールアドレスの日本語ドメイン対応のため、punycodeを利用した変換対応を追加:src/lib/login/server-schema.ts
2025/9/19
ログイン関連のサーバアクションファイルのディレクトリを調整: src/app/_actions/ → src/app/_actions/auth/
2025/9/18
middleware.tsの配置をsrc/middleware.tsに修正。/src/appの構成の場合、srcディレクトリ直下に配置しないと機能しません。
2025/9/12
DB接続用のライブラリ(src/lib/database.ts)を作成したので、3章に記述を追記して、サーバアクションとセッションのDBアクセス部分を変更
2025/9/12
初回公開
管理画面で受け付けたパスワード再発行依頼を、Server Action・Shadcn/uiのデータテーブル・メール送信を組み合わせて運用可能なワークフローに統合
2025/10/15公開
グローバルで一貫したMenuテーブルを保ちながら、部署ごとにメニュー表示をカスタマイズ
2025/10/12公開
DepartmentRole導入に伴い、プロフィール管理で「実効ロール」を参照するように修正と一部ついでの変更
2025/10/8公開
DepartmentRole導入に伴い、ユーザ管理で「実効ロール」を参照するように修正
2025/10/5公開
部署ごとのロールを実際に操作できるように、Server Actionと管理画面UIを構築
2025/10/2公開
