open atlas
↑ К треку
NestJS с нуля до senior NEST · 05 · 01

Стратегии Passport и JWT-аутентификация

Passport даёт Nest две стратегии — LocalStrategy проверяет учётные данные на /login, JwtStrategy верифицирует Bearer-токен на каждом защищённом маршруте. Глобальный JwtAuthGuard fail-closed; @Public() выводит маршруты из-под него. JWT подписан, а не зашифрован.

NEST Middle ◷ 17 min
Уровень
ОсновыJuniorMiddleSenior

Отчёт пентеста приходит с одной находкой, помеченной как critical: токен бывшего подрядчика всё ещё работает — спустя три недели после отзыва доступа. Ты проверяешь — строки в таблице users нет, место в SSO освобождено, а GET /admin/billing всё равно отдаёт 200 на токен, который они выпустили в марте. Потом ты читаешь собственный JwtModule.register: signOptions: { expiresIn: '90d' }. Нет ни logout, ни отзыва, ни denylist — JWT, однажды подписанный, валиден до истечения, а ты сказал ему жить девяносто дней. Фикс — это не патч; это понимание того, что подписанный токен есть на самом деле и как Passport его выдаёт и проверяет обратно.

Две стратегии, два момента

Аутентификация в Nest — это два разных момента, и Passport моделирует каждый отдельной стратегией. Первый момент — login: клиент шлёт логин и пароль ровно один раз, ты сверяешь их с базой, и если совпало — выдаёшь токен. Это LocalStrategy. Второй момент — каждый запрос после: клиент предъявляет токен, ты верифицируешь подпись и срок жизни и доверяешь личности внутри, не трогая базу. Это JwtStrategy. Стратегия — это класс, расширяющий PassportStrategy(...), чей метод validate() возвращает принципала, — и то, что вернёт validate(), Passport кладёт в req.user.

LocalStrategy читает учётные данные, сравнивает пароль с сохранённым хэшем и возвращает пользователя (без хэша). Парный LocalAuthGuard — это однострочник AuthGuard('local'), и его вешают на POST /login, чтобы стратегия отработала до хендлера:

import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import * as bcrypt from 'bcrypt';

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private users: UsersService) {
    super(); // по умолчанию поля называются 'username' и 'password'
  }

  async validate(username: string, password: string) {
    const user = await this.users.findByName(username);
    if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
      throw new UnauthorizedException();
    }
    const { passwordHash, ...safe } = user;
    return safe; // -> становится req.user
  }
}

Хендлер /login затем чеканит токен. К моменту запуска хендлера guard уже наполнил req.user, так что хендлеру остаётся лишь подписать payload — и никогда не клади в него пароль или что-либо секретное:

@UseGuards(LocalAuthGuard)
@Post('login')
async login(@Request() req) {
  const payload = { sub: req.user.id, username: req.user.username };
  return { access_token: this.jwt.sign(payload) };
}

Проводка модуля: secret из конфига, а не из исходников

JwtModule нужны секрет для подписи и время жизни токена, и оба места — конфигурация, а не строковый литерал в репозитории. registerAsync позволяет внедрить ConfigService через useFactory, так что секрет приходит из окружения и ротируется без правки кода. PassportModule регистрирует инфраструктуру стратегий; свои классы-стратегии ты отдаёшь как обычные провайдеры:

@Module({
  imports: [
    PassportModule,
    JwtModule.registerAsync({
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        secret: config.getOrThrow<string>('JWT_SECRET'),
        signOptions: { expiresIn: '15m' }, // короткоживущий access-токен
      }),
    }),
  ],
  providers: [AuthService, LocalStrategy, JwtStrategy],
})
export class AuthModule {}

JwtStrategy — это верификатор. ExtractJwt.fromAuthHeaderAsBearerToken() велит Passport достать токен из Authorization: Bearer &lt;token>; secretOrKey обязан быть тем же секретом, которым подписывали, иначе каждая верификация падает; а оставленный по умолчанию false у ignoreExpiration — это то, что и enforce-ит TTL. Passport верифицирует подпись и срок жизни до того, как запустится твой validate(), так что к моменту, когда ты видишь payload, он уже доказанно подлинный — тебе остаётся лишь сформировать принципала:

import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(config: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false, // отклонять просроченные токены (дефолт — оставь)
      secretOrKey: config.getOrThrow<string>('JWT_SECRET'),
    });
  }

  async validate(payload: { sub: string; username: string }) {
    return { userId: payload.sub, username: payload.username }; // -> req.user
  }
}

Fail closed: глобальный guard плюс @Public()

Опасный дефолт — защита по принципу opt-in: рассыпать @UseGuards(JwtAuthGuard) по каждому маршруту и надеяться, что никто ни один не забудет. Переверни это: зарегистрируй JWT-guard глобально через APP_GUARD, чтобы каждый маршрут был защищён по умолчанию, а затем вырежи явные дыры декоратором @Public(), который guard проверяет через Reflector. Забыть декоратор теперь означает оставить маршрут запертым, а не открытым — безопасный режим отказа.

