Skip to content

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 с двухтокенной стратегией:

  1. Access Token — короткоживущий JWT (15 минут) в HttpOnly cookie
  2. Refresh Token — долгоживущий токен (30 дней) в HttpOnly cookie + БД

Почему HTTP-only cookies

  1. Защита от XSS: JavaScript не имеет доступа к токенам
  2. SSR-совместимость: cookies автоматически передаются при SSR в Nuxt
  3. Автоматическая отправка: браузер добавляет cookies к каждому запросу
  4. 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=2592000

2. Верификация телефона (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=2592000

4. Обновление токена

POST /store/auth/refresh

Cookie: refresh_token=...

Response: 200 OK
Set-Cookie: access_token=NEW_TOKEN; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=900

5. Выход

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=0

6. Выход со всех устройств

POST /store/auth/logout-all

Response: 204 No Content

Защита от CSRF

Стратегия: SameSite + CSRF Token

  1. SameSite=Lax на cookies — защита от cross-site requests
  2. 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-forceRate limiting + account lockout
Session fixationНовый токен при каждом входе

Связанные решения