Appearance
ADR-003: Стратегия аутентификации
Дата: 2026-01-04
Статус: Принято
Авторы: Backend Team
Контекст
Проект требует аутентификации пользователей для:
- Размещения объявлений (требует верифицированного телефона)
- Доступа к личному кабинету продавца
- Обмена сообщениями между покупателями и продавцами
- Добавления в избранное
- Оставления отзывов
Требования из ТЗ
Согласно общее.md:
«Для размещения объявлений пользователям необходима регистрация на сайте с подтверждением номера телефона и почты»
Типы пользователей
Согласно autoparts-contracts-v5.md:
account_type: 'personal' | 'business'
- personal: частное лицо (по умолчанию)
- business: разборка, магазин, ИП
Роль buyer/seller — НЕ хранится, определяется контекстомРассмотренные альтернативы
| Подход | За | Против |
|---|---|---|
| JWT в localStorage | Простота | XSS-уязвимость, нет server-side logout |
| JWT в HttpOnly Cookie | Безопасность, SSR-совместимость | CSRF требует защиты |
| Session-based | Простота logout, безопасность | Состояние на сервере, масштабирование |
| HttpOnly Cookie + Refresh Token | Баланс безопасности и UX | Сложнее реализации |
Решение
Используем HTTP-only cookies с двухтокенной стратегией:
- Access Token — короткоживущий JWT (15 минут) в HttpOnly cookie
- Refresh Token — долгоживущий токен (30 дней) в HttpOnly cookie + БД
Почему HTTP-only cookies
- Защита от XSS: JavaScript не имеет доступа к токенам
- SSR-совместимость: cookies автоматически передаются при SSR в Nuxt
- Автоматическая отправка: браузер добавляет cookies к каждому запросу
- Server-side logout: можно инвалидировать сессии на сервере
Архитектура
Схема токенов
┌─────────────────────────────────────────────────────────────┐
│ КЛИЕНТ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ access_token │ │ refresh_token │ │
│ │ HttpOnly Cookie│ │ HttpOnly Cookie│ │
│ │ Secure, SameSite│ │ Secure, SameSite│ │
│ │ 15 min TTL │ │ 30 days TTL │ │
│ └────────┬────────┘ └────────┬────────┘ │
└───────────┼────────────────────┼────────────────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ СЕРВЕР │
│ │
│ ┌─────────────────┐ ┌─────────────────────────────────┐ │
│ │ Валидация JWT │ │ user_sessions (PostgreSQL) │ │
│ │ (access_token) │ │ - id │ │
│ └────────┬────────┘ │ - user_id │ │
│ │ │ - refresh_token_hash │ │
│ │ │ - user_agent │ │
│ │ │ - ip_address │ │
│ │ │ - expires_at │ │
│ │ │ - last_used_at │ │
│ ▼ │ - revoked_at │ │
│ ┌─────────────────┐ └─────────────────────────────────┘ │
│ │ Redis Cache │ │
│ │ (blacklist) │ │
│ └─────────────────┘ │
└─────────────────────────────────────────────────────────────┘Структура JWT (Access Token)
json
{
"sub": "user_01H...",
"email": "user@example.com",
"account_type": "personal",
"phone_verified": true,
"email_verified": true,
"iat": 1704360000,
"exp": 1704360900
}Таблица сессий
sql
-- Из autoparts-contracts-v5.md
CREATE TABLE user_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
refresh_token_hash VARCHAR(64) NOT NULL, -- SHA-256 hash
user_agent TEXT,
ip_address INET,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
last_used_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
revoked_at TIMESTAMP
);
CREATE INDEX idx_sessions_user ON user_sessions(user_id);
CREATE INDEX idx_sessions_token ON user_sessions(refresh_token_hash);
CREATE INDEX idx_sessions_expires ON user_sessions(expires_at) WHERE revoked_at IS NULL;Потоки аутентификации
1. Регистрация
POST /store/auth/register
{
"email": "user@example.com",
"phone": "+79001234567",
"password": "SecurePass123!"
}
Response: 201 Created
{
"data": {
"id": "user_01H...",
"email": "user@example.com",
"email_verified": false,
"phone_verified": false
},
"message": "Код подтверждения отправлен на телефон"
}
Set-Cookie: access_token=...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=900
Set-Cookie: refresh_token=...; HttpOnly; Secure; SameSite=Lax; Path=/api/auth; Max-Age=25920002. Верификация телефона (SMS)
POST /store/auth/verify-phone
{
"code": "123456"
}
Response: 200 OK
{
"data": {
"phone_verified": true
}
}3. Вход
POST /store/auth/login
{
"email": "user@example.com",
"password": "SecurePass123!"
}
Response: 200 OK
{
"data": {
"id": "user_01H...",
"email": "user@example.com",
"account_type": "personal",
"phone_verified": true
}
}
Set-Cookie: access_token=...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=900
Set-Cookie: refresh_token=...; HttpOnly; Secure; SameSite=Lax; Path=/api/auth; Max-Age=25920004. Обновление токена
POST /store/auth/refresh
Cookie: refresh_token=...
Response: 200 OK
Set-Cookie: access_token=NEW_TOKEN; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=9005. Выход
POST /store/auth/logout
Cookie: access_token=...; refresh_token=...
Response: 204 No Content
Set-Cookie: access_token=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0
Set-Cookie: refresh_token=; HttpOnly; Secure; SameSite=Lax; Path=/api/auth; Max-Age=06. Выход со всех устройств
POST /store/auth/logout-all
Response: 204 No ContentЗащита от CSRF
Стратегия: SameSite + CSRF Token
- SameSite=Lax на cookies — защита от cross-site requests
- CSRF Token для мутирующих операций (POST, PUT, DELETE)
typescript
// Middleware для CSRF валидации
export async function csrfMiddleware(req: MedusaRequest, res: MedusaResponse, next) {
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
const csrfToken = req.headers['x-csrf-token'];
const sessionCsrf = req.cookies['csrf_token'];
if (!csrfToken || csrfToken !== sessionCsrf) {
return res.status(403).json({
error: { code: 'CSRF_INVALID', message: 'Invalid CSRF token' }
});
}
}
next();
}Установка CSRF токена
typescript
// При входе или обновлении сессии
const csrfToken = crypto.randomBytes(32).toString('hex');
res.cookie('csrf_token', csrfToken, {
httpOnly: false, // Доступен для JS
secure: true,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30 * 1000 // 30 дней
});Реализация
Конфигурация cookies
typescript
// src/utils/auth-cookies.ts
export const ACCESS_TOKEN_CONFIG = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/',
maxAge: 15 * 60, // 15 минут
};
export const REFRESH_TOKEN_CONFIG = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/api/auth', // Только для auth эндпоинтов
maxAge: 30 * 24 * 60 * 60, // 30 дней
};Auth Middleware
typescript
// src/api/middlewares.ts
import { authenticate } from "@medusajs/framework";
export const authMiddleware = authenticate("user", ["session", "bearer"]);
// Кастомный middleware для проверки верификации
export async function requirePhoneVerified(req, res, next) {
if (!req.auth?.phone_verified) {
return res.status(403).json({
error: {
code: 'PHONE_NOT_VERIFIED',
message: 'Требуется подтверждение номера телефона'
}
});
}
next();
}Применение в роутах
typescript
// src/api/vendor/products/route.ts
import { authMiddleware, requirePhoneVerified } from "../../middlewares";
export const POST = [
authMiddleware,
requirePhoneVerified,
async (req: MedusaRequest, res: MedusaResponse) => {
// Создание товара
}
];Интеграция с Nuxt 3
Composable для аутентификации
typescript
// frontend/composables/useAuth.ts
export const useAuth = () => {
const user = useState<User | null>('auth-user', () => null);
const loading = useState('auth-loading', () => true);
const login = async (email: string, password: string) => {
const { data } = await useFetch('/api/store/auth/login', {
method: 'POST',
body: { email, password },
credentials: 'include', // Важно для cookies
});
user.value = data.value?.data;
return data.value;
};
const logout = async () => {
await useFetch('/api/store/auth/logout', {
method: 'POST',
credentials: 'include',
});
user.value = null;
};
const refresh = async () => {
try {
await useFetch('/api/store/auth/refresh', {
method: 'POST',
credentials: 'include',
});
} catch {
user.value = null;
}
};
return { user, loading, login, logout, refresh };
};Middleware для защищённых страниц
typescript
// frontend/middleware/auth.ts
export default defineNuxtRouteMiddleware(async (to) => {
const { user, loading } = useAuth();
if (loading.value) {
// Дождаться загрузки состояния
await until(loading).toBe(false);
}
if (!user.value) {
return navigateTo(`/auth/login?redirect=${to.fullPath}`);
}
});Безопасность
Хранение паролей
typescript
import bcrypt from 'bcrypt';
const SALT_ROUNDS = 12;
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}Политика паролей
typescript
const PASSWORD_POLICY = {
minLength: 8,
requireUppercase: true,
requireLowercase: true,
requireNumbers: true,
requireSpecial: false,
};
export function validatePassword(password: string): string[] {
const errors: string[] = [];
if (password.length < PASSWORD_POLICY.minLength) {
errors.push(`Минимум ${PASSWORD_POLICY.minLength} символов`);
}
if (PASSWORD_POLICY.requireUppercase && !/[A-Z]/.test(password)) {
errors.push('Требуется заглавная буква');
}
// ... остальные проверки
return errors;
}Rate Limiting для auth эндпоинтов
typescript
// Дополнительный rate limit для предотвращения brute-force
const authRateLimit = rateLimit({
windowMs: 15 * 60 * 1000, // 15 минут
max: 5, // 5 попыток входа
message: { error: { code: 'TOO_MANY_ATTEMPTS', message: 'Слишком много попыток. Попробуйте позже.' } },
keyGenerator: (req) => req.body?.email || req.ip,
});Последствия
Положительные
- Безопасность: XSS-защита через HttpOnly cookies
- SSR-совместимость: работает с Nuxt SSR из коробки
- Server-side logout: можно инвалидировать сессии
- Гибкость: поддержка множества устройств с управлением сессиями
Отрицательные
- Сложность: два токена вместо одного
- CSRF: требуется дополнительная защита
- Состояние: refresh токены хранятся в БД
Риски и митигация
| Риск | Митигация |
|---|---|
| Утечка refresh token | Хранение hash в БД, ротация при использовании |
| CSRF атаки | SameSite=Lax + CSRF токен |
| Brute-force | Rate limiting + account lockout |
| Session fixation | Новый токен при каждом входе |
Связанные решения
- ADR-002: REST API
- ADR-005: Кэширование — blacklist токенов в Redis