// public.decorator.ts
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
// jwt-auth.guard.ts
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  constructor(private reflector: Reflector) {
    super();
  }

  canActivate(context: ExecutionContext) {
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (isPublic) return true; // @Public()-маршрут -> пропускаем JWT-верификацию
    return super.canActivate(context); // иначе запускаем AuthGuard('jwt')
  }
}
// app.module.ts — привязываем guard глобально
providers: [{ provide: APP_GUARD, useClass: JwtAuthGuard }],

Теперь POST /login и GET /health несут @Public(); всё остальное требует валидный Bearer-токен. Поток от начала до конца:

Что лежит в req.user после каждого guard

Два guard’а наполняют req.user из разных источников, и путать их — частый баг. После LocalAuthGuard req.user — это то, что вернул LocalStrategy.validate(), то есть свежезагруженный пользователь из базы. После JwtAuthGuard req.user — это то, что вернул JwtStrategy.validate(payload), выведенное из claim’ов токена, а не из чтения базы. Если роли пользователя поменялись после выпуска токена, JWT-аутентифицированный запрос всё равно видит старые claim’ы, пока токен не истечёт.

АспектLocalStrategyJwtStrategy
Работает наТолько POST /loginКаждый защищённый маршрут
Вход validate()username, passwordдекодированный, проверенный payload
Ходит в БД?Да — грузит юзера, сверяет хэшНет — доверяет claim’ам токена
req.user становитсязагруженным юзером (без хэша)тем, что вернёт validate(payload)
GuardAuthGuard(‘local’)AuthGuard(‘jwt’), часто глобальный
Почему это работает

Почему JWT подписан, но не зашифрован? Токен — это три base64url-части (header, payload, signature), соединённые точками. Base64url — это кодирование, а не шифрование: любой, у кого есть токен, может декодировать payload и прочитать каждый claim открытым текстом (вставь любой в jwt.io и посмотри). Подпись не прячет payload; она лишь доказывает, что payload не подменили и что его выпустил держатель секрета. Так что JWT гарантирует целостность и подлинность, но никогда конфиденциальность. Никогда не клади в claim’ы пароль, секрет, PII или что-либо, что ты не написал бы на открытке, — клади непрозрачный id пользователя, а остальное сервер пусть подтянет сам.

Выбери лучший вариант

Ограничение: SaaS-дашборд, где админ должен иметь возможность УБИТЬ скомпрометированную сессию за секунды, трафик высокий, и ты гоняешь несколько stateless API-инстансов за балансировщиком. Какая модель auth подходит?

Викторина

После того как запрос прошёл глобальный JwtAuthGuard, откуда берётся значение в req.user?

Викторина

Ты регистрируешь JwtAuthGuard глобально через APP_GUARD. Что должен нести POST /login и почему?

Вспомните перед уходом
  1. 01
    Пройди полный поток Passport + JWT от login до защищённого запроса, называя каждую стратегию, что делает её validate() и что оказывается в req.user.
  2. 02
    Почему JWT — это не сессия, из которой можно выйти, и как access + refresh токены это решают?
Итог

Аутентификация в Nest — это два момента, каждый моделируется стратегией Passport, чьё возвращаемое из validate() значение становится req.user. LocalStrategy работает один раз за LocalAuthGuard на POST /login: грузит пользователя, сравнивает пароль с bcrypt-хэшем и возвращает пользователя; хендлер затем подписывает JWT через jwtService.sign(payload), кладя в него только непрозрачный id и несекретные claim’ы. AuthModule импортирует PassportModule и JwtModule.registerAsync, подтягивая секрет и signOptions.expiresIn из ConfigService, так что ключ подписи живёт в окружении. JwtStrategy верифицирует каждый защищённый запрос: ExtractJwt.fromAuthHeaderAsBearerToken() достаёт Bearer-токен, secretOrKey обязан совпадать с секретом подписи, ignoreExpiration остаётся false, чтобы enforce-ить TTL, а validate(payload) формирует req.user из проверенных claim’ов без чтения базы. Привяжи JwtAuthGuard глобально через APP_GUARD, чтобы каждый маршрут был защищён по умолчанию — fail closed — и выводи маршруты из-под него декоратором @Public(), который guard читает через Reflector. Сеньорская осторожность: JWT подписан, а не зашифрован, так что любой может base64-декодировать payload — никогда не клади секреты или PII в claim’ы — и подписанный токен нельзя un-issue, так что отзыв идёт от короткоживущих access-токенов плюс ротируемого, отслеживаемого на сервере refresh-токена, а не от долгоживущего JWT, который живёт месяцами без kill-switch. Теперь, когда ты увидишь в отчёте пентеста «токен всё ещё валиден после отзыва доступа», ты знаешь, где искать: expiresIn слишком длинный, нет ротации refresh, нет инверсии @Public() — и знаешь фикс ещё до начала постмортема.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.