NestJSにおけるGuardは、リクエストがルートハンドラに到達する前にアクセス可否を判定する重要なコンポーネントです。認証済みユーザーのみにアクセスを許可する、特定のロールを持つユーザーのみに機能を開放するなど、認可ロジックをControllerから分離して実装できます。本記事では、CanActivateインターフェースの実装から、ExecutionContextを使用したリクエスト情報の取得、ロールベースアクセス制御の構築まで、実践的なGuardの活用方法を解説します。

実行環境と前提条件

本記事の内容を実践するにあたり、以下の環境を前提としています。

項目 バージョン・要件
Node.js 20以上
npm 10以上
NestJS 11.x
TypeScript 5.x
OS Windows / macOS / Linux
エディタ VS Code(推奨)

事前に以下の準備を完了してください。

  • NestJS CLIのインストール済み
  • NestJSプロジェクトの作成済み
  • 基本的なController・Serviceの理解

NestJSプロジェクトの作成方法はNestJS入門記事、REST APIエンドポイントの実装はREST API開発記事を参照してください。

Guardとは何か

Guardは、@Injectable()デコレータが付与され、CanActivateインターフェースを実装するクラスです。リクエストがルートハンドラによって処理されるかどうかを、実行時の条件(権限、ロール、ACLなど)に基づいて判定します。

flowchart LR
    A[クライアント] --> B[リクエスト]
    B --> C[Middleware]
    C --> D[Guard]
    D --> E{認可判定}
    E -->|許可| F[Pipe]
    F --> G[Controller]
    E -->|拒否| H[403 Forbidden]
    G --> I[レスポンス]
    
    style D fill:#FF5722,color:#fff
    style E fill:#FF5722,color:#fff

Guardの実行タイミング

NestJSのリクエスト処理パイプラインにおいて、Guardは以下の順序で実行されます。

順序 コンポーネント 役割
1 Middleware リクエストの前処理(ログ、CORS等)
2 Guard 認可チェック(アクセス可否の判定)
3 Pipe データの検証・変換
4 Interceptor(pre) リクエスト前処理
5 Controller ビジネスロジックの実行
6 Interceptor(post) レスポンス後処理
7 Exception Filter 例外処理

GuardはすべてのMiddlewareの後InterceptorやPipeの前に実行されます。この順序により、認可チェックを通過したリクエストのみがバリデーションやビジネスロジックに進むことが保証されます。

MiddlewareとGuardの違い

従来のExpressアプリケーションでは、認証・認可はMiddlewareで処理されることが一般的でした。しかし、Middlewareには以下の制限があります。

観点 Middleware Guard
実行コンテキスト リクエスト/レスポンスオブジェクトのみ ExecutionContextにアクセス可能
ハンドラ情報 次に何が実行されるか不明 どのController/Handlerが実行されるか把握可能
メタデータ アクセス不可 デコレータで設定されたメタデータを読み取り可能
依存性注入 制限あり 完全にサポート

GuardはExecutionContextインスタンスにアクセスできるため、次に実行されるハンドラとそのメタデータを把握した上で認可判定を行えます。

CanActivateインターフェースの実装

Guardを作成するには、CanActivateインターフェースを実装し、canActivate()メソッドを定義します。

基本的なGuardの構造

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// src/guards/auth.guard.ts
import {
  Injectable,
  CanActivate,
  ExecutionContext,
} from '@nestjs/common';
import { Observable } from 'rxjs';

@Injectable()
export class AuthGuard implements CanActivate {
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    const request = context.switchToHttp().getRequest();
    return this.validateRequest(request);
  }

  private validateRequest(request: any): boolean {
    // リクエストの検証ロジックを実装
    // 例: トークンの存在確認
    const authHeader = request.headers.authorization;
    return !!authHeader;
  }
}

canActivate()メソッドの戻り値

canActivate()メソッドは、リクエストの許可・拒否を示すboolean値を返します。戻り値は同期的にも非同期的にも返すことができます。

戻り値の型 説明 ユースケース
boolean 同期的な判定 単純な条件チェック
Promise<boolean> 非同期的な判定 DBアクセス、外部API呼び出し
Observable<boolean> RxJSベースの判定 リアクティブな処理

戻り値と処理の関係は以下の通りです。

  • trueを返す場合:リクエストは処理される
  • falseを返す場合:NestJSはリクエストを拒否し、ForbiddenException(403 Forbidden)をスローする

非同期Guardの実装例

