Пошук уроків, статей та іншого контенту
Реалізуєте автентифікацію за допомогою JWT, створення токенів і перевірку їхніх підписів.
JWT (JSON Web Token) — це підписаний токен, який сервер видає після успішної автентифікації користувача.
Токен складається з трьох частин:
Header — тип токена й алгоритм підпису.
Payload — дані, наприклад ідентифікатор користувача.
Signature — підпис, який дає змогу перевірити, що токен не було змінено.
Частини токена кодуються у форматі Base64URL і розділяються крапками:
header.payload.signatureJWT не шифрує payload. Будь-хто, хто отримав токен, може декодувати його вміст. Тому в payload не можна зберігати паролі, секрети або інші конфіденційні дані.
Для роботи з JWT встановимо
@nestjs/jwt@nestjs/confignpm install @nestjs/jwt @nestjs/configСтворимо файл .env у корені проєкту:
JWT_SECRET=very-long-development-secretУ реальному проєкті секрет має бути довгим, випадковим і недоступним у репозиторії.
Створимо модуль:
nest generate module auth
nest generate service auth
nest generate controller authУ app.module.ts підключимо ConfigModule і AuthModule:
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AuthModule } from './auth/auth.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
AuthModule,
],
})
export class AppModule {}Опція isGlobal: true дає змогу використовувати ConfigService в інших модулях без повторного імпорту ConfigModule.
Тепер налаштуємо JwtModule у auth.module.ts:
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './jwt-auth.guard';
@Module({
imports: [
JwtModule.register({
secret: process.env.JWT_SECRET,
signOptions: {
expiresIn: '15m',
},
}),
],
controllers: [AuthController],
providers: [AuthService, JwtAuthGuard],
exports: [JwtAuthGuard],
})
export class AuthModule {}secret використовується для створення і перевірки підпису. expiresIn визначає термін дії токена.
Якщо застосунок запускається без завантаженого .env, значення process.env.JWT_SECRET може бути undefined. Для надійнішої конфігурації використаємо ConfigService:
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './jwt-auth.guard';
@Module({
imports: [
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.getOrThrow<string>('JWT_SECRET'),
signOptions: {
expiresIn: '15m',
},
}),
}),
],
controllers: [AuthController],
providers: [AuthService, JwtAuthGuard],
exports: [JwtAuthGuard],
})
export class AuthModule {}Метод getOrThrow зупинить запуск застосунку, якщо секрет не задано. Це безпечніше, ніж непомітно використовувати порожнє або стандартне значення.
Для створення токена використовується метод signAsync сервісу JwtService.
У прикладі нижче замість бази даних використовується один демонстраційний користувач. У реальному застосунку користувача потрібно отримувати з бази даних, а пароль — перевіряти за допомогою хешу.
auth.service.ts:
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
interface User {
id: number;
username: string;
password: string;
}
interface JwtPayload {
sub: number;
username: string;
}
@Injectable()
export class AuthService {
private readonly user: User = {
id: 1,
username: 'alice',
password: 'password123',
};
constructor(private readonly jwtService: JwtService) {}
async login(username: string, password: string) {
const isValid =
username === this.user.username && password === this.user.password;
if (!isValid) {
throw new UnauthorizedException('Неправильне ім’я користувача або пароль');
}
const payload: JwtPayload = {
sub: this.user.id,
username: this.user.username,
};
return {
accessToken: await this.jwtService.signAsync(payload),
};
}
}Властивість sub — стандартне поле JWT для ідентифікатора суб’єкта токена. У цьому прикладі воно містить id користувача.
Не потрібно додавати пароль до payload:
// Неправильно
const payload = {
sub: user.id,
username: user.username,
password: user.password,
};Payload доступний клієнту після декодування, тому він має містити лише мінімально необхідні дані.
Створимо endpoint POST /auth/login:
import { Body, Controller, Post } from '@nestjs/common';
import { AuthService } from './auth.service';
interface LoginRequest {
username: string;
password: string;
}
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('login')
login(@Body() body: LoginRequest) {
return this.authService.login(body.username, body.password);
}
}Запит:
POST /auth/login
Content-Type: application/json
{
"username": "alice",
"password": "password123"
}Відповідь матиме вигляд:
{
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}Клієнт має зберегти токен і надсилати його в заголовку Authorization для захищених endpoint-ів:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...Для перевірки JWT створимо guard. Він:
Отримує заголовок Authorization.
Перевіряє формат Bearer <token>.
Перевіряє підпис і термін дії токена.
Додає payload до об’єкта запиту.
Дозволяє виконання endpoint-а, якщо токен правильний.
jwt-auth.guard.ts:
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Request } from 'express';
interface JwtPayload {
sub: number;
username: string;
iat?: number;
exp?: number;
}
type AuthenticatedRequest = Request & {
user: JwtPayload;
};
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private readonly jwtService: JwtService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const token = this.extractToken(request);
if (!token) {
throw new UnauthorizedException('JWT не знайдено');
}
try {
const payload = await this.jwtService.verifyAsync<JwtPayload>(token);
// Зберігаємо перевірені дані, щоб використати їх у контролері
request.user = payload;
return true;
} catch {
throw new UnauthorizedException('Недійсний або прострочений JWT');
}
}
private extractToken(request: Request): string | undefined {
const authorization = request.headers.authorization;
if (!authorization) {
return undefined;
}
const [type, token] = authorization.split(' ');
if (type !== 'Bearer' || !token) {
return undefined;
}
return token;
}
}Метод verifyAsync перевіряє JWT за тим самим секретом, який використовувався під час його створення. Він також перевіряє стандартне поле exp, якщо термін дії токена минув.
Якщо хтось змінить payload, підпис більше не відповідатиме даним, і перевірка завершиться помилкою.
Додамо до контролера endpoint, доступний лише для автентифікованих користувачів:
import {
Body,
Controller,
Get,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import { Request } from 'express';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './jwt-auth.guard';
interface LoginRequest {
username: string;
password: string;
}
interface AuthenticatedRequest extends Request {
user: {
sub: number;
username: string;
};
}
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('login')
login(@Body() body: LoginRequest) {
return this.authService.login(body.username, body.password);
}
@Get('profile')
@UseGuards(JwtAuthGuard)
getProfile(@Req() request: AuthenticatedRequest) {
return {
userId: request.user.sub,
username: request.user.username,
};
}
}Декоратор @UseGuards(JwtAuthGuard) запускає guard перед методом контролера. Якщо guard повертає true, NestJS продовжує виконання endpoint-а. Якщо guard викидає UnauthorizedException, клієнт отримує відповідь 401 Unauthorized.
Спочатку отримаємо токен:
curl -X POST http://localhost:3000/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"password123"}'Скопіюємо значення accessToken і використаємо його для захищеного endpoint-а:
curl http://localhost:3000/auth/profile \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"Очікувана відповідь:
{
"userId": 1,
"username": "alice"
}Запит без заголовка Authorization завершиться помилкою:
{
"statusCode": 401,
"message": "JWT не знайдено",
"error": "Unauthorized"
}Нижче наведено основні файли прикладу разом.
auth.module.ts:
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './jwt-auth.guard';
@Module({
imports: [
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.getOrThrow<string>('JWT_SECRET'),
signOptions: {
expiresIn: '15m',
},
}),
}),
],
controllers: [AuthController],
providers: [AuthService, JwtAuthGuard],
})
export class AuthModule {}auth.service.ts:
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
@Injectable()
export class AuthService {
private readonly user = {
id: 1,
username: 'alice',
password: 'password123',
};
constructor(private readonly jwtService: JwtService) {}
async login(username: string, password: string) {
if (
username !== this.user.username ||
password !== this.user.password
) {
throw new UnauthorizedException('Неправильні облікові дані');
}
const payload = {
sub: this.user.id,
username: this.user.username,
};
return {
accessToken: await this.jwtService.signAsync(payload),
};
}
}jwt-auth.guard.ts:
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Request } from 'express';
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private readonly jwtService: JwtService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<
Request & { user: Record<string, unknown> }
>();
const authorization = request.headers.authorization;
const [type, token] = authorization?.split(' ') ?? [];
if (type !== 'Bearer' || !token) {
throw new UnauthorizedException('Потрібен Bearer-токен');
}
try {
request.user = await this.jwtService.verifyAsync(token);
return true;
} catch {
throw new UnauthorizedException('Недійсний або прострочений JWT');
}
}
}auth.controller.ts:
import {
Body,
Controller,
Get,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import { Request } from 'express';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './jwt-auth.guard';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('login')
login(
@Body() body: { username: string; password: string },
) {
return this.authService.login(body.username, body.password);
}
@Get('profile')
@UseGuards(JwtAuthGuard)
getProfile(@Req() request: Request & { user: Record<string, unknown> }) {
return request.user;
}
}app.module.ts:
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AuthModule } from './auth/auth.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
AuthModule,
],
})
export class AppModule {}Під час реалізації JWT-автентифікації важливо:
зберігати секретний ключ у змінних середовища;
не додавати пароль або інші секрети до payload;
використовувати достатньо довгий випадковий секрет;
встановлювати термін дії токена;
перевіряти токен на кожному захищеному endpoint-і;
не приймати токен без перевірки підпису;
повертати помилку 401 Unauthorized для відсутнього або недійсного токена.
JWT містить підпис, але не є зашифрованим контейнером. Payload можна прочитати навіть без секретного ключа. Секрет потрібен саме для створення та перевірки підпису.
Токен потрібно підписувати й перевіряти тим самим секретом. Якщо під час запуску застосунку секрет змінився, раніше створені токени стануть недійсними.
Payload доступний клієнту, тому пароль у ньому зберігати не можна.
Використовуйте verifyAsync, а не лише декодування токена. Декодування показує вміст JWT, але не доводить його справжність.
Коректний формат:
Authorization: Bearer <token>Поширені помилки:
Authorization: <token>
Authorization: Token <token>
Authorization: BearerКонструкція на кшталт process.env.JWT_SECRET || 'secret' зручна для локального тестування, але небезпечна в production. Якщо змінна середовища не завантажилася, застосунок може почати використовувати відоме значення.
Payload має містити лише дані, необхідні для ідентифікації користувача, наприклад:
{
sub: 42,
username: 'alice'
}JWT складається з header, payload і signature.
JwtService.signAsync створює токен і підписує його секретним ключем.
JwtService.verifyAsync перевіряє підпис і термін дії токена.
Токен зазвичай передається в заголовку Authorization у форматі Bearer <token>.
NestJS guard може перевірити JWT до виконання методу контролера.
Перевірені дані токена можна додати до request.user.
Payload JWT не шифрується, тому до нього не можна додавати конфіденційні дані.
Секретний ключ потрібно зберігати поза кодом застосунку.