Passkey and magic-link auth mechanisms were added.
This commit is contained in:
parent
314ccefb85
commit
0793135105
14 changed files with 1722 additions and 58 deletions
13
.env.example
13
.env.example
|
|
@ -93,7 +93,18 @@ ADMIN_DEFAULT_EMAIL=admin@contract-check.local
|
||||||
ADMIN_DEFAULT_PASSWORD=changeme-strong-password
|
ADMIN_DEFAULT_PASSWORD=changeme-strong-password
|
||||||
PASSWORD_RESET_TTL_MINUTES=60
|
PASSWORD_RESET_TTL_MINUTES=60
|
||||||
PASSWORD_MIN_LENGTH=8
|
PASSWORD_MIN_LENGTH=8
|
||||||
WEB_APP_BASE_URL=http://localhost:5173 # SPA base — used to build reset links
|
WEB_APP_BASE_URL=http://localhost:5173 # SPA base — used to build reset + magic links
|
||||||
|
|
||||||
|
# --- Passkeys (WebAuthn) ---
|
||||||
|
PASSKEY_ENABLED=true # toggle /api/v1/auth/passkeys/* routes
|
||||||
|
PASSKEY_RP_ID=localhost # effective domain of the webUI (must match the browser URL host)
|
||||||
|
PASSKEY_RP_NAME=Контракт-чек # shown in the browser passkey prompt
|
||||||
|
PASSKEY_RP_ORIGINS=http://localhost:5173 # comma-separated allowed WebAuthn origins
|
||||||
|
PASSKEY_CHALLENGE_TTL_SECONDS=120 # ceremony challenge validity (single-use, Redis)
|
||||||
|
|
||||||
|
# --- Magic-link auth (passwordless email login) ---
|
||||||
|
MAGIC_LINK_ENABLED=true # toggle /api/v1/auth/magic-link/* routes
|
||||||
|
MAGIC_LINK_TTL_MINUTES=15 # one-time link validity window
|
||||||
|
|
||||||
# --- Prescreen stage (hybrid heuristic + optional LLM fallback) ---
|
# --- Prescreen stage (hybrid heuristic + optional LLM fallback) ---
|
||||||
PRESCREEN_ENABLED=true
|
PRESCREEN_ENABLED=true
|
||||||
|
|
|
||||||
133
README.md
133
README.md
|
|
@ -1,9 +1,12 @@
|
||||||
# Контракт-чек
|
# Контракт-чек
|
||||||
|
|
||||||
LLM-сервис скрининга рисков в договорах (PDF/DOCX) по ГК РФ / ГК РБ.
|
LLM-сервис скрининга рисков в договорах по ГК РФ / ГК РБ.
|
||||||
Telegram-бот MVP + B2B API сейчас; веб + подписки — позже.
|
Telegram-бот + web-аутентификация (email/password, JWT-пара; пасскеи и магические ссылки — passwordless) + B2B API сейчас; подписки — позже.
|
||||||
|
|
||||||
Pipeline: `PDF/DOCX → текст (pymupdf/tesseract OCR) → чанки → Ollama Cloud (LLM, json-schema + repair-loop) → markdown-отчёт` с цитатами, ссылкой на пункт и дисклеймером «не заменяет юриста».
|
Pipeline: `PDF/DOCX/RTF/TXT/CSV/изображения (pymupdf / mammoth / tesseract OCR) → текст → чанки →
|
||||||
|
прескрин (гибридная эвристика + LLM: метаданные договора, роутинг) → LLM-анализ
|
||||||
|
(Ollama Cloud или YandexGPT, json-schema + repair-loop) → markdown-отчёт`
|
||||||
|
с цитатами, ссылкой на пункт и дисклеймером «не заменяет юриста».
|
||||||
|
|
||||||
## Документы
|
## Документы
|
||||||
|
|
||||||
|
|
@ -12,26 +15,36 @@ Pipeline: `PDF/DOCX → текст (pymupdf/tesseract OCR) → чанки → Ol
|
||||||
- [`docs/BUSINESS_IDEA.md`](docs/BUSINESS_IDEA.md) — идея и бизнес-модель.
|
- [`docs/BUSINESS_IDEA.md`](docs/BUSINESS_IDEA.md) — идея и бизнес-модель.
|
||||||
- [`docs/IMPLEMENTATION_PLAN.md`](docs/IMPLEMENTATION_PLAN.md) — исходный «ленивый» план. Этап 0 (прототип) актуален; этапы 1+ superseded в `docs/ARCHITECTURE.md`.
|
- [`docs/IMPLEMENTATION_PLAN.md`](docs/IMPLEMENTATION_PLAN.md) — исходный «ленивый» план. Этап 0 (прототип) актуален; этапы 1+ superseded в `docs/ARCHITECTURE.md`.
|
||||||
- [`docs/TICKETS.md`](docs/TICKETS.md) — тикеты реализации со статусами.
|
- [`docs/TICKETS.md`](docs/TICKETS.md) — тикеты реализации со статусами.
|
||||||
|
- [`docs/PHASE2_HANDOFF.md`](docs/PHASE2_HANDOFF.md), [`docs/PHASES_2_PLUS_ROADMAP.md`](docs/PHASES_2_PLUS_ROADMAP.md), [`docs/PRESCREEN_HYBRID_REFACTOR_PLAN.md`](docs/PRESCREEN_HYBRID_REFACTOR_PLAN.md) — этап 2: prescreen-стейдж и дальнейший роадмап.
|
||||||
|
- [`docs/SPIKE_PHASE0.md`](docs/SPIKE_PHASE0.md) — спайки: prescreen-библиотеки, RustFS (артефакты в `rustfs-spike/`).
|
||||||
|
|
||||||
## Архитектура (одна строка)
|
## Архитектура (одна строка)
|
||||||
|
|
||||||
Hexagonal (ports & adapters). Ядро `contract_check.core` владеет всем состоянием
|
Hexagonal (ports & adapters). Ядро `contract_check.core` владеет всем состоянием
|
||||||
(Postgres/MinIO/RabbitMQ/LLM/кредиты). Четыре сервиса собираются из него:
|
(Postgres/MinIO/RabbitMQ/LLM/кредиты). Шесть сервисов собираются из него:
|
||||||
|
|
||||||
```
|
```
|
||||||
Telegram ──► bot (aiogram, HTTP-only) ──HTTP──► api (FastAPI) ──publish──► RabbitMQ
|
Telegram ──► bot (aiogram, HTTP-only) ──HTTP──► api (FastAPI) ──publish──► RabbitMQ
|
||||||
владеет: PG, MinIO, │
|
владеет: PG, MinIO, │
|
||||||
Redis, кредитами ▼
|
Redis, кредитами ▼
|
||||||
extract.q ──► worker-extract
|
extract.q ──► worker-extract
|
||||||
(pymupdf+tesseract, CPU) │
|
(pymupdf/mammoth/tesseract, CPU) │
|
||||||
▼ publish
|
▼ publish
|
||||||
analyze.q ──► worker-analyze
|
prescreen.q ──► worker-prescreen
|
||||||
(LLM, I/O) → Report
|
(гибридные метаданные + роутинг) │
|
||||||
|
┌──────────────────────────────────────┤
|
||||||
|
▼ deep_analysis manual_review
|
||||||
|
analyze.q ──► worker-analyze │
|
||||||
|
(LLM, I/O) → Report │
|
||||||
|
▼
|
||||||
|
notify.q ──► worker-notify (SMTP: password reset, magic link и др.)
|
||||||
```
|
```
|
||||||
|
|
||||||
- **api** — единственный писатель для пользовательских мутаций (upload → reserve credit → MinIO → publish `DocumentUploaded`).
|
- **api** — единственный писатель для пользовательских мутаций (upload → reserve credit → MinIO → publish `DocumentUploaded`).
|
||||||
- **worker-extract** — CPU: достаёт текст (PDF/DOCX), при необходимости OCR, грузит `.txt` в MinIO, публикует `DocumentExtracted`.
|
- **worker-extract** — CPU: достаёт текст (PDF/DOCX/RTF/TXT/CSV, OCR для изображений; детект формата через python-magic), грузит markdown в MinIO, публикует `DocumentExtracted`.
|
||||||
|
- **worker-prescreen** — гибридное извлечение метаданных договора (Stage 1 эвристика + Stage 2 LLM при низкой уверенности), роутинг: `deep_analysis` → analyze.q / `manual_review` / `auto_approve` → report.completed. Пишет в `prescreen_results`.
|
||||||
- **worker-analyze** — I/O: LLM-анализ по чек-листу, валидация/repair, сохраняет `Report`, `status=done`.
|
- **worker-analyze** — I/O: LLM-анализ по чек-листу, валидация/repair, сохраняет `Report`, `status=done`.
|
||||||
|
- **worker-notify** — доставка email-уведомлений (восстановление пароля и др.) через SMTP; без `SMTP_HOST` — dev-логгер.
|
||||||
- **bot** — адаптер: HTTP-клиент к api, **не импортирует** core.db/s3/llm/mq (граница проверяется тестом `tests/unit/test_bot_boundary.py`).
|
- **bot** — адаптер: HTTP-клиент к api, **не импортирует** core.db/s3/llm/mq (граница проверяется тестом `tests/unit/test_bot_boundary.py`).
|
||||||
|
|
||||||
## Структура репозитория
|
## Структура репозитория
|
||||||
|
|
@ -40,42 +53,52 @@ Telegram ──► bot (aiogram, HTTP-only) ──HTTP──► api (FastAPI)
|
||||||
src/contract_check/
|
src/contract_check/
|
||||||
__main__.py # указывает на prototype (stage-0 CLI сохранён)
|
__main__.py # указывает на prototype (stage-0 CLI сохранён)
|
||||||
core/ # общий домен (импортируется каждым сервисом)
|
core/ # общий домен (импортируется каждым сервисом)
|
||||||
config.py logging.py telemetry.py sentry.py metrics.py
|
config.py logging.py telemetry.py sentry.py metrics.py errors.py
|
||||||
credits.py tokens.py api_keys.py rate_limit.py redis_client.py
|
credits.py tokens.py api_keys.py rate_limit.py redis_client.py
|
||||||
|
auth.py auth_refresh.py auth_refresh_key.py passkeys.py
|
||||||
db/ models.py session.py enums.py
|
db/ models.py session.py enums.py
|
||||||
mq/ topology.py publisher.py consumer.py messages.py
|
mq/ topology.py publisher.py consumer.py messages.py
|
||||||
s3/ port.py minio_storage.py
|
s3/ port.py minio_storage.py
|
||||||
llm/ port.py ollama_cloud.py factory.py
|
llm/ port.py factory.py ollama_cloud.py yandex_gpt.py prescreen.py
|
||||||
analysis/ extractor.py chunker.py checklist.py report_schema.py ocr.py analyzer.py
|
extraction/ port.py factory.py + adapters/
|
||||||
|
(pdf_pymupdf, docx_mammoth, rtf_striprtf, txt_chardet, ocr_tesseract)
|
||||||
|
analysis/ extractor.py chunker.py checklist.py report_schema.py ocr.py analyzer.py
|
||||||
|
notifications/ transport.py publisher.py # SMTP + dev-log
|
||||||
|
security/ passwords.py # argon2
|
||||||
api/ # FastAPI-образ
|
api/ # FastAPI-образ
|
||||||
app.py deps.py middleware.py services.py __main__.py
|
app.py deps.py middleware.py services.py __main__.py
|
||||||
admin/ # серверный UI по /admin (users; позже — подписки)
|
admin/ # серверный UI по /admin (login, users; позже — подписки)
|
||||||
routes/ health.py documents.py reports.py me.py metrics.py b2b.py
|
routes/ health.py documents.py reports.py me.py metrics.py auth.py b2b.py
|
||||||
worker_extract/ # CPU-образ (pymupdf + tesseract)
|
worker_extract/ # CPU-образ (pymupdf + mammoth + tesseract)
|
||||||
consumer.py handler.py extract_document.py __main__.py
|
consumer.py handler.py extract_document.py __main__.py
|
||||||
|
worker_prescreen/ # прескрин-образ (гибрид: эвристика + LLM)
|
||||||
|
consumer.py handler.py router.py config.py
|
||||||
|
extractor.py extractor_heuristic.py extractor_llm.py extractor_hybrid.py
|
||||||
worker_analyze/ # I/O-образ (LLM provider)
|
worker_analyze/ # I/O-образ (LLM provider)
|
||||||
consumer.py handler.py __main__.py
|
consumer.py handler.py __main__.py
|
||||||
|
worker_notify/ # email-уведомления (aiosmtplib)
|
||||||
|
consumer.py handler.py __main__.py
|
||||||
bot/ # aiogram-адаптер (самый «тощий» образ: только core.logging)
|
bot/ # aiogram-адаптер (самый «тощий» образ: только core.logging)
|
||||||
client.py config.py handlers.py __main__.py
|
client.py config.py handlers.py rate_limit.py __main__.py
|
||||||
prototype/ # stage-0 standalone CLI (бенчмарк go/no-go)
|
prototype/ # stage-0 standalone CLI (бенчмарк go/no-go)
|
||||||
docs/ # документация проекта (README остаётся в корне)
|
docs/ # документация проекта (README остаётся в корне)
|
||||||
ARCHITECTURE.md # архитектура, схемы БД, конфиг
|
|
||||||
DEPLOY.md # руководство по развёртыванию
|
|
||||||
TICKETS.md # статусы тикетов
|
|
||||||
IMPLEMENTATION_PLAN.md # исходный план (superseded для этапов 1+)
|
|
||||||
BUSINESS_IDEA.md # продукт / бизнес-модель
|
|
||||||
srv/ # Dockerfile-ы (один на сервис, deps заточены)
|
srv/ # Dockerfile-ы (один на сервис, deps заточены)
|
||||||
api/Dockerfile worker-extract/Dockerfile worker-analyze/Dockerfile
|
api/ worker-extract/ worker-prescreen/ worker-analyze/ worker-notify/ bot/ prototype/
|
||||||
bot/Dockerfile prototype/Dockerfile
|
migrations/ # alembic (async): 0001_initial … 0009_user_name
|
||||||
migrations/ # alembic (async): 0001_initial, 0002_api_keys
|
|
||||||
tests/
|
tests/
|
||||||
conftest.py
|
conftest.py
|
||||||
unit/ chunker, extractor, llm_ollama_cloud, credits, messages,
|
unit/ chunker, extractor, extraction_factory/adapters, llm_ollama_cloud,
|
||||||
rate_limit, checklist_report, bot_client, bot_boundary
|
llm_yandex_gpt, llm_prescreen_extraction, prescreen_extractor(+heuristic/
|
||||||
integration/ upload_pipeline, extract_worker, analyze_worker, b2b_api, credits_db
|
hybrid/llm), prescreen_router, credits, messages, rate_limit,
|
||||||
docker-compose.yml # default = инфра; --profile services = стек
|
checklist_report, auth, web_auth, passkeys, extract_handler,
|
||||||
pyproject.toml # hatchling + PEP 735 dependency-groups (db/mq/s3/obs/api/extract/analyze/bot/prototype/dev)
|
bot_client, bot_boundary
|
||||||
|
integration/ upload_pipeline, extract_worker, prescreen_worker, analyze_worker,
|
||||||
|
b2b_api, credits_db, auth_flow, passkeys_magic_link, admin_panel
|
||||||
|
Makefile # повседневные команды (make help)
|
||||||
|
docker-compose.yml # default = инфра; --profile services = стек; --profile edge = nginx+certbot
|
||||||
|
pyproject.toml # hatchling + PEP 735 dependency-groups (db/mq/s3/obs/api/extract/prescreen/analyze/notify/bot/prototype/dev)
|
||||||
.env.example # полный список env (см. docs/ARCHITECTURE.md §11)
|
.env.example # полный список env (см. docs/ARCHITECTURE.md §11)
|
||||||
|
rustfs-spike/ # артефакты спайка RustFS (docs/SPIKE_PHASE0.md)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Быстрый старт
|
## Быстрый старт
|
||||||
|
|
@ -84,31 +107,36 @@ pyproject.toml # hatchling + PEP 735 dependency-groups (db/mq/s3
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv sync --group dev # все группы для локальной разработки
|
uv sync --group dev # все группы для локальной разработки
|
||||||
cp .env.example .env # впишите OLLAMA_HOST / OLLAMA_API_KEY
|
cp .env.example .env # впишите OLLAMA_* / YANDEXGPT_* / JWT_SECRET / BOT_TOKEN
|
||||||
docker compose up -d # только инфра (postgres/redis/rabbitmq/minio)
|
docker compose up -d # только инфра (postgres/redis/rabbitmq/minio)
|
||||||
uv run alembic upgrade head # миграции
|
uv run alembic upgrade head # миграции
|
||||||
uv run python -m contract_check.api # api на :8000
|
uv run python -m contract_check.api # api на :8000
|
||||||
uv run python -m contract_check.worker_extract # воркер экстракции
|
uv run python -m contract_check.worker_extract # воркер экстракции
|
||||||
|
uv run python -m contract_check.worker_prescreen # воркер прескрина
|
||||||
uv run python -m contract_check.worker_analyze # воркер анализа
|
uv run python -m contract_check.worker_analyze # воркер анализа
|
||||||
|
uv run python -m contract_check.worker_notify # воркер уведомлений
|
||||||
uv run python -m contract_check.bot # Telegram-бот
|
uv run python -m contract_check.bot # Telegram-бот
|
||||||
```
|
```
|
||||||
|
|
||||||
### Через Docker Compose
|
### Через Docker Compose / Make
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env # заполнить секреты (DB, Rabbit, MinIO, Ollama, BOT_TOKEN, ...)
|
cp .env.example .env # заполнить секреты (DB, Rabbit, MinIO, LLM, BOT_TOKEN, JWT_SECRET, ...)
|
||||||
docker compose up -d # только инфра с healthchecks
|
docker compose up -d # только инфра с healthchecks
|
||||||
docker compose --profile services up -d --build # + api, worker-extract, worker-analyze, bot
|
docker compose --profile services up -d --build # + api, worker-extract, worker-prescreen, worker-analyze, worker-notify, bot
|
||||||
```
|
```
|
||||||
|
|
||||||
Профиль `services` собирает 4 образа из `srv/<service>/Dockerfile` и поднимает их
|
Или через `make`: `make dev` (install + infra + migrate + services), `make help` — полный список
|
||||||
|
целей (lint, typecheck, test, seed-token, jwt-token, admin-promote, логи/шеллы сервисов и т.п.).
|
||||||
|
|
||||||
|
Профиль `services` собирает 6 образов из `srv/<service>/Dockerfile` и поднимает их
|
||||||
с `depends_on: condition: service_healthy`. Edge-прокси (Nginx + certbot) доступен
|
с `depends_on: condition: service_healthy`. Edge-прокси (Nginx + certbot) доступен
|
||||||
профилем `edge` (`deploy/nginx/`, `docs/DEPLOY.md §13`). Observability (Prometheus/Grafana/Tempo/OTel)
|
профилем `edge` (`deploy/nginx/`, `docs/DEPLOY.md §13`). Observability (Prometheus/Grafana/Tempo/OTel)
|
||||||
— за будущим профилем `obs`.
|
— за будущим профилем `obs`.
|
||||||
|
|
||||||
Порты на хосте (смещены, чтобы не конфликтовать): Postgres `15432`, Redis `17379`,
|
Порты на хосте (смещены, чтобы не конфликтовать): Postgres `15432`, Redis `17379`,
|
||||||
RabbitMQ AMQP `5672` / UI `15672`, MinIO `9000` / console `9001`, api `8000` / metrics `9100`,
|
RabbitMQ AMQP `5672` / UI `15672`, MinIO `9000` / console `9001`, api `8000` / metrics `9100`,
|
||||||
worker metrics `9101`/`9102`, edge `80`/`443`.
|
worker metrics: extract `9101`, analyze `9102`, notify `9103`, prescreen `9104`, edge `80`/`443`.
|
||||||
|
|
||||||
### Stage-0 прототип (бенчмарк)
|
### Stage-0 прототип (бенчмарк)
|
||||||
|
|
||||||
|
|
@ -123,7 +151,7 @@ uv run python -m contract_check prototype contract.pdf --json metrics.json # +
|
||||||
|
|
||||||
JSON-роуты под `/api/v1`; серверный admin UI — по `/admin/*` (FastAPI + Jinja2 + HTMX). Auth зависит от роута:
|
JSON-роуты под `/api/v1`; серверный admin UI — по `/admin/*` (FastAPI + Jinja2 + HTMX). Auth зависит от роута:
|
||||||
|
|
||||||
- **Пользовательские роуты** (bot / web / Mini App) — `Authorization: Bearer <user_jwt>`. JWT выдаётся через `/api/v1/auth/telegram/*` после проверки identity от Telegram.
|
- **Пользовательские роуты** (bot / web / Mini App) — `Authorization: Bearer <user_jwt>`. Telegram-JWT выдаётся через `/api/v1/auth/telegram/*`; web — JWT-пара (access + refresh, refresh отзывается через Redis); passwordless (пасскей / магическая ссылка) — одиночный access JWT.
|
||||||
- **Адаптер-level** (только `/api/v1/auth/telegram/bot`) — `Authorization: Bearer <service_token>`.
|
- **Адаптер-level** (только `/api/v1/auth/telegram/bot`) — `Authorization: Bearer <service_token>`.
|
||||||
- **B2B** — `X-API-Key`. Управление B2B-ключами требует пользовательский JWT.
|
- **B2B** — `X-API-Key`. Управление B2B-ключами требует пользовательский JWT.
|
||||||
- **Health/metrics** — без auth.
|
- **Health/metrics** — без auth.
|
||||||
|
|
@ -135,23 +163,36 @@ JSON-роуты под `/api/v1`; серверный admin UI — по `/admin/*
|
||||||
| POST | `/api/v1/auth/telegram/bot` | service token | бот меняет verified `telegram_id` на JWT |
|
| POST | `/api/v1/auth/telegram/bot` | service token | бот меняет verified `telegram_id` на JWT |
|
||||||
| POST | `/api/v1/auth/telegram/web` | — | Telegram Login Widget → JWT |
|
| POST | `/api/v1/auth/telegram/web` | — | Telegram Login Widget → JWT |
|
||||||
| POST | `/api/v1/auth/telegram/miniapp` | — | Mini App `initData` → JWT |
|
| POST | `/api/v1/auth/telegram/miniapp` | — | Mini App `initData` → JWT |
|
||||||
| POST | `/api/v1/auth/register` | — | регистрация email/password |
|
| POST | `/api/v1/auth/register` | — | регистрация email/password → JWT-пара |
|
||||||
| POST | `/api/v1/auth/login` | — | вход email/password → JWT-пара |
|
| POST | `/api/v1/auth/login` | — | вход email/password → JWT-пара |
|
||||||
| GET | `/api/v1/auth/me` | user JWT | introspect JWT |
|
| POST | `/api/v1/auth/logout` | — | отзыв refresh-токена |
|
||||||
|
| POST | `/api/v1/auth/forgot-password` | — | reset-токен (хранится хэшем) + уведомление через notify.q |
|
||||||
|
| POST | `/api/v1/auth/reset-password` | — | задать новый пароль, отозвать все refresh |
|
||||||
|
| POST | `/api/v1/auth/passkeys/register/start`, `.../finish` | user JWT | регистрация пасскея (WebAuthn, challenge в Redis) |
|
||||||
|
| POST | `/api/v1/auth/passkeys/authenticate/start`, `.../finish` | — | вход по пасскею (discoverable) → access JWT |
|
||||||
|
| GET/DEL | `/api/v1/auth/passkeys`, `/api/v1/auth/passkeys/{id}` | user JWT | список / удаление пасскеев |
|
||||||
|
| POST | `/api/v1/auth/magic-link/request` | — | одноразовая ссылка входа на email (через notify.q) |
|
||||||
|
| POST | `/api/v1/auth/magic-link/verify` | — | обмен токена из письма на access JWT |
|
||||||
|
| GET | `/api/v1/auth/me`, `/api/v1/auth/me/permissions` | user JWT | introspect JWT / права |
|
||||||
| POST | `/api/v1/documents` | user JWT | multipart upload → reserve credit → MinIO → publish → `202 {document_id, correlation_id}` |
|
| POST | `/api/v1/documents` | user JWT | multipart upload → reserve credit → MinIO → publish → `202 {document_id, correlation_id}` |
|
||||||
| GET | `/api/v1/documents/{id}` | user JWT | статус + stage (для поллинга) |
|
| GET | `/api/v1/reports/{document_id}` | user JWT | поллинг: `{status, stage}` или готовый `200 {markdown, findings, ...}` |
|
||||||
| GET | `/api/v1/reports/{document_id}` | user JWT | `202 {status, stage}` или `200 {markdown, findings, ...}` |
|
| GET | `/api/v1/me` | user JWT | профиль + `credits_left` |
|
||||||
| GET | `/api/v1/me` | user JWT | `{telegram_id, credits_left}` |
|
| GET | `/api/v1/me/documents` | user JWT | список документов пользователя |
|
||||||
|
| POST | `/api/v1/me/telegram`, `/api/v1/me/password` | user JWT | привязка Telegram / смена пароля |
|
||||||
| POST | `/api/v1/analyze` | `X-API-Key` | B2B: анализ документа |
|
| POST | `/api/v1/analyze` | `X-API-Key` | B2B: анализ документа |
|
||||||
| GET | `/api/v1/b2b/reports/{id}`, `/api/v1/b2b/usage` | `X-API-Key` | B2B: отчёт / usage |
|
| GET | `/api/v1/b2b/reports/{id}`, `/api/v1/b2b/usage` | `X-API-Key` | B2B: отчёт / usage |
|
||||||
| POST/GET | `/api/v1/b2b/keys`, `/api/v1/b2b/keys/{id}/revoke`, `.../usage` | user JWT | управление B2B-ключами |
|
| POST/GET | `/api/v1/b2b/keys`, `/api/v1/b2b/keys/{id}/revoke`, `.../usage` | user JWT | управление B2B-ключами |
|
||||||
| GET/POST | `/admin/users*` | admin cookie | управление пользователями (list, create, edit, ban, role, credits) |
|
| GET/POST | `/admin/*` | admin cookie | login/logout + управление пользователями (list, create, edit, ban, role, credits, verify-telegram) |
|
||||||
|
|
||||||
Полная спецификация — `docs/ARCHITECTURE.md §15` (актуализируется).
|
Полная спецификация роутов (схемы запросов/ответов, коды ошибок) —
|
||||||
|
[`src/contract_check/api/routes/README.md`](src/contract_check/api/routes/README.md);
|
||||||
|
высокоуровневая поверхность — `docs/ARCHITECTURE.md §15` (актуализируется).
|
||||||
|
|
||||||
## Проверки (DoD)
|
## Проверки (DoD)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run ruff check . && uv run mypy src && uv run pytest -q # unit, быстро
|
uv run ruff check src tests && uv run ruff format --check src tests # lint (или make lint)
|
||||||
|
uv run ty check src # типы (или make typecheck)
|
||||||
|
uv run pytest -m "not integration" -q # unit, быстро
|
||||||
uv run pytest -m integration -q # интеграционные (нужны контейнеры)
|
uv run pytest -m integration -q # интеграционные (нужны контейнеры)
|
||||||
```
|
```
|
||||||
|
|
|
||||||
65
migrations/versions/0010_passkeys_magic_links.py
Normal file
65
migrations/versions/0010_passkeys_magic_links.py
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
"""passkey_credentials table + magic-link columns on users
|
||||||
|
|
||||||
|
Revision ID: 0010
|
||||||
|
Revises: 0009
|
||||||
|
Create Date: 2026-08-22
|
||||||
|
|
||||||
|
WebAuthn (passkey) auth: one row per registered credential; `credential_id`
|
||||||
|
and `public_key` are base64url strings. Magic-link login reuses the
|
||||||
|
password-reset pattern on users: only a SHA-256 token hash + expiry is
|
||||||
|
stored, the raw token travels inside the emailed link.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0010"
|
||||||
|
down_revision: str | None = "0009"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"passkey_credentials",
|
||||||
|
sa.Column("id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False),
|
||||||
|
sa.Column("user_id", sa.UUID(), nullable=False),
|
||||||
|
sa.Column("credential_id", sa.Text(), nullable=False),
|
||||||
|
sa.Column("public_key", sa.Text(), nullable=False),
|
||||||
|
sa.Column("sign_count", sa.BigInteger(), server_default=sa.text("0"), nullable=False),
|
||||||
|
sa.Column("device_name", sa.Text(), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.CheckConstraint("sign_count >= 0", name="passkey_credentials_sign_count_nonneg"),
|
||||||
|
)
|
||||||
|
op.create_unique_constraint(
|
||||||
|
"passkey_credentials_credential_id_unique", "passkey_credentials", ["credential_id"]
|
||||||
|
)
|
||||||
|
op.create_index("passkey_credentials_user_idx", "passkey_credentials", ["user_id"])
|
||||||
|
|
||||||
|
op.add_column("users", sa.Column("magic_link_token_hash", sa.Text(), nullable=True))
|
||||||
|
op.add_column(
|
||||||
|
"users", sa.Column("magic_link_expires_at", sa.DateTime(timezone=True), nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("users", "magic_link_expires_at")
|
||||||
|
op.drop_column("users", "magic_link_token_hash")
|
||||||
|
op.drop_index("passkey_credentials_user_idx", table_name="passkey_credentials")
|
||||||
|
op.drop_constraint(
|
||||||
|
"passkey_credentials_credential_id_unique", "passkey_credentials", type_="unique"
|
||||||
|
)
|
||||||
|
op.drop_table("passkey_credentials")
|
||||||
|
|
@ -73,6 +73,7 @@ api = [
|
||||||
"pyjwt[crypto]>=2.8",
|
"pyjwt[crypto]>=2.8",
|
||||||
"argon2-cffi>=23.1",
|
"argon2-cffi>=23.1",
|
||||||
"email-validator>=2.1",
|
"email-validator>=2.1",
|
||||||
|
"webauthn>=2.5",
|
||||||
"opentelemetry-instrumentation-fastapi>=0.45b0",
|
"opentelemetry-instrumentation-fastapi>=0.45b0",
|
||||||
"opentelemetry-instrumentation-asgi>=0.45b0",
|
"opentelemetry-instrumentation-asgi>=0.45b0",
|
||||||
"opentelemetry-instrumentation-httpx>=0.45b0",
|
"opentelemetry-instrumentation-httpx>=0.45b0",
|
||||||
|
|
@ -143,6 +144,10 @@ dev = [
|
||||||
line-length = 100
|
line-length = 100
|
||||||
target-version = "py313"
|
target-version = "py313"
|
||||||
src = ["src", "tests"]
|
src = ["src", "tests"]
|
||||||
|
# Alembic migrations are autogenerated-style history; exclude them from lint+format.
|
||||||
|
extend-exclude = ["migrations"]
|
||||||
|
# Honor the exclusion even when files are passed explicitly (pre-commit does this).
|
||||||
|
force-exclude = true
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = ["E4", "E7", "E9", "F", "UP", "B"]
|
select = ["E4", "E7", "E9", "F", "UP", "B"]
|
||||||
|
|
|
||||||
|
|
@ -9,18 +9,19 @@ from typing import Annotated, Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import Depends, Header, HTTPException, Request
|
from fastapi import Depends, Header, HTTPException, Request
|
||||||
from sqlalchemy import text
|
from sqlalchemy import select, text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ..core.api_keys import hash_api_key
|
from ..core.api_keys import hash_api_key
|
||||||
from ..core.auth import AuthError, TokenExpiredError, TokenInvalidError, verify_access_token
|
from ..core.auth import AuthError, TokenExpiredError, TokenInvalidError, verify_access_token
|
||||||
from ..core.auth_refresh import RefreshTokenStore
|
from ..core.auth_refresh import RefreshTokenStore
|
||||||
from ..core.config import get_settings
|
from ..core.config import get_settings
|
||||||
from ..core.db.models import User
|
from ..core.db.models import PasskeyCredential, User
|
||||||
from ..core.db.session import create_session_factory
|
from ..core.db.session import create_session_factory
|
||||||
from ..core.logging import get_logger
|
from ..core.logging import get_logger
|
||||||
from ..core.mq.publisher import Publisher
|
from ..core.mq.publisher import Publisher
|
||||||
from ..core.notifications.publisher import NotificationPublisher
|
from ..core.notifications.publisher import NotificationPublisher
|
||||||
|
from ..core.passkeys import PasskeyChallengeStore
|
||||||
from ..core.rate_limit import RateLimiter
|
from ..core.rate_limit import RateLimiter
|
||||||
from ..core.s3.port import Storage
|
from ..core.s3.port import Storage
|
||||||
from ..core.security.passwords import hash_password
|
from ..core.security.passwords import hash_password
|
||||||
|
|
@ -89,6 +90,22 @@ def get_refresh_store(request: Request) -> RefreshTokenStore:
|
||||||
RefreshStoreDep = Annotated[RefreshTokenStore, Depends(get_refresh_store)]
|
RefreshStoreDep = Annotated[RefreshTokenStore, Depends(get_refresh_store)]
|
||||||
|
|
||||||
|
|
||||||
|
def get_passkey_challenge_store(request: Request) -> PasskeyChallengeStore:
|
||||||
|
"""Build a PasskeyChallengeStore from the app-state Redis client.
|
||||||
|
|
||||||
|
Raises 503 if Redis is unavailable — WebAuthn ceremonies cannot function
|
||||||
|
without server-side challenge storage.
|
||||||
|
"""
|
||||||
|
redis: Any = getattr(request.app.state, "redis", None)
|
||||||
|
if redis is None:
|
||||||
|
raise HTTPException(status_code=503, detail="passkey challenge store unavailable")
|
||||||
|
settings = get_settings()
|
||||||
|
return PasskeyChallengeStore(redis, ttl_seconds=settings.passkey_challenge_ttl_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
PasskeyChallengeStoreDep = Annotated[PasskeyChallengeStore, Depends(get_passkey_challenge_store)]
|
||||||
|
|
||||||
|
|
||||||
async def require_service_token(
|
async def require_service_token(
|
||||||
session: AsyncSessionDep,
|
session: AsyncSessionDep,
|
||||||
authorization: Annotated[str | None, Header()] = None,
|
authorization: Annotated[str | None, Header()] = None,
|
||||||
|
|
@ -257,6 +274,28 @@ async def fetch_user_by_email(session: AsyncSession, email: str) -> User | None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_passkey_credentials_for_user(
|
||||||
|
session: AsyncSession, user_id: UUID
|
||||||
|
) -> list[PasskeyCredential]:
|
||||||
|
"""All passkey credentials of a user, newest first."""
|
||||||
|
result = await session.execute(
|
||||||
|
select(PasskeyCredential)
|
||||||
|
.where(PasskeyCredential.user_id == user_id)
|
||||||
|
.order_by(PasskeyCredential.created_at.desc())
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_passkey_by_credential_id(
|
||||||
|
session: AsyncSession, credential_id: str
|
||||||
|
) -> PasskeyCredential | None:
|
||||||
|
"""Look up a passkey credential by its base64url credential id."""
|
||||||
|
result = await session.execute(
|
||||||
|
select(PasskeyCredential).where(PasskeyCredential.credential_id == credential_id)
|
||||||
|
)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
|
||||||
async def fetch_user_by_id_full(session: AsyncSession, user_id: UUID) -> User | None:
|
async def fetch_user_by_id_full(session: AsyncSession, user_id: UUID) -> User | None:
|
||||||
"""Fetch a user by UUID including web-auth columns."""
|
"""Fetch a user by UUID including web-auth columns."""
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
|
|
|
||||||
|
|
@ -261,6 +261,90 @@ re-login на всех устройствах).
|
||||||
(`sub`, `telegram_id`, `type`, `exp`) сохранены для совместимости; добавлены
|
(`sub`, `telegram_id`, `type`, `exp`) сохранены для совместимости; добавлены
|
||||||
`email`, `credits_left`, `is_active`, `created_at`.
|
`email`, `credits_left`, `is_active`, `created_at`.
|
||||||
|
|
||||||
|
### Passkeys (WebAuthn) + magic-link
|
||||||
|
|
||||||
|
Passwordless-методы веб-аутентификации. Оба выдают **один access JWT**
|
||||||
|
(без refresh): `passkeyAuthenticationResponseSchema` /
|
||||||
|
`magicLinkVerifyResponseSchema` на фронте.
|
||||||
|
|
||||||
|
| Метод | Путь | Auth | Описание |
|
||||||
|
| ----- | ---------------------------------------- | ------ | -------------------------------------------- |
|
||||||
|
| POST | `/api/v1/auth/passkeys/register/start` | Bearer | PublicKeyCredentialCreationOptions + challenge в Redis |
|
||||||
|
| POST | `/api/v1/auth/passkeys/register/finish` | Bearer | Проверка attestation → 201, ключ сохранён |
|
||||||
|
| POST | `/api/v1/auth/passkeys/authenticate/start` | — | PublicKeyCredentialRequestOptions (discoverable) |
|
||||||
|
| POST | `/api/v1/auth/passkeys/authenticate/finish` | — | Проверка assertion → access JWT |
|
||||||
|
| GET | `/api/v1/auth/passkeys` | Bearer | Список пасскеев пользователя |
|
||||||
|
| DEL | `/api/v1/auth/passkeys/{id}` | Bearer | Удалить пасскей (только свой) |
|
||||||
|
| POST | `/api/v1/auth/magic-link/request` | — | Одноразовая ссылка входа на email |
|
||||||
|
| POST | `/api/v1/auth/magic-link/verify` | — | Обмен токена из письма на access JWT |
|
||||||
|
|
||||||
|
Детали:
|
||||||
|
|
||||||
|
- Ceremonies — по два шага (start/finish). Challenge живёт в Redis
|
||||||
|
`PASSKEY_CHALLENGE_TTL_SECONDS` (default 120 c), одноразовый.
|
||||||
|
Регистрация: challenge ключуется `user_id`; аутентификация — самим
|
||||||
|
challenge (клиент возвращает его в `finish`-запросе).
|
||||||
|
- `authenticatorSelection`: `residentKey=required`, `userVerification=required`
|
||||||
|
→ discoverable-креды, `allowCredentials` пустой; пользователь
|
||||||
|
определяется на finish по `credential.id` из БД.
|
||||||
|
- RP-параметры: `PASSKEY_RP_ID` / `PASSKEY_RP_NAME` / `PASSKEY_RP_ORIGINS`
|
||||||
|
(comma-separated). Флаги `PASSKEY_ENABLED`, `MAGIC_LINK_ENABLED`
|
||||||
|
включают/выключают группы эндпоинтов (404 при выключении).
|
||||||
|
- Хранение: таблица `passkey_credentials` (credential_id/public_key —
|
||||||
|
base64url, sign_count — защита от клонирования, ротация на каждом входе).
|
||||||
|
- Magic-link повторяет паттерн forgot-password: SHA-256 хеш токена +
|
||||||
|
TTL (`MAGIC_LINK_TTL_MINUTES`, default 15) в `users`, письмо через
|
||||||
|
`notify.q` (`kind=magic_link`), ссылка `{WEB_APP_BASE_URL}/magic-link?token=...`.
|
||||||
|
Ответ request всегда одинаковый (не раскрывает зарегистрированные email),
|
||||||
|
verify одноразовый — хеш сбрасывается сразу.
|
||||||
|
|
||||||
|
#### `POST /api/v1/auth/passkeys/register/start`
|
||||||
|
|
||||||
|
Тело (опционально): `{ "device_name": "YubiKey 5" }`. Ответ —
|
||||||
|
`PasskeyRegistrationOptions` (camelCase, base64url-строки; соответствует
|
||||||
|
zod-схеме фронта): `challenge`, `rp {id, name, origin}`, `user {id, name,
|
||||||
|
displayName}`, `pubKeyCredParams[]`, `timeout`, `attestation="none"`,
|
||||||
|
`excludeCredentials[]`, `authenticatorSelection {residentKey, userVerification}`.
|
||||||
|
|
||||||
|
#### `POST /api/v1/auth/passkeys/register/finish`
|
||||||
|
|
||||||
|
Тело: `{ "credential": {...result of navigator.credentials.create()...}, "device_name"? }`
|
||||||
|
(base64url). Ответ `201` — `{ "verified": true, "credentialId": "<base64url>" }`.
|
||||||
|
`400` — нет/просрочен challenge, битый attestation; `409` — credential уже
|
||||||
|
зарегистрирован.
|
||||||
|
|
||||||
|
#### `POST /api/v1/auth/passkeys/authenticate/start|finish`
|
||||||
|
|
||||||
|
start — без тела, ответ `PasskeyAuthenticationOptions`: `challenge`,
|
||||||
|
`timeout`, `rpId`, `allowCredentials=[]`, `userVerification`.
|
||||||
|
finish — тело `{ "challenge": "<из start>", "credential": {...result of
|
||||||
|
navigator.credentials.get()...} }`; ответ — access JWT (см. ниже).
|
||||||
|
`400` — неизвестный/просроченный challenge или битая подпись; `401` —
|
||||||
|
неизвестный credential; `403` — аккаунт отключён.
|
||||||
|
|
||||||
|
#### `GET /api/v1/auth/passkeys` / `DELETE /api/v1/auth/passkeys/{id}`
|
||||||
|
|
||||||
|
Список — массив `{ id, credentialID, credentialPublicKey, counter, userId,
|
||||||
|
deviceName, createdAt }`, новые первыми. Удаление чужого/несуществующего —
|
||||||
|
`404`, успех — `{ "ok": true }`.
|
||||||
|
|
||||||
|
#### `POST /api/v1/auth/magic-link/request` / `verify`
|
||||||
|
|
||||||
|
request — тело `{ "email" }`, всегда `200 { success, message, expires_in }`
|
||||||
|
(секунды). verify — тело `{ "token" }`, ответ:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"access_token": "<jwt>",
|
||||||
|
"token_type": "bearer",
|
||||||
|
"expires_in": 86400,
|
||||||
|
"user_id": "uuid",
|
||||||
|
"email": "user@example.com"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`400` — невалидный/просроченный токен (протухшие сбрасываются сразу).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## me
|
## me
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""Authentication endpoints: Telegram identity sources + webUI email/password.
|
"""Authentication endpoints: Telegram identity sources + webUI auth methods.
|
||||||
|
|
||||||
Three Telegram identity sources converge on the same access JWT:
|
Three Telegram identity sources converge on the same access JWT:
|
||||||
- /auth/telegram/bot — bot adapter exchanges a verified telegram_id for JWT
|
- /auth/telegram/bot — bot adapter exchanges a verified telegram_id for JWT
|
||||||
|
|
@ -13,10 +13,19 @@ WebUI (email/password) endpoints issue a JWT pair (access + refresh):
|
||||||
- POST /auth/forgot-password — store reset-token hash, enqueue notification
|
- POST /auth/forgot-password — store reset-token hash, enqueue notification
|
||||||
- POST /auth/reset-password — verify token, set new password
|
- POST /auth/reset-password — verify token, set new password
|
||||||
|
|
||||||
|
Passwordless webUI auth (single access token, no refresh):
|
||||||
|
- POST /auth/passkeys/register/start|finish — add a passkey (Bearer)
|
||||||
|
- POST /auth/passkeys/authenticate/start|finish — passkey login
|
||||||
|
- GET /auth/passkeys — list passkeys (Bearer)
|
||||||
|
- DEL /auth/passkeys/{id} — remove a passkey (Bearer)
|
||||||
|
- POST /auth/magic-link/request — email one-time login link
|
||||||
|
- POST /auth/magic-link/verify — exchange link token for JWT
|
||||||
|
|
||||||
Refresh tokens carry a `jti` tracked in Redis so logout is enforceable.
|
Refresh tokens carry a `jti` tracked in Redis so logout is enforceable.
|
||||||
Password reset tokens are stored as SHA-256 hashes with an expiry in the
|
Password reset and magic-link tokens are stored as SHA-256 hashes with an
|
||||||
users row; the actual delivery link is built by the notify worker.
|
expiry in the users row; the actual delivery link is built by the notify
|
||||||
Protected user endpoints receive the access JWT via
|
worker. WebAuthn challenges live in Redis (single-use, short TTL) — see
|
||||||
|
core/passkeys.py. Protected user endpoints receive the access JWT via
|
||||||
`Authorization: Bearer <jwt>` and use `deps.CurrentUser`.
|
`Authorization: Bearer <jwt>` and use `deps.CurrentUser`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -30,6 +39,12 @@ from typing import Annotated, Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Header, HTTPException, status
|
from fastapi import APIRouter, Header, HTTPException, status
|
||||||
from pydantic import BaseModel, EmailStr, Field
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
from webauthn import verify_authentication_response, verify_registration_response
|
||||||
|
from webauthn.helpers import base64url_to_bytes, bytes_to_base64url
|
||||||
|
from webauthn.helpers.exceptions import (
|
||||||
|
InvalidAuthenticationResponse,
|
||||||
|
InvalidRegistrationResponse,
|
||||||
|
)
|
||||||
|
|
||||||
from ...core.auth import (
|
from ...core.auth import (
|
||||||
AuthError,
|
AuthError,
|
||||||
|
|
@ -42,16 +57,24 @@ from ...core.auth import (
|
||||||
verify_telegram_web_payload,
|
verify_telegram_web_payload,
|
||||||
)
|
)
|
||||||
from ...core.config import get_settings
|
from ...core.config import get_settings
|
||||||
from ...core.db.models import User
|
from ...core.db.models import PasskeyCredential, User
|
||||||
from ...core.logging import get_logger
|
from ...core.logging import get_logger
|
||||||
from ...core.mq.messages import NotificationMessage
|
from ...core.mq.messages import NotificationMessage
|
||||||
|
from ...core.passkeys import (
|
||||||
|
build_authentication_options,
|
||||||
|
build_registration_options,
|
||||||
|
)
|
||||||
from ...core.security.passwords import hash_password, verify_password
|
from ...core.security.passwords import hash_password, verify_password
|
||||||
from ..deps import (
|
from ..deps import (
|
||||||
AsyncSessionDep,
|
AsyncSessionDep,
|
||||||
AuthDep,
|
AuthDep,
|
||||||
|
CurrentUserDep,
|
||||||
NotificationPublisherDep,
|
NotificationPublisherDep,
|
||||||
|
PasskeyChallengeStoreDep,
|
||||||
RefreshStoreDep,
|
RefreshStoreDep,
|
||||||
create_email_user,
|
create_email_user,
|
||||||
|
fetch_passkey_by_credential_id,
|
||||||
|
fetch_passkey_credentials_for_user,
|
||||||
fetch_user_by_email,
|
fetch_user_by_email,
|
||||||
fetch_user_by_id_full,
|
fetch_user_by_id_full,
|
||||||
get_or_create_user_for_telegram,
|
get_or_create_user_for_telegram,
|
||||||
|
|
@ -539,3 +562,548 @@ def _require_web_auth_enabled() -> None:
|
||||||
"""Gate webUI endpoints behind a feature flag (WEB_AUTH_ENABLED)."""
|
"""Gate webUI endpoints behind a feature flag (WEB_AUTH_ENABLED)."""
|
||||||
if not get_settings().web_auth_enabled:
|
if not get_settings().web_auth_enabled:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="web auth disabled")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="web auth disabled")
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Passkeys (WebAuthn)
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class PasskeyRpInfo(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
origin: str
|
||||||
|
|
||||||
|
|
||||||
|
class PasskeyUserInfo(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
displayName: str
|
||||||
|
|
||||||
|
|
||||||
|
class PubKeyCredParam(BaseModel):
|
||||||
|
alg: int
|
||||||
|
type: str
|
||||||
|
|
||||||
|
|
||||||
|
class CredDescriptor(BaseModel):
|
||||||
|
id: str
|
||||||
|
type: str
|
||||||
|
|
||||||
|
|
||||||
|
class AuthenticatorSelectionInfo(BaseModel):
|
||||||
|
residentKey: str
|
||||||
|
userVerification: str
|
||||||
|
|
||||||
|
|
||||||
|
class PasskeyRegistrationOptions(BaseModel):
|
||||||
|
"""PublicKeyCredentialCreationOptions — matches the frontend zod schema."""
|
||||||
|
|
||||||
|
challenge: str
|
||||||
|
rp: PasskeyRpInfo
|
||||||
|
user: PasskeyUserInfo
|
||||||
|
pubKeyCredParams: list[PubKeyCredParam]
|
||||||
|
timeout: int
|
||||||
|
attestation: str
|
||||||
|
excludeCredentials: list[CredDescriptor]
|
||||||
|
authenticatorSelection: AuthenticatorSelectionInfo
|
||||||
|
|
||||||
|
|
||||||
|
class PasskeyAuthenticationOptions(BaseModel):
|
||||||
|
"""PublicKeyCredentialRequestOptions — matches the frontend zod schema."""
|
||||||
|
|
||||||
|
challenge: str
|
||||||
|
timeout: int
|
||||||
|
rpId: str
|
||||||
|
allowCredentials: list[CredDescriptor]
|
||||||
|
userVerification: str
|
||||||
|
|
||||||
|
|
||||||
|
class PasskeyCredentialPublic(BaseModel):
|
||||||
|
"""Stored credential — matches passkeyCredentialSchema on the frontend."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
credentialID: str
|
||||||
|
credentialPublicKey: str
|
||||||
|
counter: int
|
||||||
|
userId: str
|
||||||
|
deviceName: str
|
||||||
|
createdAt: dt.datetime
|
||||||
|
|
||||||
|
|
||||||
|
class PasskeyRegistrationFinishResponse(BaseModel):
|
||||||
|
verified: bool
|
||||||
|
credentialId: str
|
||||||
|
|
||||||
|
|
||||||
|
class SingleTokenAuthResponse(BaseModel):
|
||||||
|
"""Shared by passkey-auth finish and magic-link verify (frontend schemas)."""
|
||||||
|
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
expires_in: int
|
||||||
|
user_id: uuid.UUID
|
||||||
|
email: str
|
||||||
|
|
||||||
|
|
||||||
|
class MagicLinkResponse(BaseModel):
|
||||||
|
"""Matches magicLinkResponseSchema on the frontend."""
|
||||||
|
|
||||||
|
success: bool = True
|
||||||
|
message: str
|
||||||
|
expires_in: int # link TTL in seconds
|
||||||
|
|
||||||
|
|
||||||
|
class PasskeyRegisterStartRequest(BaseModel):
|
||||||
|
device_name: str | None = Field(default=None, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
class RegistrationCredentialData(BaseModel):
|
||||||
|
"""`navigator.credentials.create()` result, base64url-encoded."""
|
||||||
|
|
||||||
|
clientDataJSON: str
|
||||||
|
attestationObject: str
|
||||||
|
transports: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RegistrationCredentialPayload(BaseModel):
|
||||||
|
id: str
|
||||||
|
rawId: str
|
||||||
|
type: str
|
||||||
|
response: RegistrationCredentialData
|
||||||
|
|
||||||
|
|
||||||
|
class PasskeyRegisterFinishRequest(BaseModel):
|
||||||
|
credential: RegistrationCredentialPayload
|
||||||
|
device_name: str | None = Field(default=None, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthenticationCredentialData(BaseModel):
|
||||||
|
"""`navigator.credentials.get()` result, base64url-encoded."""
|
||||||
|
|
||||||
|
clientDataJSON: str
|
||||||
|
authenticatorData: str
|
||||||
|
signature: str
|
||||||
|
userHandle: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AuthenticationCredentialPayload(BaseModel):
|
||||||
|
id: str
|
||||||
|
rawId: str
|
||||||
|
type: str
|
||||||
|
response: AuthenticationCredentialData
|
||||||
|
|
||||||
|
|
||||||
|
class PasskeyAuthenticateFinishRequest(BaseModel):
|
||||||
|
challenge: str = Field(..., min_length=16, max_length=512)
|
||||||
|
credential: AuthenticationCredentialPayload
|
||||||
|
|
||||||
|
|
||||||
|
def _require_passkey_enabled() -> None:
|
||||||
|
"""Gate passkey endpoints behind a feature flag (PASSKEY_ENABLED)."""
|
||||||
|
if not get_settings().passkey_enabled:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="passkeys disabled")
|
||||||
|
|
||||||
|
|
||||||
|
def _require_magic_link_enabled() -> None:
|
||||||
|
"""Gate magic-link endpoints behind a feature flag (MAGIC_LINK_ENABLED)."""
|
||||||
|
if not get_settings().magic_link_enabled:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="magic links disabled")
|
||||||
|
|
||||||
|
|
||||||
|
def _issue_single_token(user: User) -> SingleTokenAuthResponse:
|
||||||
|
"""Mint one access JWT (no refresh) for passwordless login flows."""
|
||||||
|
settings = get_settings()
|
||||||
|
return SingleTokenAuthResponse(
|
||||||
|
access_token=create_access_token(user.id, user.telegram_id or 0),
|
||||||
|
token_type="bearer",
|
||||||
|
expires_in=settings.jwt_access_ttl_minutes * 60,
|
||||||
|
user_id=user.id,
|
||||||
|
email=user.email or "",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/auth/passkeys/register/start",
|
||||||
|
response_model=PasskeyRegistrationOptions,
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
async def passkeys_register_start(
|
||||||
|
user: CurrentUserDep,
|
||||||
|
session: AsyncSessionDep,
|
||||||
|
challenges: PasskeyChallengeStoreDep,
|
||||||
|
body: PasskeyRegisterStartRequest | None = None,
|
||||||
|
) -> PasskeyRegistrationOptions:
|
||||||
|
"""Begin passkey registration for the authenticated user.
|
||||||
|
|
||||||
|
Returns PublicKeyCredentialCreationOptions (resident key + user
|
||||||
|
verification required, existing credentials excluded) and stores the
|
||||||
|
challenge in Redis keyed by the user id.
|
||||||
|
"""
|
||||||
|
_require_passkey_enabled()
|
||||||
|
full_user = await fetch_user_by_id_full(session, user.user_id)
|
||||||
|
if full_user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
||||||
|
|
||||||
|
existing = await fetch_passkey_credentials_for_user(session, user.user_id)
|
||||||
|
options, challenge = build_registration_options(
|
||||||
|
settings=get_settings(),
|
||||||
|
user_id=full_user.id,
|
||||||
|
user_name=full_user.email or str(full_user.id),
|
||||||
|
existing_credential_ids=[cred.credential_id for cred in existing],
|
||||||
|
)
|
||||||
|
await challenges.store_registration(
|
||||||
|
user.user_id,
|
||||||
|
challenge=challenge,
|
||||||
|
device_name=body.device_name if body else None,
|
||||||
|
)
|
||||||
|
return PasskeyRegistrationOptions(**options)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/auth/passkeys/register/finish",
|
||||||
|
response_model=PasskeyRegistrationFinishResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def passkeys_register_finish(
|
||||||
|
user: CurrentUserDep,
|
||||||
|
session: AsyncSessionDep,
|
||||||
|
challenges: PasskeyChallengeStoreDep,
|
||||||
|
body: PasskeyRegisterFinishRequest,
|
||||||
|
) -> PasskeyRegistrationFinishResponse:
|
||||||
|
"""Verify the attestation and persist the credential.
|
||||||
|
|
||||||
|
The challenge is consumed from Redis (single use). A credential id
|
||||||
|
already registered on any account is rejected with 409.
|
||||||
|
"""
|
||||||
|
_require_passkey_enabled()
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
stored = await challenges.consume_registration(user.user_id)
|
||||||
|
if stored is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="no pending registration challenge (expired or already used)",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
verified = verify_registration_response(
|
||||||
|
credential=body.credential.model_dump(exclude_none=True),
|
||||||
|
expected_challenge=base64url_to_bytes(stored["challenge"]),
|
||||||
|
expected_origin=settings.passkey_rp_origins,
|
||||||
|
expected_rp_id=settings.passkey_rp_id,
|
||||||
|
require_user_verification=True,
|
||||||
|
)
|
||||||
|
except InvalidRegistrationResponse as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail=f"invalid registration: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
credential_id = bytes_to_base64url(verified.credential_id)
|
||||||
|
duplicate = await fetch_passkey_by_credential_id(session, credential_id)
|
||||||
|
if duplicate is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT, detail="credential already registered"
|
||||||
|
)
|
||||||
|
|
||||||
|
session.add(
|
||||||
|
PasskeyCredential(
|
||||||
|
user_id=user.user_id,
|
||||||
|
credential_id=credential_id,
|
||||||
|
public_key=bytes_to_base64url(verified.credential_public_key),
|
||||||
|
sign_count=verified.sign_count,
|
||||||
|
device_name=body.device_name or stored.get("device_name"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
log.info("passkey_registered", user_id=str(user.user_id), credential_id=credential_id)
|
||||||
|
return PasskeyRegistrationFinishResponse(verified=True, credentialId=credential_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/auth/passkeys/authenticate/start",
|
||||||
|
response_model=PasskeyAuthenticationOptions,
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
async def passkeys_authenticate_start(
|
||||||
|
challenges: PasskeyChallengeStoreDep,
|
||||||
|
) -> PasskeyAuthenticationOptions:
|
||||||
|
"""Begin discoverable-credential (passkey) login.
|
||||||
|
|
||||||
|
`allowCredentials` is empty — the authenticator offers its resident keys
|
||||||
|
and the user is resolved on finish from the returned credential id.
|
||||||
|
The challenge is stored in Redis under its own base64url value.
|
||||||
|
"""
|
||||||
|
_require_passkey_enabled()
|
||||||
|
options, challenge = build_authentication_options(settings=get_settings())
|
||||||
|
await challenges.store_authentication(challenge)
|
||||||
|
return PasskeyAuthenticationOptions(**options)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/auth/passkeys/authenticate/finish",
|
||||||
|
response_model=SingleTokenAuthResponse,
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
async def passkeys_authenticate_finish(
|
||||||
|
session: AsyncSessionDep,
|
||||||
|
challenges: PasskeyChallengeStoreDep,
|
||||||
|
body: PasskeyAuthenticateFinishRequest,
|
||||||
|
) -> SingleTokenAuthResponse:
|
||||||
|
"""Verify the assertion and issue an access JWT.
|
||||||
|
|
||||||
|
The echoed challenge must still be pending in Redis (single use); the
|
||||||
|
credential is resolved to its owner, the signature is checked against
|
||||||
|
the stored public key, and the sign counter is rotated.
|
||||||
|
"""
|
||||||
|
_require_passkey_enabled()
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
if not await challenges.consume_authentication(body.challenge):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="unknown or expired challenge",
|
||||||
|
)
|
||||||
|
|
||||||
|
record = await fetch_passkey_by_credential_id(session, body.credential.id)
|
||||||
|
if record is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unknown credential")
|
||||||
|
|
||||||
|
owner = await fetch_user_by_id_full(session, record.user_id)
|
||||||
|
if owner is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
||||||
|
if not owner.is_active:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="account disabled")
|
||||||
|
|
||||||
|
try:
|
||||||
|
verified = verify_authentication_response(
|
||||||
|
credential=body.credential.model_dump(exclude_none=True),
|
||||||
|
expected_challenge=base64url_to_bytes(body.challenge),
|
||||||
|
expected_origin=settings.passkey_rp_origins,
|
||||||
|
expected_rp_id=settings.passkey_rp_id,
|
||||||
|
credential_public_key=base64url_to_bytes(record.public_key),
|
||||||
|
credential_current_sign_count=record.sign_count,
|
||||||
|
require_user_verification=True,
|
||||||
|
)
|
||||||
|
except InvalidAuthenticationResponse as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail=f"invalid authentication: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
record.sign_count = verified.new_sign_count
|
||||||
|
record.last_used_at = dt.datetime.now(tz=dt.UTC)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
log.info("user_logged_in_passkey", user_id=str(owner.id))
|
||||||
|
return _issue_single_token(owner)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/auth/passkeys",
|
||||||
|
response_model=list[PasskeyCredentialPublic],
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
async def passkeys_list(
|
||||||
|
user: CurrentUserDep,
|
||||||
|
session: AsyncSessionDep,
|
||||||
|
) -> list[PasskeyCredentialPublic]:
|
||||||
|
"""List the authenticated user's registered passkeys (newest first)."""
|
||||||
|
_require_passkey_enabled()
|
||||||
|
creds = await fetch_passkey_credentials_for_user(session, user.user_id)
|
||||||
|
return [
|
||||||
|
PasskeyCredentialPublic(
|
||||||
|
id=str(cred.id),
|
||||||
|
credentialID=cred.credential_id,
|
||||||
|
credentialPublicKey=cred.public_key,
|
||||||
|
counter=cred.sign_count,
|
||||||
|
userId=str(cred.user_id),
|
||||||
|
deviceName=cred.device_name or "",
|
||||||
|
createdAt=cred.created_at,
|
||||||
|
)
|
||||||
|
for cred in creds
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/api/v1/auth/passkeys/{passkey_id}",
|
||||||
|
response_model=OkResponse,
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
async def passkeys_delete(
|
||||||
|
user: CurrentUserDep,
|
||||||
|
session: AsyncSessionDep,
|
||||||
|
passkey_id: uuid.UUID,
|
||||||
|
) -> OkResponse:
|
||||||
|
"""Remove one of the user's passkeys. Scoped to the owning user."""
|
||||||
|
_require_passkey_enabled()
|
||||||
|
|
||||||
|
from sqlalchemy import text as sa_text
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
sa_text("DELETE FROM passkey_credentials WHERE id = :i AND user_id = :u RETURNING id"),
|
||||||
|
{"i": passkey_id, "u": user.user_id},
|
||||||
|
)
|
||||||
|
if result.first() is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="passkey not found")
|
||||||
|
await session.commit()
|
||||||
|
log.info("passkey_deleted", user_id=str(user.user_id), passkey_id=str(passkey_id))
|
||||||
|
return OkResponse(ok=True, detail="passkey deleted")
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Magic-link auth (passwordless email login)
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class MagicLinkRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
class MagicLinkVerifyRequest(BaseModel):
|
||||||
|
token: str = Field(..., min_length=1, max_length=256)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_magic_link(token: str) -> str:
|
||||||
|
base = get_settings().web_app_base_url.rstrip("/")
|
||||||
|
return f"{base}/magic-link?token={token}"
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/auth/magic-link/request",
|
||||||
|
response_model=MagicLinkResponse,
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
async def magic_link_request(
|
||||||
|
session: AsyncSessionDep,
|
||||||
|
publisher: NotificationPublisherDep,
|
||||||
|
body: MagicLinkRequest,
|
||||||
|
) -> MagicLinkResponse:
|
||||||
|
"""Generate a one-time login token, store its hash, and enqueue a notification.
|
||||||
|
|
||||||
|
Always returns the same 200 payload regardless of whether the email
|
||||||
|
exists, to avoid leaking which addresses are registered (mirrors
|
||||||
|
forgot-password).
|
||||||
|
"""
|
||||||
|
_require_magic_link_enabled()
|
||||||
|
email_normalized = body.email.lower().strip()
|
||||||
|
settings = get_settings()
|
||||||
|
generic = MagicLinkResponse(
|
||||||
|
success=True,
|
||||||
|
message="if the email exists, a magic link was sent",
|
||||||
|
expires_in=settings.magic_link_ttl_minutes * 60,
|
||||||
|
)
|
||||||
|
|
||||||
|
user = await fetch_user_by_email(session, email_normalized)
|
||||||
|
if user is None:
|
||||||
|
log.info("magic_link_unknown_email", email=email_normalized)
|
||||||
|
return generic
|
||||||
|
|
||||||
|
raw_token = secrets.token_urlsafe(32)
|
||||||
|
expires_at = dt.datetime.now(tz=dt.UTC) + dt.timedelta(minutes=settings.magic_link_ttl_minutes)
|
||||||
|
|
||||||
|
from sqlalchemy import text as sa_text
|
||||||
|
|
||||||
|
await session.execute(
|
||||||
|
sa_text(
|
||||||
|
"UPDATE users SET magic_link_token_hash = :h, magic_link_expires_at = :e WHERE id = :u"
|
||||||
|
),
|
||||||
|
{"h": _hash_reset_token(raw_token), "e": expires_at, "u": user.id},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
link = _build_magic_link(raw_token)
|
||||||
|
notification = NotificationMessage(
|
||||||
|
correlation_id=uuid.uuid4(),
|
||||||
|
kind="magic_link",
|
||||||
|
to=email_normalized,
|
||||||
|
subject="Вход по ссылке — Контракт-чек",
|
||||||
|
body_text=(
|
||||||
|
"Вы запросили вход по ссылке.\n\n"
|
||||||
|
f"Перейдите по ссылке, чтобы войти (действует "
|
||||||
|
f"{settings.magic_link_ttl_minutes} мин.):\n{link}\n\n"
|
||||||
|
"Ссылка одноразовая. Если вы не запрашивали вход — просто "
|
||||||
|
"проигнорируйте это письмо."
|
||||||
|
),
|
||||||
|
body_html=(
|
||||||
|
"<p>Вы запросили вход по ссылке.</p>"
|
||||||
|
f'<p><a href="{link}">Войти</a> '
|
||||||
|
f"(действует {settings.magic_link_ttl_minutes} мин., ссылка одноразовая)</p>"
|
||||||
|
"<p>Если вы не запрашивали вход — проигнорируйте это письмо.</p>"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await publisher.publish(notification, routing_key="notify")
|
||||||
|
except Exception as exc: # noqa: BLE001 — best-effort; token is still storable
|
||||||
|
log.error(
|
||||||
|
"magic_link_publish_failed",
|
||||||
|
user_id=str(user.id),
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info("magic_link_enqueued", user_id=str(user.id))
|
||||||
|
return generic
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/auth/magic-link/verify",
|
||||||
|
response_model=SingleTokenAuthResponse,
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
async def magic_link_verify(
|
||||||
|
session: AsyncSessionDep,
|
||||||
|
body: MagicLinkVerifyRequest,
|
||||||
|
) -> SingleTokenAuthResponse:
|
||||||
|
"""Exchange a magic-link token for an access JWT.
|
||||||
|
|
||||||
|
On success the stored token hash is cleared, so every link works exactly
|
||||||
|
once. Expired tokens are also cleared (they can never be replayed).
|
||||||
|
"""
|
||||||
|
_require_magic_link_enabled()
|
||||||
|
token_hash = _hash_reset_token(body.token)
|
||||||
|
|
||||||
|
from sqlalchemy import text as sa_text
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
sa_text("SELECT id, magic_link_expires_at FROM users WHERE magic_link_token_hash = :h"),
|
||||||
|
{"h": token_hash},
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail="invalid magic-link token"
|
||||||
|
)
|
||||||
|
|
||||||
|
user_id, expires_at = row[0], row[1]
|
||||||
|
now = dt.datetime.now(tz=dt.UTC)
|
||||||
|
if expires_at is None or expires_at < now:
|
||||||
|
await session.execute(
|
||||||
|
sa_text(
|
||||||
|
"UPDATE users "
|
||||||
|
"SET magic_link_token_hash = NULL, magic_link_expires_at = NULL "
|
||||||
|
"WHERE id = :u"
|
||||||
|
),
|
||||||
|
{"u": user_id},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail="magic-link token expired"
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.execute(
|
||||||
|
sa_text(
|
||||||
|
"UPDATE users "
|
||||||
|
"SET magic_link_token_hash = NULL, magic_link_expires_at = NULL "
|
||||||
|
"WHERE id = :u"
|
||||||
|
),
|
||||||
|
{"u": user_id},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
user = await fetch_user_by_id_full(session, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
||||||
|
if not user.is_active:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="account disabled")
|
||||||
|
|
||||||
|
log.info("user_logged_in_magic_link", user_id=str(user.id))
|
||||||
|
return _issue_single_token(user)
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,40 @@ class Settings(BaseSettings):
|
||||||
# Minimum password length enforced at register / reset.
|
# Minimum password length enforced at register / reset.
|
||||||
password_min_length: int = 8
|
password_min_length: int = 8
|
||||||
|
|
||||||
|
# --- passkeys (WebAuthn) ---
|
||||||
|
passkey_enabled: bool = Field(
|
||||||
|
default=True,
|
||||||
|
description="Toggle for /api/v1/auth/passkeys/* routes",
|
||||||
|
)
|
||||||
|
passkey_rp_id: str = Field(
|
||||||
|
default="localhost",
|
||||||
|
description="WebAuthn Relying Party id (effective domain of the webUI)",
|
||||||
|
)
|
||||||
|
passkey_rp_name: str = Field(
|
||||||
|
default="Контракт-чек",
|
||||||
|
description="Human-readable Relying Party name shown in the browser prompt",
|
||||||
|
)
|
||||||
|
# Comma-separated origins allowed as WebAuthn callers (scheme://host:port).
|
||||||
|
passkey_rp_origins: Annotated[list[str], NoDecode] = ["http://localhost:5173"]
|
||||||
|
|
||||||
|
@field_validator("passkey_rp_origins", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _split_passkey_rp_origins(cls, v: object) -> object:
|
||||||
|
"""Accept comma-separated strings (the documented .env format) or lists."""
|
||||||
|
if isinstance(v, str):
|
||||||
|
value = v.split("#", 1)[0] # tolerate inline comments
|
||||||
|
return [origin.strip() for origin in value.split(",") if origin.strip()]
|
||||||
|
return v
|
||||||
|
|
||||||
|
passkey_challenge_ttl_seconds: int = 120 # ceremony challenge validity window
|
||||||
|
|
||||||
|
# --- magic-link auth (passwordless email login) ---
|
||||||
|
magic_link_enabled: bool = Field(
|
||||||
|
default=True,
|
||||||
|
description="Toggle for /api/v1/auth/magic-link/* routes",
|
||||||
|
)
|
||||||
|
magic_link_ttl_minutes: int = 15 # magic-link token validity window
|
||||||
|
|
||||||
# --- admin panel (server-rendered, mounted at /admin in the api) ---
|
# --- admin panel (server-rendered, mounted at /admin in the api) ---
|
||||||
web_admin_enabled: bool = Field(
|
web_admin_enabled: bool = Field(
|
||||||
default=True,
|
default=True,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
"""SQLAlchemy 2 declarative models — the 6 production tables.
|
"""SQLAlchemy 2 declarative models — the 7 production tables.
|
||||||
|
|
||||||
Tables: users, documents, reports, jobs, service_tokens, invoices (stub).
|
Tables: users, documents, reports, jobs, service_tokens, invoices (stub),
|
||||||
See docs/ARCHITECTURE.md §7. UUIDs default to gen_random_uuid() server-side
|
passkey_credentials. See docs/ARCHITECTURE.md §7. UUIDs default to
|
||||||
(built into Postgres 13+, no extension needed for pg16).
|
gen_random_uuid() server-side (built into Postgres 13+, no extension needed
|
||||||
|
for pg16).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -50,6 +51,8 @@ class User(Base):
|
||||||
password_hash: Mapped[str | None] = mapped_column(Text)
|
password_hash: Mapped[str | None] = mapped_column(Text)
|
||||||
password_reset_token_hash: Mapped[str | None] = mapped_column(Text)
|
password_reset_token_hash: Mapped[str | None] = mapped_column(Text)
|
||||||
password_reset_expires_at: Mapped[dt.datetime | None] = mapped_column(DateTime(timezone=True))
|
password_reset_expires_at: Mapped[dt.datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
magic_link_token_hash: Mapped[str | None] = mapped_column(Text)
|
||||||
|
magic_link_expires_at: Mapped[dt.datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
is_active: Mapped[bool] = mapped_column(
|
is_active: Mapped[bool] = mapped_column(
|
||||||
Boolean, nullable=False, default=True, server_default=text("true")
|
Boolean, nullable=False, default=True, server_default=text("true")
|
||||||
)
|
)
|
||||||
|
|
@ -82,6 +85,47 @@ class User(Base):
|
||||||
api_keys: Mapped[list[ApiKey]] = relationship(
|
api_keys: Mapped[list[ApiKey]] = relationship(
|
||||||
back_populates="user", cascade="all, delete-orphan"
|
back_populates="user", cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
|
passkey_credentials: Mapped[list[PasskeyCredential]] = relationship(
|
||||||
|
back_populates="user", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PasskeyCredential(Base):
|
||||||
|
"""Registered WebAuthn credential (passkey) bound to a user.
|
||||||
|
|
||||||
|
`credential_id`/`public_key` are base64url-encoded bytes as produced by
|
||||||
|
the webauthn helpers; the raw bytes never touch the DB.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "passkey_credentials"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
server_default=text("gen_random_uuid()"),
|
||||||
|
)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
credential_id: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
|
||||||
|
public_key: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
sign_count: Mapped[int] = mapped_column(
|
||||||
|
BigInteger, nullable=False, default=0, server_default=text("0")
|
||||||
|
)
|
||||||
|
device_name: Mapped[str | None] = mapped_column(Text)
|
||||||
|
created_at: Mapped[dt.datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
last_used_at: Mapped[dt.datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
user: Mapped[User] = relationship(back_populates="passkey_credentials")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint("sign_count >= 0", name="passkey_credentials_sign_count_nonneg"),
|
||||||
|
Index("passkey_credentials_user_idx", "user_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Document(Base):
|
class Document(Base):
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,7 @@ NotificationKind = Literal[
|
||||||
"password_reset",
|
"password_reset",
|
||||||
"welcome",
|
"welcome",
|
||||||
"email_verification",
|
"email_verification",
|
||||||
|
"magic_link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
191
src/contract_check/core/passkeys.py
Normal file
191
src/contract_check/core/passkeys.py
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
"""WebAuthn (passkey) ceremony helpers: options builders + challenge store.
|
||||||
|
|
||||||
|
Wraps the `webauthn` library (v3) for this project's settings and provides a
|
||||||
|
Redis-backed, single-use challenge store. Two ceremonies are supported:
|
||||||
|
|
||||||
|
- registration — options are generated for an authenticated user; the
|
||||||
|
challenge is stored under the user id (no extra state token needed);
|
||||||
|
- authentication — discoverable-credential (resident key) login; the
|
||||||
|
challenge is stored under its own base64url value and the client echoes
|
||||||
|
it back in the finish request.
|
||||||
|
|
||||||
|
Keys: `cc:passkey:reg:{user_id}` -> JSON, `cc:passkey:auth:{challenge}` -> "1".
|
||||||
|
Both expire after `PASSKEY_CHALLENGE_TTL_SECONDS` (default 120s) and are
|
||||||
|
consumed (GET + DELETE) on the matching finish call.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from webauthn import generate_authentication_options, generate_registration_options
|
||||||
|
from webauthn.helpers import base64url_to_bytes, bytes_to_base64url
|
||||||
|
from webauthn.helpers.structs import (
|
||||||
|
AuthenticatorSelectionCriteria,
|
||||||
|
PublicKeyCredentialDescriptor,
|
||||||
|
ResidentKeyRequirement,
|
||||||
|
UserVerificationRequirement,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
def registration_key(user_id: uuid.UUID) -> str:
|
||||||
|
"""Redis key for a pending registration challenge."""
|
||||||
|
return f"cc:passkey:reg:{user_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def authentication_key(challenge: str) -> str:
|
||||||
|
"""Redis key for a pending authentication challenge (base64url-safe)."""
|
||||||
|
return f"cc:passkey:auth:{challenge}"
|
||||||
|
|
||||||
|
|
||||||
|
def _encode(value: bytes | str) -> str:
|
||||||
|
return value if isinstance(value, str) else bytes_to_base64url(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _enum_str(value: object) -> str:
|
||||||
|
"""Coerce a struct enum or plain str to its wire value."""
|
||||||
|
return str(getattr(value, "value", value))
|
||||||
|
|
||||||
|
|
||||||
|
def build_registration_options(
|
||||||
|
*,
|
||||||
|
settings: Settings,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
user_name: str,
|
||||||
|
existing_credential_ids: list[str] | None = None,
|
||||||
|
) -> tuple[dict[str, Any], str]:
|
||||||
|
"""Generate PublicKeyCredentialCreationOptions for a user.
|
||||||
|
|
||||||
|
Returns `(options_dict, challenge_b64url)` where `options_dict` matches
|
||||||
|
the frontend zod schema (camelCase, base64url strings).
|
||||||
|
"""
|
||||||
|
opts = generate_registration_options(
|
||||||
|
rp_id=settings.passkey_rp_id,
|
||||||
|
rp_name=settings.passkey_rp_name,
|
||||||
|
user_name=user_name,
|
||||||
|
user_display_name=user_name,
|
||||||
|
user_id=user_id.bytes,
|
||||||
|
timeout=settings.passkey_challenge_ttl_seconds * 1000,
|
||||||
|
exclude_credentials=[
|
||||||
|
PublicKeyCredentialDescriptor(id=_decode(cid)) for cid in existing_credential_ids or []
|
||||||
|
],
|
||||||
|
authenticator_selection=AuthenticatorSelectionCriteria(
|
||||||
|
resident_key=ResidentKeyRequirement.REQUIRED,
|
||||||
|
user_verification=UserVerificationRequirement.REQUIRED,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
challenge = bytes_to_base64url(opts.challenge)
|
||||||
|
selection = opts.authenticator_selection
|
||||||
|
assert selection is not None # always set above
|
||||||
|
options = {
|
||||||
|
"challenge": challenge,
|
||||||
|
"rp": {
|
||||||
|
"id": opts.rp.id,
|
||||||
|
"name": opts.rp.name,
|
||||||
|
"origin": settings.passkey_rp_origins[0],
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"id": _encode(opts.user.id),
|
||||||
|
"name": opts.user.name,
|
||||||
|
"displayName": opts.user.display_name,
|
||||||
|
},
|
||||||
|
"pubKeyCredParams": [
|
||||||
|
{"alg": int(param.alg), "type": _enum_str(param.type)}
|
||||||
|
for param in opts.pub_key_cred_params
|
||||||
|
],
|
||||||
|
"timeout": opts.timeout,
|
||||||
|
"attestation": _enum_str(opts.attestation),
|
||||||
|
"excludeCredentials": [
|
||||||
|
{"id": _encode(desc.id), "type": _enum_str(desc.type)}
|
||||||
|
for desc in opts.exclude_credentials or []
|
||||||
|
],
|
||||||
|
"authenticatorSelection": {
|
||||||
|
"residentKey": _enum_str(selection.resident_key),
|
||||||
|
"userVerification": _enum_str(selection.user_verification),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return options, challenge
|
||||||
|
|
||||||
|
|
||||||
|
def build_authentication_options(*, settings: Settings) -> tuple[dict[str, Any], str]:
|
||||||
|
"""Generate PublicKeyCredentialRequestOptions (discoverable credentials).
|
||||||
|
|
||||||
|
Returns `(options_dict, challenge_b64url)` matching the frontend schema.
|
||||||
|
`allowCredentials` is empty — the authenticator offers resident keys and
|
||||||
|
the user is resolved server-side from the returned credential id.
|
||||||
|
"""
|
||||||
|
opts = generate_authentication_options(
|
||||||
|
rp_id=settings.passkey_rp_id,
|
||||||
|
timeout=settings.passkey_challenge_ttl_seconds * 1000,
|
||||||
|
user_verification=UserVerificationRequirement.REQUIRED,
|
||||||
|
)
|
||||||
|
challenge = bytes_to_base64url(opts.challenge)
|
||||||
|
options = {
|
||||||
|
"challenge": challenge,
|
||||||
|
"timeout": opts.timeout,
|
||||||
|
"rpId": opts.rp_id,
|
||||||
|
"allowCredentials": [
|
||||||
|
{"id": _encode(desc.id), "type": _enum_str(desc.type)}
|
||||||
|
for desc in opts.allow_credentials or []
|
||||||
|
],
|
||||||
|
"userVerification": _enum_str(opts.user_verification),
|
||||||
|
}
|
||||||
|
return options, challenge
|
||||||
|
|
||||||
|
|
||||||
|
def _decode(value: str) -> bytes:
|
||||||
|
return base64url_to_bytes(value)
|
||||||
|
|
||||||
|
|
||||||
|
class PasskeyChallengeStore:
|
||||||
|
"""Wrap a Redis client with typed WebAuthn challenge operations."""
|
||||||
|
|
||||||
|
def __init__(self, redis: Any, *, ttl_seconds: int) -> None:
|
||||||
|
self._redis = redis
|
||||||
|
self._ttl = int(ttl_seconds)
|
||||||
|
|
||||||
|
async def store_registration(
|
||||||
|
self, user_id: uuid.UUID, *, challenge: str, device_name: str | None = None
|
||||||
|
) -> None:
|
||||||
|
"""Persist a pending registration challenge for the user (overwrites)."""
|
||||||
|
payload = json.dumps({"challenge": challenge, "device_name": device_name})
|
||||||
|
await self._redis.set(registration_key(user_id), payload, ex=self._ttl)
|
||||||
|
|
||||||
|
async def consume_registration(self, user_id: uuid.UUID) -> dict[str, Any] | None:
|
||||||
|
"""Pop the pending registration challenge. None if absent/expired."""
|
||||||
|
raw = await self._get_delete(registration_key(user_id))
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
data: dict[str, Any] = json.loads(raw)
|
||||||
|
return data
|
||||||
|
|
||||||
|
async def store_authentication(self, challenge: str) -> None:
|
||||||
|
"""Mark an authentication challenge as issued."""
|
||||||
|
await self._redis.set(authentication_key(challenge), "1", ex=self._ttl)
|
||||||
|
|
||||||
|
async def consume_authentication(self, challenge: str) -> bool:
|
||||||
|
"""Pop the challenge. False if absent/expired (single use)."""
|
||||||
|
raw = await self._get_delete(authentication_key(challenge))
|
||||||
|
return raw is not None
|
||||||
|
|
||||||
|
async def _get_delete(self, key: str) -> str | None:
|
||||||
|
"""GET + DELETE (works on any Redis >= 2.x; the small race is harmless
|
||||||
|
because challenges are one-shot and short-lived)."""
|
||||||
|
raw = await self._redis.get(key)
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
await self._redis.delete(key)
|
||||||
|
return raw.decode("utf-8") if isinstance(raw, bytes) else str(raw)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PasskeyChallengeStore",
|
||||||
|
"authentication_key",
|
||||||
|
"build_authentication_options",
|
||||||
|
"build_registration_options",
|
||||||
|
"registration_key",
|
||||||
|
]
|
||||||
302
tests/integration/test_passkeys_magic_link.py
Normal file
302
tests/integration/test_passkeys_magic_link.py
Normal file
|
|
@ -0,0 +1,302 @@
|
||||||
|
"""Integration tests for passkey (WebAuthn) + magic-link auth endpoints.
|
||||||
|
|
||||||
|
Run against the Docker Compose infrastructure (`docker compose up -d`).
|
||||||
|
|
||||||
|
Real WebAuthn ceremonies need a hardware/platform authenticator, so the
|
||||||
|
finish endpoints are only exercised on their error paths; the happy paths
|
||||||
|
are covered by unit tests on core/passkeys.py plus the webauthn library
|
||||||
|
itself. Magic-link verify is tested end-to-end by seeding a token hash
|
||||||
|
directly (the raw token never touches the DB).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
_TG_ID = 915_001
|
||||||
|
|
||||||
|
|
||||||
|
def _hash(token: str) -> str:
|
||||||
|
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
async def _register_email_user(client: httpx.AsyncClient, email: str) -> str:
|
||||||
|
"""Register an email/password user via the API and return the access JWT."""
|
||||||
|
r = await client.post(
|
||||||
|
"/api/v1/auth/register",
|
||||||
|
json={"email": email, "name": "Passkey Tester", "password": "supersecret123"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 201, f"register failed: {r.status_code} {r.text}"
|
||||||
|
return r.json()["access_token"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_passkey_register_start_returns_options(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
infra: dict[str, str],
|
||||||
|
) -> None:
|
||||||
|
from tests.integration.conftest import user_token
|
||||||
|
|
||||||
|
token = await user_token(client, infra, telegram_id=_TG_ID)
|
||||||
|
|
||||||
|
r = await client.post(
|
||||||
|
"/api/v1/auth/passkeys/register/start",
|
||||||
|
json={"device_name": "YubiKey 5"},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
body = r.json()
|
||||||
|
assert body["rp"]["id"]
|
||||||
|
assert body["rp"]["name"]
|
||||||
|
assert body["rp"]["origin"].startswith("http")
|
||||||
|
assert body["user"]["id"]
|
||||||
|
assert body["timeout"] > 0
|
||||||
|
assert body["attestation"] == "none"
|
||||||
|
assert body["authenticatorSelection"] == {
|
||||||
|
"residentKey": "required",
|
||||||
|
"userVerification": "required",
|
||||||
|
}
|
||||||
|
assert all(p["type"] == "public-key" for p in body["pubKeyCredParams"])
|
||||||
|
assert body["excludeCredentials"] == []
|
||||||
|
assert len(body["challenge"]) >= 16
|
||||||
|
|
||||||
|
|
||||||
|
async def test_passkey_register_start_requires_user_jwt(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
) -> None:
|
||||||
|
r = await client.post("/api/v1/auth/passkeys/register/start", json={})
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_passkey_register_finish_rejects_garbage_attestation(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
infra: dict[str, str],
|
||||||
|
) -> None:
|
||||||
|
from tests.integration.conftest import user_token
|
||||||
|
|
||||||
|
token = await user_token(client, infra, telegram_id=_TG_ID + 1)
|
||||||
|
|
||||||
|
start = await client.post(
|
||||||
|
"/api/v1/auth/passkeys/register/start",
|
||||||
|
json={},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
assert start.status_code == 200
|
||||||
|
|
||||||
|
r = await client.post(
|
||||||
|
"/api/v1/auth/passkeys/register/finish",
|
||||||
|
json={
|
||||||
|
"credential": {
|
||||||
|
"id": "garbage",
|
||||||
|
"rawId": "garbage",
|
||||||
|
"type": "public-key",
|
||||||
|
"response": {"clientDataJSON": "e30", "attestationObject": "e30"},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert "invalid registration" in r.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_passkey_register_finish_without_challenge_fails(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
infra: dict[str, str],
|
||||||
|
) -> None:
|
||||||
|
from tests.integration.conftest import user_token
|
||||||
|
|
||||||
|
token = await user_token(client, infra, telegram_id=_TG_ID + 2)
|
||||||
|
r = await client.post(
|
||||||
|
"/api/v1/auth/passkeys/register/finish",
|
||||||
|
json={
|
||||||
|
"credential": {
|
||||||
|
"id": "garbage",
|
||||||
|
"rawId": "garbage",
|
||||||
|
"type": "public-key",
|
||||||
|
"response": {"clientDataJSON": "e30", "attestationObject": "e30"},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert "no pending registration challenge" in r.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_passkey_authenticate_start_returns_options(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
) -> None:
|
||||||
|
r = await client.post("/api/v1/auth/passkeys/authenticate/start")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
body = r.json()
|
||||||
|
assert body["rpId"]
|
||||||
|
assert body["allowCredentials"] == []
|
||||||
|
assert body["userVerification"] == "required"
|
||||||
|
assert len(body["challenge"]) >= 16
|
||||||
|
|
||||||
|
|
||||||
|
async def test_passkey_authenticate_finish_rejects_unknown_challenge(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
) -> None:
|
||||||
|
r = await client.post(
|
||||||
|
"/api/v1/auth/passkeys/authenticate/finish",
|
||||||
|
json={
|
||||||
|
"challenge": "dGhpcy1pcy1ub3QtYS1yZWFsLWNoYWxsZW5nZQ",
|
||||||
|
"credential": {
|
||||||
|
"id": "unknown",
|
||||||
|
"rawId": "unknown",
|
||||||
|
"type": "public-key",
|
||||||
|
"response": {
|
||||||
|
"clientDataJSON": "e30",
|
||||||
|
"authenticatorData": "e30",
|
||||||
|
"signature": "e30",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert "unknown or expired challenge" in r.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_passkey_list_and_delete(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
infra: dict[str, str],
|
||||||
|
db_session,
|
||||||
|
) -> None:
|
||||||
|
from tests.integration.conftest import user_token
|
||||||
|
|
||||||
|
token = await user_token(client, infra, telegram_id=_TG_ID + 3)
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
# Fetch the user id behind the JWT.
|
||||||
|
me = await client.get("/api/v1/auth/me", headers=headers)
|
||||||
|
assert me.status_code == 200
|
||||||
|
user_id = me.json()["sub"]
|
||||||
|
|
||||||
|
empty = await client.get("/api/v1/auth/passkeys", headers=headers)
|
||||||
|
assert empty.status_code == 200
|
||||||
|
assert empty.json() == []
|
||||||
|
|
||||||
|
# Seed one credential row directly (full attestation needs an authenticator).
|
||||||
|
cred_id = "itestcred" + uuid.uuid4().hex
|
||||||
|
result = await db_session.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO passkey_credentials "
|
||||||
|
"(user_id, credential_id, public_key, sign_count, device_name) "
|
||||||
|
"VALUES (:u, :c, :k, 0, 'Integration key') RETURNING id"
|
||||||
|
),
|
||||||
|
{"u": user_id, "c": cred_id, "k": "itestpubkey"},
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
assert row is not None
|
||||||
|
await db_session.commit()
|
||||||
|
passkey_row_id = str(row[0])
|
||||||
|
|
||||||
|
listed = await client.get("/api/v1/auth/passkeys", headers=headers)
|
||||||
|
assert listed.status_code == 200
|
||||||
|
items = listed.json()
|
||||||
|
assert len(items) == 1
|
||||||
|
assert items[0]["credentialID"] == cred_id
|
||||||
|
assert items[0]["deviceName"] == "Integration key"
|
||||||
|
assert items[0]["counter"] == 0
|
||||||
|
assert items[0]["userId"] == user_id
|
||||||
|
|
||||||
|
deleted = await client.delete(f"/api/v1/auth/passkeys/{passkey_row_id}", headers=headers)
|
||||||
|
assert deleted.status_code == 200
|
||||||
|
assert deleted.json()["ok"] is True
|
||||||
|
|
||||||
|
gone = await client.delete(f"/api/v1/auth/passkeys/{passkey_row_id}", headers=headers)
|
||||||
|
assert gone.status_code == 404
|
||||||
|
|
||||||
|
final = await client.get("/api/v1/auth/passkeys", headers=headers)
|
||||||
|
assert final.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_magic_link_request_is_generic_for_unknown_email(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
) -> None:
|
||||||
|
r = await client.post(
|
||||||
|
"/api/v1/auth/magic-link/request",
|
||||||
|
json={"email": "nobody-here@example.com"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["success"] is True
|
||||||
|
assert body["message"]
|
||||||
|
assert body["expires_in"] > 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_magic_link_verify_roundtrip(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
db_session,
|
||||||
|
) -> None:
|
||||||
|
email = f"magic-{uuid.uuid4().hex[:8]}@example.com"
|
||||||
|
token = await _register_email_user(client, email)
|
||||||
|
|
||||||
|
me = await client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"})
|
||||||
|
user_id = me.json()["sub"]
|
||||||
|
|
||||||
|
# Seed a magic-link token the way magic-link/request would (raw token
|
||||||
|
# never touches the DB — only its SHA-256 hash).
|
||||||
|
raw = "itest-magic-token-" + uuid.uuid4().hex
|
||||||
|
await db_session.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE users SET magic_link_token_hash = :h, "
|
||||||
|
"magic_link_expires_at = now() + interval '15 minutes' "
|
||||||
|
"WHERE id = :u"
|
||||||
|
),
|
||||||
|
{"h": _hash(raw), "u": user_id},
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
r = await client.post("/api/v1/auth/magic-link/verify", json={"token": raw})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
body = r.json()
|
||||||
|
assert body["token_type"] == "bearer"
|
||||||
|
assert body["expires_in"] > 0
|
||||||
|
assert body["user_id"] == user_id
|
||||||
|
assert body["email"] == email
|
||||||
|
assert body["access_token"]
|
||||||
|
|
||||||
|
# The issued JWT must introspect as the same user.
|
||||||
|
me2 = await client.get(
|
||||||
|
"/api/v1/auth/me", headers={"Authorization": f"Bearer {body['access_token']}"}
|
||||||
|
)
|
||||||
|
assert me2.status_code == 200
|
||||||
|
assert me2.json()["sub"] == user_id
|
||||||
|
|
||||||
|
# The link is one-time: replaying it fails.
|
||||||
|
replay = await client.post("/api/v1/auth/magic-link/verify", json={"token": raw})
|
||||||
|
assert replay.status_code == 400
|
||||||
|
assert "invalid magic-link token" in replay.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_magic_link_verify_rejects_expired_token(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
db_session,
|
||||||
|
) -> None:
|
||||||
|
email = f"magic-exp-{uuid.uuid4().hex[:8]}@example.com"
|
||||||
|
token = await _register_email_user(client, email)
|
||||||
|
|
||||||
|
me = await client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"})
|
||||||
|
user_id = me.json()["sub"]
|
||||||
|
|
||||||
|
raw = "expired-magic-token-" + uuid.uuid4().hex
|
||||||
|
await db_session.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE users SET magic_link_token_hash = :h, "
|
||||||
|
"magic_link_expires_at = now() - interval '1 minute' "
|
||||||
|
"WHERE id = :u"
|
||||||
|
),
|
||||||
|
{"h": _hash(raw), "u": user_id},
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
r = await client.post("/api/v1/auth/magic-link/verify", json={"token": raw})
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert "expired" in r.json()["detail"]
|
||||||
194
tests/unit/test_passkeys.py
Normal file
194
tests/unit/test_passkeys.py
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
"""Unit tests for passkey (WebAuthn) helpers and the challenge store."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import uuid
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from contract_check.core.config import get_settings
|
||||||
|
from contract_check.core.passkeys import (
|
||||||
|
PasskeyChallengeStore,
|
||||||
|
authentication_key,
|
||||||
|
build_authentication_options,
|
||||||
|
build_registration_options,
|
||||||
|
registration_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear_settings_cache(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
get_settings.cache_clear()
|
||||||
|
monkeypatch.setenv("JWT_SECRET", "unit-test-secret-do-not-use-in-prod")
|
||||||
|
monkeypatch.setenv("PASSKEY_RP_ID", "localhost")
|
||||||
|
monkeypatch.setenv("PASSKEY_RP_NAME", "Contract-check")
|
||||||
|
monkeypatch.setenv("PASSKEY_RP_ORIGINS", "http://localhost:5173,https://app.example.com")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
yield
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeRedis:
|
||||||
|
"""In-memory async stand-in for redis.asyncio.Redis (SET/GET/DELETE/EXISTS)."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._data: dict[str, str] = {}
|
||||||
|
self._ttls: dict[str, float] = {}
|
||||||
|
|
||||||
|
async def set(self, key: str, value: str, ex: int | None = None) -> None:
|
||||||
|
self._data[key] = value
|
||||||
|
if ex is not None:
|
||||||
|
self._ttls[key] = dt.datetime.now(tz=dt.UTC).timestamp() + ex
|
||||||
|
|
||||||
|
async def get(self, key: str) -> str | None:
|
||||||
|
if key in self._data and self._unexpired(key):
|
||||||
|
return self._data[key]
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def exists(self, key: str) -> int:
|
||||||
|
return 1 if key in self._data and self._unexpired(key) else 0
|
||||||
|
|
||||||
|
async def delete(self, *keys: str) -> int:
|
||||||
|
removed = 0
|
||||||
|
for k in keys:
|
||||||
|
if k in self._data:
|
||||||
|
del self._data[k]
|
||||||
|
self._ttls.pop(k, None)
|
||||||
|
removed += 1
|
||||||
|
return removed
|
||||||
|
|
||||||
|
def _unexpired(self, key: str) -> bool:
|
||||||
|
if key not in self._ttls:
|
||||||
|
return True
|
||||||
|
return dt.datetime.now(tz=dt.UTC).timestamp() < self._ttls[key]
|
||||||
|
|
||||||
|
|
||||||
|
# ── options builders ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_registration_options_matches_frontend_schema() -> None:
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
existing = "Zo8eBlCJ-fK9xM3vQw7yTg"
|
||||||
|
|
||||||
|
options, challenge = build_registration_options(
|
||||||
|
settings=get_settings(),
|
||||||
|
user_id=user_id,
|
||||||
|
user_name="user@example.com",
|
||||||
|
existing_credential_ids=[existing],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert options["challenge"] == challenge
|
||||||
|
assert options["rp"] == {
|
||||||
|
"id": "localhost",
|
||||||
|
"name": "Contract-check",
|
||||||
|
"origin": "http://localhost:5173",
|
||||||
|
}
|
||||||
|
assert options["user"]["id"] and options["user"]["name"] == "user@example.com"
|
||||||
|
assert options["user"]["displayName"] == "user@example.com"
|
||||||
|
assert options["timeout"] == get_settings().passkey_challenge_ttl_seconds * 1000
|
||||||
|
assert options["attestation"] == "none"
|
||||||
|
assert options["authenticatorSelection"] == {
|
||||||
|
"residentKey": "required",
|
||||||
|
"userVerification": "required",
|
||||||
|
}
|
||||||
|
assert all(p["type"] == "public-key" for p in options["pubKeyCredParams"])
|
||||||
|
assert {p["alg"] for p in options["pubKeyCredParams"]} >= {-7, -257}
|
||||||
|
# The previously stored credential is excluded (base64url roundtrip).
|
||||||
|
assert options["excludeCredentials"] == [{"id": existing, "type": "public-key"}]
|
||||||
|
|
||||||
|
# Roundtrips through the API response model without coercion errors.
|
||||||
|
from contract_check.api.routes.auth import PasskeyRegistrationOptions
|
||||||
|
|
||||||
|
assert PasskeyRegistrationOptions(**options).challenge == challenge
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_authentication_options_matches_frontend_schema() -> None:
|
||||||
|
options, challenge = build_authentication_options(settings=get_settings())
|
||||||
|
|
||||||
|
assert options["challenge"] == challenge
|
||||||
|
assert options["rpId"] == "localhost"
|
||||||
|
assert options["timeout"] == get_settings().passkey_challenge_ttl_seconds * 1000
|
||||||
|
assert options["allowCredentials"] == []
|
||||||
|
assert options["userVerification"] == "required"
|
||||||
|
|
||||||
|
from contract_check.api.routes.auth import PasskeyAuthenticationOptions
|
||||||
|
|
||||||
|
assert PasskeyAuthenticationOptions(**options).rpId == "localhost"
|
||||||
|
|
||||||
|
|
||||||
|
# ── challenge store ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def store_factory() -> Callable[[], tuple[PasskeyChallengeStore, _FakeRedis]]:
|
||||||
|
def _make() -> tuple[PasskeyChallengeStore, _FakeRedis]:
|
||||||
|
redis = _FakeRedis()
|
||||||
|
return PasskeyChallengeStore(redis, ttl_seconds=120), redis
|
||||||
|
|
||||||
|
return _make
|
||||||
|
|
||||||
|
|
||||||
|
async def test_registration_challenge_roundtrip(
|
||||||
|
store_factory: Callable[[], tuple[PasskeyChallengeStore, _FakeRedis]],
|
||||||
|
) -> None:
|
||||||
|
store, redis = store_factory()
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
|
await store.store_registration(user_id, challenge="c-1", device_name="YubiKey 5")
|
||||||
|
|
||||||
|
assert registration_key(user_id) in redis._data
|
||||||
|
stored = await store.consume_registration(user_id)
|
||||||
|
assert stored == {"challenge": "c-1", "device_name": "YubiKey 5"}
|
||||||
|
# Single use: second consume finds nothing.
|
||||||
|
assert await store.consume_registration(user_id) is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_registration_challenge_overwrites(
|
||||||
|
store_factory: Callable[[], tuple[PasskeyChallengeStore, _FakeRedis]],
|
||||||
|
) -> None:
|
||||||
|
store, _ = store_factory()
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
|
await store.store_registration(user_id, challenge="first")
|
||||||
|
await store.store_registration(user_id, challenge="second", device_name="iPhone")
|
||||||
|
|
||||||
|
stored = await store.consume_registration(user_id)
|
||||||
|
assert stored is not None
|
||||||
|
assert stored["challenge"] == "second"
|
||||||
|
assert stored["device_name"] == "iPhone"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_authentication_challenge_single_use(
|
||||||
|
store_factory: Callable[[], tuple[PasskeyChallengeStore, _FakeRedis]],
|
||||||
|
) -> None:
|
||||||
|
store, redis = store_factory()
|
||||||
|
|
||||||
|
await store.store_authentication("cha-llenge")
|
||||||
|
|
||||||
|
assert authentication_key("cha-llenge") in redis._data
|
||||||
|
assert await store.consume_authentication("cha-llenge") is True
|
||||||
|
assert await store.consume_authentication("cha-llenge") is False
|
||||||
|
assert authentication_key("cha-llenge") not in redis._data
|
||||||
|
|
||||||
|
|
||||||
|
async def test_consume_missing_challenges_returns_falsy(
|
||||||
|
store_factory: Callable[[], tuple[PasskeyChallengeStore, _FakeRedis]],
|
||||||
|
) -> None:
|
||||||
|
store, _ = store_factory()
|
||||||
|
|
||||||
|
assert await store.consume_registration(uuid.uuid4()) is None
|
||||||
|
assert await store.consume_authentication("never-issued") is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_expired_authentication_challenge_rejected(
|
||||||
|
store_factory: Callable[[], tuple[PasskeyChallengeStore, _FakeRedis]],
|
||||||
|
) -> None:
|
||||||
|
store, redis = store_factory()
|
||||||
|
|
||||||
|
await store.store_authentication("short-lived")
|
||||||
|
# Force the stored TTL into the past (as if the challenge had expired).
|
||||||
|
redis._ttls[authentication_key("short-lived")] = dt.datetime.now(tz=dt.UTC).timestamp() - 1
|
||||||
|
|
||||||
|
assert await store.consume_authentication("short-lived") is False
|
||||||
85
uv.lock
generated
85
uv.lock
generated
|
|
@ -392,6 +392,38 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
|
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cbor2"
|
||||||
|
version = "6.1.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c6/14/b02446bacfe44351b1689c04937ade007588f44570431880a6937e525e6c/cbor2-6.1.4.tar.gz", hash = "sha256:01ecc79a28f33d17331943ce508fc1e21f4b06553c73f874f4c77120d72b2ef9", size = 90840, upload-time = "2026-08-01T20:41:39.797Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/17/0b20c88e76942ede86c98cdce138681690f95908c540c264fff847729cd4/cbor2-6.1.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c48a7c938fc5fa5300ff82b5df09068dcb4838685ae8556b5ee8279d74f97ab4", size = 403677, upload-time = "2026-08-01T20:41:02.561Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/3d/93eed770864540c5c9ea0841008208e9db686b7335f42520705b7d6dc6b2/cbor2-6.1.4-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:4bd29f21529e279d50fc14f1a811f7b05b4d8e66a7969163cce98983b6817245", size = 449762, upload-time = "2026-08-01T20:41:04.094Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/21/69e4d37f00319b3d37322355aedc83154b4d8b75dc9e9789c06e1fbd8a92/cbor2-6.1.4-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:36ae16d64b1f7b620c1af748e7b6947e20069ef80eee56871c5fbb84cc635905", size = 460420, upload-time = "2026-08-01T20:41:05.891Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/26/2cfdd5ee826205a88a826bb38b7a572c676ec3efa29574be5cdbd04b4859/cbor2-6.1.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:69978901302ecbc8cda57b520487c5c5240ed217de783eb7728fceb258311d76", size = 516490, upload-time = "2026-08-01T20:41:07.52Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/86/d687cd1c2c9f9a986e8552ad1fdbd22411cc86389b5705dba6ec6f7e3226/cbor2-6.1.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad4efa23fee6447e56a269191044e06eb39e809458bcd674e164fe9445feafd0", size = 528810, upload-time = "2026-08-01T20:41:09.144Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/08/88cecf20b8825bdd991c47b317415c08ef9e7d5f05a1def9acd346edabde/cbor2-6.1.4-cp313-cp313-win32.whl", hash = "sha256:d2560c2ba6a95904ba2a0ca257af878c4344409d9b46d8e646d8ebb617b1e0dd", size = 278058, upload-time = "2026-08-01T20:41:10.48Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/67/ba140234a6415c16dcfbe0585ce12f905157b70e9cb1bb63a2b6d5721e70/cbor2-6.1.4-cp313-cp313-win_amd64.whl", hash = "sha256:c08b9c7d2ea013e24a0cb819b872b0119dde404f64a1182c0b24095b7bba781f", size = 299315, upload-time = "2026-08-01T20:41:12.067Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/7f/35d53ff4252a5a85656480d3a81d5a5af823979ccd0c5cac95196a7548a6/cbor2-6.1.4-cp313-cp313-win_arm64.whl", hash = "sha256:598710183daae69cbdeb177a870ec64aa601de8138a61491fd256826d15a860f", size = 289976, upload-time = "2026-08-01T20:41:13.63Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/5d/c5374c76471ab41dff4420a276569a56352e83166374fba6f40fd0bde7ad/cbor2-6.1.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:24da0a481294ac416e1e369e2d204b2b1d993cbd082d0d99fa3d6f5f27ae5e69", size = 407497, upload-time = "2026-08-01T20:41:15.189Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/f9/b9f12a5e24d5ae355e4c0f6d37330a2bbedad3331247a223a51c4cd39d5e/cbor2-6.1.4-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0859a0837e6e2d4fe5f5b849f6475797e4db545da98c19db4b1d3487bd47aa22", size = 452191, upload-time = "2026-08-01T20:41:16.705Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/22/8224b01f95a6fe07b1a64082aea34d9f49068392b3de93f5f3a10c73c62e/cbor2-6.1.4-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:c0f5f2d6d3b58e44146860c049f3c082207a4005588b8926d51bf937ab66773c", size = 462383, upload-time = "2026-08-01T20:41:18.17Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/52/437e4aa4f5df1fb41020d64b3d99a8239f0f99a3a75eb6ffa5cb66004b7f/cbor2-6.1.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:239db0f92d537fd29eaec4e40195fc3b2b48bc34a5887059658162489a9eb6ae", size = 518700, upload-time = "2026-08-01T20:41:19.592Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/45/2f5ea5bfe0fd800b3739c7df8679bdffa9f7def6b2f2fee064ada1c63e85/cbor2-6.1.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3f4a434c36bb0d33aeb48ddae8e8b673ca7e1f14545ee7cf4a4c7c39380ea9a2", size = 531243, upload-time = "2026-08-01T20:41:21.21Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/c6/0beac64cb74cd3217f295f9bb0d64675e1809c683a31ea2a49ac9d4d1504/cbor2-6.1.4-cp314-cp314-win32.whl", hash = "sha256:6abcf072b8c0fdc8ad7902ee26a906cafbf3427d026b662ff21166a253f85e18", size = 285248, upload-time = "2026-08-01T20:41:22.658Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/7d/4afa096ddc94049f5a514690891b02a18319e146ceb14465ce30c8340a8b/cbor2-6.1.4-cp314-cp314-win_amd64.whl", hash = "sha256:855764e02dc60ab9413acd044e997c3170000fdea6155d6c43a923a1d966dbe6", size = 313044, upload-time = "2026-08-01T20:41:24.066Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/b5/e614cee861772f6b5c4d926b066d2e7dbc11e220b50ba716ba91e430fb0f/cbor2-6.1.4-cp314-cp314-win_arm64.whl", hash = "sha256:c6b28b928c5f2dbf47dffa12dce9c8e36fe6ac1c1358bc326499c0736263b66f", size = 304088, upload-time = "2026-08-01T20:41:25.431Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/41/3b28184154f6cbf7e47c1b7fb4a7a291c54f27a6f3a0a2f64b078c6a13e1/cbor2-6.1.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7336ff4cb7d161ec43b65eef43bf3e9bcab44bd152efb54dd637b7afe711254f", size = 401042, upload-time = "2026-08-01T20:41:26.819Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/1a/a8624023b84b41c43a150a89517c104aed0e467bd258866f13be4c3ac0c6/cbor2-6.1.4-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:8f1019494b0ec81a3df3ebb01b6acb446d5b946fe35845b1726379abd66a71da", size = 445301, upload-time = "2026-08-01T20:41:28.35Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/39/07dd0ea957c1f48673d3947f97ee36826efd4a824053dd0ec4df2f0c89d6/cbor2-6.1.4-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:179a794bf4be1d46ff190695929f65f0b42019c156919846ae539d2a7ec42e54", size = 459816, upload-time = "2026-08-01T20:41:29.839Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/8e/2015175132a27c1daed434f671ac6d9c1311461995df47f201307700e0da/cbor2-6.1.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9b904b8d0f4ddac9259197d21d121fae4cb8b555700d65bc12c5d46a2e6c2025", size = 511565, upload-time = "2026-08-01T20:41:31.939Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/66/420991095d9473614b205d4c4e40b5d3b9f1ee4410eb3c48c1e902947837/cbor2-6.1.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:71fcf4f237d68bf4445bf45070f36f82b333f2e6a62612aa2c256683b51378a9", size = 527709, upload-time = "2026-08-01T20:41:33.413Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/7c/73057e7a38488a816a0d40ff9e7cd9f418800894582e2e48fb2f47ce66a2/cbor2-6.1.4-cp314-cp314t-win32.whl", hash = "sha256:7deccc50fd0b55c4c7dd265b144c5358a645121e457c0ae3722b5ad59832b257", size = 281462, upload-time = "2026-08-01T20:41:35.127Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/5d/d5db22837cb566de733b9d1c418cdf1912ccb1efc7b179e295430b1d81a2/cbor2-6.1.4-cp314-cp314t-win_amd64.whl", hash = "sha256:f3fc7d15cba4174373df2496070faa4a927fe3ed772130d281808120aec7b61c", size = 309165, upload-time = "2026-08-01T20:41:36.716Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/5f/ff2c6da83553a692219a0a62a21b57a27ded4405200e50db758a17fbaf15/cbor2-6.1.4-cp314-cp314t-win_arm64.whl", hash = "sha256:164ca22b509408435b2d8236c80c964e4fc77c085ab034569cd04c40d5cc8883", size = 298386, upload-time = "2026-08-01T20:41:38.392Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "certifi"
|
name = "certifi"
|
||||||
version = "2026.7.22"
|
version = "2026.7.22"
|
||||||
|
|
@ -709,6 +741,7 @@ api = [
|
||||||
{ name = "sentry-sdk" },
|
{ name = "sentry-sdk" },
|
||||||
{ name = "sqlalchemy" },
|
{ name = "sqlalchemy" },
|
||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
|
{ name = "webauthn" },
|
||||||
]
|
]
|
||||||
bot = [
|
bot = [
|
||||||
{ name = "aiogram" },
|
{ name = "aiogram" },
|
||||||
|
|
@ -762,6 +795,7 @@ dev = [
|
||||||
{ name = "testcontainers", extra = ["minio", "rabbitmq"] },
|
{ name = "testcontainers", extra = ["minio", "rabbitmq"] },
|
||||||
{ name = "ty" },
|
{ name = "ty" },
|
||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
|
{ name = "webauthn" },
|
||||||
]
|
]
|
||||||
extract = [
|
extract = [
|
||||||
{ name = "aio-pika" },
|
{ name = "aio-pika" },
|
||||||
|
|
@ -864,6 +898,7 @@ api = [
|
||||||
{ name = "sentry-sdk", specifier = ">=2" },
|
{ name = "sentry-sdk", specifier = ">=2" },
|
||||||
{ name = "sqlalchemy", specifier = ">=2.0" },
|
{ name = "sqlalchemy", specifier = ">=2.0" },
|
||||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.29" },
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.29" },
|
||||||
|
{ name = "webauthn", specifier = ">=2.5" },
|
||||||
]
|
]
|
||||||
bot = [
|
bot = [
|
||||||
{ name = "aiogram", specifier = ">=3.4" },
|
{ name = "aiogram", specifier = ">=3.4" },
|
||||||
|
|
@ -917,6 +952,7 @@ dev = [
|
||||||
{ name = "testcontainers", extras = ["rabbitmq", "postgres", "minio"], specifier = ">=4" },
|
{ name = "testcontainers", extras = ["rabbitmq", "postgres", "minio"], specifier = ">=4" },
|
||||||
{ name = "ty", specifier = ">=0.0.72" },
|
{ name = "ty", specifier = ">=0.0.72" },
|
||||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.29" },
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.29" },
|
||||||
|
{ name = "webauthn", specifier = ">=2.5" },
|
||||||
]
|
]
|
||||||
extract = [
|
extract = [
|
||||||
{ name = "aio-pika", specifier = ">=9.4" },
|
{ name = "aio-pika", specifier = ">=9.4" },
|
||||||
|
|
@ -2218,6 +2254,27 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" },
|
{ url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyasn1"
|
||||||
|
version = "0.6.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyasn1-modules"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pyasn1" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pycparser"
|
name = "pycparser"
|
||||||
version = "3.0"
|
version = "3.0"
|
||||||
|
|
@ -2383,6 +2440,18 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/f6/f1/de34a1c53fe2bf8c6e71db84b0ced782d408970c9810d2b456a2ae96814c/pymupdf-1.28.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:fd481ed48bef56305c41fb7e05a055c03345c899c7b101dad086258b438f8168", size = 25802333, upload-time = "2026-08-06T21:39:41.426Z" },
|
{ url = "https://files.pythonhosted.org/packages/f6/f1/de34a1c53fe2bf8c6e71db84b0ced782d408970c9810d2b456a2ae96814c/pymupdf-1.28.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:fd481ed48bef56305c41fb7e05a055c03345c899c7b101dad086258b438f8168", size = 25802333, upload-time = "2026-08-06T21:39:41.426Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyopenssl"
|
||||||
|
version = "26.4.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "cryptography" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/3f/e8/7325d258199b159eb2c03fe32107533e2832e70e63f4fb88a6aa00023201/pyopenssl-26.4.0.tar.gz", hash = "sha256:28dfcce0162b9211413e26dfbfdf1d24317fbeba18fc93c12400a1856b2a0bc7", size = 182046, upload-time = "2026-08-01T19:50:50.512Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/ad/2cf6d3fa2fae5c79e1ed9960c0d42badd0f94d81dd12b50604cdc839e648/pyopenssl-26.4.0-py3-none-any.whl", hash = "sha256:f0eb0cb2d581d3ad2b9c489468485e7f2ab6727d08401bcf9d824c3caddf3c1c", size = 56026, upload-time = "2026-08-01T19:50:48.94Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytesseract"
|
name = "pytesseract"
|
||||||
version = "0.3.13"
|
version = "0.3.13"
|
||||||
|
|
@ -2884,6 +2953,22 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" },
|
{ url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webauthn"
|
||||||
|
version = "3.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "cbor2" },
|
||||||
|
{ name = "cryptography" },
|
||||||
|
{ name = "pyasn1" },
|
||||||
|
{ name = "pyasn1-modules" },
|
||||||
|
{ name = "pyopenssl" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/22/b19c91e850c4578b7d6cdb53453c5fe2f2e99d0c56e322c65c3caf1b3051/webauthn-3.0.0.tar.gz", hash = "sha256:324e54e1f6eeef486623b5d90df6fcd74ae04ff0c137d2b818a8f709b6ca3ab8", size = 160472, upload-time = "2026-06-29T22:40:33.478Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/d3/38d4efaedba74d854f88b60fd7b80ab37869032f9a9ad54d1892dab20241/webauthn-3.0.0-py3-none-any.whl", hash = "sha256:b5d0c02b6efa16be683f8a75abd2073f5e59a15f42623cc22c31f27600259e64", size = 73887, upload-time = "2026-06-29T22:40:32.171Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "websockets"
|
name = "websockets"
|
||||||
version = "17.0.1"
|
version = "17.0.1"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue