Files
vidconf/docs/api/auth.md

21 KiB
Raw Blame History

Аутентификация и авторизация

Эндпоинты для регистрации, верификации email, входа и управления JWT-токенами.

Быстрый старт

# 1. Регистрация
curl -X POST http://localhost:8000/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"user@example.com","name_user":"John","password":"securepass123"}'

# 2. Проверить письмо, скопировать токен из логов (ConsoleEmailBackend)
# 3. Подтвердить email
curl -X POST http://localhost:8000/api/v1/auth/verify-email \
  -H "Content-Type: application/json" \
  -d '{"token":"ТОКЕНЗ_ПИСЬМА"}'

# 4. Войти и получить access-токен
curl -X POST http://localhost:8000/api/v1/auth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d 'username=user@example.com&password=securepass123' \
  -i  # -i чтобы увидеть Set-Cookie с refresh-токеном

# 5. Использовать access-токен
curl http://localhost:8000/api/v1/users/me \
  -H "Authorization: Bearer ТВОЙ_ACCESS_TOKEN"

# 6. Обновить access-токен (refresh-токен в cookie автоматический)
curl -X POST http://localhost:8000/api/v1/auth/refresh \
  -b "refresh_token=ТВОЙ_REFRESH_TOKEN" \
  -i

# 7. Выход (отозвать refresh-токен)
curl -X POST http://localhost:8000/api/v1/auth/logout \
  -b "refresh_token=ТВОЙ_REFRESH_TOKEN" \
  -i

Эндпоинты

GET /api/v1/auth/registration-options

Публичные опции карточки регистрации (без авторизации) — доступен ли выбор команды и список команд для селектора.

Ответ (200 OK):

{
  "team_choice_enabled": true,
  "teams": [
    {"id": "550e8400-e29b-41d4-a716-446655440000", "name": "Alpha"},
    {"id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "name": "Backend"}
  ],
  "email_domain": "example.com"
}

Поля:

  • team_choice_enabled — значение настройки инстанса registration_team_choice (админка, PUT /api/v1/admin/settings); дефолт false
  • teams — список команд, отсортированный по названию; пустой массив, если team_choice_enabled=false (справочник команд не раскрывается, пока выбор выключен)
  • email_domain — эталонный домен email при включённой настройке инстанса registration_email_domain_enabled, иначе null

POST /api/v1/auth/register

Зарегистрировать нового пользователя и отправить письмо для подтверждения email.

Тело запроса:

{
  "email": "user@example.com",
  "name_user": "Иван Петров",
  "password": "securepass123",
  "team_id": "550e8400-e29b-41d4-a716-446655440000"
}

Поля:

  • email (строка, email): Адрес электронной почты; уникален в системе
  • name_user (строка, 1-255 символов): Отображаемое имя пользователя
  • password (строка, минимум 8 символов): Пароль (хэшируется с argon2)
  • team_id (UUID, опционально): Команда пользователя; допустим только когда registration_team_choice включена в настройках инстанса (см. GET /registration-options) и team_id ссылается на существующую команду

Ответ (201 Created):

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "email": "user@example.com",
  "name_user": "Иван Петров",
  "role": "user"
}

Ошибки:

  • 409 Conflict (email_already_registered) — пользователь с таким email уже зарегистрирован
  • 400 Bad Request (invalid_team_selection) — передан team_id, а выбор команды выключен в настройках, либо команда с таким id не существует (единая ошибка для обоих случаев — публичный эндпоинт не перебирает id команд)
  • 400 Bad Request (invalid_email_domain) — включена верификация домена email (registration_email_domain_enabled), а домен в email (часть после @, без учёта регистра) не совпадает с эталонным registration_email_domain; проверяется до создания пользователя
  • 422 Unprocessable Entity — валидация (пароль < 8 символов, некорректный email и т.д.)

Побочный эффект:

  • На указанный email отправляется письмо с ссылкой на верификацию (консоль в dev)

POST /api/v1/auth/verify-email

Подтвердить email пользователя по одноразовому токену из письма.

Тело запроса:

{
  "token": "ДЛИННЫЙ_ТОКЕНЗ_ПИСЬМА"
}

Поля:

  • token (строка): Одноразовый токен подтверждения (256 бит, URL-safe base64)

