From 07931351052c9da26a3f8b5fe47f1bd71459fd83 Mon Sep 17 00:00:00 2001 From: febux Date: Sat, 22 Aug 2026 20:38:21 +0300 Subject: [PATCH] Passkey and magic-link auth mechanisms were added. --- .env.example | 13 +- README.md | 133 ++-- .../versions/0010_passkeys_magic_links.py | 65 ++ pyproject.toml | 5 + src/contract_check/api/deps.py | 43 +- src/contract_check/api/routes/README.md | 84 +++ src/contract_check/api/routes/auth.py | 578 +++++++++++++++++- src/contract_check/core/config.py | 34 ++ src/contract_check/core/db/models.py | 52 +- src/contract_check/core/mq/messages.py | 1 + src/contract_check/core/passkeys.py | 191 ++++++ tests/integration/test_passkeys_magic_link.py | 302 +++++++++ tests/unit/test_passkeys.py | 194 ++++++ uv.lock | 85 +++ 14 files changed, 1722 insertions(+), 58 deletions(-) create mode 100644 migrations/versions/0010_passkeys_magic_links.py create mode 100644 src/contract_check/core/passkeys.py create mode 100644 tests/integration/test_passkeys_magic_link.py create mode 100644 tests/unit/test_passkeys.py diff --git a/.env.example b/.env.example index 6b8e215..7469fe2 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index e9f63e3..3a104cb 100644 --- a/README.md +++ b/README.md @@ -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//Dockerfile` и поднимает их +Или через `make`: `make dev` (install + infra + migrate + services), `make help` — полный список +целей (lint, typecheck, test, seed-token, jwt-token, admin-promote, логи/шеллы сервисов и т.п.). + +Профиль `services` собирает 6 образов из `srv//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 `. JWT выдаётся через `/api/v1/auth/telegram/*` после проверки identity от Telegram. +- **Пользовательские роуты** (bot / web / Mini App) — `Authorization: Bearer `. Telegram-JWT выдаётся через `/api/v1/auth/telegram/*`; web — JWT-пара (access + refresh, refresh отзывается через Redis); passwordless (пасскей / магическая ссылка) — одиночный access JWT. - **Адаптер-level** (только `/api/v1/auth/telegram/bot`) — `Authorization: Bearer `. - **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 # интеграционные (нужны контейнеры) ``` diff --git a/migrations/versions/0010_passkeys_magic_links.py b/migrations/versions/0010_passkeys_magic_links.py new file mode 100644 index 0000000..a317b51 --- /dev/null +++ b/migrations/versions/0010_passkeys_magic_links.py @@ -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") diff --git a/pyproject.toml b/pyproject.toml index b6f8b0b..c284bd3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/src/contract_check/api/deps.py b/src/contract_check/api/deps.py index 0f13d5f..f974ad9 100644 --- a/src/contract_check/api/deps.py +++ b/src/contract_check/api/deps.py @@ -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( diff --git a/src/contract_check/api/routes/README.md b/src/contract_check/api/routes/README.md index e1de3f0..40e4f2d 100644 --- a/src/contract_check/api/routes/README.md +++ b/src/contract_check/api/routes/README.md @@ -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": "" }`. +`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": "", + "token_type": "bearer", + "expires_in": 86400, + "user_id": "uuid", + "email": "user@example.com" +} +``` + +`400` — невалидный/просроченный токен (протухшие сбрасываются сразу). + --- ## me diff --git a/src/contract_check/api/routes/auth.py b/src/contract_check/api/routes/auth.py index 67d16de..043f913 100644 --- a/src/contract_check/api/routes/auth.py +++ b/src/contract_check/api/routes/auth.py @@ -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 ` 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=( + "

Вы запросили вход по ссылке.

" + f'

Войти ' + f"(действует {settings.magic_link_ttl_minutes} мин., ссылка одноразовая)

" + "

Если вы не запрашивали вход — проигнорируйте это письмо.

" + ), + ) + 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) diff --git a/src/contract_check/core/config.py b/src/contract_check/core/config.py index 679ddf4..3f95873 100644 --- a/src/contract_check/core/config.py +++ b/src/contract_check/core/config.py @@ -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, diff --git a/src/contract_check/core/db/models.py b/src/contract_check/core/db/models.py index 0ae60f8..f1881c6 100644 --- a/src/contract_check/core/db/models.py +++ b/src/contract_check/core/db/models.py @@ -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): diff --git a/src/contract_check/core/mq/messages.py b/src/contract_check/core/mq/messages.py index e2d59ee..78ba065 100644 --- a/src/contract_check/core/mq/messages.py +++ b/src/contract_check/core/mq/messages.py @@ -113,6 +113,7 @@ NotificationKind = Literal[ "password_reset", "welcome", "email_verification", + "magic_link", ] diff --git a/src/contract_check/core/passkeys.py b/src/contract_check/core/passkeys.py new file mode 100644 index 0000000..d4a9891 --- /dev/null +++ b/src/contract_check/core/passkeys.py @@ -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", +] diff --git a/tests/integration/test_passkeys_magic_link.py b/tests/integration/test_passkeys_magic_link.py new file mode 100644 index 0000000..3602691 --- /dev/null +++ b/tests/integration/test_passkeys_magic_link.py @@ -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"] diff --git a/tests/unit/test_passkeys.py b/tests/unit/test_passkeys.py new file mode 100644 index 0000000..6425631 --- /dev/null +++ b/tests/unit/test_passkeys.py @@ -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 diff --git a/uv.lock b/uv.lock index b4e3de9..5cc5bc8 100644 --- a/uv.lock +++ b/uv.lock @@ -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"