← назад к разделу

Когда добавляешь защиту маршрутов в 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/:idcustomer или 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 не знает доменной модели.

Сценарий, где это ломается:

  1. Gateway получает GET /orders/order-12345, JWT валиден, principal.sub='cust-99'.
  2. Gateway пытается ответить на вопрос «чей это заказ» — для этого нужно идти в базу или в order-service.
  3. Gateway фактически становится вторым сервисом с копией доменной модели.
  4. Когда у заказа появляются соавторы или делегирование — 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.