データベースやキャッシュへのアクセスが必要な場合、非同期でGuardを実装できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// src/guards/api-key.guard.ts
import {
  Injectable,
  CanActivate,
  ExecutionContext,
  UnauthorizedException,
} from '@nestjs/common';

@Injectable()
export class ApiKeyGuard implements CanActivate {
  // 実際のアプリケーションではDBやキャッシュから取得
  private readonly validApiKeys = new Set([
    'sk-valid-api-key-001',
    'sk-valid-api-key-002',
  ]);

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();
    const apiKey = request.headers['x-api-key'];

    if (!apiKey) {
      throw new UnauthorizedException('API key is missing');
    }

    const isValid = await this.validateApiKey(apiKey);
    
    if (!isValid) {
      throw new UnauthorizedException('Invalid API key');
    }

    return true;
  }

  private async validateApiKey(apiKey: string): Promise<boolean> {
    // 実際のアプリケーションではDBやRedisへのクエリ
    return this.validApiKeys.has(apiKey);
  }
}

ExecutionContextによるリクエスト情報の取得

ExecutionContextArgumentsHostを継承し、現在の実行プロセスに関する追加情報を提供します。Guardの中でリクエスト情報やハンドラのメタデータにアクセスするために使用します。

ExecutionContextの主要メソッド

メソッド 説明 戻り値
switchToHttp() HTTPコンテキストに切り替え HttpArgumentsHost
switchToRpc() マイクロサービスコンテキストに切り替え RpcArgumentsHost
switchToWs() WebSocketコンテキストに切り替え WsArgumentsHost
getClass() 現在のControllerクラスを取得 Type<T>
getHandler() 現在のハンドラ(メソッド)を取得 Function
getType() アプリケーションのタイプを取得 'http' / 'rpc' / 'graphql'

HTTPリクエスト情報の取得

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// src/guards/request-logger.guard.ts
import {
  Injectable,
  CanActivate,
  ExecutionContext,
} from '@nestjs/common';
import { Request } from 'express';

@Injectable()
export class RequestLoggerGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const httpContext = context.switchToHttp();
    const request = httpContext.getRequest<Request>();
    const response = httpContext.getResponse();

    // リクエスト情報の取得
    console.log('Method:', request.method);
    console.log('URL:', request.url);
    console.log('Headers:', request.headers);
    console.log('Body:', request.body);
    console.log('Query:', request.query);
    console.log('Params:', request.params);
    console.log('IP:', request.ip);

    // Controller/Handlerの情報取得
    const controllerClass = context.getClass();
    const handler = context.getHandler();
    console.log('Controller:', controllerClass.name);
    console.log('Handler:', handler.name);

    return true;
  }
}

アプリケーションタイプの判定

複数のプロトコルをサポートする汎用的なGuardを作成する場合、getType()メソッドでアプリケーションタイプを判定できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// src/guards/multi-protocol.guard.ts
import {
  Injectable,
  CanActivate,
  ExecutionContext,
} from '@nestjs/common';

@Injectable()
export class MultiProtocolGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const type = context.getType();

    if (type === 'http') {
      const request = context.switchToHttp().getRequest();
      return this.validateHttpRequest(request);
    } else if (type === 'rpc') {
      const data = context.switchToRpc().getData();
      return this.validateRpcRequest(data);
    } else if (type === 'ws') {
      const client = context.switchToWs().getClient();
      return this.validateWsRequest(client);
    }

    return false;
  }

  private validateHttpRequest(request: any): boolean {
    return !!request.headers.authorization;
  }

  private validateRpcRequest(data: any): boolean {
    return !!data.token;
  }

  private validateWsRequest(client: any): boolean {
    return !!client.handshake?.auth?.token;
  }
}

@UseGuards()デコレータによる適用

Guardを適用するには、@UseGuards()デコレータを使用します。適用範囲に応じて、メソッドレベル、Controllerレベル、グローバルレベルで設定できます。

メソッドレベルでの適用

特定のルートハンドラにのみGuardを適用します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// src/users/users.controller.ts
import {
  Controller,
  Get,
  Post,
  UseGuards,
  Body,
} from '@nestjs/common';
import { AuthGuard } from '../guards/auth.guard';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Get()
  findAll() {
    // Guardなし - 誰でもアクセス可能
    return this.usersService.findAll();
  }

  @Post()
  @UseGuards(AuthGuard)
  create(@Body() createUserDto: CreateUserDto) {
    // AuthGuard適用 - 認証済みユーザーのみアクセス可能
    return this.usersService.create(createUserDto);
  }
}

