Files
vidconf/docs/api/auth.md

514 lines
21 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Аутентификация и авторизация
Эндпоинты для регистрации, верификации email, входа и управления JWT-токенами.
## Быстрый старт
```bash
# 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):**
```json
{
"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.
**Тело запроса:**
```json
{
"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):**
```json
{
"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 пользователя по одноразовому токену из письма.
**Тело запроса:**
```json
{
"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):**
```json
{
"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):**
```json
{
"sub": "550e8400-e29b-41d4-a716-446655440000",
"role": "user",
"type": "access",
"exp": 1234567890,
"iat": 1234567200
}
```
**Содержимое refresh-токена (payload):**
```json
{
"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):**
```json
{
"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):**
```json
{
"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` — требуется аутентификация**
```python
@router.get("/profile")
async def profile(user: Annotated[User, Depends(get_current_user)]) -> UserOut:
"""Доступно только аутентифицированным пользователям."""
return user
```
- Возвращает объект User
- 401 если токен отсутствует, невалиден или истёк
**`get_current_user_optional` — опциональная аутентификация (guest = None)**
```python
@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**
```python
@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)
```typescript
// Регистрация
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 примеры
```bash
# Полный цикл регистрации и входа
# 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`:
```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`