Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Як реалізувати автентифікацію з Passport у NestJS?». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Автентифікація з Passport у NestJS** - стратегії містять логіку перевірки (JWT, пароль), guards застосовують їх до маршрутів. Встанови `@nestjs/passport`, `passport-jwt`, `passport-local`, `@nestjs/jwt`. Створи `JwtStrategy` що розширює `PassportStrategy(Strategy)` з методом `validate()`, зареєструй у `AuthModule`, захищай маршрути через `@UseGuards(AuthGuard('jwt'))`. ```typescript @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { constructor() { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), secretOrKey: process.env.JWT_SECRET }); } async validate(payload: { sub: string; email: string }) { return { id: payload.sub, email: payload.email }; // стає req.user } } ``` **Ключове:** результат `validate()` стає `req.user`. Відсутній `PassportModule` в `imports` ламає всі стратегії в рантаймі.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**Автентифікація з 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')` перестає працювати.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.