Controllerレベルでの適用

Controller内のすべてのルートハンドラにGuardを適用します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// src/admin/admin.controller.ts
import {
  Controller,
  Get,
  Post,
  Delete,
  UseGuards,
  Param,
} from '@nestjs/common';
import { AuthGuard } from '../guards/auth.guard';
import { AdminService } from './admin.service';

@Controller('admin')
@UseGuards(AuthGuard)
export class AdminController {
  constructor(private readonly adminService: AdminService) {}

  @Get('dashboard')
  getDashboard() {
    return this.adminService.getDashboard();
  }

  @Get('users')
  getUsers() {
    return this.adminService.getUsers();
  }

  @Delete('users/:id')
  deleteUser(@Param('id') id: string) {
    return this.adminService.deleteUser(id);
  }
}

グローバルレベルでの適用

アプリケーション全体にGuardを適用する方法は2つあります。

方法1: main.tsで設定

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { AuthGuard } from './guards/auth.guard';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalGuards(new AuthGuard());
  await app.listen(3000);
}
bootstrap();

この方法では依存性注入が利用できません。Guardが他のサービスに依存する場合は、次の方法を使用します。

方法2: モジュールで設定(推奨)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// src/app.module.ts
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { AuthGuard } from './guards/auth.guard';
import { UsersModule } from './users/users.module';

@Module({
  imports: [UsersModule],
  providers: [
    {
      provide: APP_GUARD,
      useClass: AuthGuard,
    },
  ],
})
export class AppModule {}

この方法では、Guardをモジュールシステムに統合するため、依存性注入が完全にサポートされます。

複数Guardの適用

複数のGuardを順番に適用できます。すべてのGuardがtrueを返した場合のみリクエストが処理されます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// src/protected/protected.controller.ts
import {
  Controller,
  Get,
  UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '../guards/auth.guard';
import { RolesGuard } from '../guards/roles.guard';
import { ApiKeyGuard } from '../guards/api-key.guard';

@Controller('protected')
@UseGuards(AuthGuard, RolesGuard, ApiKeyGuard)
export class ProtectedController {
  @Get()
  getProtectedResource() {
    return { message: 'This is a protected resource' };
  }
}

Guardは配列の順序で実行されます。上記の例では、AuthGuardRolesGuardApiKeyGuardの順に評価されます。

ロールベースアクセス制御の実装

Guardの最も一般的なユースケースは、ロールベースアクセス制御(RBAC)です。カスタムデコレータとReflectorを組み合わせて実装します。

Rolesデコレータの作成

まず、ハンドラに必要なロールを設定するためのカスタムデコレータを作成します。

1
2
3
4
// src/decorators/roles.decorator.ts
import { Reflector } from '@nestjs/core';

export const Roles = Reflector.createDecorator<string[]>();

Reflector.createDecorator()を使用することで、型安全なデコレータを簡単に作成できます。

RolesGuardの実装

ハンドラに設定されたロールと、リクエストユーザーのロールを比較するGuardを実装します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// src/guards/roles.guard.ts
import {
  Injectable,
  CanActivate,
  ExecutionContext,
  ForbiddenException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Roles } from '../decorators/roles.decorator';

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

  canActivate(context: ExecutionContext): boolean {
    // ハンドラに設定されたロールを取得
    const requiredRoles = this.reflector.get(Roles, context.getHandler());

    // ロールが設定されていない場合はアクセスを許可
    if (!requiredRoles || requiredRoles.length === 0) {
      return true;
    }

    // リクエストからユーザー情報を取得
    const request = context.switchToHttp().getRequest();
    const user = request.user;

    // ユーザーが存在しない場合はアクセス拒否
    if (!user) {
      throw new ForbiddenException('User not found in request');
    }

    // ユーザーのロールが必要なロールに含まれているか確認
    const hasRole = requiredRoles.some((role) => user.roles?.includes(role));

    if (!hasRole) {
      throw new ForbiddenException(
        `Required roles: ${requiredRoles.join(', ')}`,
      );
    }

    return true;
  }
}

