🔐

【第2回】NestJS(GraphQL)×Next.jsでJWT認証を実装する - NestJSでのロールベースアクセス制御(全3回)

に公開

本記事のサマリ

前回はNestJSでのJWT認証の基盤部分を実装しましたが、今回は認可(Authorization)、特にロールベースアクセス制御(RBAC)の実装に焦点を当てます。認証でユーザーのID/Passを確認した後、そのユーザーが特定のリソースにアクセスする権限を持っているかを判断する仕組みを構築していきます。GraphQL環境でのGuardの組み合わせ方や、JWTペイロードを活用したロール管理の実装パターンを実際のコードとともに解説します。

今回のコードは下記に公開しています。
https://github.com/toto-inu/202511-ts-auth

認証と認可の違いを明確にする

認証(Authentication)と認可(Authorization)は、セキュリティにおいて密接に関連していますが、まったく異なる責務を持っています。混同しがちな概念ですが、設計上明確に分離することで保守性とセキュリティの両方を向上させることができます。

認証(Authentication):「誰なのか」を確認する

  • JWTトークンの署名を検証し、トークンが有効かどうかを判断
  • ペイロードからユーザー情報を取得し、データベースでユーザーの存在を確認
  • request.user にユーザー情報を設定

認可(Authorization):「何ができるのか」を判断する

  • 認証済みユーザーが特定のリソースにアクセスする権限があるかを確認
  • ユーザーのロールや属性に基づいてアクセス制御を実行
  • 権限がない場合はアクセスを拒否

この分離により、「ログインはしているが権限がない」ケースを適切にハンドリングできます。例えば、USERロールのユーザーがADMIN専用の機能にアクセスしようとした場合、ログインページにリダイレクトするのではなく、適切な権限不足メッセージを表示したり、アクセス可能なページに案内したりできます。

ロールベースアクセス制御の設計

今回のTodoアプリでは、シンプルな2段階のロール設計を採用しました。Prismaスキーマで列挙型として定義することで、型安全性を確保しています。

// api/prisma/schema.prisma
enum Role {
  USER
  ADMIN
}

model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  password  String
  role      Role     @default(USER) // デフォルトはUSERロール
  todos     Todo[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@map("users")
}

ロール設計の判断基準

  • USER:一般ユーザー。自分のTodoの作成・編集・削除が可能
  • ADMIN:管理者ユーザー。全ユーザーのデータ閲覧、カテゴリ管理などの管理機能にアクセス可能

デフォルトロールを USER に設定することで、新規登録時に特別な処理をせずとも適切な権限が付与されます。ADMIN権限が必要なユーザーは、データベースで手動でロールを変更するか、専用の管理機能を通じて昇格させる運用にしています。

TypeScriptの型定義側でも対応する列挙型を定義し、フロントエンドとバックエンドで一貫した型安全性を保ちます。

// api/src/common/enums/role.enum.ts
export enum Role {
  USER = 'USER',
  ADMIN = 'ADMIN',
}

RolesGuardの実装と仕組み

ロールベースアクセス制御の核となるのが RolesGuard です。NestJSの CanActivate インターフェースを実装し、リクエストがロール要件を満たしているかを判断します。

CanActivateインターフェースとは

CanActivate は、NestJSのGuardが実装すべきインターフェースです。このインターフェースを実装することで、リクエストの処理を続行するか拒否するかを制御できます。

interface CanActivate {
  canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean>;
}

canActivate メソッドは以下の責務を持ちます。

  • 戻り値が true:リクエストの処理を続行し、Resolverメソッドが実行される
  • 戻り値が false:リクエストを拒否し、403 Forbiddenエラーを返す
  • 例外をスロー:カスタムエラーメッセージとともにリクエストを拒否

ExecutionContext は、現在実行中のコンテキスト(HTTP、GraphQL、WebSocketなど)に関する情報を提供するオブジェクトです。これを使うことで、REST API、GraphQL、マイクロサービスなど、様々な実行環境で動作するGuardを実装できます。

RolesGuardの実装

それでは、実際の RolesGuard の実装を見ていきましょう。

// api/src/auth/guards/roles.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { GqlExecutionContext } from '@nestjs/graphql';
import { Role } from '../../common/enums/role.enum';

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    // @Roles()デコレーターで指定されたロール要件を取得
    const requiredRoles = this.reflector.getAllAndOverride<Role[]>('roles', [
      context.getHandler(), // メソッドレベルのメタデータ
      context.getClass(),   // クラスレベルのメタデータ
    ]);

    // ロール指定がない場合はアクセス許可
    if (!requiredRoles) {
      return true;
    }

    // GraphQLコンテキストからHTTPリクエストを取得
    const ctx = GqlExecutionContext.create(context);
    const user = ctx.getContext().req.user;

    // ユーザーが認証されていない場合は拒否
    if (!user) {
      return false;
    }

    // 必要なロールのいずれかをユーザーが持っているかチェック
    return requiredRoles.some((role) => user.role === role);
  }
}

