Passkey and magic-link auth mechanisms were added.

This commit is contained in:
febux 2026-08-22 20:38:21 +03:00
parent 314ccefb85
commit 0793135105
14 changed files with 1722 additions and 58 deletions

View file

@ -93,7 +93,18 @@ ADMIN_DEFAULT_EMAIL=admin@contract-check.local
ADMIN_DEFAULT_PASSWORD=changeme-strong-password
PASSWORD_RESET_TTL_MINUTES=60
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_ENABLED=true

133
README.md
View file

@ -1,9 +1,12 @@
# Контракт-чек
LLM-сервис скрининга рисков в договорах (PDF/DOCX) по ГК РФ / ГК РБ.
Telegram-бот MVP + B2B API сейчас; веб + подписки — позже.
LLM-сервис скрининга рисков в договорах по ГК РФ / ГК РБ.
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/IMPLEMENTATION_PLAN.md`](docs/IMPLEMENTATION_PLAN.md) — исходный «ленивый» план. Этап 0 (прототип) актуален; этапы 1+ superseded в `docs/ARCHITECTURE.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` владеет всем состоянием
(Postgres/MinIO/RabbitMQ/LLM/кредиты). Четыре сервиса собираются из него:
(Postgres/MinIO/RabbitMQ/LLM/кредиты). Шесть сервисов собираются из него:
```
Telegram ──► bot (aiogram, HTTP-only) ──HTTP──► api (FastAPI) ──publish──► RabbitMQ
владеет: PG, MinIO, │
Redis, кредитами ▼
владеет: PG, MinIO, │
Redis, кредитами ▼
extract.q ──► worker-extract
(pymupdf+tesseract, CPU) │
▼ publish
analyze.q ──► worker-analyze
(LLM, I/O) → Report
(pymupdf/mammoth/tesseract, CPU) │
▼ publish
prescreen.q ──► worker-prescreen
(гибридные метаданные + роутинг) │
┌──────────────────────────────────────┤
▼ 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`).
- **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-notify** — доставка email-уведомлений (восстановление пароля и др.) через SMTP; без `SMTP_HOST` — dev-логгер.
- **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/
__main__.py # указывает на prototype (stage-0 CLI сохранён)
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
auth.py auth_refresh.py auth_refresh_key.py passkeys.py
db/ models.py session.py enums.py
mq/ topology.py publisher.py consumer.py messages.py
s3/ port.py minio_storage.py
llm/ port.py ollama_cloud.py factory.py
analysis/ extractor.py chunker.py checklist.py report_schema.py ocr.py analyzer.py
llm/ port.py factory.py ollama_cloud.py yandex_gpt.py prescreen.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-образ
app.py deps.py middleware.py services.py __main__.py
admin/ # серверный UI по /admin (users; позже — подписки)
routes/ health.py documents.py reports.py me.py metrics.py b2b.py
worker_extract/ # CPU-образ (pymupdf + tesseract)
admin/ # серверный UI по /admin (login, users; позже — подписки)
routes/ health.py documents.py reports.py me.py metrics.py auth.py b2b.py
worker_extract/ # CPU-образ (pymupdf + mammoth + tesseract)
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)
consumer.py handler.py __main__.py
worker_notify/ # email-уведомления (aiosmtplib)
consumer.py handler.py __main__.py
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)
docs/ # документация проекта (README остаётся в корне)
ARCHITECTURE.md # архитектура, схемы БД, конфиг
DEPLOY.md # руководство по развёртыванию
TICKETS.md # статусы тикетов
IMPLEMENTATION_PLAN.md # исходный план (superseded для этапов 1+)
BUSINESS_IDEA.md # продукт / бизнес-модель
srv/ # Dockerfile-ы (один на сервис, deps заточены)
api/Dockerfile worker-extract/Dockerfile worker-analyze/Dockerfile
bot/Dockerfile prototype/Dockerfile
migrations/ # alembic (async): 0001_initial, 0002_api_keys
api/ worker-extract/ worker-prescreen/ worker-analyze/ worker-notify/ bot/ prototype/
migrations/ # alembic (async): 0001_initial … 0009_user_name
tests/
conftest.py
unit/ chunker, extractor, llm_ollama_cloud, credits, messages,
rate_limit, checklist_report, bot_client, bot_boundary
integration/ upload_pipeline, extract_worker, analyze_worker, b2b_api, credits_db
docker-compose.yml # default = инфра; --profile services = стек
pyproject.toml # hatchling + PEP 735 dependency-groups (db/mq/s3/obs/api/extract/analyze/bot/prototype/dev)
unit/ chunker, extractor, extraction_factory/adapters, llm_ollama_cloud,
llm_yandex_gpt, llm_prescreen_extraction, prescreen_extractor(+heuristic/
hybrid/llm), prescreen_router, credits, messages, rate_limit,
checklist_report, auth, web_auth, passkeys, extract_handler,
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)
rustfs-spike/ # артефакты спайка RustFS (docs/SPIKE_PHASE0.md)
```
## Быстрый старт
@ -84,31 +107,36 @@ pyproject.toml # hatchling + PEP 735 dependency-groups (db/mq/s3
```bash
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)
uv run alembic upgrade head # миграции
uv run python -m contract_check.api # api на :8000
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_notify # воркер уведомлений
uv run python -m contract_check.bot # Telegram-бот
```
### Через Docker Compose
### Через Docker Compose / Make
```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 --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) доступен
профилем `edge` (`deploy/nginx/`, `docs/DEPLOY.md §13`). Observability (Prometheus/Grafana/Tempo/OTel)
— за будущим профилем `obs`.
Порты на хосте (смещены, чтобы не конфликтовать): Postgres `15432`, Redis `17379`,
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 прототип (бенчмарк)
@ -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 зависит от роута:
- **Пользовательские роуты** (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>`.
- **B2B**`X-API-Key`. Управление B2B-ключами требует пользовательский JWT.
- **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/web` | — | Telegram Login Widget → 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-пара |
| 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}` |
| GET | `/api/v1/documents/{id}` | user JWT | статус + stage (для поллинга) |
| GET | `/api/v1/reports/{document_id}` | user JWT | `202 {status, stage}` или `200 {markdown, findings, ...}` |
| GET | `/api/v1/me` | user JWT | `{telegram_id, credits_left}` |
| GET | `/api/v1/reports/{document_id}` | user JWT | поллинг: `{status, stage}` или готовый `200 {markdown, findings, ...}` |
| GET | `/api/v1/me` | user JWT | профиль + `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: анализ документа |
| 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-ключами |
| 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)
```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 # интеграционные (нужны контейнеры)
```

View 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")

View file

@ -73,6 +73,7 @@ api = [
"pyjwt[crypto]>=2.8",
"argon2-cffi>=23.1",
"email-validator>=2.1",
"webauthn>=2.5",
"opentelemetry-instrumentation-fastapi>=0.45b0",
"opentelemetry-instrumentation-asgi>=0.45b0",
"opentelemetry-instrumentation-httpx>=0.45b0",
@ -143,6 +144,10 @@ dev = [
line-length = 100
target-version = "py313"
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]
select = ["E4", "E7", "E9", "F", "UP", "B"]

View file

@ -9,18 +9,19 @@ from typing import Annotated, Any
from uuid import UUID
from fastapi import Depends, Header, HTTPException, Request
from sqlalchemy import text
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from ..core.api_keys import hash_api_key
from ..core.auth import AuthError, TokenExpiredError, TokenInvalidError, verify_access_token
from ..core.auth_refresh import RefreshTokenStore
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.logging import get_logger
from ..core.mq.publisher import Publisher
from ..core.notifications.publisher import NotificationPublisher
from ..core.passkeys import PasskeyChallengeStore
from ..core.rate_limit import RateLimiter
from ..core.s3.port import Storage
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)]
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(
session: AsyncSessionDep,
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:
"""Fetch a user by UUID including web-auth columns."""
result = await session.execute(

View file

@ -261,6 +261,90 @@ re-login на всех устройствах).
(`sub`, `telegram_id`, `type`, `exp`) сохранены для совместимости; добавлены
`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