ControllerでのRBACの適用

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// src/articles/articles.controller.ts
import {
  Controller,
  Get,
  Post,
  Put,
  Delete,
  Param,
  Body,
  UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '../guards/auth.guard';
import { RolesGuard } from '../guards/roles.guard';
import { Roles } from '../decorators/roles.decorator';
import { ArticlesService } from './articles.service';
import { CreateArticleDto } from './dto/create-article.dto';
import { UpdateArticleDto } from './dto/update-article.dto';

@Controller('articles')
@UseGuards(AuthGuard, RolesGuard)
export class ArticlesController {
  constructor(private readonly articlesService: ArticlesService) {}

  @Get()
  @Roles(['user', 'editor', 'admin'])
  findAll() {
    return this.articlesService.findAll();
  }

  @Get(':id')
  @Roles(['user', 'editor', 'admin'])
  findOne(@Param('id') id: string) {
    return this.articlesService.findOne(id);
  }

  @Post()
  @Roles(['editor', 'admin'])
  create(@Body() createArticleDto: CreateArticleDto) {
    return this.articlesService.create(createArticleDto);
  }

  @Put(':id')
  @Roles(['editor', 'admin'])
  update(
    @Param('id') id: string,
    @Body() updateArticleDto: UpdateArticleDto,
  ) {
    return this.articlesService.update(id, updateArticleDto);
  }

  @Delete(':id')
  @Roles(['admin'])
  remove(@Param('id') id: string) {
    return this.articlesService.remove(id);
  }
}

Controllerとハンドラのロールをマージする

Controllerレベルとハンドラレベルの両方でロールを設定し、それらをマージまたはオーバーライドする場合、ReflectorgetAllAndOverride()またはgetAllAndMerge()メソッドを使用します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// src/guards/roles.guard.ts(マージ版)
import {
  Injectable,
  CanActivate,
  ExecutionContext,
  ForbiddenException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Roles } from '../decorators/roles.decorator';

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

  canActivate(context: ExecutionContext): boolean {
    // ハンドラのロールで上書き(ハンドラ > Controller)
    const requiredRoles = this.reflector.getAllAndOverride(Roles, [
      context.getHandler(),
      context.getClass(),
    ]);

    // または、両方のロールをマージ
    // const requiredRoles = this.reflector.getAllAndMerge(Roles, [
    //   context.getHandler(),
    //   context.getClass(),
    // ]);

    if (!requiredRoles || requiredRoles.length === 0) {
      return true;
    }

    const request = context.switchToHttp().getRequest();
    const user = request.user;

    if (!user) {
      throw new ForbiddenException('User not found in request');
    }

    const hasRole = requiredRoles.some((role) => user.roles?.includes(role));

    if (!hasRole) {
      throw new ForbiddenException(
        `Required roles: ${requiredRoles.join(', ')}`,
      );
    }

    return true;
  }
}
メソッド 動作 結果例
getAllAndOverride() ハンドラの値を優先、なければControllerの値 Controller: ['user'], Handler: ['admin']['admin']
getAllAndMerge() 両方の値をマージ Controller: ['user'], Handler: ['admin']['user', 'admin']

Publicルートの実装

グローバルGuardを使用しながら、特定のルートを認証なしでアクセス可能にするパターンを実装します。

IsPublicデコレータの作成

1
2
3
4
5
// src/decorators/public.decorator.ts
import { SetMetadata } from '@nestjs/common';

export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

AuthGuardでPublicルートをスキップ

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// src/guards/auth.guard.ts
import {
  Injectable,
  CanActivate,
  ExecutionContext,
  UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';

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

  canActivate(context: ExecutionContext): boolean {
    // Publicデコレータが設定されているかチェック
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);

    // Publicの場合は認証をスキップ
    if (isPublic) {
      return true;
    }

    const request = context.switchToHttp().getRequest();
    const authHeader = request.headers.authorization;

    if (!authHeader) {
      throw new UnauthorizedException('Authorization header is missing');
    }

    // トークンの検証ロジック
    // 実際のアプリケーションではJWTの検証などを行う
    return this.validateToken(authHeader);
  }

  private validateToken(authHeader: string): boolean {
    // Bearer トークンの形式を確認
    if (!authHeader.startsWith('Bearer ')) {
      return false;
    }

    const token = authHeader.substring(7);
    // トークンの検証処理(例:JWT検証)
    return token.length > 0;
  }
}

Controllerでの使用例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// src/app.controller.ts
import { Controller, Get } from '@nestjs/common';
import { Public } from './decorators/public.decorator';

@Controller()
export class AppController {
  @Get()
  @Public()
  getRoot() {
    // 認証不要
    return { message: 'Welcome to the API' };
  }

  @Get('health')
  @Public()
  healthCheck() {
    // 認証不要
    return { status: 'ok' };
  }

  @Get('protected')
  getProtected() {
    // 認証必要
    return { message: 'This is a protected resource' };
  }
}

