Skip to main content

Як реалізувати автентифікацію з 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

Швидке налаштування

Встанови пакети:

bash
npm install @nestjs/passport passport passport-local passport-jwt @nestjs/jwt npm install --save-dev @types/passport-local @types/passport-jwt

Потім підключи модуль:

typescript
// 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 (тільки для логіну)

typescript
// 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:

typescript
// 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:

typescript
// 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 для розробки і забути прибрати перед деплоєм - це реальна дірка в безпеці.

Сервіс автентифікації: видача токенів

typescript
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' }), }; }

Контролер: зв'язуємо все разом

typescript
// 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().

typescript
// 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:

typescript
@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 падають в рантаймі без зрозумілої причини.

typescript
// Неправильно @Module({ imports: [JwtModule.register({...})] }) // Правильно @Module({ imports: [PassportModule, JwtModule.register({...})] })

Захардкоджений секрет JWT. Будь-який git log його розкриє. Усі підписані токени стають підробними.

typescript
// Неправильно secretOrKey: 'my-secret' // Правильно secretOrKey: process.env.JWT_SECRET

validate() повертає null без виключення. Passport трактує null як невдалу автентифікацію, але якщо не кинути UnauthorizedException, повідомлення про помилку важко зрозуміти.

typescript
// Неправильно: тихий провал 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, використовує його на захищених ендпоінтах.

typescript
// 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 і підходить для цього:

typescript
// 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 вирішує це чисто:

typescript
// 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') перестає працювати.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?