Як реалізувати автентифікацію з Passport у NestJS?
Автентифікація з Passport у NestJS - паттерн, де класи-стратегії (JWT, local, OAuth2) містять логіку перевірки, а guards вирішують, які маршрути потребують автентифікації.
Теорія
TL;DR
- Passport схожий на контроль в аеропорту: кожна стратегія - окремий пункт перевірки (документи, сканер), guards відкривають або закривають ворота
- Стратегії відповідають на питання як перевіряти (підпис JWT, хеш пароля); guards - коли запускати перевірку
- Local strategy тільки для логіну; JWT strategy для всіх захищених маршрутів
- Чотири пакети:
@nestjs/passport,passport-local,passport-jwt,@nestjs/jwt - Найчастіша помилка при налаштуванні - відсутній
PassportModuleуimports
Швидке налаштування
Встанови пакети:
npm install @nestjs/passport passport passport-local passport-jwt @nestjs/jwt
npm install --save-dev @types/passport-local @types/passport-jwtПотім підключи модуль:
// auth.module.ts
@Module({
imports: [
PassportModule,
JwtModule.register({
secret: process.env.JWT_SECRET,
signOptions: { expiresIn: '1h' },
}),
UsersModule,
],
providers: [AuthService, JwtStrategy, LocalStrategy],
controllers: [AuthController],
})
export class AuthModule {}PassportModule прив'язує стратегії до Express-шару. Без нього AuthGuard('jwt') падає в рантаймі без зрозумілого повідомлення про помилку.
Як стратегії і guards з'єднуються
Стратегія - це клас-провайдер, що розширює PassportStrategy(Strategy) і реалізує метод validate(). Коли запит потрапляє на захищений маршрут, AuthGuard('jwt') викликає passport.authenticate('jwt') всередині. Passport знаходить зареєстровану стратегію, перевіряє токен і додає результат до req.user. Guard запускає стратегію, стратегія виконує роботу. Саме такий розподіл робить заміну стратегій безболісною.
Local strategy (тільки для логіну)
// local.strategy.ts
import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private authService: AuthService) {
super({ usernameField: 'email' }); // перевизначаємо поле 'username' на 'email'
}
async validate(email: string, password: string): Promise<any> {
const user = await this.authService.validateUser(email, password);
if (!user) throw new UnauthorizedException('Невірні облікові дані');
return user; // потрапляє в req.user
}
}Перевірка на рівні сервісу використовує bcrypt:
// auth.service.ts (validateUser)
async validateUser(email: string, password: string) {
const user = await this.usersService.findByEmail(email);
if (user && await bcrypt.compare(password, user.password)) {
const { password, ...result } = user;
return result; // не повертаємо поле password
}
return null;
}JWT strategy (захищені маршрути)
Після логіну клієнт зберігає токен і надсилає його як Bearer-заголовок у кожному наступному запиті. JWT strategy перевіряє підпис і дістає payload:
// jwt.strategy.ts
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false, // прострочені токени повертають 401 автоматично
secretOrKey: process.env.JWT_SECRET,
});
}
async validate(payload: { sub: string; email: string }) {
return { id: payload.sub, email: payload.email }; // стає req.user
}
}ignoreExpiration: false - правильне значення за замовчуванням. Встановити true для розробки і забути прибрати перед деплоєм - це реальна дірка в безпеці.
Сервіс автентифікації: видача токенів
async login(user: any) {
const payload = { sub: user.id, email: user.email };
return {
access_token: this.jwtService.sign(payload),
refresh_token: this.jwtService.sign(payload, { expiresIn: '7d' }),
};
}Контролер: зв'язуємо все разом
// auth.controller.ts
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@UseGuards(AuthGuard('local'))
@Post('login')
async login(@Request() req) {
return this.authService.login(req.user); // req.user заповнила LocalStrategy
}
@UseGuards(AuthGuard('jwt'))
@Get('profile')
getProfile(@Request() req) {
return req.user; // заповнила JwtStrategy
}
}Глобальний JWT guard з декоратором @Public()
Додавати @UseGuards(AuthGuard('jwt')) до кожного маршруту - це зайва робота. Кращий паттерн: застосувати guard глобально, а відкриті маршрути позначати декоратором @Public().
// 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>('isPublic', [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
return super.canActivate(context);
}
}Реєструємо в AppModule:
@Module({
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard },
],
})
export class AppModule {}Тепер кожен маршрут захищений за замовчуванням. Для логіну, health-check або будь-якого публічного ендпоінту додай @Public().
Коли яку стратегію використовувати
- API для мобільних або SPA-клієнтів: JWT strategy, серверні сесії не потрібні
- Ендпоінт логіну: local strategy для перевірки облікових даних, далі одразу JWT
- Соціальний вхід:
passport-google-oauth20або інша OAuth2-стратегія - Внутрішні мікросервіси: кастомна стратегія з перевіркою API key
- Просте веб-застосування без API: пропусти Passport, використай
@nestjs/sessionнапряму
Типові помилки
Відсутній PassportModule у imports. Стратегії реєструються через нього. Без імпорту guards падають в рантаймі без зрозумілої причини.
// Неправильно
@Module({ imports: [JwtModule.register({...})] })
// Правильно
@Module({ imports: [PassportModule, JwtModule.register({...})] })Захардкоджений секрет JWT. Будь-який git log його розкриє. Усі підписані токени стають підробними.
// Неправильно
secretOrKey: 'my-secret'
// Правильно
secretOrKey: process.env.JWT_SECRETvalidate() повертає null без виключення. Passport трактує null як невдалу автентифікацію, але якщо не кинути UnauthorizedException, повідомлення про помилку важко зрозуміти.
// Неправильно: тихий провал
async validate(payload: any) {
return this.usersService.findById(payload.sub); // може повернути null
}
// Правильно
async validate(payload: any) {
const user = await this.usersService.findById(payload.sub);
if (!user) throw new UnauthorizedException();
return user;
}ignoreExpiration: true. Локально зручно. В продакшені прострочені токени проходять перевірку нескінченно.
Local strategy для звичайних API-запитів. Local strategy читає облікові дані з тіла запиту. Вона належить тільки ендпоінту логіну. Для всього іншого - JWT.
Потік запиту коротко
POST /auth/login
Body: { email, password }
-> AuthGuard('local') запускає LocalStrategy.validate()
-> AuthService.validateUser() перевіряє bcrypt-хеш
-> Повертає { access_token, refresh_token }
GET /api/profile
Header: Authorization: Bearer <token>
-> AuthGuard('jwt') запускає JwtStrategy.validate()
-> Підпис JWT перевірений, payload декодований
-> req.user заповнений -> контролер виконується
-> 401 якщо токен відсутній, прострочений або підробленийДе зустрічається в реальних проєктах
- E-commerce API (схожі на паттерни MedusaJS): JWT для корзини і замовлень, local strategy тільки для адмін-логіну
- Стартери Prisma + NestJS: кастомна JWT strategy, впроваджена в контекст GraphQL
- API-шлюз NestJS мікросервісів: стратегія з API key між внутрішніми сервісами
- SaaS-дашборди:
passport-google-oauth20для соціального входу, JWT після OAuth callback
Для виходу (logout): статичні JWT-токени не можна відкликати за визначенням. Продакшн-паттерн - додати claim jti (JWT ID), зберігати відкликані jti в Redis з TTL токена і перевіряти чорний список у JwtStrategy.validate().
Питання на співбесіді
Q: Опиши покроково, що відбувається коли запит потрапляє на маршрут із @UseGuards(AuthGuard('jwt')).
A: Guard викликає canActivate(), який викликає passport.authenticate('jwt'). Passport знаходить зареєстровану JwtStrategy, дістає Bearer-токен, перевіряє підпис і викликає validate(payload). Результат validate() стає req.user. Якщо щось йде не так, Passport повертає 401 до виконання контролера.
Q: Яка різниця між AuthGuard('jwt') і класом, що розширює AuthGuard('jwt')?
A: Рядкова версія викликає стратегію за назвою без будь-яких змін. Розширення AuthGuard('jwt') дозволяє перевизначити canActivate() або handleRequest() для логування, throttling, перевірки ролей або паттерну з @Public() - без змін у контролері.
Q: Як реалізувати logout з JWT?
A: JWT-токени статичні, сервер не може їх відкликати напряму. Стандартний підхід: додати claim jti при підписуванні, зберігати відкликані jti в Redis з TTL токена і відхиляти їх у validate(). Це класичний blacklist-паттерн.
Q: Навіщо @nestjs/passport якщо є @nestjs/jwt?
A: @nestjs/jwt підписує і верифікує токени. Passport додає шар інтеграції з запитом: дістає токен, викликає validate(), встановлює req.user. Він також дає єдиний інтерфейс для кількох стратегій (local, JWT, OAuth2) без змін у коді контролерів.
Q: Senior: як обробляти паралельні логіни з різних пристроїв і інвалідувати сесії при зміні пароля?
A: Зберігай поле version в записі користувача і вбудовуй його в JWT payload. При зміні пароля інкрементуй version в базі. У JwtStrategy.validate() завантажуй користувача і порівнюй версії. Якщо версія в токені не збігається з поточною - кидай UnauthorizedException. Це інвалідує всі існуючі токени без blacklist.
Приклади
Базовий: логін і захищений профіль
Мінімальне робоче налаштування. Користувач логіниться через email і пароль, отримує JWT, використовує його на захищених ендпоінтах.
// auth.controller.ts
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@UseGuards(AuthGuard('local'))
@Post('login')
async login(@Request() req) {
// req.user заповнила LocalStrategy.validate() після bcrypt-перевірки
return this.authService.login(req.user);
// Результат: { access_token: 'eyJ...', refresh_token: 'eyJ...' }
}
@UseGuards(AuthGuard('jwt'))
@Get('profile')
getProfile(@Request() req) {
return req.user;
// Результат: { id: '123', email: 'user@example.com' }
// Без валідного Bearer-токена: 401 Unauthorized
}
}При логіні LocalStrategy викликає validateUser(), яка порівнює bcrypt-хеш. AuthService.login() підписує JWT із sub (ID користувача) і email. При запиті профілю JwtStrategy декодує payload і повертає його як req.user.
Проміжний: доступ на основі ролей через кастомний guard
Після перевірки JWT часто потрібно перевірити роль користувача. Метод handleRequest() запускається після валідації Passport і підходить для цього:
// admin.guard.ts
@Injectable()
export class AdminGuard extends AuthGuard('jwt') {
handleRequest(err: any, user: any) {
if (err || !user) throw err || new UnauthorizedException();
if (!user.roles?.includes('admin')) {
throw new ForbiddenException('Потрібен доступ адміністратора');
}
return user;
}
}
// контролер
@UseGuards(AdminGuard)
@Get('admin/dashboard')
getDashboard(@Request() req) {
return { user: req.user, data: 'контент тільки для адмінів' };
// 401 якщо немає токена, 403 якщо токен валідний але роль не 'admin'
}Логіка ролей залишається поза контролером і поза стратегією. Кожен шар має одне завдання.
Просунутий: стратегія refresh token із кастомним екстрактором і іменованою стратегією
Access token живе кілька хвилин. Refresh token - довше (7-30 днів) і ротується при використанні. Окрема іменована стратегія Passport вирішує це чисто:
// refresh.strategy.ts
@Injectable()
export class RefreshStrategy extends PassportStrategy(Strategy, 'refresh') {
// 'refresh' - назва стратегії для AuthGuard('refresh')
constructor() {
super({
jwtFromRequest: ExtractJwt.fromBodyField('refreshToken'), // читає з тіла POST
ignoreExpiration: false,
secretOrKey: process.env.REFRESH_SECRET, // інший секрет ніж у access token
});
}
async validate(payload: any) {
// В продакшені: перевірити Redis blacklist за payload.jti
return { userId: payload.sub };
}
}
// контролер
@UseGuards(AuthGuard('refresh'))
@Post('auth/refresh')
async refresh(@Request() req) {
// req.user = { userId: '...' } з RefreshStrategy.validate()
return this.authService.rotateTokens(req.user.userId);
// Результат: { access_token: '...', refresh_token: '...' (новий, старий інвалідований) }
}Другий аргумент PassportStrategy(Strategy, 'refresh') - ключова деталь. Без унікальної назви друга стратегія перезаписує першу і AuthGuard('jwt') перестає працювати.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.