カスタム例外レスポンスの実装

Guardでfalseを返すとデフォルトでForbiddenExceptionがスローされますが、カスタム例外をスローすることでより適切なエラーレスポンスを返すことができます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
// src/guards/custom-auth.guard.ts
import {
  Injectable,
  CanActivate,
  ExecutionContext,
  UnauthorizedException,
  ForbiddenException,
  HttpException,
  HttpStatus,
} from '@nestjs/common';

@Injectable()
export class CustomAuthGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();
    const authHeader = request.headers.authorization;

    // 認証ヘッダーがない場合は401
    if (!authHeader) {
      throw new UnauthorizedException({
        statusCode: HttpStatus.UNAUTHORIZED,
        message: 'Authentication required',
        error: 'Unauthorized',
        hint: 'Please provide a valid Bearer token in the Authorization header',
      });
    }

    // トークン形式が不正な場合は401
    if (!authHeader.startsWith('Bearer ')) {
      throw new UnauthorizedException({
        statusCode: HttpStatus.UNAUTHORIZED,
        message: 'Invalid token format',
        error: 'Unauthorized',
        hint: 'Token must be in format: Bearer <token>',
      });
    }

    const token = authHeader.substring(7);
    const validationResult = this.validateToken(token);

    // トークンが期限切れの場合は401
    if (validationResult === 'expired') {
      throw new HttpException(
        {
          statusCode: HttpStatus.UNAUTHORIZED,
          message: 'Token has expired',
          error: 'TokenExpired',
          hint: 'Please refresh your token',
        },
        HttpStatus.UNAUTHORIZED,
      );
    }

    // トークンが無効な場合は403
    if (validationResult === 'invalid') {
      throw new ForbiddenException({
        statusCode: HttpStatus.FORBIDDEN,
        message: 'Invalid token',
        error: 'Forbidden',
      });
    }

    return true;
  }

  private validateToken(token: string): 'valid' | 'expired' | 'invalid' {
    // 実際のトークン検証ロジック
    if (token === 'expired-token') return 'expired';
    if (token.length < 10) return 'invalid';
    return 'valid';
  }
}

実践的なGuardの実装パターン

IPアドレス制限Guard

特定のIPアドレスからのアクセスのみを許可するGuardです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// src/guards/ip-whitelist.guard.ts
import {
  Injectable,
  CanActivate,
  ExecutionContext,
  ForbiddenException,
} from '@nestjs/common';
import { Request } from 'express';

@Injectable()
export class IpWhitelistGuard implements CanActivate {
  private readonly whitelist = [
    '127.0.0.1',
    '::1',
    '192.168.1.0/24',
  ];

  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest<Request>();
    const clientIp = this.getClientIp(request);

    if (!this.isWhitelisted(clientIp)) {
      throw new ForbiddenException(
        `Access denied from IP: ${clientIp}`,
      );
    }

    return true;
  }

  private getClientIp(request: Request): string {
    const forwardedFor = request.headers['x-forwarded-for'];
    if (typeof forwardedFor === 'string') {
      return forwardedFor.split(',')[0].trim();
    }
    return request.ip || request.socket.remoteAddress || '';
  }

  private isWhitelisted(ip: string): boolean {
    return this.whitelist.some((allowed) => {
      if (allowed.includes('/')) {
        // CIDR表記の場合はサブネットマッチング
        return this.matchCidr(ip, allowed);
      }
      return ip === allowed;
    });
  }

  private matchCidr(ip: string, cidr: string): boolean {
    const [subnet, bits] = cidr.split('/');
    const mask = ~(2 ** (32 - parseInt(bits)) - 1);
    const ipNum = this.ipToNumber(ip);
    const subnetNum = this.ipToNumber(subnet);
    return (ipNum & mask) === (subnetNum & mask);
  }

  private ipToNumber(ip: string): number {
    return ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet), 0);
  }
}

レートリミットGuard

シンプルなレートリミットを実装するGuardです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// src/guards/rate-limit.guard.ts
import {
  Injectable,
  CanActivate,
  ExecutionContext,
  HttpException,
  HttpStatus,
} from '@nestjs/common';
import { Request } from 'express';

interface RateLimitRecord {
  count: number;
  resetTime: number;
}

@Injectable()
export class RateLimitGuard implements CanActivate {
  private readonly requests = new Map<string, RateLimitRecord>();
  private readonly maxRequests = 100;
  private readonly windowMs = 60 * 1000; // 1分

  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest<Request>();
    const clientKey = this.getClientKey(request);
    const now = Date.now();

