Стратегии Passport и JWT-аутентификация
Passport даёт Nest две стратегии — LocalStrategy проверяет учётные данные на /login, JwtStrategy верифицирует Bearer-токен на каждом защищённом маршруте. Глобальный JwtAuthGuard fail-closed; @Public() выводит маршруты из-под него. JWT подписан, а не зашифрован.
Отчёт пентеста приходит с одной находкой, помеченной как 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 <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’ы, пока токен не истечёт.
| Аспект | LocalStrategy | JwtStrategy |
|---|---|---|
| Работает на | Только POST /login | Каждый защищённый маршрут |
| Вход validate() | username, password | декодированный, проверенный payload |
| Ходит в БД? | Да — грузит юзера, сверяет хэш | Нет — доверяет claim’ам токена |
| req.user становится | загруженным юзером (без хэша) | тем, что вернёт validate(payload) |
| Guard | AuthGuard(‘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 и почему?
- 01Пройди полный поток Passport + JWT от login до защищённого запроса, называя каждую стратегию, что делает её validate() и что оказывается в req.user.
- 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-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.