🔐
Next.js 15とDDDで作る堅牢なシステム設計【番外編: セッション管理と認証・認可】
はじめに
本記事は、「Next.js 15とDDDで作る堅牢なシステム設計」シリーズの番外編です。
DDDで実装する際、「ユーザーセッション(認証・認可)はどこで管理すればいいのか?」 という質問をよくいただきます。セッション管理は横断的関心事(Cross-Cutting Concern)であり、複数の層にまたがるため、設計判断が難しいポイントです。
本記事では、DDDの原則に従いつつ、Next.js 15とFirebase Authを使った実践的なセッション管理の設計と実装を解説します。
この記事で学べること
- DDDにおけるセッション管理の考え方
- 各層での認証・認可の責務分離
- Firebase Authとの統合パターン
- Next.js 15のServer Componentsでの認証実装
- 権限ベースのアクセス制御
- ミドルウェアを使った保護
前提知識
- DDDの基本概念を理解していること
- Next.js 13以降のApp Routerを使ったことがある
- FirebaseまたはNextAuthなどの認証ライブラリの基本を知っている
セッション管理の基本原則
セッション管理は横断的関心事
認証・認可は、すべての層に関係する横断的関心事(Cross-Cutting Concern) です。
┌─────────────────────────────────────┐
│ Presentation Layer │
│ 責務: セッション情報の表示 │
│ - ユーザー名の表示 │
│ - 権限に応じたUI制御 │
│ - ログイン/ログアウトフォーム │
└──────────────┬──────────────────────┘
│ セッション取得
┌──────────────▼──────────────────────┐
│ Application Layer │
│ 責務: 認証・認可チェック │
│ - 認証状態の確認 │
│ - 権限の検証 │
│ - アクセス制御 │
└──────────────┬──────────────────────┘
│ 権限チェック
┌──────────────▼──────────────────────┐
│ Domain Layer │
│ 責務: 権限の概念とビジネスルール │
│ - Userエンティティ │
│ - 権限の定義 │
│ - ビジネスルール │
└──────────────▲──────────────────────┘
│ 実装
┌──────────────┴──────────────────────┐
│ Infrastructure Layer │
│ 責務: セッションの永続化 │
│ - Firebase Auth連携 │
│ - セッション取得・保存 │
│ - トークン管理 │
└─────────────────────────────────────┘
各層の責務の明確化
重要なのは、各層が適切な責務のみを持つ ことです。
| 層 | やるべきこと | やってはいけないこと |
|---|---|---|
| Domain | 権限の概念定義、ビジネスルール | セッション取得、認証ライブラリの直接使用 |
| Infrastructure | セッションの取得・保存、認証サービス連携 | ビジネスルール、UI表示 |
| Application | 認証・認可チェック、コンテキスト受け渡し | セッションの直接取得、UI表示 |
| Presentation | UI表示制御、ユーザー情報表示 | ビジネスルール、直接的なDB操作 |
Domain層: 権限の概念とビジネスルール
Domain層では、認証そのものではなく、権限の概念 を扱います。
Userエンティティの実装
// domain/user/entities/user.ts
export type UserId = string;
export type Role = 'admin' | 'editor' | 'viewer';
export interface UserProps {
id: UserId;
email: string;
name: string;
role: Role;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
}
export class User {
private constructor(private props: UserProps) {
this.validate();
}
static reconstruct(props: UserProps): User {
return new User(props);
}
private validate(): void {
if (!this.props.email.includes('@')) {
throw new Error('有効なメールアドレスが必要です');
}
if (this.props.name.trim().length < 2) {
throw new Error('名前は2文字以上である必要があります');
}
}
// Getters
get id(): UserId {
return this.props.id;
}
get email(): string {
return this.props.email;
}
get name(): string {
return this.props.name;
}
get role(): Role {
return this.props.role;
}
get isActive(): boolean {
return this.props.isActive;
}
/**
* ビジネスロジック: クイズ公開権限のチェック
*/
canPublishQuiz(): boolean {
return this.props.role === 'admin' || this.props.role === 'editor';
}
/**
* ビジネスロジック: クイズ削除権限のチェック
*/
canDeleteQuiz(): boolean {
return this.props.role === 'admin';
}
/**
* ビジネスロジック: クイズ編集権限のチェック
* 管理者、または自分が作成したクイズなら編集可能
*/
canEditQuiz(quizCreatorId: UserId): boolean {
return this.props.role === 'admin' || this.props.id === quizCreatorId;
}
/**
* ビジネスロジック: カテゴリ管理権限のチェック
*/
canManageCategories(): boolean {
return this.props.role === 'admin';
}
/**
* 汎用的な権限チェック
*/
hasPermission(permission: Permission): boolean {
const permissions = ROLE_PERMISSIONS[this.props.role];
return permissions.includes(permission);
}
/**
* ビジネスロジック: ロール変更
*/
changeRole(newRole: Role): User {
// 管理者は最低1人必要などのビジネスルールを追加可能
return new User({
...this.props,
role: newRole,
updatedAt: new Date(),
});
}
/**
* ビジネスロジック: アカウント無効化
*/
deactivate(): User {
if (!this.props.isActive) {
throw new Error('既に無効化されています');
}
return new User({
...this.props,
isActive: false,
updatedAt: new Date(),
});
}
toObject(): UserProps {
return { ...this.props };
}
}
権限システムの設計
// domain/user/value-objects/permission.ts
/**
* アプリケーション全体で使用する権限の定義
*/
export type Permission =
// クイズ関連
| 'quiz.create'
| 'quiz.edit.own' // 自分のクイズのみ編集
| 'quiz.edit.any' // すべてのクイズを編集
| 'quiz.delete'
| 'quiz.publish'
| 'quiz.view.draft' // 下書きの閲覧
// カテゴリ関連
| 'category.create'
| 'category.edit'
| 'category.delete'
// ユーザー管理
| 'user.manage';
/**
* ロールごとの権限マッピング
*/
export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
admin: [
'quiz.create',
'quiz.edit.own',
'quiz.edit.any',
'quiz.delete',
'quiz.publish',
'quiz.view.draft',
'category.create',
'category.edit',
'category.delete',
'user.manage',
],
editor: [
'quiz.create',
'quiz.edit.own',
'quiz.publish',
'quiz.view.draft',
],
viewer: [
// 公開されたクイズの閲覧のみ
],
};
リソース所有権の概念
// domain/quiz/entities/quiz.ts
export interface QuizProps {
id: QuizId;
title: string;
categoryId: CategoryId;
questions: Question[];
difficulty: 'easy' | 'medium' | 'hard';
isPublished: boolean;
createdBy: UserId; // ← 作成者情報を追加
createdAt: Date;
updatedAt: Date;
}
export class Quiz {
// ... 他のメソッド
/**
* ビジネスロジック: 特定のユーザーが編集可能かチェック
*/
canBeEditedBy(user: User): boolean {
// 管理者は常に編集可能
if (user.hasPermission('quiz.edit.any')) {
return true;
}
// 作成者のみ編集可能
if (user.hasPermission('quiz.edit.own') &&
this.props.createdBy === user.id) {
return true;
}
return false;
}
/**
* ビジネスロジック: 特定のユーザーが削除可能かチェック
*/
canBeDeletedBy(user: User): boolean {
// 公開中のクイズは削除不可
if (this.props.isPublished) {
return false;
}
// 管理者のみ削除可能
return user.hasPermission('quiz.delete');
}
}
Domain層のポイント:
- ✅ 権限の概念を定義
- ✅ ビジネスルールを実装
- ❌ セッションの取得はしない
- ❌ 認証ライブラリを使わない
Infrastructure層: セッションの取得と保存
Infrastructure層では、**技術的な詳細(Firebase Auth等)**を扱います。
SessionManagerの実装
// infrastructure/auth/session-manager.ts
import { cookies } from 'next/headers';
import { auth } from '@/lib/firebase-admin';
import { db } from '@/infrastructure/firebase/config';
import { User } from '@/domain/user/entities/user';
/**
* セッション管理(Infrastructure層)
* Firebase Authとの連携を担当
*/
export class SessionManager {
/**
* 現在のログインユーザーを取得
*/
async getCurrentUser(): Promise<User | null> {
try {
// 1. Cookieからセッショントークンを取得
const sessionCookie = cookies().get('session')?.value;
if (!sessionCookie) {
return null;
}
// 2. Firebase Authでトークンを検証
const decodedToken = await auth.verifySessionCookie(sessionCookie, true);
// 3. Firestoreからユーザー詳細情報を取得
const userDoc = await db
.collection('users')
.doc(decodedToken.uid)
.get();
if (!userDoc.exists) {
return null;
}
const userData = userDoc.data();
// 4. ドメインオブジェクトに変換
return User.reconstruct({
id: decodedToken.uid,
email: userData!.email,
name: userData!.name,
role: userData!.role,
isActive: userData!.isActive,
createdAt: userData!.createdAt.toDate(),
updatedAt: userData!.updatedAt.toDate(),
});
} catch (error) {
console.error('Failed to get current user:', error);
return null;
}
}
/**
* IDトークンからセッションを作成
*/
async createSession(idToken: string): Promise<void> {
try {
// 7日間有効なセッションCookieを作成
const expiresIn = 60 * 60 * 24 * 7 * 1000; // 7日間
const sessionCookie = await auth.createSessionCookie(idToken, {
expiresIn,
});
// HTTPOnlyなCookieとして保存
cookies().set('session', sessionCookie, {
maxAge: expiresIn,
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
});
} catch (error) {
console.error('Failed to create session:', error);
throw new Error('セッションの作成に失敗しました');
}
}
/**
* セッションを削除(ログアウト)
*/
async destroySession(): Promise<void> {
try {
const sessionCookie = cookies().get('session')?.value;
if (sessionCookie) {
// Firebase Authでセッションを無効化
const decodedToken = await auth.verifySessionCookie(sessionCookie);
await auth.revokeRefreshTokens(decodedToken.uid);
}
// Cookieを削除
cookies().delete('session');
} catch (error) {
console.error('Failed to destroy session:', error);
// Cookieは必ず削除
cookies().delete('session');
}
}
/**
* セッションの更新
*/
async refreshSession(): Promise<void> {
try {
const user = await this.getCurrentUser();
if (!user) {
throw new Error('セッションが無効です');
}
// 新しいトークンを取得してセッションを更新
// (実装は認証フローに依存)
} catch (error) {
console.error('Failed to refresh session:', error);
throw new Error('セッションの更新に失敗しました');
}
}
}
ユーザーリポジトリの実装
// infrastructure/firebase/repositories/firebase-user-repository.ts
import { User, UserId } from '@/domain/user/entities/user';
import { UserRepository } from '@/domain/user/repositories/user-repository';
import { db, COLLECTIONS } from '../config';
interface UserDocument {
id: string;
email: string;
name: string;
role: 'admin' | 'editor' | 'viewer';
isActive: boolean;
createdAt: Date;
updatedAt: Date;
}
export class FirebaseUserRepository implements UserRepository {
private collection = db.collection(COLLECTIONS.USERS);
private toDomain(doc: UserDocument): User {
return User.reconstruct({
id: doc.id,
email: doc.email,
name: doc.name,
role: doc.role,
isActive: doc.isActive,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
});
}
async findById(id: UserId): Promise<User | null> {
try {
const docSnap = await this.collection.doc(id).get();
if (!docSnap.exists) {
return null;
}
const data = docSnap.data() as UserDocument;
return this.toDomain(data);
} catch (error) {
console.error('Failed to find user:', error);
throw new Error('ユーザーの取得に失敗しました');
}
}
async save(user: User): Promise<void> {
try {
const doc = user.toObject();
await this.collection.doc(user.id).set(doc);
} catch (error) {
console.error('Failed to save user:', error);
throw new Error('ユーザーの保存に失敗しました');
}
}
}
Infrastructure層のポイント:
- ✅ Firebase Auth固有のコード
- ✅ セッションの取得・保存
- ✅ ドメインオブジェクトへの変換
- ❌ ビジネスルールの実装はしない
Application層: 認証・認可の統合
Application層では、認証・認可チェックを一元管理 します。
認証ヘルパー関数
// features/common/auth/require-auth.ts
'use server';
import { SessionManager } from '@/infrastructure/auth/session-manager';
import { User } from '@/domain/user/entities/user';
import { Permission } from '@/domain/user/value-objects/permission';
/**
* 認証が必要なアクションのヘルパー
*/
export async function requireAuth(): Promise<User> {
const sessionManager = new SessionManager();
const user = await sessionManager.getCurrentUser();
if (!user) {
throw new Error('認証が必要です');
}
if (!user.isActive) {
throw new Error('アカウントが無効化されています');
}
return user;
}
/**
* 特定の権限が必要なアクションのヘルパー
*/
export async function requirePermission(
permission: Permission
): Promise<User> {
const user = await requireAuth();
if (!user.hasPermission(permission)) {
throw new Error('この操作を実行する権限がありません');
}
return user;
}
/**
* 特定のロールが必要なアクションのヘルパー
*/
export async function requireRole(
...allowedRoles: Array<'admin' | 'editor' | 'viewer'>
): Promise<User> {
const user = await requireAuth();
if (!allowedRoles.includes(user.role)) {
throw new Error('この操作を実行する権限がありません');
}
return user;
}
/**
* オプション: ログインユーザー情報の取得(認証不要)
*/
export async function getOptionalUser(): Promise<User | null> {
const sessionManager = new SessionManager();
return await sessionManager.getCurrentUser();
}
Server Actionsでの使用例
クイズ作成
// features/quiz/actions/create-quiz.ts
'use server';
import { revalidatePath } from 'next/cache';
import { requirePermission } from '@/features/common/auth/require-auth';
import { Quiz } from '@/domain/quiz/entities/quiz';
import { Question } from '@/domain/quiz/entities/question';
import { FirebaseQuizRepository } from '@/infrastructure/firebase/repositories/firebase-quiz-repository';
import { createQuizSchema } from '../schemas/quiz-schema';
type ActionResult<T> =
| { success: true; data: T }
| { success: false; error: string };
export async function createQuizJson(
data: unknown
): Promise<ActionResult<{ id: string }>> {
try {
// 1. 権限チェック
const currentUser = await requirePermission('quiz.create');
// 2. バリデーション
const validated = createQuizSchema.parse(data);
// 3. ドメインオブジェクトの生成
const questions = validated.questions.map(q =>
Question.create({
text: q.text,
choices: q.choices,
correctAnswerIndex: q.correctAnswerIndex,
explanation: q.explanation,
})
);
const quiz = Quiz.create({
title: validated.title,
categoryId: validated.categoryId,
difficulty: validated.difficulty,
questions,
createdBy: currentUser.id, // 作成者を記録
});
// 4. 永続化
const repository = new FirebaseQuizRepository();
await repository.save(quiz);
revalidatePath('/quizzes');
return {
success: true,
data: { id: quiz.id },
};
} catch (error) {
console.error('Quiz creation error:', error);
if (error instanceof Error) {
return {
success: false,
error: error.message,
};
}
return {
success: false,
error: 'クイズの作成に失敗しました',
};
}
}
クイズ公開
// features/quiz/actions/publish-quiz.ts
'use server';
import { revalidatePath } from 'next/cache';
import { requirePermission } from '@/features/common/auth/require-auth';
import { FirebaseQuizRepository } from '@/infrastructure/firebase/repositories/firebase-quiz-repository';
type ActionResult<T> =
| { success: true; data: T }
| { success: false; error: string };
export async function publishQuiz(
id: string
): Promise<ActionResult<{ id: string }>> {
try {
// 1. 権限チェック
const currentUser = await requirePermission('quiz.publish');
// 2. クイズ取得
const repository = new FirebaseQuizRepository();
const quiz = await repository.findById(id);
if (!quiz) {
return {
success: false,
error: 'クイズが見つかりません',
};
}
// 3. ドメインロジックで公開可能かチェック
if (!quiz.canBeEditedBy(currentUser)) {
return {
success: false,
error: 'このクイズを公開する権限がありません',
};
}
// 4. 公開処理(ビジネスルールの検証を含む)
const publishedQuiz = quiz.publish();
// 5. 永続化
await repository.save(publishedQuiz);
revalidatePath('/quizzes');
return {
success: true,
data: { id: publishedQuiz.id },
};
} catch (error) {
console.error('Quiz publish error:', error);
if (error instanceof Error) {
return {
success: false,
error: error.message,
};
}
return {
success: false,
error: '公開に失敗しました',
};
}
}
クイズ削除
// features/quiz/actions/delete-quiz.ts
'use server';
import { revalidatePath } from 'next/cache';
import { requirePermission } from '@/features/common/auth/require-auth';
import { FirebaseQuizRepository } from '@/infrastructure/firebase/repositories/firebase-quiz-repository';
export async function deleteQuiz(id: string) {
try {
// 1. 権限チェック
const currentUser = await requirePermission('quiz.delete');
// 2. クイズ取得
const repository = new FirebaseQuizRepository();
const quiz = await repository.findById(id);
if (!quiz) {
return {
success: false,
error: 'クイズが見つかりません',
};
}
// 3. ドメインロジックで削除可能かチェック
if (!quiz.canBeDeletedBy(currentUser)) {
return {
success: false,
error: 'このクイズを削除する権限がありません',
};
}
// 4. 削除
await repository.delete(id);
revalidatePath('/quizzes');
return {
success: true,
data: { id },
};
} catch (error) {
console.error('Quiz delete error:', error);
if (error instanceof Error) {
return {
success: false,
error: error.message,
};
}
return {
success: false,
error: '削除に失敗しました',
};
}
}
ログイン/ログアウトActions
// features/auth/actions/login.ts
'use server';
import { redirect } from 'next/navigation';
import { SessionManager } from '@/infrastructure/auth/session-manager';
export async function login(idToken: string) {
try {
const sessionManager = new SessionManager();
await sessionManager.createSession(idToken);
redirect('/quizzes');
} catch (error) {
console.error('Login error:', error);
return {
success: false,
error: 'ログインに失敗しました',
};
}
}
// features/auth/actions/logout.ts
'use server';
import { redirect } from 'next/navigation';
import { SessionManager } from '@/infrastructure/auth/session-manager';
export async function logout() {
try {
const sessionManager = new SessionManager();
await sessionManager.destroySession();
redirect('/login');
} catch (error) {
console.error('Logout error:', error);
return {
success: false,
error: 'ログアウトに失敗しました',
};
}
}
Application層のポイント:
- ✅ 認証・認可を一元管理
- ✅ ヘルパー関数で再利用性向上
- ✅ エラーハンドリングを統一
- ❌ UI表示ロジックは書かない
Presentation層: UI表示制御
Presentation層では、認証状態に応じたUI表示 を行います。
Server Componentでの認証
// app/quizzes/page.tsx
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { Plus } from 'lucide-react';
import { SessionManager } from '@/infrastructure/auth/session-manager';
import { getQuizzes } from '@/features/quiz/actions/get-quizzes';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
export default async function QuizzesPage() {
// 1. セッション取得
const sessionManager = new SessionManager();
const currentUser = await sessionManager.getCurrentUser();
// 2. 未認証ならログインページへリダイレクト
if (!currentUser) {
redirect('/login');
}
// 3. データ取得
const quizzes = await getQuizzes();
return (
<div className="container mx-auto px-4 py-8">
{/* ヘッダー */}
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-3xl font-bold">クイズ管理</h1>
<p className="text-gray-600 mt-2">
ようこそ、{currentUser.name}さん
<span className="ml-2 text-sm text-gray-500">
({currentUser.role === 'admin' ? '管理者' :
currentUser.role === 'editor' ? '編集者' : '閲覧者'})
</span>
</p>
</div>
{/* 権限に応じてボタン表示 */}
{currentUser.hasPermission('quiz.create') && (
<Link href="/quizzes/new">
<Button>
<Plus className="w-4 h-4 mr-2" />
新規作成
</Button>
</Link>
)}
</div>
{/* クイズ一覧 */}
<div className="grid gap-4">
{quizzes.map((quiz) => (
<Card key={quiz.id} className="p-6">
<div className="flex justify-between items-start">
<div className="flex-1">
<h2 className="text-xl font-semibold">{quiz.title}</h2>
<p className="text-gray-600 mt-2">
{quiz.questionCount}問 · {quiz.difficulty}
</p>
</div>
<div className="flex gap-2">
{/* 編集権限チェック */}
{currentUser.canEditQuiz(quiz.createdBy) && (
<Link href={`/quizzes/${quiz.id}/edit`}>
<Button variant="outline">編集</Button>
</Link>
)}
{/* 公開権限チェック */}
{currentUser.hasPermission('quiz.publish') && (
<PublishButton
quizId={quiz.id}
isPublished={quiz.isPublished}
/>
)}
{/* 削除権限チェック */}
{currentUser.canDeleteQuiz() && !quiz.isPublished && (
<DeleteButton quizId={quiz.id} />
)}
</div>
</div>
</Card>
))}
</div>
</div>
);
}
Client Componentでの権限制御
// features/quiz/components/quiz-actions.tsx
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { publishQuiz, unpublishQuiz } from '../actions/publish-quiz';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/use-toast';
interface QuizActionsProps {
quizId: string;
isPublished: boolean;
canPublish: boolean;
canDelete: boolean;
}
export function QuizActions({
quizId,
isPublished,
canPublish,
canDelete,
}: QuizActionsProps) {
const router = useRouter();
const { toast } = useToast();
const [isLoading, setIsLoading] = useState(false);
const handlePublish = async () => {
if (!canPublish) return;
setIsLoading(true);
try {
const result = isPublished
? await unpublishQuiz(quizId)
: await publishQuiz(quizId);
if (result.success) {
toast({
title: isPublished ? '非公開にしました' : '公開しました',
});
router.refresh();
} else {
toast({
title: 'エラー',
description: result.error,
variant: 'destructive',
});
}
} catch (error) {
toast({
title: 'エラー',
description: '処理に失敗しました',
variant: 'destructive',
});
} finally {
setIsLoading(false);
}
};
return (
<div className="flex gap-2">
{/* 公開ボタンは権限がある場合のみ表示 */}
{canPublish && (
<Button
onClick={handlePublish}
disabled={isLoading}
variant={isPublished ? 'outline' : 'default'}
>
{isPublished ? '非公開にする' : '公開する'}
</Button>
)}
{/* 削除ボタンは権限がある場合のみ表示 */}
{canDelete && !isPublished && (
<Button variant="destructive" disabled={isLoading}>
削除
</Button>
)}
</div>
);
}
ログインページ
// app/login/page.tsx
'use client';
import { useState } from 'react';
import { signInWithEmailAndPassword } from 'firebase/auth';
import { auth } from '@/lib/firebase';
import { login } from '@/features/auth/actions/login';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card } from '@/components/ui/card';
export default function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError(null);
try {
// Firebase Authでログイン
const userCredential = await signInWithEmailAndPassword(
auth,
email,
password
);
// IDトークンを取得
const idToken = await userCredential.user.getIdToken();
// Server Actionでセッション作成
await login(idToken);
} catch (err: any) {
setError(err.message || 'ログインに失敗しました');
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center">
<Card className="w-full max-w-md p-8">
<h1 className="text-2xl font-bold mb-6">ログイン</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Input
type="email"
placeholder="メールアドレス"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div>
<Input
type="password"
placeholder="パスワード"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
{error && (
<p className="text-sm text-red-500">{error}</p>
)}
<Button
type="submit"
className="w-full"
disabled={isLoading}
>
{isLoading ? 'ログイン中...' : 'ログイン'}
</Button>
</form>
</Card>
</div>
);
}
Presentation層のポイント:
- ✅ 認証状態に応じたUI表示
- ✅ 権限に基づくボタン制御
- ✅ ユーザー情報の表示
- ❌ ビジネスルールは書かない
ミドルウェアによるルート保護
Next.js 15のMiddlewareを使って、ルート全体を保護します。
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { auth } from '@/lib/firebase-admin';
export async function middleware(request: NextRequest) {
const sessionCookie = request.cookies.get('session')?.value;
// 認証が必要なパス
const protectedPaths = ['/quizzes', '/categories', '/settings'];
const isProtectedPath = protectedPaths.some((path) =>
request.nextUrl.pathname.startsWith(path)
);
// 保護されたパスで、セッションがない場合
if (isProtectedPath && !sessionCookie) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('redirect', request.nextUrl.pathname);
return NextResponse.redirect(loginUrl);
}
// セッションがある場合、有効性を検証
if (sessionCookie) {
try {
await auth.verifySessionCookie(sessionCookie, true);
} catch (error) {
// セッションが無効な場合、Cookieを削除してログインページへ
const response = NextResponse.redirect(new URL('/login', request.url));
response.cookies.delete('session');
return response;
}
}
return NextResponse.next();
}
export const config = {
matcher: [
'/quizzes/:path*',
'/categories/:path*',
'/settings/:path*',
],
};
ベストプラクティス
1. 各層の責務を守る
// ✅ 良い例: 各層が適切な責務を持つ
// Domain層: 権限の概念
class User {
canPublishQuiz(): boolean {
return this.role === 'admin' || this.role === 'editor';
}
}
// Infrastructure層: セッション取得
class SessionManager {
async getCurrentUser(): Promise<User | null> {
// Firebase Authとの連携
}
}
// Application層: 認証・認可チェック
async function publishQuiz(id: string) {
const user = await requirePermission('quiz.publish');
// ...
}
// Presentation層: UI制御
{currentUser.canPublishQuiz() && <PublishButton />}
// ❌ 悪い例: Domain層で認証処理
class User {
async canPublishQuiz(): Promise<boolean> {
// ❌ Domain層でFirebase Authにアクセス
const token = await auth.currentUser.getIdToken();
// ...
}
}
2. セッション情報の受け渡し
// ✅ 方法1: Application層で取得してドメインロジックに渡す
async function createQuiz(data: unknown) {
const currentUser = await requireAuth();
const quiz = Quiz.create({
...data,
createdBy: currentUser.id,
});
}
// ✅ 方法2: コンテキストオブジェクトで渡す
interface ActionContext {
currentUser: User;
}
async function createQuiz(data: unknown, context: ActionContext) {
const quiz = Quiz.create({
...data,
createdBy: context.currentUser.id,
});
}
3. 権限チェックの一元化
// ✅ ヘルパー関数で統一
export async function requirePermission(permission: Permission): Promise<User> {
const user = await requireAuth();
if (!user.hasPermission(permission)) {
throw new Error('権限がありません');
}
return user;
}
// すべてのServer Actionsで使用
async function publishQuiz(id: string) {
const user = await requirePermission('quiz.publish');
// ...
}
4. エラーハンドリングの統一
// カスタムエラークラス
export class AuthenticationError extends Error {
constructor(message: string = '認証が必要です') {
super(message);
this.name = 'AuthenticationError';
}
}
export class AuthorizationError extends Error {
constructor(message: string = '権限がありません') {
super(message);
this.name = 'AuthorizationError';
}
}
// Server Actionsでのハンドリング
try {
const user = await requirePermission('quiz.publish');
// ...
} catch (error) {
if (error instanceof AuthenticationError) {
return { success: false, error: 'ログインしてください' };
}
if (error instanceof AuthorizationError) {
return { success: false, error: '権限がありません' };
}
// その他のエラー
}
まとめ
セッション管理の配置まとめ
| 層 | 責務 | 具体例 |
|---|---|---|
| Domain | 権限の概念、ビジネスルール |
User.canPublishQuiz(), Quiz.canBeEditedBy()
|
| Infrastructure | セッション取得・保存、認証サービス連携 |
SessionManager.getCurrentUser(), Firebase Auth連携 |
| Application | 認証・認可チェック、コンテキスト受け渡し |
requireAuth(), requirePermission()
|
| Presentation | UI表示制御、ユーザー情報表示 | 条件付きボタン表示、ユーザー名表示 |
重要なポイント
1. Domain層には認証ロジックを入れない
// ✅ OK: 権限の概念
class User {
canPublishQuiz(): boolean {
return this.role === 'admin';
}
}
// ❌ NG: セッション取得
class User {
async getCurrentSession() {
return await firebase.auth().currentUser;
}
}
2. Infrastructure層で技術的詳細を隠蔽
// ✅ OK: SessionManagerを通じてアクセス
const user = await new SessionManager().getCurrentUser();
// ❌ NG: 直接Firebase Authにアクセス
const user = await firebase.auth().currentUser;
3. Application層で認証・認可を一元管理
// ✅ OK: ヘルパー関数で統一
const user = await requirePermission('quiz.publish');
// ❌ NG: 各所で個別にチェック
if (!user || !user.hasPermission('quiz.publish')) { ... }
この設計のメリット
-
技術スタックからの独立性
- Firebase AuthからNextAuthへの移行が容易
- Infrastructure層のみ変更すればOK
-
テスタビリティの向上
- モックSessionManagerで簡単にテスト
- ドメインロジックを独立してテスト可能
-
保守性の向上
- 認証ロジックが一箇所に集約
- ビジネスルールとの分離が明確
-
チーム開発の効率化
- 責務が明確で分業しやすい
- コードレビューがしやすい
次のステップ
- ロールベースアクセス制御(RBAC)の拡張
- リソースベース権限の実装
- 監査ログの追加
- 多要素認証(MFA)の統合
参考資料
この記事が役に立ったら、いいね・ブックマークをいただけると嬉しいです!
質問やフィードバックは、コメント欄でお待ちしています。
Discussion