![[管理画面フォーマット開発編 #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. はじめに
本章では、今回の記事の背景と目的を整理します。前回の記事 【管理画面フォーマット開発編 #1】 Prisma × PostgreSQLで進めるDB設計 では、Prisma × PostgreSQL を用いた DB 設計を行い、ログインに必要なテーブル構造を整備しました。今回はその続編として、 実際に動作するログイン機能 を実装していきます。
前回までの振り返り
前回の記事で構築したポイントをまとめると、以下のようになります。
項目 | 内容 |
---|---|
ログイン境界 | Department.code を利用し、部署ごとにユーザーを管理 |
ユーザー管理 | User.email を部署内で一意に制約 |
パスワード | User.hashedPassword に argon2 でハッシュ化して保存 |
ロール管理 | Role.code と priority を持ち、RBAC を実現できる構成 |
初期データ | Seed スクリプトで管理者ユーザー・ロール・契約を投入済み |
これにより、認証に必要な土台はすでに完成しています。
今回の実装範囲
今回の記事では、以下の内容を実装対象とします。
- ログインフォームとサーバアクションの接続
- JWT 発行(ただしペイロードは jti と exp のみ)
- httpOnly Cookie に保存して middleware で保護
- 失敗時のエラーメッセージ(項目別+列挙検知時の曖昧化)
- 5回連続失敗による一時ロック
このように、セキュリティ要件を満たしつつ UX にも配慮したログイン機能を「実践記録」として構築していきます。
今後の流れ
本記事(管理画面フォーマット開発編 #2)はログインにフォーカスし、ログイン成立後に最低限ページを表示できる状態までを目指します。次の記事(管理画面フォーマット開発編 #3)では、ログイン済みユーザー情報を Context(AuthProvider) としてアプリ全体で共通利用できるように拡張し、メニューや各画面の RBAC 制御を体系化していく予定です。
技術スタック
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 での照合を行います。
アカウントIDは部署コード、メールは部署内一意、パスワードは argon2 での照合を行います。
項目 | 入力値 | DB側の検証対象 | 備考 |
---|---|---|---|
アカウントID | Department.code | Department.code | 15文字以上、大小英数字を各1以上含む |
メールアドレス | User.email | User.email | 部署内で一意制約 @@unique([departmentId, email]) |
パスワード | 平文入力 | User.hashedPassword | argon2.verify で照合 |
セッション管理
セッションは JWT と Session テーブルを組み合わせて管理します。JWT は「鍵」としてのみ機能させ、実体のユーザー情報はサーバ側で参照します。
項目 | 内容 |
---|---|
JWT ペイロード | jti(Session.id)、exp のみ |
Cookie 属性 | httpOnly, secure, sameSite=lax, path=/ |
middleware | JWT 検証(署名・期限)+ Session 存在確認 |
Session テーブル | jti に紐づくセッションを保持、失効や期限切れを管理 |
成功・失敗時の挙動
ログインの成否に応じて、リダイレクトやエラーメッセージ表示を制御します。UX とセキュリティの両立を図るため、通常は項目別エラーを返しつつ、列挙攻撃が疑われる場合は一時的に曖昧メッセージへ切り替えます。
状況 | 挙動 |
---|---|
成功時 | Cookie に JWT を保存し、/dashboard にリダイレクト |
失敗時(通常) | 項目別にエラーメッセージ表示(アカウント / メール / パスワード) |
失敗時(列挙検知中) | 曖昧メッセージ「アカウントまたは認証情報が正しくありません」を表示 |
5回連続失敗 | ユーザーを 15 分間ロック(解除は時間経過または成功時リセット) |
このように、仕様全体を通じて「安全性」と「使いやすさ」の両立を意識した構成としています。
2. セッションストア設計と作成
ここでは、JWT にユーザー情報を含めず「鍵」としてのみ使うための仕組みとして、サーバ側に用意するセッションストアの設計を説明します。これにより、ログイン状態を一元管理し、即時失効や監査ログの基盤を整えられます。
設計の目的
セッションストアを設けることで、以下のような利点が得られます。
目的 | 説明 |
---|---|
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文字列) |
セッションのライフサイクル
セッションの生成から失効までの流れを整理します。
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.prisma
に Session
モデルを追加します。既存データを保持するため、マイグレーションは追加のみ行います。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 は
jti
と exp
のみを持つ署名付きトークンとして扱います。アルゴリズムは固定、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}
解説
SignJWT
でjti
のみをクレームに入れ、exp
はsetExpirationTime
で付与します。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)
で即時に反映されます。
環境変数の設定
.env
にSESSION_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 に保存して/dashboard
へredirect
。 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.tsx
の onSubmit
を、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
を使い、送信ボタンを無効化しつつラベルを「ログイン中…」に切替。- サーバアクションの戻りで
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
は使いません。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回失敗でロック→ロック中メッセージが表示される |
曖昧化 | 閾値接近時に曖昧メッセージへ切替される |
Cookie | session 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.ts
の lookupSessionFromCookie
を用いて、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.tsx
とnot-found.tsx
に上記のように追記しておきます。ここまでで、今回の目標は達成です。
5. まとめと次回予告
本記事では、ログイン機能の完成形として「サーバアクションによるログイン/ログアウト」「セッション管理」「middlewareでの早期蹴り」「ルート保護」を一通り実装しました。ここで一度全体を整理し、次回以降の展開につなげます。
本記事で実現したこと
今回の実装で達成したポイントを表にまとめます。
項目 | 実装内容 | ポイント |
---|---|---|
ログイン処理 | loginAction でDB照合・JWT発行・Cookie保存 | 5回連続失敗でロック、曖昧エラーメッセージ切替 |
セッション管理 | Session テーブルを導入、JWTはjtiのみ | 即時失効・権限変更の即時反映が可能 |
ログアウト処理 | logoutAction でrevokedAt 更新+Cookie削除 | 即時失効とUXの両立 |
middleware | JWT署名と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を可能にし、認可レベルを整理 |
参考文献
今回の記事執筆にあたり、以下のドキュメントや記事を参考にしました。
リンクはすべて公式または信頼性の高い技術情報源です。
リンクはすべて公式または信頼性の高い技術情報源です。
- 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
この記事の執筆・編集担当
DE
松本 孝太郎
DELOGs編集部/中年新米プログラマー
ここ数年はReact&MUIのフロントエンドエンジニアって感じでしたが、Next.jsを学んで少しずつできることが広がりつつあります。その実践記録をできるだけ共有していければと思っています。
この記事の更新履歴
2025/9/12
DB接続用のライブラリ(src/lib/database.ts)を作成したので、3章に記述を追記して、サーバアクションとセッションのDBアクセス部分を変更
2025/9/12
初回公開
▼ 関連記事
[管理画面フォーマット開発編 #3] AuthProviderでログイン済みユーザー情報を全体共有
ログイン成功直後に取得したユーザー情報をAuthProvider(Client Context)でアプリ全体に配布
2025/9/12公開
![[管理画面フォーマット開発編 #3] AuthProviderでログイン済みユーザー情報を全体共有のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-auth-provider%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット開発編 #1] Prisma × PostgreSQLで進めるDB設計
管理画面フォーマット(UIのみ版)を土台に、バックエンドの第一弾としてのDB設計
2025/9/10公開
![[管理画面フォーマット開発編 #1] Prisma × PostgreSQLで進めるDB設計のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-prisma-db-design%2Fhero-thumbnail.jpg&w=1200&q=75)
JWTとロールでAPIを守る ─ RBAC導入とGuard関数実装
APIを安全にする鍵は「ロールベースの認可」。JWTのpayloadに含めたロール情報を活用し、Admin専用APIの実装を通じてRBACの基本を実践
2025/8/5公開

Prisma × PostgreSQLで始めるユーザー・ロール管理
スキーマ設計とDB連携の基礎構築を通じて、認可の土台となるユーザー・ロール情報の管理を実践
2025/8/3公開

JWTで保護されたユーザ一覧を実装する ─ 認証・ロール・一覧表示まで
ログイン済みのadminユーザーだけにユーザー一覧を表示します。JWT認証の保護ルートとRBAC導入の第一歩となる実装
2025/7/30公開