Ответ (204 No Content): — пустой ответ при успехе

Ошибки:

  • 400 Bad Request (invalid_or_expired_token) — токен не найден, уже использован или истёк (TTL по .env: EMAIL_VERIFICATION_TTL_HOURS, по умолчанию 24 часа)

Побочный эффект:

  • Устанавливает email_verified = true для пользователя
  • Отмечает токен как использованный (used_at = now)

POST /api/v1/auth/token

OAuth2 password flow: аутентификация по email и паролю. Выдаёт пару токенов (access + refresh).

Тело запроса: (form-data или application/x-www-form-urlencoded)

username=user@example.com&password=securepass123

Параметры:

  • username (строка): Email пользователя (OAuth2 соглашение использует username)
  • password (строка): Пароль в открытом виде

Ответ (200 OK):

{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "bearer"
}

Поля:

  • access_token (строка): JWT access-токен; используется в заголовке Authorization: Bearer <token>
  • token_type (строка): Всегда "bearer"

Заголовок ответа:

Set-Cookie: refresh_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...; HttpOnly; Secure; SameSite=Strict; Max-Age=2592000

Ошибки:

  • 401 Unauthorized (invalid_credentials) — email не найден или пароль неверный
  • 403 Forbidden (email_not_verified) — email ещё не подтвержден (требуется /verify-email)

Описание токенов:

Параметр Тип TTL Место Ротация
access_token JWT 15 минут Тело ответа Не ротируется; истекает автоматически
refresh_token JWT 30 дней httpOnly cookie Ротируется при каждом использовании /refresh

Содержимое access-токена (payload):

{
  "sub": "550e8400-e29b-41d4-a716-446655440000",
  "role": "user",
  "type": "access",
  "exp": 1234567890,
  "iat": 1234567200
}

Содержимое refresh-токена (payload):

{
  "sub": "550e8400-e29b-41d4-a716-446655440000",
  "jti": "UNIQUE_ID",
  "type": "refresh",
  "exp": 1234567890,
  "iat": 1234567200
}

POST /api/v1/auth/refresh

Ротировать refresh-токен и выдать новый access-токен.

Параметры:

  • Cookie: refresh_token=ТВОЙ_REFRESH_TOKEN (httpOnly, отправляется браузером автоматически)

Ответ (200 OK):

{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "bearer"
}

Заголовок ответа:

Set-Cookie: refresh_token=NEW_JWT; HttpOnly; Secure; SameSite=Strict; Max-Age=2592000

Ошибки:

  • 401 Unauthorized (missing_refresh_token) — cookie refresh_token не передана
  • 401 Unauthorized (invalid_refresh_token) — токен невалиден, просрочен, отозван или уже был использован (reuse)

Механика ротации:

  1. Backend декодирует refresh-токен и проверяет его jti в Redis (refresh:{jti}user_id)
  2. Если jti есть в Redis → токен валиден, пользователь существует
  3. Если jti отсутствует → reuse или отозван → 401
  4. Старый jti немедленно удаляется из Redis
  5. Выдаётся новый refresh-токен с новым jti, добавляется в Redis
  6. Повторное использование уже потраченного refresh-токена автоматически блокируется

POST /api/v1/auth/logout

Отозвать refresh-токен (удалить его из Redis) и погасить httpOnly cookie.

Параметры:

  • Cookie: refresh_token=ТВОЙ_REFRESH_TOKEN (опционально; если отсутствует, просто удалится cookie)

Ответ (204 No Content): — пустой ответ при успехе

Побочный эффект:

  • Удаляет jti из Redis → последующие попытки использовать этот refresh-токен дадут 401
  • Удаляет cookie refresh_token (устанавливает в пустое значение)

GET /api/v1/users/me

Получить профиль текущего аутентифицированного пользователя.

Параметры:

  • Заголовок: Authorization: Bearer <access_token> (обязателен)

Ответ (200 OK):

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "email": "user@example.com",
  "name_user": "Иван Петров",
  "role": "user"
}

Ошибки:

  • 401 Unauthorized (not_authenticated) — access-токен отсутствует, невалиден или истёк

RBAC (Role-Based Access Control)

Роли

  • admin — администратор (полный доступ, требуется require_admin)
  • user — обычный пользователь (доступ к основным функциям)
  • guest — не аутентифицированный пользователь (отсутствие JWT)

Зависимости FastAPI для авторизации

get_current_user — требуется аутентификация

@router.get("/profile")
async def profile(user: Annotated[User, Depends(get_current_user)]) -> UserOut:
    """Доступно только аутентифицированным пользователям."""
    return user
  • Возвращает объект User
  • 401 если токен отсутствует, невалиден или истёк

get_current_user_optional — опциональная аутентификация (guest = None)

@router.get("/public")
async def public_data(user: Annotated[User | None, Depends(get_current_user_optional)]) -> dict:
    """Доступно всем; guest может прочитать, но не будет знать о себе."""
    if user is None:
        return {"message": "hello guest"}
    return {"message": f"hello {user.name_user}"}
  • Возвращает User или None
  • Никогда не выбрасывает 401; отсутствие JWT → None
  • Важно: Guest в системе — это отсутствие JWT, а не отдельное enum-значение в БД

require_admin — требуется роль admin

@router.delete("/users/{id}")
async def delete_user(id: uuid.UUID, admin: Annotated[User, Depends(require_admin)]) -> None:
    """Доступно только администраторам."""
    # ...
  • 401 если не аутентифицирован
  • 403 если роль не admin

Архитектурные решения

Refresh-токены в httpOnly cookies

Refresh-токены хранятся в httpOnly cookie (недоступно из JavaScript), чтобы защитить их от XSS. Браузер отправляет cookie автоматически на каждый запрос к /api/v1/auth/refresh.

Почему httpOnly + Secure + SameSite=Strict:

  • httpOnly — недоступно из JavaScript (защита от XSS)
  • Secure — передаётся только по HTTPS (защита от MITM)
  • SameSite=Strict — не отправляется при кросс-сайтовых запросах (защита от CSRF)

Server-side refresh-token validation

Refresh-токены проверяются через Redis-хранилище (refresh:{jti} → user_id):

  • Отзыв: удаление из Redis на logout или reuse
  • Ротация: старый jti удаляется немедленно после использования → reuse → 401
  • TTL: Redis-ключ имеет TTL, равный сроку жизни токена

Это позволяет:

  1. Отозвать токены без пересоздания ключей подписи
  2. Обнаружить replay-атаки (повторное использование старого токена)
  3. Контролировать количество активных сессий per user (если реализовать)

Сравнение с альтернативами

Подход Плюсы Минусы Используется
httpOnly cookie Защита от XSS Требует SameSite для защиты CSRF Refresh-токен
Bearer token в теле Явный контроль Уязвимо для XSS Access-токен (одноразовый)
Opaque tokens (session ID) Компактно Требует БД на каждый запрос Не используется

Примеры использования

JavaScript / TypeScript (frontend)

// Регистрация
async function register(email: string, name: string, password: string) {
  const res = await fetch('/api/v1/auth/register', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, name_user: name, password }),
  })
  if (!res.ok) throw new Error(`Error: ${res.status}`)
  return await res.json()
}

// Подтверждение email
async function verifyEmail(token: string) {
  const res = await fetch('/api/v1/auth/verify-email', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ token }),
  })
  if (!res.ok) throw new Error(`Error: ${res.status}`)
}

// Вход
async function login(email: string, password: string) {
  const formData = new URLSearchParams()
  formData.append('username', email)
  formData.append('password', password)
  const res = await fetch('/api/v1/auth/token', {
    method: 'POST',
    body: formData,
    credentials: 'include', // ВАЖНО: отправлять cookies
  })
  if (!res.ok) throw new Error(`Error: ${res.status}`)
  const data = await res.json()
  localStorage.setItem('access_token', data.access_token) // Храним access-токен
  // refresh-токен в cookie автоматический (httpOnly, браузер управляет)
  return data
}

// Использование access-токена
async function getProfile() {
  const token = localStorage.getItem('access_token')
  const res = await fetch('/api/v1/users/me', {
    headers: { 'Authorization': `Bearer ${token}` },
  })
  if (!res.ok) throw new Error(`Error: ${res.status}`)
  return await res.json()
}

