🔐

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')) { ... }

この設計のメリット

  1. 技術スタックからの独立性

    • Firebase AuthからNextAuthへの移行が容易
    • Infrastructure層のみ変更すればOK
  2. テスタビリティの向上

    • モックSessionManagerで簡単にテスト
    • ドメインロジックを独立してテスト可能
  3. 保守性の向上

    • 認証ロジックが一箇所に集約
    • ビジネスルールとの分離が明確
  4. チーム開発の効率化

    • 責務が明確で分業しやすい
    • コードレビューがしやすい

次のステップ

  • ロールベースアクセス制御(RBAC)の拡張
  • リソースベース権限の実装
  • 監査ログの追加
  • 多要素認証(MFA)の統合

参考資料

この記事が役に立ったら、いいね・ブックマークをいただけると嬉しいです!
質問やフィードバックは、コメント欄でお待ちしています。

Discussion