Що таке guards у NestJS і як реалізувати аутентифікацію?
Guards у NestJS - це класи, що реалізують CanActivate і виконуються перед обробниками маршрутів, щоб вирішити, чи може запит продовжити свій шлях на основі автентифікації або прав користувача.
Теорія
TL;DR
- Guard - це охоронець на вході: він перевіряє токен перш ніж запит потрапить до контролера; немає дійсного токена - немає доступу
canActivateповертаєtrue, щоб пропустити запит, або кидає виняток, щоб заблокувати; поверненняfalseдає 403- Guards виконуються після middleware, але до interceptors, pipes і обробника маршруту
- Застосовуй через
@UseGuards()на методі, класі контролера або глобально черезAPP_GUARD - Автентифікація (хто ти?) і авторизація (що тобі дозволено?) - різні задачі; для обох використовуй ланцюжок з двох guards
Швидкий приклад
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
@Injectable()
export class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
if (!request.headers.authorization) {
throw new UnauthorizedException('Токен відсутній');
}
return true; // запит проходить до обробника
}
}
// Захист одного маршруту
@Get('profile')
@UseGuards(AuthGuard)
getProfile() { return 'захищені дані'; }GET /profile без заголовка Authorization повертає 401. З заголовком контролер виконується.
Місце guards у pipeline
NestJS обробляє кожен запит у такому порядку: middleware, guards, interceptors, pipes, обробник маршруту, фільтри винятків. Guards стоять на другому місці. Вони бачать запит після парсингу middleware і зупиняють його до того, як починається бізнес-логіка.
Якщо canActivate повертає false або кидає виняток, pipeline зупиняється тут. Фільтр винятків перехоплює помилку і формує HTTP-відповідь. Код контролера не виконується взагалі.
Об'єкт ExecutionContext дає guards більше можливостей ніж middleware. Він обгортає платформу (Express або Fastify) і надає доступ до context.switchToHttp().getRequest() для отримання запиту, а також context.getHandler() і context.getClass() для читання метаданих, встановлених декораторами на кшталт @Roles('admin').
Коли використовувати guards
- Один маршрут потребує автентифікації:
@UseGuards(JwtAuthGuard)на методі - Весь контролер потребує автентифікації:
@UseGuards(JwtAuthGuard)на класі - Весь застосунок захищено за замовчуванням: реєструй через
APP_GUARDу модулі (підтримує DI, на відміну відapp.useGlobalGuards()уmain.ts) - Деякі маршрути мають бути відкритими у глобально захищеному застосунку: для цього є патерн із декоратором
@Public() - Перевірка ролей поверх перевірки токена:
@UseGuards(JwtAuthGuard, RolesGuard)
Guard vs Middleware vs Interceptor
| Guard | Middleware | Interceptor | |
|---|---|---|---|
Має ExecutionContext | Так | Ні | Так |
| Читає метадані маршруту | Так | Ні | Так |
| Виконується до обробника | Так | Так | Так (до обробника) |
| Може заблокувати запит | Так | Так | Так |
| Основна задача | Автентифікація, авторизація | Парсинг, CORS, логування | Трансформація відповіді |
Middleware працює на рівні Express/Fastify і нічого не знає про маршрути та метадані NestJS. Guards виконуються всередині NestJS і можуть бачити, який саме контролер і метод викликається. Middleware - для глобальних HTTP-задач, як парсинг cookie. Guards - для перевірки доступу.
Типові помилки
Помилка 1: Забути async при використанні jwtService.verifyAsync
// Неправильно - Promise ігнорується, guard завжди повертає true
canActivate(context: ExecutionContext): boolean {
this.jwtService.verifyAsync(token); // ніколи не await-ується
return true;
}
// Правильно
async canActivate(context: ExecutionContext): Promise<boolean> {
const payload = await this.jwtService.verifyAsync(token);
request.user = payload;
return true;
}Помилка 2: Не прикріплювати payload до request.user
Guard перевіряє токен, але контролер все одно потребує даних користувача. Без request.user = payload кожен обробник, що звертається до req.user, отримає undefined. Це тихий баг, який часто спливає лише в продакшені.
Помилка 3: Забути @Injectable() на класі guard
Без @Injectable() NestJS не може створити екземпляр guard через DI-контейнер. Застосунок падає при старті з незрозумілою помилкою.
Помилка 4: Кидати new Error() замість UnauthorizedException
new Error() обходить фільтр винятків NestJS. У відповіді повертається 500 з HTML-тілом замість чистого JSON з кодом 401. Використовуй UnauthorizedException для 401 і ForbiddenException для 403.
Помилка 5: Глобальний guard блокує публічні маршрути
Глобально зареєстрований JWT guard вимагатиме токен навіть на /health, /auth/login і /auth/register. Додай патерн @Public(), щоб ці маршрути пропускали перевірку токена.
Де зустрічається у реальних проєктах
- NestJS + Prisma: guard перевіряє, чи
req.user.idзбігається з власником ресурсу перед записом або видаленням - GraphQL:
@UseGuards()на resolver-ах працює так само, як і на REST-контролерах; використовуєтьсяcontext.switchToGraphql()замістьswitchToHttp() - Мікросервіси:
JwtAuthGuardреєструється глобально,@Public()ставиться тільки на auth-ендпоїнти - Fastify-адаптер: код guard залишається ідентичним;
switchToHttp()сам розбирається з різницею платформ
Питання на співбесіді
Q: Який повний порядок виконання у NestJS?
A: Спочатку middleware, потім guards, потім interceptors (до обробника), потім pipes, потім сам обробник, потім interceptors знову (після обробника). Фільтри винятків спрацьовують в кінці, якщо щось кинуло помилку.
Q: Як поєднати кілька guards у ланцюжок?
A: @UseGuards(Guard1, Guard2) - обидва мають повернути true. NestJS зупиняється на першому guard, який повернув false або кинув виняток.
Q: Яка різниця між auth guard і roles guard?
A: Auth guard перевіряє особу: чи дійсний цей токен і чи існує такий користувач? Roles guard перевіряє права: чи є у цього користувача потрібна роль? Вони вирішують різні задачі і зазвичай використовуються разом у ланцюжку.
Q: Як тестувати guard ізольовано?
A: Створи тестовий модуль із guard як провайдером. Замокай ExecutionContext як простий об'єкт: { switchToHttp: () => ({ getRequest: () => mockReq }) }. Перевір, що canActivate повертає true для коректних даних і кидає виняток для некоректних.
Q (Senior): Як зробити /auth/login доступним без токена, якщо JwtAuthGuard зареєстрований глобально?
A: Створи декоратор @Public() через SetMetadata('isPublic', true). У guard-і читай цей прапор через Reflector.getAllAndOverride перед перевіркою токена. Якщо маршрут помічений як публічний, одразу повертай true і пропускай перевірку JWT.
Приклади
Базовий AuthGuard
Найпростіший guard перевіряє наявність будь-якого заголовка Authorization. Добре підходить для розуміння структури перед додаванням JWT-логіки.
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
@Injectable()
export class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const authHeader = request.headers.authorization;
if (!authHeader) {
throw new UnauthorizedException('Заголовок Authorization відсутній');
}
return true; // будь-яке значення заголовка проходить; у реальному застосунку тут валідується токен
}
}@Injectable() обов'язковий. Guard кидає UnauthorizedException (401), коли заголовка немає, і повертає true інакше. Наступний guard у ланцюжку виконується тільки якщо цей пропустив запит.
JWT Auth Guard (продакшн-патерн)
Цей guard перевіряє Bearer-токен через @nestjs/jwt, прикріплює декодований payload до request.user і обробляє прострочені або некоректні токени чистим 401.
// guards/jwt-auth.guard.ts
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Request } from 'express';
interface AuthRequest extends Request {
user?: Record<string, unknown>;
}
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private jwtService: JwtService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<AuthRequest>();
const token = this.extractToken(request);
if (!token) throw new UnauthorizedException('Bearer-токен відсутній');
try {
const payload = await this.jwtService.verifyAsync(token, {
secret: process.env.JWT_SECRET,
});
request.user = payload; // доступно як req.user у будь-якому контролері
} catch {
throw new UnauthorizedException('Недійсний або прострочений токен');
}
return true;
}
private extractToken(request: AuthRequest): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}
// users.controller.ts
@Controller('users')
@UseGuards(JwtAuthGuard)
export class UsersController {
@Get('profile')
getProfile(@Req() req: AuthRequest) {
return req.user; // { sub: 'userId', email: '...', iat: ... }
}
}Дійсний Bearer-токен повертає 200 з декодованим payload. Прострочений або підмінений токен повертає 401. async/await на verifyAsync не є опціональним: без нього guard завжди пропускає запит незалежно від коректності токена.
Roles Guard із декоратором @Public()
Два продакшн-патерни в одному прикладі: перевірка ролей через Reflector (клас із @nestjs/core) і позначення маршрутів публічними у глобально захищеному застосунку. Відсутній @Public() на маршруті логіну - одна з найпоширеніших причин плутанини при переході з рівня методів на глобальний рівень.
// decorators/roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
// decorators/public.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
// guards/roles.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
context.getHandler(), // спочатку метадані методу
context.getClass(), // потім метадані контролера
]);
if (!requiredRoles) return true; // @Roles() не вказано - пропускаємо
const { user } = context.switchToHttp().getRequest();
return requiredRoles.some((role) => user.roles?.includes(role));
}
}
// admin.controller.ts
@Controller('admin')
@UseGuards(JwtAuthGuard, RolesGuard) // спочатку перевірка токена, потім ролей
export class AdminController {
@Get('users')
@Roles('admin')
getAllUsers() {
return 'тільки для адміністраторів';
}
}
// auth.controller.ts - логін має бути публічним при глобальному guard
@Public()
@Post('auth/login')
login(@Body() loginDto: LoginDto) {
return this.authService.login(loginDto);
}
// app.module.ts - глобальна реєстрація з підтримкою DI
@Module({
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: RolesGuard },
],
})
export class AppModule {}Користувач із roles: ['admin'] у JWT payload проходить обидва guards. Користувач із roles: ['viewer'] проходить перевірку токена, але отримує 403 від RolesGuard. Декоратор @Public() на маршруті логіну повністю пропускає перевірку JWT - неавтентифіковані користувачі можуть отримати токен.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.