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

AUTH · CHECK LEVELS Фаза 16. Безопасность и авторизация

Где делать auth-проверку в NestJS

TypeScript · Guards · RBAC · ABAC

Подпись на Gateway, роль на endpoint, владение в обработчике.

1. Gateway подтверждает личность

1 / 5

Подпись JWT и срок действия одинаковы для всех маршрутов. Их проверяют на Gateway или глобальным Guard до бизнес-кода.

Request + JWTGateway / AuthGuardsignature + expAuthenticated user

2. RolesGuard проверяет endpoint

2 / 5

RBAC отвечает, может ли роль вызвать конкретную операцию. Refund требует admin, создание заказа - customer.

RolesGuardPOST /refund · adminPOST /orders · customerHandler or 403

3. Владение проверяют с ресурсом

3 / 5

Роль customer не разрешает отменять чужой заказ. Handler загружает Order и сравнивает customerId с actor.id.

actor.id = aliceLoad Order :idorder.customerId === actor.idAllow or 403

4. 401 и 403 не взаимозаменяемы

4 / 5

401 означает отсутствие действительной аутентификации. 403 означает, что пользователь известен, но операция запрещена.

401 Unauthorizedmissing or invalid tokenidentity not established403 Forbiddenrole or ownership denieduser is known

5. Слои не дублируют друг друга

5 / 5

Gateway отвечает «кто», Guard - «можно ли вызвать», Handler - «можно ли к этому объекту». Единое разделение убирает разные решения по одному запросу.

AuthenticationRBAC endpointthen domain ownershipDomain operation

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

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

  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(). Иначе — закрыт по умолчанию.

Что почитать дальше