Reflectorの役割を理解する

Reflector は、NestJSのメタデータリフレクション機能を提供するサービスです。デコレーターを通じてクラスやメソッドに設定されたメタデータを実行時に取得できます。

getAllAndOverride メソッドは、以下の優先順位でメタデータを取得します。

  1. メソッドレベル@Roles(Role.ADMIN) が個別のメソッドに設定されている場合
  2. クラスレベル@Roles(Role.ADMIN) がResolverクラス全体に設定されている場合

これにより、クラス全体にデフォルトのロール要件を設定しつつ、特定のメソッドでは異なるロール要件を指定するといった柔軟な設定が可能になります。

@Resolver()
@Roles(Role.ADMIN) // クラス全体でADMINロールが必要
export class CategoryResolver {
  
  @Query(() => [Category])
  async categories() {
    // このメソッドはADMINロールが必要
    return this.categoryService.findAll();
  }

  @Query(() => [Category])
  @Roles(Role.USER, Role.ADMIN) // メソッドレベルで上書き
  async publicCategories() {
    // このメソッドはUSERでもADMINでもアクセス可能
    return this.categoryService.findPublic();
  }
}

GraphQLでのコンテキスト処理

REST APIでは context.switchToHttp().getRequest() でHTTPリクエストを取得できますが、GraphQLでは異なるアプローチが必要です。

const ctx = GqlExecutionContext.create(context);
const user = ctx.getContext().req.user;

GqlExecutionContext.create(context) により、GraphQL特有のコンテキスト構造に対応しています。これを忘れるとREST APIでは動作するがGraphQLエンドポイントでは認可が機能しないという問題が起こります。

@Roles()デコレーターの実装

ロール要件をメタデータとして設定するカスタムデコレーターを実装します。

// api/src/auth/decorators/roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
import { Role } from '../../common/enums/role.enum';

export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);

このデコレーターは、指定されたロール情報を 'roles' キーでメタデータとして保存します。RolesGuard がこのキーを使ってメタデータを取得し、認可判定を行います。

使用例

// ADMIN ロールのみアクセス可能
@Roles(Role.ADMIN)
@Query(() => [User])
async allUsers() {
  return this.userService.findAll();
}

// USER または ADMIN ロールでアクセス可能
@Roles(Role.USER, Role.ADMIN)
@Query(() => User)
async me(@CurrentUser() user: User) {
  return user;
}

// ロール指定なし(全ユーザーアクセス可能)
@Query(() => [Todo])
async publicTodos() {
  return this.todoService.findPublic();
}

複数のロールを指定した場合は「OR条件」で評価されます。いずれかのロールを持っていればアクセスが許可される仕組みです。

JWTペイロードにロール情報を含める理由

JWTトークンのペイロードにロール情報を含めることで、データベースアクセスを減らして認可判定の性能を向上させることができます。

// api/src/auth/auth.service.ts (トークン生成部分)
async signup(signupInput: SignupInput): Promise<AuthResponse> {
  const hashedPassword = await bcrypt.hash(signupInput.password, 10);

  const user = await this.prisma.user.create({
    data: {
      email: signupInput.email,
      password: hashedPassword,
      // role は DEFAULT(USER) が自動設定される
    },
  });

  // JWTペイロードにロール情報を含める
  const accessToken = this.jwtService.sign({
    sub: user.id,           // JWT標準クレーム:ユーザーID
    email: user.email,      // カスタムクレーム:メール
    role: user.role,        // カスタムクレーム:ロール
  });

  return { accessToken, user: user as any };
}

ペイロードにロール情報を含める利点

  1. パフォーマンスの向上:認可判定のたびにデータベースアクセスが不要
  2. スケーラビリティ:データベース負荷を軽減
  3. レスポンシブネス:認可判定が高速

考慮すべきトレードオフ

  1. ロール変更の反映:ユーザーのロールを変更してもトークンの有効期限まで古い情報が使われる
  2. トークンサイズ:ペイロードが大きくなるとネットワーク転送量が増加
  3. セキュリティ:JWTは署名されているが暗号化されていないため、機密情報の格納は避けるべき

このトレードオフを考慮した設計判断として、今回は以下の方針を採用しました。

  • トークンの有効期限を1日に設定(ロール変更の反映タイミングを許容範囲内に)
  • 必要最小限の情報のみをペイロードに含める
  • 機密性の高いロール変更が発生した場合は、必要に応じてトークンの無効化機能を検討

