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:#fffGuardの実行タイミング#
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によるリクエスト情報の取得#
ExecutionContextはArgumentsHostを継承し、現在の実行プロセスに関する追加情報を提供します。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は配列の順序で実行されます。上記の例では、AuthGuard → RolesGuard → ApiKeyGuardの順に評価されます。
ロールベースアクセス制御の実装#
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レベルとハンドラレベルの両方でロールを設定し、それらをマージまたはオーバーライドする場合、ReflectorのgetAllAndOverride()または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によるレスポンス加工の実装を学ぶことで、より高度なリクエスト/レスポンス処理が可能になります。
参考リンク#