Когда добавляешь защиту маршрутов в NestJS, первый инстинкт — поставить проверку там, где удобнее. В результате часть логики оседает в Guard-е, часть — в контроллере, часть — внутри сервиса. Через месяц уже непонятно: какой слой за что отвечает и почему один endpoint возвращает 401, а другой — 403 в той же ситуации.
На самом деле auth — это три разных вопроса, и каждый уместен только на своём уровне.
Три вопроса — три уровня
Первый вопрос: кто делает запрос? Это аутентификация — проверка JWT-подписи. Нужна для каждого защищённого запроса, не зависит от конкретного endpoint, поэтому живёт на Gateway или в глобальном Guard-е.
Второй вопрос: может ли этот пользователь вообще вызвать этот endpoint? Это авторизация по роли (RBAC). Нужна на уровне endpoint-а: POST /admin/orders/refund — только для admin, POST /orders — только для customer.
Третий вопрос: имеет ли этот пользователь доступ именно к этому объекту? Это авторизация по ресурсу (ABAC). Нужна внутри обработчика, после того как объект уже загружен из базы.
| Уровень | Вопрос | NestJS-механизм |
|---|---|---|
| Gateway / глобальный Guard | Кто этот клиент? | JwtAuthGuard (passport-jwt + jwks-rsa) |
| Endpoint / контроллер | Можно ли этой роли? | @Roles(...) + RolesGuard |
| Обработчик (Handler) | Можно ли этому юзеру именно этот объект? | сравнение aggregate.ownerId === principal.sub |
Gateway: проверяем подпись JWT
На этом уровне приложение только убеждается, что токен настоящий: подпись верна, токен не просрочен, выпущен нужным провайдером.
JwtStrategy настраивается один раз на всё приложение. Она получает публичный ключ из JWK Set провайдера, проверяет подпись, exp, iss, aud, и возвращает Principal — типизированный объект, который NestJS кладёт в request.user.
// adapters/in/http/security/jwt.strategy.ts
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: AppConfig) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
algorithms: ['RS256'],
audience: config.auth.audience,
issuer: config.auth.issuer,
secretOrKeyProvider: passportJwtSecret({
jwksUri: config.auth.jwksUri,
cache: true,
cacheMaxAge: 300_000, // кеш ~5 минут
rateLimit: true,
}),
});
}
validate(claims: JwtClaims): Principal {
return { sub: claims.sub, roles: extractRoles(claims) };
}
}
Метод validate вызывается только после того, как библиотека убедилась в подписи и сроке жизни токена. Самостоятельный jwt.decode без проверки подписи — серьёзная ошибка безопасности: такой токен легко подделать.
Если JWT невалиден — JwtAuthGuard выбрасывает UnauthorizedException, запрос возвращает 401. Обработчики не вызываются.
Что Gateway при этом не делает: он не знает, какие роли нужны для конкретного пути, и не знает ничего про заказы или пользователей.
Контроллер: проверяем роль
После того как JWT прошёл проверку, нужно убедиться, что роль пользователя подходит для конкретного endpoint-а.
Глобальные APP_GUARD регистрируются в AppModule в правильном порядке: сначала JwtAuthGuard (возвращает 401 при невалидном токене), потом RolesGuard (возвращает 403 при недостаточных правах).
// app.module.ts
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: RolesGuard },
],
RolesGuard читает метаданные декоратора и сравнивает роли пользователя с требуемыми. Если @Roles(...) не указан вообще — Guard запрещает запрос: endpoint без явной разметки считается закрытым.
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(ctx: ExecutionContext): boolean {
const required = this.reflector.get(Roles, ctx.getHandler());
if (!required) throw new ForbiddenException();
const { user } = ctx.switchToHttp().getRequest<{ user: Principal }>();
if (!required.some((r) => user.roles.includes(r))) throw new ForbiddenException();
return true;
}
}
Разметка выглядит так:
@Controller('orders')
export class OrderController {
@Post()
@Roles(['customer'])
async create(@Body() dto: CreateOrderDto, @Req() req: Request): Promise<OrderResponse> {
return this.handler.execute(dto, req.user as Principal);
}
@Get(':id')
@Roles(['customer', 'admin'])
async getById(@Param('id') id: string, @Req() req: Request): Promise<OrderResponse> {
return this.handler.execute({ orderId: id }, req.user as Principal);
}
}
POST /orders— толькоcustomer.GET /orders/:id—customerилиadmin.POST /admin/orders/:id/refund— толькоadmin.
Если роль не подходит — RolesGuard возвращает 403 до того, как запрос попадёт в обработчик.
Что контроллер при этом не проверяет: он не знает, чей конкретно заказ. Роль customer позволяет читать заказы в принципе — но не любой заказ.
Обработчик: проверяем доступ к объекту
Роль пользователя разрешает доступ к endpoint-у. Но customer с sub='cust-99' не должен читать заказ с customerId='cust-42'. Это проверяется только после загрузки объекта из базы.
// core/order/handlers/get-order-by-id.handler.ts
@Injectable()
export class GetOrderByIdHandler {
constructor(private readonly orders: OrderRepository) {}
async execute(query: GetOrderByIdQuery, principal: Principal): Promise<Order> {
const order = await this.orders.byId(query.orderId);
if (!order) throw new OrderNotFoundError(query.orderId);
if (!principal.roles.includes('admin') && order.customerId !== principal.sub) {
throw new ForbiddenError(query.orderId);
}
return order;
}
}
Та же модель работает для других агрегатов:
// core/product/handlers/update-product.handler.ts
async execute(cmd: UpdateProductCommand, principal: Principal): Promise<void> {
const product = await this.products.byId(cmd.productId);
if (!product) throw new ProductNotFoundError(cmd.productId);
if (!principal.roles.includes('admin') && product.sellerId !== principal.sub) {
throw new ForbiddenError(cmd.productId);
}
// ...
}
Роль admin обходит проверку на владельца — но такие действия должны попадать в журнал аудита.
Эту логику размещают в Handler или выносят в отдельный @Injectable() AccessPolicy. В контроллере — нельзя: контроллер не должен знать про доменные объекты и их владельцев.
Почему ABAC не на Gateway
Кажется, логично перенести проверку «чей заказ» на вход в систему. Но Gateway не знает доменной модели.
Сценарий, где это ломается:
- Gateway получает
GET /orders/order-12345, JWT валиден,principal.sub='cust-99'. - Gateway пытается ответить на вопрос «чей это заказ» — для этого нужно идти в базу или в order-service.
- Gateway фактически становится вторым сервисом с копией доменной модели.
- Когда у заказа появляются соавторы или делегирование — Gateway нужно обновлять вместе с order-service.
Итог: двойной источник правды, размытая ответственность. Корректное место для этой проверки — там, где живёт агрегат Order.
Частые ошибки
Не различать 401 и 403. UnauthorizedException (401) — токен невалиден или отсутствует. ForbiddenException (403) — токен хороший, но прав не хватает. Путать их — значит давать клиенту неверный сигнал о том, что делать дальше.
Endpoint без @Roles(...) и без явного @Public(). Такой endpoint опасен: если RolesGuard не находит метаданных, лучше выдать 403 по умолчанию, чем пропустить запрос. Глобальные APP_GUARD именно так и работают.
Самостоятельный разбор JWT без проверки подписи. jwt.decode только раскодирует payload, но не проверяет подпись. Это значит, что любой желающий может передать поддельный токен с нужными данными, и приложение его примет.
ABAC в контроллере. Контроллер не должен загружать доменные объекты ради проверки прав. Это обязанность обработчика.
Коротко
- Auth — это три разных вопроса: кто, может ли вообще, может ли этот конкретный объект.
- JWT-подпись проверяется один раз глобально через
JwtAuthGuard— не в каждом Guard отдельно. - Роли проверяются на уровне endpoint-а через
@Roles(...)+RolesGuard. - Доступ к конкретному ресурсу проверяется в обработчике, после загрузки объекта из базы.
- Gateway не знает доменной модели — ABAC там не место.
- 401 — невалидный токен; 403 — прав нет. Путать их нельзя.
- Каждый endpoint должен иметь
@Roles(...)или явный@Public(). Иначе — закрыт по умолчанию.
Что почитать дальше
- JWT validation в NestJS —
JwtStrategy,passportJwtSecret,jwks-rsa. - RBAC: маппинг ролей —
extractRoles, разрешённые роли,RolesGuard. - ABAC: владение ресурсом —
AccessPolicy, обход дляadmin.