GraphQL Resolverでの認可適用

実際のResolverでの認可適用例を見てみましょう。TodoアプリでのCategoryResolverを例にします。

// api/src/category/category.resolver.ts
import { Resolver, Query, Mutation, Args } from '@nestjs/graphql';
import { UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Role } from '../common/enums/role.enum';
import { Category } from './entities/category.entity';
import { CreateCategoryInput, UpdateCategoryInput } from './dto/category.input';
import { CategoryService } from './category.service';
import { User } from '../user/entities/user.entity';

@Resolver(() => Category)
export class CategoryResolver {
  constructor(private categoryService: CategoryService) {}

  // 全ユーザーが閲覧可能な公開カテゴリ
  @Query(() => [Category])
  async publicCategories() {
    return this.categoryService.findPublic();
  }

  // ログインユーザーのみ閲覧可能
  @Query(() => [Category])
  @UseGuards(JwtAuthGuard)
  async myCategories(@CurrentUser() user: User) {
    return this.categoryService.findByUserId(user.id);
  }

  // ADMIN ロールのみがすべてのカテゴリを閲覧可能
  @Query(() => [Category])
  @UseGuards(JwtAuthGuard, RolesGuard)
  @Roles(Role.ADMIN)
  async allCategories() {
    return this.categoryService.findAll();
  }

  // ADMIN ロールのみがカテゴリを作成可能
  @Mutation(() => Category)
  @UseGuards(JwtAuthGuard, RolesGuard)
  @Roles(Role.ADMIN)
  async createCategory(@Args('input') input: CreateCategoryInput) {
    return this.categoryService.create(input);
  }

  // ADMIN ロールのみがカテゴリを更新可能
  @Mutation(() => Category)
  @UseGuards(JwtAuthGuard, RolesGuard)
  @Roles(Role.ADMIN)
  async updateCategory(
    @Args('id') id: number,
    @Args('input') input: UpdateCategoryInput,
  ) {
    return this.categoryService.update(id, input);
  }

  // ADMIN ロールのみがカテゴリを削除可能
  @Mutation(() => Boolean)
  @UseGuards(JwtAuthGuard, RolesGuard)
  @Roles(Role.ADMIN)
  async deleteCategory(@Args('id') id: number) {
    await this.categoryService.delete(id);
    return true;
  }
}

複数Guardの実行順序

@UseGuards(JwtAuthGuard, RolesGuard) のように複数のGuardを指定した場合、左から順番に実行されます。

  1. JwtAuthGuard:JWTトークンを検証してユーザー情報を request.user に設定
  2. RolesGuardrequest.user のロール情報を使って認可判定

この順序が重要で、RolesGuard が実行される時点で request.user にユーザー情報が設定されている必要があります。順序を間違えると認可が正しく動作しません。

段階的なアクセス制御

上記の例では、段階的なアクセス制御を実装しています。

  1. 認証不要publicCategories - 全ユーザーアクセス可能
  2. 認証必要myCategories - ログインユーザーのみ
  3. 認証+認可必要allCategories, createCategory など - ADMINロールが必要

このような段階的なアクセス制御により、機能の重要性に応じて適切なセキュリティレベルを設定できます。

複数Guardの組み合わせパターン

実際の運用では、様々なGuardの組み合わせパターンが考えられます。

基本パターン

// 1. 認証のみ
@UseGuards(JwtAuthGuard)
async getUserProfile(@CurrentUser() user: User) {}

// 2. 認証 + ロール認可
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.ADMIN)
async adminFunction() {}

// 3. 認証不要
async publicData() {}

リソース所有者チェックのパターン

ユーザーが自分のリソースのみアクセスできるような認可も実装できます。

// api/src/todo/guards/todo-owner.guard.ts
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { TodoService } from '../todo.service';

@Injectable()
export class TodoOwnerGuard implements CanActivate {
  constructor(private todoService: TodoService) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const ctx = GqlExecutionContext.create(context);
    const { req } = ctx.getContext();
    const user = req.user;
  
    // GraphQLのArgumentsからtodoIdを取得
    const args = ctx.getArgs();
    const todoId = args.id;

    const todo = await this.todoService.findOne(todoId);
  
    if (!todo || todo.userId !== user.id) {
      throw new ForbiddenException('このTodoにアクセスする権限がありません');
    }

    return true;
  }
}

// 使用例
@Mutation(() => Todo)
@UseGuards(JwtAuthGuard, TodoOwnerGuard)
async updateTodo(
  @Args('id') id: number,
  @Args('input') input: UpdateTodoInput,
) {
  return this.todoService.update(id, input);
}