View file

@ -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:
- /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/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.
Password reset tokens are stored as SHA-256 hashes with an expiry in the
users row; the actual delivery link is built by the notify worker.
Protected user endpoints receive the access JWT via
Password reset and magic-link tokens are stored as SHA-256 hashes with an
expiry in the users row; the actual delivery link is built by the notify
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`.
"""
@ -30,6 +39,12 @@ from typing import Annotated, Any
from fastapi import APIRouter, Header, HTTPException, status
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 (
AuthError,
@ -42,16 +57,24 @@ from ...core.auth import (
verify_telegram_web_payload,
)
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.mq.messages import NotificationMessage
from ...core.passkeys import (
build_authentication_options,
build_registration_options,
)
from ...core.security.passwords import hash_password, verify_password
from ..deps import (
AsyncSessionDep,
AuthDep,
CurrentUserDep,
NotificationPublisherDep,
PasskeyChallengeStoreDep,
RefreshStoreDep,
create_email_user,
fetch_passkey_by_credential_id,
fetch_passkey_credentials_for_user,
fetch_user_by_email,
fetch_user_by_id_full,
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)."""
if not get_settings().web_auth_enabled:
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)

View file

@ -129,6 +129,40 @@ class Settings(BaseSettings):
# Minimum password length enforced at register / reset.
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) ---
web_admin_enabled: bool = Field(
default=True,

View file

@ -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).
See docs/ARCHITECTURE.md §7. UUIDs default to gen_random_uuid() server-side
(built into Postgres 13+, no extension needed for pg16).
Tables: users, documents, reports, jobs, service_tokens, invoices (stub),
passkey_credentials. See docs/ARCHITECTURE.md §7. UUIDs default to
gen_random_uuid() server-side (built into Postgres 13+, no extension needed
for pg16).
"""
from __future__ import annotations
@ -50,6 +51,8 @@ class User(Base):
password_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))
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(
Boolean, nullable=False, default=True, server_default=text("true")
)
@ -82,6 +85,47 @@ class User(Base):
api_keys: Mapped[list[ApiKey]] = relationship(
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):

View file

@ -113,6 +113,7 @@ NotificationKind = Literal[
"password_reset",
"welcome",
"email_verification",
"magic_link",
]

View 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",
]

View 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
View 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
View file

@ -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" },
]
[[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]]
name = "certifi"
version = "2026.7.22"
@ -709,6 +741,7 @@ api = [
{ name = "sentry-sdk" },
{ name = "sqlalchemy" },
{ name = "uvicorn", extra = ["standard"] },
{ name = "webauthn" },
]
bot = [
{ name = "aiogram" },
@ -762,6 +795,7 @@ dev = [
{ name = "testcontainers", extra = ["minio", "rabbitmq"] },
{ name = "ty" },
{ name = "uvicorn", extra = ["standard"] },
{ name = "webauthn" },
]
extract = [
{ name = "aio-pika" },
@ -864,6 +898,7 @@ api = [
{ name = "sentry-sdk", specifier = ">=2" },
{ name = "sqlalchemy", specifier = ">=2.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.29" },
{ name = "webauthn", specifier = ">=2.5" },
]
bot = [
{ name = "aiogram", specifier = ">=3.4" },
@ -917,6 +952,7 @@ dev = [
{ name = "testcontainers", extras = ["rabbitmq", "postgres", "minio"], specifier = ">=4" },
{ name = "ty", specifier = ">=0.0.72" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.29" },
{ name = "webauthn", specifier = ">=2.5" },
]
extract = [
{ 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" },
]
[[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]]
name = "pycparser"
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" },
]
[[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]]
name = "pytesseract"
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" },
]
[[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]]
name = "websockets"
version = "17.0.1"