Аутентификация в распределенных веб-сервисах на Node.js требует строгого соблюдения криптографических стандартов. Использование устаревшего алгоритма bcrypt уступает современному стандарту Argon2id. Хранение токенов в localStorage браузера делает приложение уязвимым к XSS-краже сессий. Промышленный стандарт — связка PostgreSQL с Prisma ORM, разделение токенов на короткоживущий Access Token (15 минут) и долгоживущий Refresh Token (7 дней), их ротация при каждом обновлении и сохранение в HttpOnly cookies.
localStorage браузера:[ Браузер (Client) ] [ Fastify Backend ] [ PostgreSQL ]
│ │ │
│ 1. POST /api/auth/login { email, pass } │ │
├─────────────────────────────────────────>│ 2. Поиск пользователя │
│ ├───────────────────────────────>│
│ │<───────────────────────────────┤
│ │ 3. Проверка пароля Argon2id │
│ │ 4. Генерация Access + Refresh │
│ │ 5. Сохранение Refresh хэша │
│ ├───────────────────────────────>│
│ 6. Set-Cookie: access_token (HttpOnly) │ │
│ Set-Cookie: refresh_token (HttpOnly) │ │
│<─────────────────────────────────────────┤ │
│ │ │
│ 7. Запрос с истекшим Access Token │ │
├─────────────────────────────────────────>│ 8. Ошибка 401: Token Expired │
│<─────────────────────────────────────────┤ │
│ │ │
│ 9. POST /api/auth/refresh │ 10. Поиск и ротация токена: │
├─────────────────────────────────────────>│ - Удаление старого токена │
│ │ - Выпуск новой пары │
│ 11. Новые HttpOnly Cookies │<───────────────────────────────┤
│<─────────────────────────────────────────┤ │Создание моделей User, Session и Enum ролей Role (USER, ADMIN) с индексами по email и tokenHash.
Настройка сервиса паролей с параметрами memoryCost: 65536, timeCost: 3 и параллелизмом.
Конфигурация плагинов Fastify для подписания JWT асимметричными или надежными секретами и установки cookie.
Создание контроллеров с валидацией входных данных Zod и установкой защищенных HttpOnly SameSite cookies.
Реализация Fastify preHandler хука authenticateUser и requireRole('ADMIN') для защиты роутов.
Почему хранение Refresh Token в cookie с флагами HttpOnly; Secure; SameSite=Strict надежнее, чем сохранение его в localStorage браузера?
Fastify извлекает зашифрованный access_token из входящих заголовков Cookie и проверяет подпись секрета.
Поддельные или поврежденные куки мгновенно отклоняются со статусом 401 Unauthorized.
Библиотека декодирует полезную нагрузку (sub: userId, role: 'ADMIN', exp) и проверяет срок годности токена.
Если срок действия истек, клиенту отдается ошибка TOKEN_EXPIRED для инициации вызова /refresh.
Для критических операций проверяется статус пользователя в БД: не заблокирован ли аккаунт и совпадает ли версия сессии.
Позволяет моментально отзывать доступ при блокировке пользователя без ожидания истечения JWT.
Хук сопоставляет роль пользователя с требуемой ролью эндпоинта. При несовпадении прав возвращается 403 Forbidden.
Контроллер маршрута вызывается только при 100% подтверждении авторизации.
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
enum Role {
USER
MODERATOR
ADMIN
}
model User {
id String @id @default(uuid())
email String @unique
passwordHash String
role Role @default(USER)
isActive Boolean @default(true)
sessions Session[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([email])
@@map("users")
}
model Session {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
refreshToken String @unique
userAgent String?
ipAddress String?
expiresAt DateTime
createdAt DateTime @default(now())
@@index([userId])
@@index([refreshToken])
@@map("sessions")
}import argon2 from 'argon2';
export class PasswordService {
/**
* Хеширует пароль с алгоритмом Argon2id и аппаратным оверхедом
*/
public static async hashPassword(password: string): Promise<string> {
return argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 2 ** 16, // 64 МБ оперативной памяти
timeCost: 3, // 3 итерации
parallelism: 1,
});
}
/**
* Сверяет открытый пароль с сохраненным хешем в защищенном режиме
*/
public static async verifyPassword(hash: string, plain: string): Promise<boolean> {
try {
return await argon2.verify(hash, plain);
} catch (err: unknown) {
return false;
}
}
}import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod';
import { z } from 'zod';
import { randomBytes } from 'node:crypto';
import { prisma } from '@/infra/prisma';
import { PasswordService } from '@/services/password.service';
const LoginSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
export const authController: FastifyPluginAsyncZod = async (app) => {
// Эндпоинт входа в систему (Login)
app.post('/login', { schema: { body: LoginSchema } }, async (request, reply) => {
const { email, password } = request.body;
const user = await prisma.user.findUnique({ where: { email } });
if (!user || !user.isActive) {
return reply.status(401).send({ message: 'Неверный адрес почты или пароль' });
}
const isValid = await PasswordService.verifyPassword(user.passwordHash, password);
if (!isValid) {
return reply.status(401).send({ message: 'Неверный адрес почты или пароль' });
}
// 1. Создаем короткоживущий Access Token (15 минут)
const accessToken = app.jwt.sign(
{ sub: user.id, role: user.role, email: user.email },
{ expiresIn: '15m' }
);
// 2. Генерируем криптографически стойкий Refresh Token (7 дней)
const rawRefreshToken = randomBytes(40).toString('hex');
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
// 3. Сохраняем сессию в БД
await prisma.session.create({
data: {
userId: user.id,
refreshToken: rawRefreshToken,
expiresAt,
userAgent: request.headers['user-agent'],
ipAddress: request.ip,
},
});
// 4. Устанавливаем защищенные HttpOnly cookies
reply.setCookie('access_token', accessToken, {
path: '/',
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 15 * 60, // 15 минут
});
reply.setCookie('refresh_token', rawRefreshToken, {
path: '/api/v1/auth',
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60, // 7 дней
});
return reply.status(200).send({
user: { id: user.id, email: user.email, role: user.role },
});
});
// Эндпоинт безопасной ротации токена (Refresh)
app.post('/refresh', async (request, reply) => {
const rawRefreshToken = request.cookies.refresh_token;
if (!rawRefreshToken) {
return reply.status(401).send({ message: 'Refresh token отсутствует' });
}
// Ищем активную сессию в базе
const session = await prisma.session.findUnique({
where: { refreshToken: rawRefreshToken },
include: { user: true },
});
if (!session || session.expiresAt < new Date() || !session.user.isActive) {
// При подозрении на кражу удаляем куки
reply.clearCookie('access_token', { path: '/' });
reply.clearCookie('refresh_token', { path: '/api/v1/auth' });
return reply.status(401).send({ message: 'Сессия недействительна или истекла' });
}
// Ротация: удаляем старый Refresh Token и выпускаем новый
await prisma.session.delete({ where: { id: session.id } });
const newAccessToken = app.jwt.sign(
{ sub: session.user.id, role: session.user.role, email: session.user.email },
{ expiresIn: '15m' }
);
const newRefreshToken = randomBytes(40).toString('hex');
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
await prisma.session.create({
data: {
userId: session.user.id,
refreshToken: newRefreshToken,
expiresAt,
userAgent: request.headers['user-agent'],
ipAddress: request.ip,
},
});
reply.setCookie('access_token', newAccessToken, {
path: '/',
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 15 * 60,
});
reply.setCookie('refresh_token', newRefreshToken, {
path: '/api/v1/auth',
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60,
});
return reply.status(200).send({ success: true });
});
};Если один и тот же Refresh Token используется повторно без перевыпуска, украденный токен дает злоумышленнику пожизненный доступ к учетной записи. При каждом вызове /refresh обязательно удаляйте использованный токен из базы данных и возвращайте клиенту совершенно новый токен (Token Rotation).
Строка секрета вида secret: 'my-super-secret' легко подбирается брутфорсом по словарю за пару минут. Используйте криптографически стойкие ключи длиной не менее 256 бит, сгенерированные через node -e "console.log(crypto.randomBytes(32).toString('hex'))", и передавайте их исключительно через защищенные переменные окружения.
Установка cookie с опцией path: '/' заставляет браузер отправлять 7-дневный Refresh Token при каждом запросе изображений, стилей и любых GET-запросах. Ограничивайте область видимости куки strictly эндпоинтом обновления: path: '/api/v1/auth'.
Если при неверном логине сервер мгновенно возвращает ответ за 2 мс, а при существующем логине тратит 120 мс на вычисление Argon2id, злоумышленник может составить базу существующих адресов почты по времени ответа (Timing Attack). Всегда выполняйте фиктивную проверку пароля даже при отсутствии пользователя в БД.
Что должен сделать бэкенд, если в эндпоинт /refresh поступил запрос с Refresh Token, который УЖЕ был использован и удален из базы данных 5 минут назад?