条件付きロール認可のパターン

より複雑な認可ロジックも実装できます。

// USERは自分のデータのみ、ADMINは全データにアクセス可能
@Injectable()
export class FlexibleRolesGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const ctx = GqlExecutionContext.create(context);
    const user = ctx.getContext().req.user;
    const args = ctx.getArgs();

    // ADMINは常にアクセス可能
    if (user.role === Role.ADMIN) {
      return true;
    }

    // USERは自分のuserIdが指定されている場合のみアクセス可能
    if (user.role === Role.USER && args.userId === user.id) {
      return true;
    }

    return false;
  }
}

認可に関する実装知見

今回のロールベースアクセス制御の実装を通じて、いくつかの重要な知見を得ました。

Guardの責務分離の重要性

認証Guardと認可Guardを分離することで、以下の利点があります。

  1. テスタビリティ:それぞれ独立してテストできる
  2. 再利用性:認証Guardは他のプロジェクトでも使える
  3. 保守性:認可ロジックの変更が認証に影響しない

メタデータ駆動の設計

@Roles() デコレーターを使ったメタデータ駆動の設計により、認可要件がコードレベルで明確になります。新しい開発者がResolverを見ただけで、どのロールが必要かが一目で分かります。

// 認可要件が明確
@Roles(Role.ADMIN)
async deleteAllData() {
  // 危険な処理
}

エラーハンドリングの統一

認可エラーは適切なHTTPステータスコード(403 Forbidden)を返すことが重要です。NestJSのフィルターシステムと組み合わせることで、統一されたエラーレスポンスを提供できます。

// api/src/common/filters/graphql-exception.filter.ts
import { Catch, ArgumentsHost } from '@nestjs/common';
import { GqlArgumentsHost, GqlExceptionFilter } from '@nestjs/graphql';
import { ForbiddenException } from '@nestjs/common';

@Catch(ForbiddenException)
export class GraphQLForbiddenFilter implements GqlExceptionFilter {
  catch(exception: ForbiddenException, host: ArgumentsHost) {
    const gqlHost = GqlArgumentsHost.create(host);
    return {
      message: exception.message,
      code: 'FORBIDDEN',
      statusCode: 403,
    };
  }
}

パフォーマンスの最適化

ロール情報をJWTペイロードに含めることで、認可判定のパフォーマンスを大幅に改善できます。ただし、ロール変更の即座反映が必要な場合は、以下のような対応を検討する必要があります。

  • Redis等を使ったトークンブラックリスト機能
  • 短い有効期限とリフレッシュトークンの組み合わせ
  • WebSocketを使ったリアルタイムでのロール変更通知

スケーラブルなロール設計

今回は USERADMIN の2段階でしたが、プロジェクトが成長すると、より細かいロール設計が必要になる場合があります。

enum Role {
  USER = 'USER',
  MODERATOR = 'MODERATOR',     // 中間管理者
  ADMIN = 'ADMIN',             // システム管理者
  SUPER_ADMIN = 'SUPER_ADMIN', // 最高管理者
}

// または権限ベースの設計
enum Permission {
  READ_USERS = 'READ_USERS',
  WRITE_USERS = 'WRITE_USERS',
  DELETE_USERS = 'DELETE_USERS',
  MANAGE_CATEGORIES = 'MANAGE_CATEGORIES',
}

このような場合は、CASLやPermifyといった認可専用ライブラリの導入を検討することも重要です。

まとめと次回予告

今回は、NestJSでのロールベースアクセス制御の実装について深掘りしました。認証と認可の責務を明確に分離し、RolesGuard@Roles() デコレーターを組み合わせることで、認可システムを構築できました!

特に重要なポイントは以下の通りです。

  • 認証と認可の責務分離:それぞれ独立したGuardで実装
  • メタデータ駆動の設計:デコレーターによる宣言的な認可要件の定義
  • JWTペイロードの活用:パフォーマンス向上のためのロール情報の埋め込み
  • GraphQLでの適切なコンテキスト処理GqlExecutionContext を使った正しい実装

次回の最終回では、フロントエンド(Next.js)側での認証実装に焦点を当てます。Apollo Clientを使った認証ヘッダーの管理、Context APIでの認証状態管理、そしてProtectedRouteによるクライアントサイドでのルート保護について詳しく解説する予定です。バックエンドとフロントエンドが連携した、完全なJWT認証システムの全体像を把握できるようになるでしょう✨

認可システムは、アプリケーションのセキュリティの根幹を成す重要な部分です。今回の実装パターンを参考にしながら、各プロジェクトの要件に合わせてカスタマイズしていただければと思います👍

株式会社StellarCreate | Tech blog📚

Discussion