# Аутентификация и авторизация Эндпоинты для регистрации, верификации 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_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 ` (обязателен) **Ответ (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`