// Обновление access-токена (refresh-токен в cookie отправляется автоматически)
async function refreshToken() {
  const res = await fetch('/api/v1/auth/refresh', {
    method: 'POST',
    credentials: 'include', // ВАЖНО: отправлять cookies
  })
  if (!res.ok) throw new Error(`Error: ${res.status}`)
  const data = await res.json()
  localStorage.setItem('access_token', data.access_token)
  return data
}

// Выход
async function logout() {
  const res = await fetch('/api/v1/auth/logout', {
    method: 'POST',
    credentials: 'include', // ВАЖНО: отправлять cookies
  })
  localStorage.removeItem('access_token') // Удаляем access-токен
  // refresh-токен будет удалён серверноvim (cookie)
}

curl примеры

# Полный цикл регистрации и входа

# 1. Регистрация
curl -X POST http://localhost:8000/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"alice@example.com","name_user":"Alice","password":"securepass123"}'
# Ответ: {"id":"...", "email":"alice@example.com", "name_user":"Alice", "role":"user"}

# 2. Проверить логи консоли (ConsoleEmailBackend выведет токен подтверждения)
# Из вывода скопировать token, например: "...token=abc123..."

# 3. Подтвердить email
curl -X POST http://localhost:8000/api/v1/auth/verify-email \
  -H "Content-Type: application/json" \
  -d '{"token":"abc123"}'
# Ответ: (204 No Content)

# 4. Вход
curl -X POST http://localhost:8000/api/v1/auth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d 'username=alice@example.com&password=securepass123' \
  -i
# Ответ:
# HTTP/1.1 200 OK
# Set-Cookie: refresh_token=eyJ...; HttpOnly; Secure; SameSite=Strict; ...
# {"access_token":"eyJ...", "token_type":"bearer"}

# 5. Сохранить access-токен из ответа, использовать его
export ACCESS_TOKEN="eyJ..."

# 6. Получить профиль
curl http://localhost:8000/api/v1/users/me \
  -H "Authorization: Bearer $ACCESS_TOKEN"
# Ответ: {"id":"...", "email":"alice@example.com", "name_user":"Alice", "role":"user"}

# 7. Обновить access-токен (refresh_token в cookie отправляется автоматически)
curl -X POST http://localhost:8000/api/v1/auth/refresh \
  -b "refresh_token=eyJ..." \
  -i
# Ответ:
# HTTP/1.1 200 OK
# Set-Cookie: refresh_token=NEW_TOKEN; HttpOnly; Secure; SameSite=Strict; ...
# {"access_token":"NEW_ACCESS_TOKEN", "token_type":"bearer"}

# 8. Выход
curl -X POST http://localhost:8000/api/v1/auth/logout \
  -b "refresh_token=eyJ..." \
  -i
# Ответ: HTTP/1.1 204 No Content (cookie удалена)

# Попытка использовать refresh-токен после logout → 401
curl -X POST http://localhost:8000/api/v1/auth/refresh \
  -b "refresh_token=eyJ..." \
  -i
# Ответ: HTTP/1.1 401 Unauthorized {"detail":"invalid_refresh_token"}

Константы и конфигурация

Все TTL и сроки хранения настраиваются через .env:

# Access-токен (JWT)
ACCESS_TOKEN_TTL_MINUTES=15

# Refresh-токен (JWT + Redis)
REFRESH_TOKEN_TTL_DAYS=30

# Токен подтверждения email
EMAIL_VERIFICATION_TTL_HOURS=24

# JWT secret (используется для подписи всех токенов)
JWT_SECRET=your-secret-key-here

# Алгоритм хэширования паролей (argon2)
ARGON2_TIME_COST=2
ARGON2_MEMORY_COST=19  # 2^19 КБ = 512 МБ
ARGON2_PARALLELISM=1

Связанные файлы

  • Реализация: backend/api/auth.py, backend/api/users.py, backend/api/deps.py
  • Бизнес-логика: backend/services/auth.py
  • Безопасность: backend/core/security.py (token creation/verification, password hashing)
  • Модели: backend/models/user.py, backend/models/email_verification.py
  • Схемы: backend/schemas/auth.py
  • Тесты: backend/tests/test_auth.py, backend/tests/test_rbac.py