Skip to main content

Що таке guards у NestJS і як реалізувати аутентифікацію?

Guards у NestJS - це класи, що реалізують CanActivate і виконуються перед обробниками маршрутів, щоб вирішити, чи може запит продовжити свій шлях на основі автентифікації або прав користувача.

Теорія

TL;DR

  • Guard - це охоронець на вході: він перевіряє токен перш ніж запит потрапить до контролера; немає дійсного токена - немає доступу
  • canActivate повертає true, щоб пропустити запит, або кидає виняток, щоб заблокувати; повернення false дає 403
  • Guards виконуються після middleware, але до interceptors, pipes і обробника маршруту
  • Застосовуй через @UseGuards() на методі, класі контролера або глобально через APP_GUARD
  • Автентифікація (хто ти?) і авторизація (що тобі дозволено?) - різні задачі; для обох використовуй ланцюжок з двох guards

Швидкий приклад

typescript
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

GuardMiddlewareInterceptor
Має ExecutionContextТакНіТак
Читає метадані маршрутуТакНіТак
Виконується до обробникаТакТакТак (до обробника)
Може заблокувати запитТакТакТак
Основна задачаАвтентифікація, авторизаціяПарсинг, CORS, логуванняТрансформація відповіді

Middleware працює на рівні Express/Fastify і нічого не знає про маршрути та метадані NestJS. Guards виконуються всередині NestJS і можуть бачити, який саме контролер і метод викликається. Middleware - для глобальних HTTP-задач, як парсинг cookie. Guards - для перевірки доступу.

Типові помилки

Помилка 1: Забути async при використанні jwtService.verifyAsync

typescript
// Неправильно - 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-логіки.

typescript
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.

typescript
// 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() на маршруті логіну - одна з найпоширеніших причин плутанини при переході з рівня методів на глобальний рівень.

typescript
// 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 - неавтентифіковані користувачі можуть отримати токен.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?