    const record = this.requests.get(clientKey);

    if (!record || now > record.resetTime) {
      // 新しいウィンドウを開始
      this.requests.set(clientKey, {
        count: 1,
        resetTime: now + this.windowMs,
      });
      return true;
    }

    if (record.count >= this.maxRequests) {
      const retryAfter = Math.ceil((record.resetTime - now) / 1000);
      throw new HttpException(
        {
          statusCode: HttpStatus.TOO_MANY_REQUESTS,
          message: 'Too many requests',
          retryAfter,
        },
        HttpStatus.TOO_MANY_REQUESTS,
      );
    }

    record.count++;
    return true;
  }

  private getClientKey(request: Request): string {
    const ip = request.ip || request.socket.remoteAddress || 'unknown';
    return `rate-limit:${ip}`;
  }
}

組み合わせGuardの活用

複数のGuardを論理的に組み合わせるパターンです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// src/guards/composite.guard.ts
import {
  Injectable,
  CanActivate,
  ExecutionContext,
} from '@nestjs/common';
import { AuthGuard } from './auth.guard';
import { RolesGuard } from './roles.guard';

@Injectable()
export class CompositeGuard implements CanActivate {
  constructor(
    private readonly authGuard: AuthGuard,
    private readonly rolesGuard: RolesGuard,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    // 認証チェック
    const isAuthenticated = await this.authGuard.canActivate(context);
    if (!isAuthenticated) {
      return false;
    }

    // ロールチェック
    const hasRole = await this.rolesGuard.canActivate(context);
    return hasRole;
  }
}

Guardのテスト

Guardは独立したクラスとして実装されるため、単体テストが容易です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// src/guards/auth.guard.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from './auth.guard';

describe('AuthGuard', () => {
  let guard: AuthGuard;
  let reflector: Reflector;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [AuthGuard, Reflector],
    }).compile();

    guard = module.get<AuthGuard>(AuthGuard);
    reflector = module.get<Reflector>(Reflector);
  });

  const createMockContext = (headers: Record<string, string> = {}) => {
    return {
      switchToHttp: () => ({
        getRequest: () => ({
          headers,
        }),
      }),
      getHandler: () => jest.fn(),
      getClass: () => jest.fn(),
    } as unknown as ExecutionContext;
  };

  it('should be defined', () => {
    expect(guard).toBeDefined();
  });

  it('should return true for valid authorization header', () => {
    const context = createMockContext({
      authorization: 'Bearer valid-token-123',
    });

    expect(guard.canActivate(context)).toBe(true);
  });

  it('should throw UnauthorizedException when authorization header is missing', () => {
    const context = createMockContext({});

    expect(() => guard.canActivate(context)).toThrow(UnauthorizedException);
  });

  it('should throw UnauthorizedException for invalid token format', () => {
    const context = createMockContext({
      authorization: 'InvalidFormat token',
    });

    expect(() => guard.canActivate(context)).toThrow(UnauthorizedException);
  });
});

期待される結果として、以下のテストがすべてパスします。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
npm run test -- --testPathPattern=auth.guard

# 出力例
 PASS  src/guards/auth.guard.spec.ts
  AuthGuard
    ✓ should be defined (5 ms)
    ✓ should return true for valid authorization header (2 ms)
    ✓ should throw UnauthorizedException when authorization header is missing (3 ms)
    ✓ should throw UnauthorizedException for invalid token format (1 ms)

Test Suites: 1 passed, 1 total
Tests:       4 passed, 4 total

まとめ

本記事では、NestJSのGuardによるアクセス制御の実装方法を解説しました。

学習した内容を振り返ります。

トピック 学習内容
Guardの基本 CanActivateインターフェースの実装、Guardの実行タイミング
ExecutionContext HTTPコンテキストの取得、Controller/Handler情報へのアクセス
Guardの適用 @UseGuards()デコレータ、メソッド/Controller/グローバルレベルでの適用
ロールベースアクセス制御 Reflectorによるメタデータ取得、カスタムデコレータの作成
実践パターン Publicルート、IPホワイトリスト、レートリミット
テスト モックContextを使用したGuardの単体テスト

Guardを適切に活用することで、認可ロジックをControllerから分離し、再利用可能で保守しやすいアクセス制御を実現できます。次のステップとして、Interceptorによるレスポンス加工の実装を学ぶことで、より高度なリクエスト/レスポンス処理が可能になります。

参考リンク