New notification service was added. Auth routes were extended.
This commit is contained in:
parent
c77ba029a3
commit
9c9181bc50
31 changed files with 1772 additions and 40 deletions
16
.env.example
16
.env.example
|
|
@ -19,6 +19,7 @@ REDIS_URL=redis://redis:6379/0
|
||||||
RABBITMQ_URL=amqp://contract_check:contract_check@rabbitmq:5672//
|
RABBITMQ_URL=amqp://contract_check:contract_check@rabbitmq:5672//
|
||||||
MQ_PREFETCH_EXTRACT=1 # CPU-bound extraction; tune to CPU count
|
MQ_PREFETCH_EXTRACT=1 # CPU-bound extraction; tune to CPU count
|
||||||
MQ_PREFETCH_ANALYZE=3 # mirrors Ollama Pro concurrency
|
MQ_PREFETCH_ANALYZE=3 # mirrors Ollama Pro concurrency
|
||||||
|
MQ_PREFETCH_NOTIFY=5 # notification worker (I/O bound)
|
||||||
MQ_MAX_ATTEMPTS=5 # before a message lands on the DLQ
|
MQ_MAX_ATTEMPTS=5 # before a message lands on the DLQ
|
||||||
MQ_RETRY_BASE_MS=2000 # exponential backoff base (2s, 4s, 8s, ...)
|
MQ_RETRY_BASE_MS=2000 # exponential backoff base (2s, 4s, 8s, ...)
|
||||||
|
|
||||||
|
|
@ -67,6 +68,21 @@ TELEGRAM_BOT_TOKEN=
|
||||||
JWT_SECRET= # HS256 secret for signing user JWTs; generate with `openssl rand -hex 32`
|
JWT_SECRET= # HS256 secret for signing user JWTs; generate with `openssl rand -hex 32`
|
||||||
JWT_ALGORITHM=HS256
|
JWT_ALGORITHM=HS256
|
||||||
JWT_ACCESS_TTL_MINUTES=1440 # 24 hours default
|
JWT_ACCESS_TTL_MINUTES=1440 # 24 hours default
|
||||||
|
JWT_REFRESH_TTL_DAYS=30 # refresh-token lifetime for webUI auth
|
||||||
|
|
||||||
|
# --- WebUI auth (email + password) ---
|
||||||
|
WEB_AUTH_ENABLED=true # toggle /api/v1/auth/{register,login,...} routes
|
||||||
|
PASSWORD_RESET_TTL_MINUTES=60
|
||||||
|
PASSWORD_MIN_LENGTH=8
|
||||||
|
WEB_APP_BASE_URL=http://localhost:5173 # SPA base — used to build reset links
|
||||||
|
|
||||||
|
# --- SMTP (notification transport; empty host → dev logger) ---
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USERNAME=
|
||||||
|
SMTP_PASSWORD=
|
||||||
|
SMTP_FROM=no-reply@contract-check.local
|
||||||
|
SMTP_USE_TLS=true
|
||||||
|
|
||||||
# --- Telegram bot (adapter, HTTP-only to api) ---
|
# --- Telegram bot (adapter, HTTP-only to api) ---
|
||||||
BOT_TOKEN= # same value as TELEGRAM_BOT_TOKEN (kept for the bot image)
|
BOT_TOKEN= # same value as TELEGRAM_BOT_TOKEN (kept for the bot image)
|
||||||
|
|
|
||||||
11
Makefile
11
Makefile
|
|
@ -3,8 +3,9 @@
|
||||||
|
|
||||||
.PHONY: help install lint typecheck test test-unit test-integration migrate \
|
.PHONY: help install lint typecheck test test-unit test-integration migrate \
|
||||||
infra-up infra-down infra-logs services-up services-down services-logs \
|
infra-up infra-down infra-logs services-up services-down services-logs \
|
||||||
api api-logs bot bot-logs worker-extract worker-analyze seed-token \
|
api api-logs bot bot-logs worker-extract worker-analyze worker-notify \
|
||||||
jwt-secret jwt-token jwt-verify health shell-api shell-bot shell-db clean
|
seed-token jwt-secret jwt-token jwt-verify health shell-api shell-bot \
|
||||||
|
shell-db clean
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Help
|
# Help
|
||||||
|
|
@ -86,6 +87,12 @@ worker-analyze: ## Start/restart analyze worker
|
||||||
worker-analyze-logs: ## Tail analyze worker logs
|
worker-analyze-logs: ## Tail analyze worker logs
|
||||||
docker compose logs -f worker-analyze
|
docker compose logs -f worker-analyze
|
||||||
|
|
||||||
|
worker-notify: ## Start/restart notify worker
|
||||||
|
docker compose --profile services up -d --build --remove-orphans worker-notify
|
||||||
|
|
||||||
|
worker-notify-logs: ## Tail notify worker logs
|
||||||
|
docker compose logs -f worker-notify
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Database
|
# Database
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -170,6 +170,17 @@ services:
|
||||||
JWT_SECRET: ${JWT_SECRET}
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
JWT_ALGORITHM: ${JWT_ALGORITHM:-HS256}
|
JWT_ALGORITHM: ${JWT_ALGORITHM:-HS256}
|
||||||
JWT_ACCESS_TTL_MINUTES: ${JWT_ACCESS_TTL_MINUTES:-1440}
|
JWT_ACCESS_TTL_MINUTES: ${JWT_ACCESS_TTL_MINUTES:-1440}
|
||||||
|
JWT_REFRESH_TTL_DAYS: ${JWT_REFRESH_TTL_DAYS:-30}
|
||||||
|
WEB_AUTH_ENABLED: ${WEB_AUTH_ENABLED:-true}
|
||||||
|
PASSWORD_RESET_TTL_MINUTES: ${PASSWORD_RESET_TTL_MINUTES:-60}
|
||||||
|
PASSWORD_MIN_LENGTH: ${PASSWORD_MIN_LENGTH:-8}
|
||||||
|
WEB_APP_BASE_URL: ${WEB_APP_BASE_URL:-http://localhost:5173}
|
||||||
|
SMTP_HOST: ${SMTP_HOST:-}
|
||||||
|
SMTP_PORT: ${SMTP_PORT:-587}
|
||||||
|
SMTP_USERNAME: ${SMTP_USERNAME:-}
|
||||||
|
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||||
|
SMTP_FROM: ${SMTP_FROM:-no-reply@contract-check.local}
|
||||||
|
SMTP_USE_TLS: ${SMTP_USE_TLS:-true}
|
||||||
ports:
|
ports:
|
||||||
- "${API_PORT:-8000}:8000"
|
- "${API_PORT:-8000}:8000"
|
||||||
- "${API_METRICS_PORT:-9100}:9100"
|
- "${API_METRICS_PORT:-9100}:9100"
|
||||||
|
|
@ -270,6 +281,37 @@ services:
|
||||||
ports:
|
ports:
|
||||||
- "9102:9102"
|
- "9102:9102"
|
||||||
|
|
||||||
|
worker-notify:
|
||||||
|
profiles: ["services"]
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: srv/worker-notify/Dockerfile
|
||||||
|
container_name: contract_check-worker-notify
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
rabbitmq:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
ENV: ${ENV:-dev}
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||||
|
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-contract_check}:${POSTGRES_PASSWORD:-contract_check}@postgres:5432/${POSTGRES_DB:-contract_check}
|
||||||
|
RABBITMQ_URL: amqp://${RABBITMQ_USER:-contract_check}:${RABBITMQ_PASS:-contract_check}@rabbitmq:5672/${RABBITMQ_VHOST:-/}
|
||||||
|
SENTRY_DSN: ${SENTRY_DSN:-}
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-}
|
||||||
|
OTEL_SERVICE_NAME: worker-notify
|
||||||
|
MQ_PREFETCH_NOTIFY: ${MQ_PREFETCH_NOTIFY:-5}
|
||||||
|
MQ_MAX_ATTEMPTS: ${MQ_MAX_ATTEMPTS:-5}
|
||||||
|
MQ_RETRY_BASE_MS: ${MQ_RETRY_BASE_MS:-2000}
|
||||||
|
SMTP_HOST: ${SMTP_HOST:-}
|
||||||
|
SMTP_PORT: ${SMTP_PORT:-587}
|
||||||
|
SMTP_USERNAME: ${SMTP_USERNAME:-}
|
||||||
|
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||||
|
SMTP_FROM: ${SMTP_FROM:-no-reply@contract-check.local}
|
||||||
|
SMTP_USE_TLS: ${SMTP_USE_TLS:-true}
|
||||||
|
WEB_APP_BASE_URL: ${WEB_APP_BASE_URL:-http://localhost:5173}
|
||||||
|
ports:
|
||||||
|
- "9103:9103"
|
||||||
|
|
||||||
# Telegram bot adapter (aiogram 3, HTTP-only to api). Per docs/ARCHITECTURE.md §17
|
# Telegram bot adapter (aiogram 3, HTTP-only to api). Per docs/ARCHITECTURE.md §17
|
||||||
# the bot holds no DB/MQ/S3 credentials — it depends on `api` being healthy,
|
# the bot holds no DB/MQ/S3 credentials — it depends on `api` being healthy,
|
||||||
# not on the infra containers directly, enforcing the hexagonal boundary even
|
# not on the infra containers directly, enforcing the hexagonal boundary even
|
||||||
|
|
|
||||||
|
|
@ -355,11 +355,12 @@ class DocumentUploaded(BaseModel):
|
||||||
correlation_id: UUID
|
correlation_id: UUID
|
||||||
document_id: UUID
|
document_id: UUID
|
||||||
user_id: UUID
|
user_id: UUID
|
||||||
s3_key: str # users/{uid}/docs/{did}.{ext}
|
s3_key: str # users/{uid}/docs/{did}.{ext}
|
||||||
filename: str
|
filename: str
|
||||||
mime: str
|
mime: str
|
||||||
attempt: int = 0
|
attempt: int = 0
|
||||||
|
|
||||||
|
|
||||||
class DocumentExtracted(BaseModel):
|
class DocumentExtracted(BaseModel):
|
||||||
correlation_id: UUID
|
correlation_id: UUID
|
||||||
document_id: UUID
|
document_id: UUID
|
||||||
|
|
@ -554,6 +555,7 @@ double-refund:
|
||||||
```python
|
```python
|
||||||
NON_REFUNDABLE_INFRA_ONLY = {"extraction_failed"} # user-garbage input
|
NON_REFUNDABLE_INFRA_ONLY = {"extraction_failed"} # user-garbage input
|
||||||
|
|
||||||
|
|
||||||
async def refund_credit(session, document_id, failure_class, policy) -> bool:
|
async def refund_credit(session, document_id, failure_class, policy) -> bool:
|
||||||
if policy == "infra_only" and failure_class in NON_REFUNDABLE_INFRA_ONLY:
|
if policy == "infra_only" and failure_class in NON_REFUNDABLE_INFRA_ONLY:
|
||||||
return False # user pays for undetectable garbage
|
return False # user pays for undetectable garbage
|
||||||
|
|
@ -606,9 +608,10 @@ GigaChat, or YandexGPT without touching `analyzer.py`.
|
||||||
from typing import Protocol, Sequence
|
from typing import Protocol, Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class AnalysisResult:
|
class AnalysisResult:
|
||||||
findings: list # list[Finding] (from core.analysis.report_schema)
|
findings: list # list[Finding] (from core.analysis.report_schema)
|
||||||
model_used: str
|
model_used: str
|
||||||
fell_back: bool
|
fell_back: bool
|
||||||
repaired: bool
|
repaired: bool
|
||||||
|
|
@ -616,6 +619,7 @@ class AnalysisResult:
|
||||||
eval_tokens: int
|
eval_tokens: int
|
||||||
latency_sec: float
|
latency_sec: float
|
||||||
|
|
||||||
|
|
||||||
class LLMProvider(Protocol):
|
class LLMProvider(Protocol):
|
||||||
async def analyze(self, text: str, *, checklist: str) -> AnalysisResult: ...
|
async def analyze(self, text: str, *, checklist: str) -> AnalysisResult: ...
|
||||||
async def aclose(self) -> None: ...
|
async def aclose(self) -> None: ...
|
||||||
|
|
@ -647,8 +651,10 @@ Map adapter-internal failures to `FailureClass`:
|
||||||
```python
|
```python
|
||||||
def build_llm_provider(settings) -> LLMProvider:
|
def build_llm_provider(settings) -> LLMProvider:
|
||||||
match settings.llm_provider:
|
match settings.llm_provider:
|
||||||
case "ollama_cloud": return OllamaCloudProvider(settings)
|
case "ollama_cloud":
|
||||||
case _: raise ValueError(f"unknown LLM_PROVIDER={settings.llm_provider!r}")
|
return OllamaCloudProvider(settings)
|
||||||
|
case _:
|
||||||
|
raise ValueError(f"unknown LLM_PROVIDER={settings.llm_provider!r}")
|
||||||
```
|
```
|
||||||
|
|
||||||
`LLM_PROVIDER` env (default `ollama_cloud`). Future providers register here.
|
`LLM_PROVIDER` env (default `ollama_cloud`). Future providers register here.
|
||||||
|
|
|
||||||
56
migrations/versions/0003_web_auth.py
Normal file
56
migrations/versions/0003_web_auth.py
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
"""Email/password columns on users + webUI auth (Stage 4).
|
||||||
|
|
||||||
|
Revision ID: 0003
|
||||||
|
Revises: 0002
|
||||||
|
Create Date: 2026-08-12
|
||||||
|
|
||||||
|
Adds nullable email/password_hash/reset columns to the existing Telegram-only
|
||||||
|
users table. Existing Telegram rows remain valid (telegram_id still satisfies
|
||||||
|
the new users_identity_present check). Email is unique; both telegram_id and
|
||||||
|
email may co-exist on one row (linking the two identities later is a no-op).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0003"
|
||||||
|
down_revision: str | None = "0002"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("users", sa.Column("email", sa.Text(), nullable=True))
|
||||||
|
op.add_column("users", sa.Column("password_hash", sa.Text(), nullable=True))
|
||||||
|
op.add_column("users", sa.Column("password_reset_token_hash", sa.Text(), nullable=True))
|
||||||
|
op.add_column(
|
||||||
|
"users", sa.Column("password_reset_expires_at", sa.DateTime(timezone=True), nullable=True)
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"users",
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_unique_constraint("users_email_unique", "users", ["email"])
|
||||||
|
|
||||||
|
# A user must have at least one identity anchor (telegram_id OR email).
|
||||||
|
op.create_check_constraint(
|
||||||
|
"users_identity_present",
|
||||||
|
"users",
|
||||||
|
"telegram_id IS NOT NULL OR email IS NOT NULL",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_constraint("users_identity_present", "users", type_="check")
|
||||||
|
op.drop_constraint("users_email_unique", "users", type_="unique")
|
||||||
|
op.drop_column("users", "is_active")
|
||||||
|
op.drop_column("users", "password_reset_expires_at")
|
||||||
|
op.drop_column("users", "password_reset_token_hash")
|
||||||
|
op.drop_column("users", "password_hash")
|
||||||
|
op.drop_column("users", "email")
|
||||||
|
|
@ -70,6 +70,8 @@ api = [
|
||||||
"python-multipart>=0.0.9",
|
"python-multipart>=0.0.9",
|
||||||
"redis>=5.0",
|
"redis>=5.0",
|
||||||
"pyjwt[crypto]>=2.8",
|
"pyjwt[crypto]>=2.8",
|
||||||
|
"argon2-cffi>=23.1",
|
||||||
|
"email-validator>=2.1",
|
||||||
"opentelemetry-instrumentation-fastapi>=0.45b0",
|
"opentelemetry-instrumentation-fastapi>=0.45b0",
|
||||||
"opentelemetry-instrumentation-asgi>=0.45b0",
|
"opentelemetry-instrumentation-asgi>=0.45b0",
|
||||||
"opentelemetry-instrumentation-httpx>=0.45b0",
|
"opentelemetry-instrumentation-httpx>=0.45b0",
|
||||||
|
|
@ -93,6 +95,12 @@ analyze = [
|
||||||
bot = [
|
bot = [
|
||||||
"aiogram>=3.4",
|
"aiogram>=3.4",
|
||||||
]
|
]
|
||||||
|
notify = [
|
||||||
|
{ include-group = "db" },
|
||||||
|
{ include-group = "mq" },
|
||||||
|
{ include-group = "obs" },
|
||||||
|
"aiosmtplib>=3.0",
|
||||||
|
]
|
||||||
prototype = [
|
prototype = [
|
||||||
"pymupdf>=1.24",
|
"pymupdf>=1.24",
|
||||||
"python-docx>=1.1",
|
"python-docx>=1.1",
|
||||||
|
|
@ -102,6 +110,7 @@ dev = [
|
||||||
{ include-group = "extract" },
|
{ include-group = "extract" },
|
||||||
{ include-group = "analyze" },
|
{ include-group = "analyze" },
|
||||||
{ include-group = "bot" },
|
{ include-group = "bot" },
|
||||||
|
{ include-group = "notify" },
|
||||||
{ include-group = "prototype" },
|
{ include-group = "prototype" },
|
||||||
"pytest>=8",
|
"pytest>=8",
|
||||||
"pytest-asyncio>=0.23",
|
"pytest-asyncio>=0.23",
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ from ..core.llm import port as llm_port # noqa: F401 — package loaded
|
||||||
from ..core.logging import bind_context, configure_logging, get_logger
|
from ..core.logging import bind_context, configure_logging, get_logger
|
||||||
from ..core.metrics import redis_connected
|
from ..core.metrics import redis_connected
|
||||||
from ..core.mq.publisher import Publisher
|
from ..core.mq.publisher import Publisher
|
||||||
|
from ..core.notifications.publisher import NotificationPublisher
|
||||||
from ..core.rate_limit import MemoryRateLimiter, RateLimiter, RedisRateLimiter
|
from ..core.rate_limit import MemoryRateLimiter, RateLimiter, RedisRateLimiter
|
||||||
from ..core.redis_client import get_redis_client
|
from ..core.redis_client import get_redis_client
|
||||||
from ..core.s3.minio_storage import MinioStorage
|
from ..core.s3.minio_storage import MinioStorage
|
||||||
|
|
@ -49,6 +50,9 @@ async def lifespan(app: FastAPI) -> Any:
|
||||||
publisher = Publisher(settings.rabbitmq_url, origin="api")
|
publisher = Publisher(settings.rabbitmq_url, origin="api")
|
||||||
await publisher.connect()
|
await publisher.connect()
|
||||||
|
|
||||||
|
notification_publisher = NotificationPublisher(settings.rabbitmq_url, origin="api")
|
||||||
|
await notification_publisher.connect()
|
||||||
|
|
||||||
# Redis is used for rate-limiting/sessions. In dev/tests without Redis we
|
# Redis is used for rate-limiting/sessions. In dev/tests without Redis we
|
||||||
# transparently fall back to an in-memory bucket so unit tests stay
|
# transparently fall back to an in-memory bucket so unit tests stay
|
||||||
# dependency-free.
|
# dependency-free.
|
||||||
|
|
@ -63,18 +67,25 @@ async def lifespan(app: FastAPI) -> Any:
|
||||||
log.warning("redis_unavailable", redis_url=settings.redis_url, error=str(exc))
|
log.warning("redis_unavailable", redis_url=settings.redis_url, error=str(exc))
|
||||||
rate_limiter = MemoryRateLimiter()
|
rate_limiter = MemoryRateLimiter()
|
||||||
redis_connected.set(0)
|
redis_connected.set(0)
|
||||||
|
# webUI auth cannot work without Redis (refresh-token store). Wipe the
|
||||||
|
# handle so the RefreshStoreDep raises 503 rather than silently no-op'ing.
|
||||||
|
redis_client = None
|
||||||
|
|
||||||
app.state.storage = storage
|
app.state.storage = storage
|
||||||
app.state.publisher = publisher
|
app.state.publisher = publisher
|
||||||
|
app.state.notification_publisher = notification_publisher
|
||||||
app.state.rate_limiter = rate_limiter
|
app.state.rate_limiter = rate_limiter
|
||||||
|
app.state.redis = redis_client
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
await publisher.close()
|
await publisher.close()
|
||||||
try:
|
await notification_publisher.close()
|
||||||
await redis_client.aclose()
|
if redis_client is not None:
|
||||||
except Exception:
|
try:
|
||||||
pass
|
await redis_client.aclose()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
shutdown_telemetry()
|
shutdown_telemetry()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from typing import Annotated
|
from typing import Annotated, Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import Depends, Header, HTTPException, Request
|
from fastapi import Depends, Header, HTTPException, Request
|
||||||
|
|
@ -12,11 +12,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ..core.api_keys import hash_api_key
|
from ..core.api_keys import hash_api_key
|
||||||
from ..core.auth import AuthError, TokenExpiredError, TokenInvalidError, verify_access_token
|
from ..core.auth import AuthError, TokenExpiredError, TokenInvalidError, verify_access_token
|
||||||
|
from ..core.auth_refresh import RefreshTokenStore
|
||||||
from ..core.config import get_settings
|
from ..core.config import get_settings
|
||||||
from ..core.db.models import User
|
from ..core.db.models import User
|
||||||
from ..core.db.session import create_session_factory
|
from ..core.db.session import create_session_factory
|
||||||
from ..core.logging import get_logger
|
from ..core.logging import get_logger
|
||||||
from ..core.mq.publisher import Publisher
|
from ..core.mq.publisher import Publisher
|
||||||
|
from ..core.notifications.publisher import NotificationPublisher
|
||||||
from ..core.rate_limit import RateLimiter
|
from ..core.rate_limit import RateLimiter
|
||||||
from ..core.s3.port import Storage
|
from ..core.s3.port import Storage
|
||||||
from ..core.tokens import hash_token
|
from ..core.tokens import hash_token
|
||||||
|
|
@ -49,6 +51,41 @@ def get_publisher(request: Request) -> Publisher:
|
||||||
PublisherDep = Annotated[Publisher, Depends(get_publisher)]
|
PublisherDep = Annotated[Publisher, Depends(get_publisher)]
|
||||||
|
|
||||||
|
|
||||||
|
def get_notification_publisher(request: Request) -> NotificationPublisher:
|
||||||
|
publisher: NotificationPublisher = request.app.state.notification_publisher
|
||||||
|
return publisher
|
||||||
|
|
||||||
|
|
||||||
|
NotificationPublisherDep = Annotated[NotificationPublisher, Depends(get_notification_publisher)]
|
||||||
|
|
||||||
|
|
||||||
|
def get_redis(request: Request) -> Any:
|
||||||
|
"""Return the app-state async Redis client.
|
||||||
|
|
||||||
|
Set on app.state.redis in the api lifespan. Used by the refresh-token store.
|
||||||
|
"""
|
||||||
|
redis: Any = request.app.state.redis
|
||||||
|
return redis
|
||||||
|
|
||||||
|
|
||||||
|
RedisDep = Annotated[Any, Depends(get_redis)]
|
||||||
|
|
||||||
|
|
||||||
|
def get_refresh_store(request: Request) -> RefreshTokenStore:
|
||||||
|
"""Build a RefreshTokenStore from the app-state Redis client.
|
||||||
|
|
||||||
|
Raises 503 if Redis is unavailable — webUI auth cannot function without it.
|
||||||
|
"""
|
||||||
|
redis: Any = getattr(request.app.state, "redis", None)
|
||||||
|
if redis is None:
|
||||||
|
raise HTTPException(status_code=503, detail="refresh-token store unavailable")
|
||||||
|
settings = get_settings()
|
||||||
|
return RefreshTokenStore(redis, ttl_seconds=settings.jwt_refresh_ttl_days * 24 * 3600)
|
||||||
|
|
||||||
|
|
||||||
|
RefreshStoreDep = Annotated[RefreshTokenStore, Depends(get_refresh_store)]
|
||||||
|
|
||||||
|
|
||||||
async def require_service_token(
|
async def require_service_token(
|
||||||
session: AsyncSessionDep,
|
session: AsyncSessionDep,
|
||||||
authorization: Annotated[str | None, Header()] = None,
|
authorization: Annotated[str | None, Header()] = None,
|
||||||
|
|
@ -115,6 +152,76 @@ async def get_or_create_user_by_id(session: AsyncSession, user_id: UUID) -> User
|
||||||
return User(id=row[0], telegram_id=row[1], created_at=row[2], credits_left=row[3])
|
return User(id=row[0], telegram_id=row[1], created_at=row[2], credits_left=row[3])
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_user_by_email(session: AsyncSession, email: str) -> User | None:
|
||||||
|
"""Fetch a user by email (case-sensitive — normalize upstream). Returns None if not found."""
|
||||||
|
result = await session.execute(
|
||||||
|
text(
|
||||||
|
"SELECT id, telegram_id, email, password_hash, is_active, created_at, credits_left "
|
||||||
|
"FROM users WHERE email = :e"
|
||||||
|
),
|
||||||
|
{"e": email},
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return User(
|
||||||
|
id=row[0],
|
||||||
|
telegram_id=row[1],
|
||||||
|
email=row[2],
|
||||||
|
password_hash=row[3],
|
||||||
|
is_active=row[4],
|
||||||
|
created_at=row[5],
|
||||||
|
credits_left=row[6],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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(
|
||||||
|
text(
|
||||||
|
"SELECT id, telegram_id, email, password_hash, is_active, created_at, credits_left "
|
||||||
|
"FROM users WHERE id = :u"
|
||||||
|
),
|
||||||
|
{"u": user_id},
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return User(
|
||||||
|
id=row[0],
|
||||||
|
telegram_id=row[1],
|
||||||
|
email=row[2],
|
||||||
|
password_hash=row[3],
|
||||||
|
is_active=row[4],
|
||||||
|
created_at=row[5],
|
||||||
|
credits_left=row[6],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_email_user(session: AsyncSession, *, email: str, password_hash: str) -> User:
|
||||||
|
"""Insert a new email/password user with 0 credits and return it."""
|
||||||
|
result = await session.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO users (email, password_hash, credits_left) "
|
||||||
|
"VALUES (:e, :p, 0) "
|
||||||
|
"RETURNING id, telegram_id, email, password_hash, is_active, created_at, credits_left"
|
||||||
|
),
|
||||||
|
{"e": email, "p": password_hash},
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
assert row is not None
|
||||||
|
await session.commit()
|
||||||
|
return User(
|
||||||
|
id=row[0],
|
||||||
|
telegram_id=row[1],
|
||||||
|
email=row[2],
|
||||||
|
password_hash=row[3],
|
||||||
|
is_active=row[4],
|
||||||
|
created_at=row[5],
|
||||||
|
credits_left=row[6],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_rate_limiter(request: Request) -> RateLimiter:
|
def get_rate_limiter(request: Request) -> RateLimiter:
|
||||||
"""Return the app-state rate limiter (Redis in prod, Memory in tests)."""
|
"""Return the app-state rate limiter (Redis in prod, Memory in tests)."""
|
||||||
limiter: RateLimiter = request.app.state.rate_limiter
|
limiter: RateLimiter = request.app.state.rate_limiter
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,103 @@ Mini App initData. **Auth:** нет (проверяется подпись ` ini
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### WebUI auth (email + password)
|
||||||
|
|
||||||
|
Шесть эндпоинтов для SPA-фронта. Возвращают **JWT-пару**: короткий access
|
||||||
|
(default 24h) + длинный refresh (default 30d, ttl в Redis). Refresh-token
|
||||||
|
поддерживает отзыв через logout; access невалидируется только истечением TTL.
|
||||||
|
|
||||||
|
| Метод | Путь | Auth | Описание |
|
||||||
|
| ----- | --------------------------- | ------ | --------------------------------------- |
|
||||||
|
| POST | `/api/v1/auth/register` | — | Регистрация → 201 + JWT pair |
|
||||||
|
| POST | `/api/v1/auth/login` | — | Вход → 200 + JWT pair |
|
||||||
|
| POST | `/api/v1/auth/logout` | — | Отзыв refresh-токена (в теле) |
|
||||||
|
| GET | `/api/v1/auth/me` | Bearer | Текущий пользователь (расширенный ответ)|
|
||||||
|
| POST | `/api/v1/auth/forgot-password` | — | Запрос сброса (202, всегда одинаковый ответ) |
|
||||||
|
| POST | `/api/v1/auth/reset-password` | — | Сброс пароля по токену из письма |
|
||||||
|
|
||||||
|
#### `POST /api/v1/auth/register`
|
||||||
|
|
||||||
|
Тело — `RegisterRequest`:
|
||||||
|
|
||||||
|
| Поле | Тип | Условие |
|
||||||
|
| ---------- | ------- | --------------- |
|
||||||
|
| `email` | EmailStr | валидный email |
|
||||||
|
| `password` | str | `8..128` символов |
|
||||||
|
|
||||||
|
`409` если email занят. `201` — `TokenPairResponse`.
|
||||||
|
|
||||||
|
#### `POST /api/v1/auth/login`
|
||||||
|
|
||||||
|
Тело — `LoginRequest` (те же поля). `401` при неверных кредах.
|
||||||
|
|
||||||
|
#### `TokenPairResponse`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"access_token": "<jwt>",
|
||||||
|
"refresh_token": "<jwt>",
|
||||||
|
"token_type": "bearer",
|
||||||
|
"expires_in": 86400,
|
||||||
|
"user": {
|
||||||
|
"id": "uuid",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"telegram_id": null,
|
||||||
|
"credits_left": 0,
|
||||||
|
"is_active": true,
|
||||||
|
"created_at": "2026-08-12T..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `POST /api/v1/auth/logout`
|
||||||
|
|
||||||
|
Тело — `LogoutRequest`:
|
||||||
|
|
||||||
|
| Поле | Тип |
|
||||||
|
| --------------- | --- |
|
||||||
|
| `refresh_token` | str |
|
||||||
|
|
||||||
|
Идемпотентен. Access-токен остаётся валидным до истечения своего TTL
|
||||||
|
(см. `JWT_ACCESS_TTL_MINUTES`); refresh уничтожается в Redis сразу.
|
||||||
|
|
||||||
|
#### `POST /api/v1/auth/forgot-password`
|
||||||
|
|
||||||
|
Тело — `ForgotPasswordRequest` (`email`). Всегда отвечает `202 OkResponse` с
|
||||||
|
телом `{"ok": true, "detail": "if the email exists, a reset link was sent"}`
|
||||||
|
— чтобы не раскрывать, какие адреса зарегистрированы. API:
|
||||||
|
|
||||||
|
1. Генерирует `secrets.token_urlsafe(32)`, хранит только SHA-256 от него
|
||||||
|
в `users.password_reset_token_hash` с TTL = `PASSWORD_RESET_TTL_MINUTES`.
|
||||||
|
2. Публикует `NotificationMessage(kind=password_reset)` в очередь
|
||||||
|
`notify.q` (RabbitMQ).
|
||||||
|
3. `worker-notify` достаёт сообщение и шлёт письмо через SMTP
|
||||||
|
(`SMTP_HOST`/`SMTP_PORT`/`SMTP_USE_TLS`/`SMTP_FROM`). При пустом
|
||||||
|
`SMTP_HOST` (dev) тело письма пишется в лог.
|
||||||
|
|
||||||
|
#### `POST /api/v1/auth/reset-password`
|
||||||
|
|
||||||
|
Тело — `ResetPasswordRequest`:
|
||||||
|
|
||||||
|
| Поле | Тип | Условие |
|
||||||
|
| ---------- | --- | --------------- |
|
||||||
|
| `token` | str | из письма |
|
||||||
|
| `password` | str | `8..128` символов |
|
||||||
|
|
||||||
|
`400` при невалидном/просроченном токене. Успех: пароль перезаписывается
|
||||||
|
(argon2id), `password_reset_token_hash` сбрасывается, **все активные
|
||||||
|
refresh-токены этого пользователя отзываются** в Redis (принудительный
|
||||||
|
re-login на всех устройствах).
|
||||||
|
|
||||||
|
#### `GET /api/v1/auth/me`
|
||||||
|
|
||||||
|
Расширение прежнего introspect-эндпоинта: теперь читает Bearer access JWT,
|
||||||
|
ищет пользователя в БД и возвращает полный профиль. Старые поля
|
||||||
|
(`sub`, `telegram_id`, `type`, `exp`) сохранены для совместимости; добавлены
|
||||||
|
`email`, `credits_left`, `is_active`, `created_at`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## me
|
## me
|
||||||
|
|
||||||
Файл: [`me.py`](me.py). Тег: `me`. **Auth:** `CurrentUserDep` (user JWT).
|
Файл: [`me.py`](me.py). Тег: `me`. **Auth:** `CurrentUserDep` (user JWT).
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,63 @@
|
||||||
"""Telegram-based authentication endpoints.
|
"""Authentication endpoints: Telegram identity sources + webUI email/password.
|
||||||
|
|
||||||
Three identity sources converge on the same JWT:
|
Three Telegram identity sources converge on the same access JWT:
|
||||||
- /auth/telegram/bot — bot adapter exchanges a verified telegram_id for JWT
|
- /auth/telegram/bot — bot adapter exchanges a verified telegram_id for JWT
|
||||||
- /auth/telegram/web — Telegram Login Widget callback
|
- /auth/telegram/web — Telegram Login Widget callback
|
||||||
- /auth/telegram/miniapp — Mini App initData
|
- /auth/telegram/miniapp — Mini App initData
|
||||||
|
|
||||||
Protected user endpoints receive the JWT via `Authorization: Bearer <jwt>` and use
|
WebUI (email/password) endpoints issue a JWT pair (access + refresh):
|
||||||
`deps.CurrentUser`.
|
- POST /auth/register — email + password -> user + pair
|
||||||
|
- POST /auth/login — email + password -> pair
|
||||||
|
- POST /auth/logout — revoke refresh
|
||||||
|
- GET /auth/me — current user (Bearer access JWT)
|
||||||
|
- POST /auth/forgot-password — store reset-token hash, enqueue notification
|
||||||
|
- POST /auth/reset-password — verify token, set new password
|
||||||
|
|
||||||
|
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
|
||||||
|
`Authorization: Bearer <jwt>` and use `deps.CurrentUser`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
import uuid
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Header, HTTPException, status
|
from fastapi import APIRouter, Header, HTTPException, status
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
|
||||||
from ...core.auth import (
|
from ...core.auth import (
|
||||||
AuthError,
|
AuthError,
|
||||||
create_access_token,
|
create_access_token,
|
||||||
|
create_refresh_token,
|
||||||
verify_access_token,
|
verify_access_token,
|
||||||
verify_bot_identity,
|
verify_bot_identity,
|
||||||
|
verify_refresh_token,
|
||||||
verify_telegram_miniapp_init_data,
|
verify_telegram_miniapp_init_data,
|
||||||
verify_telegram_web_payload,
|
verify_telegram_web_payload,
|
||||||
)
|
)
|
||||||
from ...core.config import get_settings
|
from ...core.config import get_settings
|
||||||
from ...core.db.models import User
|
from ...core.db.models import User
|
||||||
from ..deps import AsyncSessionDep, AuthDep, get_or_create_user_for_telegram
|
from ...core.logging import get_logger
|
||||||
|
from ...core.mq.messages import NotificationMessage
|
||||||
|
from ...core.security.passwords import hash_password, verify_password
|
||||||
|
from ..deps import (
|
||||||
|
AsyncSessionDep,
|
||||||
|
AuthDep,
|
||||||
|
NotificationPublisherDep,
|
||||||
|
RefreshStoreDep,
|
||||||
|
create_email_user,
|
||||||
|
fetch_user_by_email,
|
||||||
|
fetch_user_by_id_full,
|
||||||
|
get_or_create_user_for_telegram,
|
||||||
|
)
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
router = APIRouter(tags=["auth"])
|
router = APIRouter(tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -64,6 +94,26 @@ class TokenIntrospectResponse(BaseModel):
|
||||||
exp: int
|
exp: int
|
||||||
|
|
||||||
|
|
||||||
|
class WebUserPublic(BaseModel):
|
||||||
|
"""User profile subset safe to return to the webUI."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
email: str | None = None
|
||||||
|
telegram_id: int | None = None
|
||||||
|
credits_left: int
|
||||||
|
is_active: bool
|
||||||
|
created_at: dt.datetime
|
||||||
|
|
||||||
|
|
||||||
|
class MeResponse(TokenIntrospectResponse):
|
||||||
|
"""Extends the introspection shape with web-user fields (additive)."""
|
||||||
|
|
||||||
|
email: str | None = None
|
||||||
|
credits_left: int = 0
|
||||||
|
is_active: bool = True
|
||||||
|
created_at: dt.datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
def _issue_token(user: User) -> AuthResponse:
|
def _issue_token(user: User) -> AuthResponse:
|
||||||
token = create_access_token(user.id, user.telegram_id or 0)
|
token = create_access_token(user.id, user.telegram_id or 0)
|
||||||
ttl = get_settings().jwt_access_ttl_minutes * 60
|
ttl = get_settings().jwt_access_ttl_minutes * 60
|
||||||
|
|
@ -120,25 +170,30 @@ async def auth_telegram_miniapp(
|
||||||
return _issue_token(user)
|
return _issue_token(user)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/auth/me", status_code=status.HTTP_200_OK)
|
@router.get("/api/v1/auth/me", response_model=MeResponse, status_code=status.HTTP_200_OK)
|
||||||
async def introspect_token(
|
async def me(
|
||||||
|
session: AsyncSessionDep,
|
||||||
authorization: Annotated[str | None, Header()] = None,
|
authorization: Annotated[str | None, Header()] = None,
|
||||||
) -> TokenIntrospectResponse:
|
) -> MeResponse:
|
||||||
"""Debug/introspection endpoint: return claims for a Bearer JWT."""
|
"""Current user — verifies the Bearer JWT and returns the user's profile.
|
||||||
if not authorization or not authorization.lower().startswith("bearer "):
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing bearer token")
|
|
||||||
|
|
||||||
token = authorization[7:].strip()
|
Backward-compatible with the previous introspect shape (sub/telegram_id/
|
||||||
try:
|
type/exp) and adds email/credits_left/is_active/created_at.
|
||||||
claims = verify_access_token(token)
|
"""
|
||||||
except AuthError as exc:
|
claims = _require_access_claims(authorization)
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
|
user = await fetch_user_by_id_full(session, claims.sub)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
||||||
|
|
||||||
return TokenIntrospectResponse(
|
return MeResponse(
|
||||||
sub=str(claims.sub),
|
sub=str(user.id),
|
||||||
telegram_id=claims.telegram_id,
|
telegram_id=user.telegram_id or 0,
|
||||||
type=claims.type,
|
type=claims.type,
|
||||||
exp=claims.exp or 0,
|
exp=claims.exp or 0,
|
||||||
|
email=user.email,
|
||||||
|
credits_left=user.credits_left,
|
||||||
|
is_active=bool(user.is_active),
|
||||||
|
created_at=user.created_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -146,3 +201,307 @@ async def introspect_token(
|
||||||
async def token_permissions_dummy() -> dict[str, Any]:
|
async def token_permissions_dummy() -> dict[str, Any]:
|
||||||
"""Placeholder for future RBAC expansion."""
|
"""Placeholder for future RBAC expansion."""
|
||||||
return {"permissions": ["upload", "read_reports", "read_me"]}
|
return {"permissions": ["upload", "read_reports", "read_me"]}
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# WebUI auth (email + password)
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str = Field(..., min_length=8, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str = Field(..., min_length=1, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
class TokenPairResponse(BaseModel):
|
||||||
|
"""JWT pair returned by register/login."""
|
||||||
|
|
||||||
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
expires_in: int # access TTL in seconds
|
||||||
|
user: WebUserPublic
|
||||||
|
|
||||||
|
|
||||||
|
class LogoutRequest(BaseModel):
|
||||||
|
refresh_token: str = Field(..., min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class ForgotPasswordRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
class ResetPasswordRequest(BaseModel):
|
||||||
|
token: str = Field(..., min_length=1, max_length=256)
|
||||||
|
password: str = Field(..., min_length=8, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
class OkResponse(BaseModel):
|
||||||
|
"""Generic `{ok: true, ...}` payload for state-mutating auth endpoints."""
|
||||||
|
|
||||||
|
ok: bool = True
|
||||||
|
detail: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _require_access_claims(authorization: str | None) -> Any:
|
||||||
|
"""Shared bearer-extraction used by `me` and any future stateless endpoint."""
|
||||||
|
if not authorization or not authorization.lower().startswith("bearer "):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing bearer token")
|
||||||
|
token = authorization[7:].strip()
|
||||||
|
try:
|
||||||
|
return verify_access_token(token)
|
||||||
|
except AuthError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _web_user_public(user: User) -> WebUserPublic:
|
||||||
|
return WebUserPublic(
|
||||||
|
id=str(user.id),
|
||||||
|
email=user.email,
|
||||||
|
telegram_id=user.telegram_id,
|
||||||
|
credits_left=user.credits_left,
|
||||||
|
is_active=bool(user.is_active),
|
||||||
|
created_at=user.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _issue_pair(user: User, refresh_store: RefreshStoreDep) -> TokenPairResponse:
|
||||||
|
"""Mint an access + refresh pair for a verified user."""
|
||||||
|
settings = get_settings()
|
||||||
|
access = create_access_token(user.id, user.telegram_id or 0)
|
||||||
|
jti = await refresh_store.issue(user.id)
|
||||||
|
refresh = create_refresh_token(user.id, jti)
|
||||||
|
return TokenPairResponse(
|
||||||
|
access_token=access,
|
||||||
|
refresh_token=refresh,
|
||||||
|
token_type="bearer",
|
||||||
|
expires_in=settings.jwt_access_ttl_minutes * 60,
|
||||||
|
user=_web_user_public(user),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_reset_token(token: str) -> str:
|
||||||
|
"""SHA-256 of a reset token — store this, never the raw token."""
|
||||||
|
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_reset_link(token: str) -> str:
|
||||||
|
base = get_settings().web_app_base_url.rstrip("/")
|
||||||
|
return f"{base}/reset-password?token={token}"
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/auth/register",
|
||||||
|
response_model=TokenPairResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def register(
|
||||||
|
session: AsyncSessionDep,
|
||||||
|
refresh_store: RefreshStoreDep,
|
||||||
|
body: RegisterRequest,
|
||||||
|
) -> TokenPairResponse:
|
||||||
|
"""Register a new email/password user and issue a JWT pair."""
|
||||||
|
_require_web_auth_enabled()
|
||||||
|
email_normalized = body.email.lower().strip()
|
||||||
|
|
||||||
|
existing = await fetch_user_by_email(session, email_normalized)
|
||||||
|
if existing is not None:
|
||||||
|
# Do not leak which emails are registered.
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="email already registered")
|
||||||
|
|
||||||
|
hashed = hash_password(body.password)
|
||||||
|
user = await create_email_user(session, email=email_normalized, password_hash=hashed)
|
||||||
|
log.info("user_registered", user_id=str(user.id), email=email_normalized)
|
||||||
|
return await _issue_pair(user, refresh_store)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
response_model=TokenPairResponse,
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
async def login(
|
||||||
|
session: AsyncSessionDep,
|
||||||
|
refresh_store: RefreshStoreDep,
|
||||||
|
body: LoginRequest,
|
||||||
|
) -> TokenPairResponse:
|
||||||
|
"""Email + password -> JWT pair."""
|
||||||
|
_require_web_auth_enabled()
|
||||||
|
email_normalized = body.email.lower().strip()
|
||||||
|
|
||||||
|
user = await fetch_user_by_email(session, email_normalized)
|
||||||
|
if user is None or not user.password_hash:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid credentials")
|
||||||
|
if not verify_password(body.password, user.password_hash):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid credentials")
|
||||||
|
if not user.is_active:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="account disabled")
|
||||||
|
|
||||||
|
log.info("user_logged_in", user_id=str(user.id))
|
||||||
|
return await _issue_pair(user, refresh_store)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/auth/logout",
|
||||||
|
response_model=OkResponse,
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
async def logout(
|
||||||
|
refresh_store: RefreshStoreDep,
|
||||||
|
body: LogoutRequest,
|
||||||
|
) -> OkResponse:
|
||||||
|
"""Revoke the supplied refresh token. Access token stays valid until it expires."""
|
||||||
|
_require_web_auth_enabled()
|
||||||
|
try:
|
||||||
|
claims = verify_refresh_token(body.refresh_token)
|
||||||
|
except AuthError as exc:
|
||||||
|
# Already expired or invalid — nothing to revoke. Return ok for idempotency.
|
||||||
|
log.info("logout_invalid_refresh", error=str(exc))
|
||||||
|
return OkResponse(ok=True, detail="already revoked")
|
||||||
|
removed = await refresh_store.revoke(claims.sub, claims.jti)
|
||||||
|
log.info("user_logged_out", user_id=str(claims.sub), removed=removed)
|
||||||
|
return OkResponse(ok=True, detail="refresh revoked")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/auth/forgot-password",
|
||||||
|
response_model=OkResponse,
|
||||||
|
status_code=status.HTTP_202_ACCEPTED,
|
||||||
|
)
|
||||||
|
async def forgot_password(
|
||||||
|
session: AsyncSessionDep,
|
||||||
|
publisher: NotificationPublisherDep,
|
||||||
|
body: ForgotPasswordRequest,
|
||||||
|
) -> OkResponse:
|
||||||
|
"""Generate a reset token, store its hash + expiry, and enqueue a notification.
|
||||||
|
|
||||||
|
Always returns 202 with `ok=true` regardless of whether the email exists,
|
||||||
|
to avoid leaking which addresses are registered.
|
||||||
|
"""
|
||||||
|
_require_web_auth_enabled()
|
||||||
|
email_normalized = body.email.lower().strip()
|
||||||
|
|
||||||
|
user = await fetch_user_by_email(session, email_normalized)
|
||||||
|
if user is None:
|
||||||
|
log.info("forgot_password_unknown_email", email=email_normalized)
|
||||||
|
return OkResponse(ok=True, detail="if the email exists, a reset link was sent")
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
raw_token = secrets.token_urlsafe(32)
|
||||||
|
token_hash = _hash_reset_token(raw_token)
|
||||||
|
expires_at = dt.datetime.now(tz=dt.UTC) + dt.timedelta(
|
||||||
|
minutes=settings.password_reset_ttl_minutes
|
||||||
|
)
|
||||||
|
|
||||||
|
from sqlalchemy import text as sa_text
|
||||||
|
|
||||||
|
await session.execute(
|
||||||
|
sa_text(
|
||||||
|
"UPDATE users "
|
||||||
|
"SET password_reset_token_hash = :h, password_reset_expires_at = :e "
|
||||||
|
"WHERE id = :u"
|
||||||
|
),
|
||||||
|
{"h": token_hash, "e": expires_at, "u": user.id},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
reset_link = _build_reset_link(raw_token)
|
||||||
|
notification = NotificationMessage(
|
||||||
|
correlation_id=uuid.uuid4(),
|
||||||
|
kind="password_reset",
|
||||||
|
to=email_normalized,
|
||||||
|
subject="Восстановление пароля — Контракт-чек",
|
||||||
|
body_text=(
|
||||||
|
"Вы запросили сброс пароля.\n\n"
|
||||||
|
f"Перейдите по ссылке, чтобы задать новый пароль (действует "
|
||||||
|
f"{settings.password_reset_ttl_minutes} мин.):\n{reset_link}\n\n"
|
||||||
|
"Если вы не запрашивали сброс — просто проигнорируйте это письмо."
|
||||||
|
),
|
||||||
|
body_html=(
|
||||||
|
"<p>Вы запросили сброс пароля.</p>"
|
||||||
|
f'<p><a href="{reset_link}">Задать новый пароль</a> '
|
||||||
|
f"(действует {settings.password_reset_ttl_minutes} мин.)</p>"
|
||||||
|
"<p>Если вы не запрашивали сброс — проигнорируйте это письмо.</p>"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await publisher.publish(notification, routing_key="notify")
|
||||||
|
except Exception as exc: # noqa: BLE001 — best-effort; reset is still storable
|
||||||
|
log.error(
|
||||||
|
"forgot_password_publish_failed",
|
||||||
|
user_id=str(user.id),
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info("forgot_password_enqueued", user_id=str(user.id))
|
||||||
|
return OkResponse(ok=True, detail="if the email exists, a reset link was sent")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/auth/reset-password",
|
||||||
|
response_model=OkResponse,
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
async def reset_password(
|
||||||
|
session: AsyncSessionDep,
|
||||||
|
refresh_store: RefreshStoreDep,
|
||||||
|
body: ResetPasswordRequest,
|
||||||
|
) -> OkResponse:
|
||||||
|
"""Verify a reset token and set the new password.
|
||||||
|
|
||||||
|
On success: clears the stored token hash, rotates the password, and revokes
|
||||||
|
all active refresh tokens for the user (forcing re-login everywhere).
|
||||||
|
"""
|
||||||
|
_require_web_auth_enabled()
|
||||||
|
token_hash = _hash_reset_token(body.token)
|
||||||
|
|
||||||
|
from sqlalchemy import text as sa_text
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
sa_text(
|
||||||
|
"SELECT id, password_reset_expires_at FROM users WHERE password_reset_token_hash = :h"
|
||||||
|
),
|
||||||
|
{"h": token_hash},
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid reset token")
|
||||||
|
|
||||||
|
user_id = row[0]
|
||||||
|
expires_at = row[1]
|
||||||
|
now = dt.datetime.now(tz=dt.UTC)
|
||||||
|
if expires_at is None or expires_at < now:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="reset token expired")
|
||||||
|
|
||||||
|
new_hash = hash_password(body.password)
|
||||||
|
await session.execute(
|
||||||
|
sa_text(
|
||||||
|
"UPDATE users "
|
||||||
|
"SET password_hash = :p, password_reset_token_hash = NULL, "
|
||||||
|
" password_reset_expires_at = NULL "
|
||||||
|
"WHERE id = :u"
|
||||||
|
),
|
||||||
|
{"p": new_hash, "u": user_id},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# Best-effort revoke of existing sessions; ignore Redis hiccups so the
|
||||||
|
# password itself is still rotated.
|
||||||
|
try:
|
||||||
|
await refresh_store.revoke_all(user_id)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning("reset_password_revoke_failed", user_id=str(user_id), error=str(exc))
|
||||||
|
|
||||||
|
log.info("user_password_reset", user_id=str(user_id))
|
||||||
|
return OkResponse(ok=True, detail="password updated")
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ from .logging import get_logger
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
|
||||||
JWT_TYPE_ACCESS = "access"
|
JWT_TYPE_ACCESS = "access"
|
||||||
|
JWT_TYPE_REFRESH = "refresh"
|
||||||
|
|
||||||
|
|
||||||
class AuthError(Exception):
|
class AuthError(Exception):
|
||||||
|
|
@ -92,6 +93,10 @@ def _jwt_access_ttl() -> dt.timedelta:
|
||||||
return dt.timedelta(minutes=get_settings().jwt_access_ttl_minutes)
|
return dt.timedelta(minutes=get_settings().jwt_access_ttl_minutes)
|
||||||
|
|
||||||
|
|
||||||
|
def _jwt_refresh_ttl() -> dt.timedelta:
|
||||||
|
return dt.timedelta(days=get_settings().jwt_refresh_ttl_days)
|
||||||
|
|
||||||
|
|
||||||
def create_access_token(user_id: uuid.UUID, telegram_id: int) -> str:
|
def create_access_token(user_id: uuid.UUID, telegram_id: int) -> str:
|
||||||
"""Sign a fresh access JWT for a verified user."""
|
"""Sign a fresh access JWT for a verified user."""
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
@ -148,6 +153,85 @@ def verify_access_token(token: str) -> AccessTokenClaims:
|
||||||
raise TokenInvalidError("malformed token claims") from exc
|
raise TokenInvalidError("malformed token claims") from exc
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class RefreshTokenClaims:
|
||||||
|
"""Payload for a refresh JWT. The `jti` is checked against the refresh store."""
|
||||||
|
|
||||||
|
sub: uuid.UUID # user_id
|
||||||
|
jti: str # opaque id used as the Redis-store key
|
||||||
|
type: str
|
||||||
|
exp: int | None = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"sub": str(self.sub),
|
||||||
|
"jti": self.jti,
|
||||||
|
"type": self.type,
|
||||||
|
"exp": self.exp,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, payload: dict[str, Any]) -> RefreshTokenClaims:
|
||||||
|
return cls(
|
||||||
|
sub=uuid.UUID(str(payload["sub"])),
|
||||||
|
jti=str(payload["jti"]),
|
||||||
|
type=str(payload.get("type", JWT_TYPE_REFRESH)),
|
||||||
|
exp=payload.get("exp"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_refresh_token(user_id: uuid.UUID, jti: str) -> str:
|
||||||
|
"""Sign a refresh JWT. `jti` is the lookup key in the refresh-token store."""
|
||||||
|
settings = get_settings()
|
||||||
|
now = dt.datetime.now(tz=dt.UTC)
|
||||||
|
claims = RefreshTokenClaims(sub=user_id, jti=jti, type=JWT_TYPE_REFRESH)
|
||||||
|
payload = claims.to_dict()
|
||||||
|
payload.update(
|
||||||
|
{
|
||||||
|
"iat": int(now.timestamp()),
|
||||||
|
"exp": int((now + _jwt_refresh_ttl()).timestamp()),
|
||||||
|
"iss": settings.otel_service_name or "contract-check",
|
||||||
|
"aud": "contract-check",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
token: str = jwt.encode(
|
||||||
|
payload,
|
||||||
|
key=_jwt_secret(),
|
||||||
|
algorithm=_jwt_algorithm(),
|
||||||
|
)
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def verify_refresh_token(token: str) -> RefreshTokenClaims:
|
||||||
|
"""Verify a refresh JWT signature/expiry. Does NOT check the store — see core.auth_refresh.
|
||||||
|
|
||||||
|
Raises TokenInvalidError / TokenExpiredError on failure.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(
|
||||||
|
token,
|
||||||
|
key=_jwt_secret(),
|
||||||
|
algorithms=[_jwt_algorithm()],
|
||||||
|
audience="contract-check",
|
||||||
|
options={
|
||||||
|
"require": ["sub", "jti", "exp", "iat"],
|
||||||
|
"verify_aud": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except jwt.ExpiredSignatureError as exc:
|
||||||
|
raise TokenExpiredError("token expired") from exc
|
||||||
|
except jwt.InvalidTokenError as exc:
|
||||||
|
raise TokenInvalidError("invalid token") from exc
|
||||||
|
|
||||||
|
if payload.get("type") != JWT_TYPE_REFRESH:
|
||||||
|
raise TokenInvalidError("unexpected token type")
|
||||||
|
|
||||||
|
try:
|
||||||
|
return RefreshTokenClaims.from_dict(payload)
|
||||||
|
except (KeyError, ValueError, TypeError) as exc:
|
||||||
|
raise TokenInvalidError("malformed token claims") from exc
|
||||||
|
|
||||||
|
|
||||||
def _telegram_secret_key(bot_token: str) -> bytes:
|
def _telegram_secret_key(bot_token: str) -> bytes:
|
||||||
"""Telegram uses HMAC_SHA256(BOT_TOKEN, 'WebAppData') as the signing key."""
|
"""Telegram uses HMAC_SHA256(BOT_TOKEN, 'WebAppData') as the signing key."""
|
||||||
return hmac.new(
|
return hmac.new(
|
||||||
|
|
|
||||||
58
src/contract_check/core/auth_refresh.py
Normal file
58
src/contract_check/core/auth_refresh.py
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
"""Redis-backed refresh-token store.
|
||||||
|
|
||||||
|
Refresh tokens are signed JWTs (core/auth.py) carrying a `jti`. To make logout
|
||||||
|
enforceable, the `jti` is also tracked in Redis with a TTL equal to the JWT's
|
||||||
|
own expiry. On logout the key is deleted; the access JWT stays valid until its
|
||||||
|
short expiry, but no new access tokens can be minted from the revoked refresh.
|
||||||
|
|
||||||
|
Keys: `cc:refresh:{user_id}:{jti}` -> "1".
|
||||||
|
A Redis flush invalidates every active refresh session (acceptable trade-off
|
||||||
|
for stateless access tokens — clients must re-login).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .auth_refresh_key import refresh_key
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshTokenStore:
|
||||||
|
"""Wrap a Redis client with typed refresh-token operations."""
|
||||||
|
|
||||||
|
def __init__(self, redis: Any, *, ttl_seconds: int) -> None:
|
||||||
|
self._redis = redis
|
||||||
|
self._ttl = int(ttl_seconds)
|
||||||
|
|
||||||
|
async def issue(self, user_id: uuid.UUID) -> str:
|
||||||
|
"""Mint a new jti and persist it with the configured TTL. Returns the jti."""
|
||||||
|
jti = uuid.uuid4().hex
|
||||||
|
await self._redis.set(refresh_key(user_id, jti), "1", ex=self._ttl)
|
||||||
|
return jti
|
||||||
|
|
||||||
|
async def is_valid(self, user_id: uuid.UUID, jti: str) -> bool:
|
||||||
|
"""True iff the jti is present (not revoked, not expired)."""
|
||||||
|
if not jti:
|
||||||
|
return False
|
||||||
|
return bool(await self._redis.exists(refresh_key(user_id, jti)))
|
||||||
|
|
||||||
|
async def revoke(self, user_id: uuid.UUID, jti: str) -> bool:
|
||||||
|
"""Delete a single jti. Returns True if a key was removed."""
|
||||||
|
if not jti:
|
||||||
|
return False
|
||||||
|
deleted = await self._redis.delete(refresh_key(user_id, jti))
|
||||||
|
return bool(deleted)
|
||||||
|
|
||||||
|
async def revoke_all(self, user_id: uuid.UUID) -> int:
|
||||||
|
"""Revoke every active refresh token for a user. Returns count removed."""
|
||||||
|
pattern = refresh_key(user_id, "*")
|
||||||
|
cursor = 0
|
||||||
|
removed = 0
|
||||||
|
while True:
|
||||||
|
cursor, keys = await self._redis.scan(cursor=cursor, match=pattern, count=100)
|
||||||
|
if keys:
|
||||||
|
removed += int(await self._redis.delete(*keys))
|
||||||
|
if int(cursor) == 0:
|
||||||
|
break
|
||||||
|
return removed
|
||||||
15
src/contract_check/core/auth_refresh_key.py
Normal file
15
src/contract_check/core/auth_refresh_key.py
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
"""Key-format helpers for the Redis refresh-token store.
|
||||||
|
|
||||||
|
Kept separate from auth_refresh.py so it can be imported by tests/migrations
|
||||||
|
without dragging in the redis client type.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
_PREFIX = "cc:refresh"
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_key(user_id: uuid.UUID, jti: str) -> str:
|
||||||
|
return f"{_PREFIX}:{user_id}:{jti}"
|
||||||
|
|
@ -43,6 +43,7 @@ class Settings(BaseSettings):
|
||||||
# --- RabbitMQ tuning ---
|
# --- RabbitMQ tuning ---
|
||||||
mq_prefetch_extract: int = 1
|
mq_prefetch_extract: int = 1
|
||||||
mq_prefetch_analyze: int = 3
|
mq_prefetch_analyze: int = 3
|
||||||
|
mq_prefetch_notify: int = 5
|
||||||
mq_max_attempts: int = 5
|
mq_max_attempts: int = 5
|
||||||
mq_retry_base_ms: int = 2000
|
mq_retry_base_ms: int = 2000
|
||||||
|
|
||||||
|
|
@ -88,6 +89,31 @@ class Settings(BaseSettings):
|
||||||
jwt_secret: str = Field(..., description="HS256 secret for signing user JWTs")
|
jwt_secret: str = Field(..., description="HS256 secret for signing user JWTs")
|
||||||
jwt_algorithm: str = "HS256"
|
jwt_algorithm: str = "HS256"
|
||||||
jwt_access_ttl_minutes: int = 24 * 60 # 24 hours default; tune per env
|
jwt_access_ttl_minutes: int = 24 * 60 # 24 hours default; tune per env
|
||||||
|
jwt_refresh_ttl_days: int = 30 # refresh-token lifetime for webUI auth
|
||||||
|
|
||||||
|
# --- webUI auth (email + password) ---
|
||||||
|
web_auth_enabled: bool = Field(
|
||||||
|
default=True,
|
||||||
|
description="Toggle for /api/v1/auth/{register,login,...} email-password routes",
|
||||||
|
)
|
||||||
|
password_reset_ttl_minutes: int = 60 # reset-token validity window
|
||||||
|
web_app_base_url: str = Field(
|
||||||
|
default="http://localhost:5173",
|
||||||
|
description="Base URL of the webUI SPA — used to build password-reset links",
|
||||||
|
)
|
||||||
|
# Minimum password length enforced at register / reset.
|
||||||
|
password_min_length: int = 8
|
||||||
|
|
||||||
|
# --- SMTP (notification transport; empty host disables sending) ---
|
||||||
|
smtp_host: str = ""
|
||||||
|
smtp_port: int = 587
|
||||||
|
smtp_username: str = ""
|
||||||
|
smtp_password: str = ""
|
||||||
|
smtp_from: str = Field(
|
||||||
|
default="no-reply@contract-check.local",
|
||||||
|
description="From: address used by the notify worker",
|
||||||
|
)
|
||||||
|
smtp_use_tls: bool = True # STARTTLS on port 587; set false for plain SMTP
|
||||||
|
|
||||||
# --- logging ---
|
# --- logging ---
|
||||||
log_format: str = "json" # json | console
|
log_format: str = "json" # json | console
|
||||||
|
|
|
||||||
|
|
@ -45,12 +45,26 @@ class User(Base):
|
||||||
server_default=text("gen_random_uuid()"),
|
server_default=text("gen_random_uuid()"),
|
||||||
)
|
)
|
||||||
telegram_id: Mapped[int | None] = mapped_column(BigInteger, unique=True)
|
telegram_id: Mapped[int | None] = mapped_column(BigInteger, unique=True)
|
||||||
|
email: Mapped[str | None] = mapped_column(Text, unique=True)
|
||||||
|
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))
|
||||||
|
is_active: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=True, server_default=text("true")
|
||||||
|
)
|
||||||
created_at: Mapped[dt.datetime] = mapped_column(
|
created_at: Mapped[dt.datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
)
|
)
|
||||||
credits_left: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
credits_left: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
__table_args__ = (CheckConstraint("credits_left >= 0", name="users_credits_nonneg"),)
|
__table_args__ = (
|
||||||
|
CheckConstraint("credits_left >= 0", name="users_credits_nonneg"),
|
||||||
|
# A user must have at least one identity anchor.
|
||||||
|
CheckConstraint(
|
||||||
|
"telegram_id IS NOT NULL OR email IS NOT NULL",
|
||||||
|
name="users_identity_present",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
documents: Mapped[list[Document]] = relationship(
|
documents: Mapped[list[Document]] = relationship(
|
||||||
back_populates="user", cascade="all, delete-orphan"
|
back_populates="user", cascade="all, delete-orphan"
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ credits_refunded = Counter(
|
||||||
mq_published = Counter(
|
mq_published = Counter(
|
||||||
"contract_check_mq_published_total",
|
"contract_check_mq_published_total",
|
||||||
"Messages published to RabbitMQ.",
|
"Messages published to RabbitMQ.",
|
||||||
["queue"], # extract | analyze
|
["queue"], # extract | analyze | notify
|
||||||
)
|
)
|
||||||
mq_failed = Counter(
|
mq_failed = Counter(
|
||||||
"contract_check_mq_failed_total",
|
"contract_check_mq_failed_total",
|
||||||
|
|
@ -73,6 +73,10 @@ analyze_duration = Histogram(
|
||||||
"contract_check_analyze_duration_seconds",
|
"contract_check_analyze_duration_seconds",
|
||||||
"Document analysis (analyze.q handler) latency.",
|
"Document analysis (analyze.q handler) latency.",
|
||||||
)
|
)
|
||||||
|
notify_duration = Histogram(
|
||||||
|
"contract_check_notify_duration_seconds",
|
||||||
|
"Notification send (notify.q handler) latency.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def start_metrics_server(port: int) -> None:
|
def start_metrics_server(port: int) -> None:
|
||||||
|
|
|
||||||
|
|
@ -22,12 +22,12 @@ import asyncio
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from aio_pika import DeliveryMode, Message, connect_robust
|
from aio_pika import DeliveryMode, Message, connect_robust
|
||||||
from pydantic import ValidationError
|
from pydantic import BaseModel, ValidationError
|
||||||
|
|
||||||
from ..db.enums import FailureClass
|
from ..db.enums import FailureClass
|
||||||
from ..errors import TerminalError
|
from ..errors import TerminalError
|
||||||
from ..logging import get_logger, set_correlation_id
|
from ..logging import get_logger, set_correlation_id
|
||||||
from .messages import PipelineMessage
|
from .messages import NotificationMessage, PipelineMessage
|
||||||
from .topology import (
|
from .topology import (
|
||||||
DLQ_FOR,
|
DLQ_FOR,
|
||||||
EXCHANGE_RETRY,
|
EXCHANGE_RETRY,
|
||||||
|
|
@ -49,18 +49,27 @@ if TYPE_CHECKING:
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Any pydantic BaseModel with an `attempt` int field and a `next_attempt()`
|
||||||
|
# method can ride the retry mechanics. PipelineMessage and NotificationMessage
|
||||||
|
# both qualify.
|
||||||
|
type RetryableMessage = PipelineMessage | NotificationMessage
|
||||||
|
|
||||||
|
|
||||||
def _is_terminal(exc: BaseException) -> bool:
|
def _is_terminal(exc: BaseException) -> bool:
|
||||||
"""Return True for errors that will not be fixed by RabbitMQ retries."""
|
"""Return True for errors that will not be fixed by RabbitMQ retries."""
|
||||||
return isinstance(exc, TerminalError)
|
return isinstance(exc, TerminalError)
|
||||||
|
|
||||||
|
|
||||||
class Consumer[MsgT: PipelineMessage]:
|
class Consumer[MsgT: RetryableMessage]:
|
||||||
"""Base RabbitMQ consumer bound to one main queue."""
|
"""Base RabbitMQ consumer bound to one main queue."""
|
||||||
|
|
||||||
#: subclass declares which queue + routing key + message model it owns
|
#: subclass declares which queue + routing key + message model it owns
|
||||||
queue: str = ""
|
queue: str = ""
|
||||||
routing_key: str = ""
|
routing_key: str = ""
|
||||||
message_model: type[PipelineMessage] = PipelineMessage
|
message_model: type[BaseModel] = PipelineMessage
|
||||||
|
#: retry exchange used when nacking (defaults to the contracts retry exchange;
|
||||||
|
#: notify pipeline overrides with EXCHANGE_NOTIFY_RETRY).
|
||||||
|
retry_exchange: str = EXCHANGE_RETRY
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|
@ -229,7 +238,7 @@ class Consumer[MsgT: PipelineMessage]:
|
||||||
H_ATTEMPT: new_attempt,
|
H_ATTEMPT: new_attempt,
|
||||||
H_ORIGIN: self._origin,
|
H_ORIGIN: self._origin,
|
||||||
}
|
}
|
||||||
retry_exchange = await self._channel.get_exchange(EXCHANGE_RETRY, ensure=False)
|
retry_exchange = await self._channel.get_exchange(self.retry_exchange, ensure=False)
|
||||||
await retry_exchange.publish(
|
await retry_exchange.publish(
|
||||||
Message(
|
Message(
|
||||||
body,
|
body,
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ traces trace api → rabbit → workers under one id.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Self
|
from typing import Literal, Self
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
@ -47,3 +47,32 @@ class DocumentExtracted(PipelineMessage):
|
||||||
extracted_s3_key: str
|
extracted_s3_key: str
|
||||||
char_count: int
|
char_count: int
|
||||||
ocr_used: bool
|
ocr_used: bool
|
||||||
|
|
||||||
|
|
||||||
|
# ── notifications ──────────────────────────────────────────────────────────────
|
||||||
|
# The notify pipeline is decoupled from the contracts pipeline: it shares the
|
||||||
|
# retry/DLQ mechanics but lives on its own exchange/queue (see topology.py).
|
||||||
|
NotificationKind = Literal[
|
||||||
|
"password_reset",
|
||||||
|
"welcome",
|
||||||
|
"email_verification",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationMessage(BaseModel):
|
||||||
|
"""api → notify.x[notify] → worker-notify.
|
||||||
|
|
||||||
|
Carries everything the SMTP transport needs so the worker is stateless
|
||||||
|
beyond the broker. `context` is a free-form payload interpreted per `kind`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
correlation_id: uuid.UUID
|
||||||
|
kind: NotificationKind
|
||||||
|
to: str = Field(..., description="Recipient email address")
|
||||||
|
subject: str
|
||||||
|
body_text: str
|
||||||
|
body_html: str | None = None
|
||||||
|
attempt: int = Field(default=0, ge=0)
|
||||||
|
|
||||||
|
def next_attempt(self) -> Self:
|
||||||
|
return self.model_copy(update={"attempt": self.attempt + 1})
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,8 @@ if TYPE_CHECKING:
|
||||||
# ── exchanges ────────────────────────────────────────────────────────────────
|
# ── exchanges ────────────────────────────────────────────────────────────────
|
||||||
EXCHANGE_MAIN = "contracts.x"
|
EXCHANGE_MAIN = "contracts.x"
|
||||||
EXCHANGE_RETRY = "contracts.retry.x"
|
EXCHANGE_RETRY = "contracts.retry.x"
|
||||||
|
EXCHANGE_NOTIFY = "notify.x"
|
||||||
|
EXCHANGE_NOTIFY_RETRY = "notify.retry.x"
|
||||||
|
|
||||||
# ── queues ───────────────────────────────────────────────────────────────────
|
# ── queues ───────────────────────────────────────────────────────────────────
|
||||||
QUEUE_EXTRACT = "extract.q"
|
QUEUE_EXTRACT = "extract.q"
|
||||||
|
|
@ -44,12 +46,17 @@ QUEUE_EXTRACT_RETRY = "extract.retry.q"
|
||||||
QUEUE_ANALYZE_RETRY = "analyze.retry.q"
|
QUEUE_ANALYZE_RETRY = "analyze.retry.q"
|
||||||
QUEUE_EXTRACT_DLQ = "extract.dlq"
|
QUEUE_EXTRACT_DLQ = "extract.dlq"
|
||||||
QUEUE_ANALYZE_DLQ = "analyze.dlq"
|
QUEUE_ANALYZE_DLQ = "analyze.dlq"
|
||||||
|
QUEUE_NOTIFY = "notify.q"
|
||||||
|
QUEUE_NOTIFY_RETRY = "notify.retry.q"
|
||||||
|
QUEUE_NOTIFY_DLQ = "notify.dlq"
|
||||||
|
|
||||||
# ── routing keys ─────────────────────────────────────────────────────────────
|
# ── routing keys ─────────────────────────────────────────────────────────────
|
||||||
RK_EXTRACT = "extract"
|
RK_EXTRACT = "extract"
|
||||||
RK_ANALYZE = "analyze"
|
RK_ANALYZE = "analyze"
|
||||||
RK_RETRY_EXTRACT = "retry.extract"
|
RK_RETRY_EXTRACT = "retry.extract"
|
||||||
RK_RETRY_ANALYZE = "retry.analyze"
|
RK_RETRY_ANALYZE = "retry.analyze"
|
||||||
|
RK_NOTIFY = "notify"
|
||||||
|
RK_RETRY_NOTIFY = "retry.notify"
|
||||||
|
|
||||||
# ── message headers ──────────────────────────────────────────────────────────
|
# ── message headers ──────────────────────────────────────────────────────────
|
||||||
H_CORRELATION_ID = "x-correlation-id"
|
H_CORRELATION_ID = "x-correlation-id"
|
||||||
|
|
@ -70,6 +77,7 @@ _RETRY_QUEUE_FOR: dict[str, tuple[str, str, str]] = {
|
||||||
DLQ_FOR: dict[str, str] = {
|
DLQ_FOR: dict[str, str] = {
|
||||||
QUEUE_EXTRACT: QUEUE_EXTRACT_DLQ,
|
QUEUE_EXTRACT: QUEUE_EXTRACT_DLQ,
|
||||||
QUEUE_ANALYZE: QUEUE_ANALYZE_DLQ,
|
QUEUE_ANALYZE: QUEUE_ANALYZE_DLQ,
|
||||||
|
QUEUE_NOTIFY: QUEUE_NOTIFY_DLQ,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -115,9 +123,34 @@ async def declare_all(channel: AbstractChannel) -> None:
|
||||||
await rq.bind(retry_exchange, routing_key=f"retry.{main_rk}")
|
await rq.bind(retry_exchange, routing_key=f"retry.{main_rk}")
|
||||||
|
|
||||||
# Quorum DLQs (no bindings; published to directly via default exchange).
|
# Quorum DLQs (no bindings; published to directly via default exchange).
|
||||||
for dlq in (QUEUE_EXTRACT_DLQ, QUEUE_ANALYZE_DLQ):
|
for dlq in (QUEUE_EXTRACT_DLQ, QUEUE_ANALYZE_DLQ, QUEUE_NOTIFY_DLQ):
|
||||||
await channel.declare_queue(dlq, durable=True, arguments=_QUORUM_ARGS)
|
await channel.declare_queue(dlq, durable=True, arguments=_QUORUM_ARGS)
|
||||||
|
|
||||||
|
# ── notify pipeline (parallel to the contracts pipeline) ────────────────
|
||||||
|
notify_exchange = await channel.declare_exchange(EXCHANGE_NOTIFY, durable=True)
|
||||||
|
notify_retry_exchange = await channel.declare_exchange(EXCHANGE_NOTIFY_RETRY, durable=True)
|
||||||
|
|
||||||
|
notify_q = await channel.declare_queue(
|
||||||
|
QUEUE_NOTIFY,
|
||||||
|
durable=True,
|
||||||
|
arguments={
|
||||||
|
**_QUORUM_ARGS,
|
||||||
|
"x-dead-letter-exchange": EXCHANGE_NOTIFY_RETRY,
|
||||||
|
"x-dead-letter-routing-key": RK_RETRY_NOTIFY,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await notify_q.bind(notify_exchange, routing_key=RK_NOTIFY)
|
||||||
|
|
||||||
|
notify_retry_q = await channel.declare_queue(
|
||||||
|
QUEUE_NOTIFY_RETRY,
|
||||||
|
durable=True,
|
||||||
|
arguments={
|
||||||
|
"x-dead-letter-exchange": EXCHANGE_NOTIFY,
|
||||||
|
"x-dead-letter-routing-key": RK_NOTIFY,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await notify_retry_q.bind(notify_retry_exchange, routing_key=RK_RETRY_NOTIFY)
|
||||||
|
|
||||||
|
|
||||||
# ── lazy-mode policy for existing (non-lazy) retry queues ───────────────────
|
# ── lazy-mode policy for existing (non-lazy) retry queues ───────────────────
|
||||||
# RabbitMQ classic queues only expire per-message TTL when the message reaches
|
# RabbitMQ classic queues only expire per-message TTL when the message reaches
|
||||||
|
|
|
||||||
0
src/contract_check/core/notifications/__init__.py
Normal file
0
src/contract_check/core/notifications/__init__.py
Normal file
86
src/contract_check/core/notifications/publisher.py
Normal file
86
src/contract_check/core/notifications/publisher.py
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
"""RabbitMQ publisher for the notify pipeline.
|
||||||
|
|
||||||
|
Bound to the `notify.x` exchange (separate from contracts.x so the two domains
|
||||||
|
have independent DLQ/retry topology). Same publisher-confirms semantics as the
|
||||||
|
contracts Publisher: a publish that is not confirmed raises, so the api never
|
||||||
|
returns 202/200 with a notification silently dropped.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from aio_pika import DeliveryMode, Message, connect_robust
|
||||||
|
|
||||||
|
from ..logging import get_logger
|
||||||
|
from ..mq.messages import NotificationMessage
|
||||||
|
from ..mq.topology import (
|
||||||
|
EXCHANGE_NOTIFY,
|
||||||
|
H_ATTEMPT,
|
||||||
|
H_CORRELATION_ID,
|
||||||
|
H_ORIGIN,
|
||||||
|
declare_all,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from aio_pika.abc import AbstractChannel, AbstractExchange, AbstractRobustConnection
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationPublisher:
|
||||||
|
"""Robust RabbitMQ publisher bound to the notify exchange."""
|
||||||
|
|
||||||
|
def __init__(self, url: str, *, origin: str = "api") -> None:
|
||||||
|
self._url = url
|
||||||
|
self._origin = origin
|
||||||
|
self._conn: AbstractRobustConnection | None = None
|
||||||
|
self._channel: AbstractChannel | None = None
|
||||||
|
self._exchange: AbstractExchange | None = None
|
||||||
|
|
||||||
|
async def connect(self) -> None:
|
||||||
|
self._conn = await connect_robust(self._url)
|
||||||
|
channel = await self._conn.channel(publisher_confirms=True)
|
||||||
|
self._channel = channel
|
||||||
|
# declare_all is idempotent and also declares the notify topology.
|
||||||
|
await declare_all(channel)
|
||||||
|
self._exchange = await channel.declare_exchange(EXCHANGE_NOTIFY, durable=True)
|
||||||
|
log.info("notification_publisher_connected", url=self._url, origin=self._origin)
|
||||||
|
|
||||||
|
async def publish(self, message: NotificationMessage, routing_key: str = "notify") -> None:
|
||||||
|
"""Publish a NotificationMessage. Raises on no-confirm."""
|
||||||
|
if self._exchange is None:
|
||||||
|
raise RuntimeError("NotificationPublisher not connected; call connect() first")
|
||||||
|
body = message.model_dump_json().encode("utf-8")
|
||||||
|
amqp = Message(
|
||||||
|
body,
|
||||||
|
content_type="application/json",
|
||||||
|
delivery_mode=DeliveryMode.PERSISTENT,
|
||||||
|
correlation_id=str(message.correlation_id),
|
||||||
|
message_id=str(message.correlation_id),
|
||||||
|
headers={
|
||||||
|
H_CORRELATION_ID: str(message.correlation_id),
|
||||||
|
H_ATTEMPT: message.attempt,
|
||||||
|
H_ORIGIN: self._origin,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await self._exchange.publish(amqp, routing_key=routing_key)
|
||||||
|
log.debug(
|
||||||
|
"notification_published",
|
||||||
|
routing_key=routing_key,
|
||||||
|
correlation_id=str(message.correlation_id),
|
||||||
|
kind=message.kind,
|
||||||
|
to=message.to,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
if self._conn is not None:
|
||||||
|
await self._conn.close()
|
||||||
|
self._conn = self._channel = self._exchange = None
|
||||||
|
|
||||||
|
async def __aenter__(self) -> NotificationPublisher:
|
||||||
|
await self.connect()
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *exc: object) -> None:
|
||||||
|
await self.close()
|
||||||
88
src/contract_check/core/notifications/transport.py
Normal file
88
src/contract_check/core/notifications/transport.py
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
"""Async SMTP transport for the notify worker.
|
||||||
|
|
||||||
|
Kept as a thin port so tests can substitute a no-op sink. `aiosmtplib` is the
|
||||||
|
only async sender; in dev (SMTP_HOST empty) we just log the message body.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import email.message
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
import aiosmtplib
|
||||||
|
|
||||||
|
from ..config import Settings, get_settings
|
||||||
|
from ..logging import get_logger
|
||||||
|
from ..mq.messages import NotificationMessage
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationTransport(Protocol):
|
||||||
|
"""Send one notification. Implementations: SMTP / dev-logger."""
|
||||||
|
|
||||||
|
async def send(self, message: NotificationMessage) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class DevLogTransport:
|
||||||
|
"""Dev fallback: log instead of sending. Used when SMTP_HOST is empty."""
|
||||||
|
|
||||||
|
async def send(self, message: NotificationMessage) -> None:
|
||||||
|
log.info(
|
||||||
|
"notification_dev_sink",
|
||||||
|
kind=message.kind,
|
||||||
|
to=message.to,
|
||||||
|
subject=message.subject,
|
||||||
|
body=message.body_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SmtpTransport:
|
||||||
|
"""Production SMTP transport via aiosmtplib. Connections are per-send to keep the
|
||||||
|
worker stateless; for high throughput swap to a pooled connection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, settings: Settings | None = None) -> None:
|
||||||
|
self._settings = settings or get_settings()
|
||||||
|
|
||||||
|
async def send(self, message: NotificationMessage) -> None:
|
||||||
|
settings = self._settings
|
||||||
|
if not settings.smtp_host:
|
||||||
|
# Misconfiguration at runtime — fall back to logging rather than crashing.
|
||||||
|
log.warning(
|
||||||
|
"smtp_host_empty_fallback_to_dev",
|
||||||
|
kind=message.kind,
|
||||||
|
to=message.to,
|
||||||
|
)
|
||||||
|
await DevLogTransport().send(message)
|
||||||
|
return
|
||||||
|
|
||||||
|
msg = email.message.EmailMessage()
|
||||||
|
msg["From"] = settings.smtp_from
|
||||||
|
msg["To"] = message.to
|
||||||
|
msg["Subject"] = message.subject
|
||||||
|
msg.set_content(message.body_text)
|
||||||
|
if message.body_html:
|
||||||
|
msg.add_alternative(message.body_html, subtype="html")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await aiosmtplib.send(
|
||||||
|
msg,
|
||||||
|
hostname=settings.smtp_host,
|
||||||
|
port=settings.smtp_port,
|
||||||
|
username=settings.smtp_username or None,
|
||||||
|
password=settings.smtp_password or None,
|
||||||
|
start_tls=settings.smtp_use_tls,
|
||||||
|
)
|
||||||
|
log.info(
|
||||||
|
"notification_sent",
|
||||||
|
kind=message.kind,
|
||||||
|
to=message.to,
|
||||||
|
subject=message.subject,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 — surfaced as retryable infra failure
|
||||||
|
raise SmtpTransportError(f"smtp send failed: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
class SmtpTransportError(Exception):
|
||||||
|
"""Raised when SMTP delivery fails so the consumer can retry/DLQ."""
|
||||||
0
src/contract_check/core/security/__init__.py
Normal file
0
src/contract_check/core/security/__init__.py
Normal file
55
src/contract_check/core/security/passwords.py
Normal file
55
src/contract_check/core/security/passwords.py
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
"""Password hashing (argon2id) via argon2-cffi.
|
||||||
|
|
||||||
|
Pure crypto helpers — no FastAPI, no DB. Used by the api at register/login and
|
||||||
|
by password-reset. argon2-cffi is the OWASP-recommended PHC-format hasher; the
|
||||||
|
encoded hash embeds salt + params so the verifier auto-detects them on verify.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from argon2 import PasswordHasher
|
||||||
|
from argon2.exceptions import InvalidHash, VerificationError, VerifyMismatchError
|
||||||
|
|
||||||
|
# Reasonable defaults (OWASP cheat-sheet). Tunable via env on the hasher if needed.
|
||||||
|
_hasher = PasswordHasher(
|
||||||
|
time_cost=3,
|
||||||
|
memory_cost=64 * 1024, # 64 MiB
|
||||||
|
parallelism=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordError(Exception):
|
||||||
|
"""Raised when a password fails hashing or verification."""
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(plain: str) -> str:
|
||||||
|
"""Return an argon2id PHC-string hash of `plain`. Raises PasswordError on misuse."""
|
||||||
|
if not plain:
|
||||||
|
raise PasswordError("password must not be empty")
|
||||||
|
try:
|
||||||
|
return _hasher.hash(plain)
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
raise PasswordError(f"failed to hash password: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain: str, hashed: str) -> bool:
|
||||||
|
"""Constant-time check that `plain` matches a stored PHC hash.
|
||||||
|
|
||||||
|
Returns False on mismatch or malformed hash (never raises for those cases).
|
||||||
|
"""
|
||||||
|
if not plain or not hashed:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return _hasher.verify(hashed, plain)
|
||||||
|
except VerifyMismatchError:
|
||||||
|
return False
|
||||||
|
except (VerificationError, InvalidHash):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def needs_rehash(hashed: str) -> bool:
|
||||||
|
"""True if the stored hash uses outdated params and should be re-hashed on next login."""
|
||||||
|
try:
|
||||||
|
return _hasher.check_needs_rehash(hashed)
|
||||||
|
except (InvalidHash, TypeError):
|
||||||
|
return False
|
||||||
0
src/contract_check/worker_notify/__init__.py
Normal file
0
src/contract_check/worker_notify/__init__.py
Normal file
62
src/contract_check/worker_notify/__main__.py
Normal file
62
src/contract_check/worker_notify/__main__.py
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
"""worker-notify entrypoint: connects to RabbitMQ and runs the notify consumer."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import signal
|
||||||
|
|
||||||
|
from ..core.config import get_settings
|
||||||
|
from ..core.logging import bind_context, configure_logging, get_logger
|
||||||
|
from ..core.metrics import start_metrics_server
|
||||||
|
from ..core.sentry import init_sentry
|
||||||
|
from ..core.telemetry import setup_telemetry, shutdown_telemetry
|
||||||
|
from .consumer import NotifyConsumer
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
configure_logging(
|
||||||
|
settings.log_level,
|
||||||
|
json_output=settings.json_logs,
|
||||||
|
service="worker-notify",
|
||||||
|
env=settings.env,
|
||||||
|
)
|
||||||
|
bind_context(service="worker-notify", env=settings.env)
|
||||||
|
init_sentry("worker-notify")
|
||||||
|
setup_telemetry("worker-notify")
|
||||||
|
|
||||||
|
start_metrics_server(9103)
|
||||||
|
|
||||||
|
consumer = NotifyConsumer(
|
||||||
|
url=settings.rabbitmq_url,
|
||||||
|
origin="worker-notify",
|
||||||
|
prefetch=settings.mq_prefetch_notify,
|
||||||
|
max_attempts=settings.mq_max_attempts,
|
||||||
|
retry_base_ms=settings.mq_retry_base_ms,
|
||||||
|
)
|
||||||
|
await consumer.connect()
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
stop_event = asyncio.Event()
|
||||||
|
|
||||||
|
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||||
|
loop.add_signal_handler(sig, stop_event.set)
|
||||||
|
|
||||||
|
consumer_task = asyncio.create_task(consumer.run())
|
||||||
|
stop_task = asyncio.create_task(stop_event.wait())
|
||||||
|
|
||||||
|
log.info("worker_notify_started", prefetch=settings.mq_prefetch_notify)
|
||||||
|
try:
|
||||||
|
await asyncio.wait(
|
||||||
|
{consumer_task, stop_task},
|
||||||
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await consumer.stop()
|
||||||
|
shutdown_telemetry()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
61
src/contract_check/worker_notify/consumer.py
Normal file
61
src/contract_check/worker_notify/consumer.py
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
"""worker-notify consumer: wires the notify handler into the RabbitMQ base.
|
||||||
|
|
||||||
|
Consumes `notify.q` and routes failures to `notify.retry.q` / `notify.dlq`
|
||||||
|
via the topology declared in core/mq/topology.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ..core.db.enums import FailureClass
|
||||||
|
from ..core.logging import get_logger
|
||||||
|
from ..core.metrics import notify_duration
|
||||||
|
from ..core.mq.consumer import Consumer
|
||||||
|
from ..core.mq.messages import NotificationMessage
|
||||||
|
from ..core.mq.topology import EXCHANGE_NOTIFY_RETRY
|
||||||
|
from .handler import NotifyHandler
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class NotifyConsumer(Consumer[NotificationMessage]):
|
||||||
|
"""Consumes `notify.q` and dispatches via the SMTP transport."""
|
||||||
|
|
||||||
|
queue: str = "notify.q"
|
||||||
|
routing_key: str = "notify"
|
||||||
|
message_model = NotificationMessage
|
||||||
|
retry_exchange: str = EXCHANGE_NOTIFY_RETRY
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
origin: str,
|
||||||
|
prefetch: int,
|
||||||
|
max_attempts: int,
|
||||||
|
retry_base_ms: int,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(
|
||||||
|
url,
|
||||||
|
origin=origin,
|
||||||
|
prefetch=prefetch,
|
||||||
|
max_attempts=max_attempts,
|
||||||
|
retry_base_ms=retry_base_ms,
|
||||||
|
)
|
||||||
|
self._handler = NotifyHandler()
|
||||||
|
|
||||||
|
def classify(self, exc: BaseException) -> FailureClass:
|
||||||
|
return self._handler.classify(exc)
|
||||||
|
|
||||||
|
@notify_duration.time()
|
||||||
|
async def handle(self, payload: NotificationMessage) -> None:
|
||||||
|
await self._handler.handle(payload)
|
||||||
|
|
||||||
|
async def on_failure(
|
||||||
|
self, payload: NotificationMessage, failure_class: FailureClass, attempt: int, error: str
|
||||||
|
) -> None:
|
||||||
|
await self._handler.on_failure(payload, failure_class, attempt, error)
|
||||||
|
|
||||||
|
async def on_dlq(
|
||||||
|
self, payload: NotificationMessage, failure_class: FailureClass, error: str
|
||||||
|
) -> None:
|
||||||
|
await self._handler.on_terminal_failure(payload, failure_class, error)
|
||||||
78
src/contract_check/worker_notify/handler.py
Normal file
78
src/contract_check/worker_notify/handler.py
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
"""worker-notify handler: deliver one NotificationMessage via the SMTP transport.
|
||||||
|
|
||||||
|
Kept separate from the consumer so it can be unit-tested in-process without a
|
||||||
|
real RabbitMQ. Owns:
|
||||||
|
- transport selection (SMTP in prod, dev logger when SMTP_HOST is empty)
|
||||||
|
- failure classification (smtp failures → infra, retried by the consumer)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ..core.config import get_settings
|
||||||
|
from ..core.db.enums import FailureClass
|
||||||
|
from ..core.logging import get_logger
|
||||||
|
from ..core.metrics import mq_failed
|
||||||
|
from ..core.mq.messages import NotificationMessage
|
||||||
|
from ..core.notifications.transport import (
|
||||||
|
DevLogTransport,
|
||||||
|
NotificationTransport,
|
||||||
|
SmtpTransport,
|
||||||
|
)
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class NotifyHandler:
|
||||||
|
"""Business logic for worker-notify."""
|
||||||
|
|
||||||
|
def __init__(self, transport: NotificationTransport | None = None) -> None:
|
||||||
|
self._settings = get_settings()
|
||||||
|
self._transport: NotificationTransport = transport or self._default_transport()
|
||||||
|
|
||||||
|
def _default_transport(self) -> NotificationTransport:
|
||||||
|
# Dev sink when SMTP is not configured; production uses real SMTP.
|
||||||
|
if not self._settings.smtp_host:
|
||||||
|
return DevLogTransport()
|
||||||
|
return SmtpTransport(self._settings)
|
||||||
|
|
||||||
|
async def handle(self, payload: NotificationMessage) -> None:
|
||||||
|
await self._transport.send(payload)
|
||||||
|
log.info(
|
||||||
|
"notify_delivered",
|
||||||
|
kind=payload.kind,
|
||||||
|
to=payload.to,
|
||||||
|
attempt=payload.attempt,
|
||||||
|
)
|
||||||
|
|
||||||
|
def classify(self, exc: BaseException) -> FailureClass:
|
||||||
|
# Any SMTP-side failure is treated as infra (transient network/cred).
|
||||||
|
from ..core.notifications.transport import SmtpTransportError
|
||||||
|
|
||||||
|
if isinstance(exc, SmtpTransportError):
|
||||||
|
return "infra"
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
async def on_failure(
|
||||||
|
self, payload: NotificationMessage, failure_class: FailureClass, attempt: int, error: str
|
||||||
|
) -> None:
|
||||||
|
mq_failed.labels(queue="notify", failure_class=failure_class).inc()
|
||||||
|
log.warning(
|
||||||
|
"notify_failed",
|
||||||
|
kind=payload.kind,
|
||||||
|
to=payload.to,
|
||||||
|
attempt=attempt,
|
||||||
|
failure_class=failure_class,
|
||||||
|
error=error,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def on_terminal_failure(
|
||||||
|
self, payload: NotificationMessage, failure_class: FailureClass, error: str
|
||||||
|
) -> None:
|
||||||
|
mq_failed.labels(queue="notify", failure_class=failure_class).inc()
|
||||||
|
log.error(
|
||||||
|
"notify_dlq",
|
||||||
|
kind=payload.kind,
|
||||||
|
to=payload.to,
|
||||||
|
failure_class=failure_class,
|
||||||
|
error=error,
|
||||||
|
)
|
||||||
34
srv/worker-notify/Dockerfile
Normal file
34
srv/worker-notify/Dockerfile
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# ─── Stage 1: build deps + project into a venv via uv ─────────────────────────
|
||||||
|
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder
|
||||||
|
|
||||||
|
ENV UV_COMPILE_BYTECODE=1 \
|
||||||
|
UV_LINK_MODE=copy \
|
||||||
|
UV_PYTHON_DOWNLOADS=never \
|
||||||
|
UV_PROJECT_ENVIRONMENT=/app/.venv
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY pyproject.toml uv.lock ./
|
||||||
|
COPY README.md ./
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
# Install only the notify group (core + db/mq/obs + aiosmtplib).
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
|
uv sync --frozen --no-default-groups --group notify --no-install-project && \
|
||||||
|
uv pip install --no-deps .
|
||||||
|
|
||||||
|
# ─── Stage 2: lean runtime ─────────────────────────────────────────────────
|
||||||
|
FROM python:3.13-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PATH=/app/.venv/bin:$PATH
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /app/.venv /app/.venv
|
||||||
|
|
||||||
|
EXPOSE 9103
|
||||||
|
CMD ["python", "-m", "contract_check.worker_notify"]
|
||||||
223
tests/unit/test_web_auth.py
Normal file
223
tests/unit/test_web_auth.py
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
"""Unit tests for webUI auth: argon2 hashing, refresh-token store, JWT pair."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import uuid
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from contract_check.core.auth import (
|
||||||
|
RefreshTokenClaims,
|
||||||
|
TokenExpiredError,
|
||||||
|
TokenInvalidError,
|
||||||
|
create_refresh_token,
|
||||||
|
verify_refresh_token,
|
||||||
|
)
|
||||||
|
from contract_check.core.auth_refresh import RefreshTokenStore
|
||||||
|
from contract_check.core.auth_refresh_key import refresh_key
|
||||||
|
from contract_check.core.config import get_settings
|
||||||
|
from contract_check.core.security.passwords import (
|
||||||
|
PasswordError,
|
||||||
|
hash_password,
|
||||||
|
needs_rehash,
|
||||||
|
verify_password,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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("JWT_ALGORITHM", "HS256")
|
||||||
|
monkeypatch.setenv("JWT_ACCESS_TTL_MINUTES", "1440")
|
||||||
|
monkeypatch.setenv("JWT_REFRESH_TTL_DAYS", "30")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
yield
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
# ── argon2 ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_hash_and_verify_password_roundtrip() -> None:
|
||||||
|
h = hash_password("correct horse battery staple")
|
||||||
|
assert h != "correct horse battery staple"
|
||||||
|
assert h.startswith("$argon2id$")
|
||||||
|
assert verify_password("correct horse battery staple", h)
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_password_rejects_wrong_password() -> None:
|
||||||
|
h = hash_password("hunter2")
|
||||||
|
assert not verify_password("hunter3", h)
|
||||||
|
assert not verify_password("", h)
|
||||||
|
assert not verify_password("hunter2", "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_hash_password_rejects_empty() -> None:
|
||||||
|
with pytest.raises(PasswordError):
|
||||||
|
hash_password("")
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_password_handles_malformed_hash() -> None:
|
||||||
|
assert not verify_password("anything", "not-a-real-hash")
|
||||||
|
assert not verify_password("anything", "$argon2id$truncated")
|
||||||
|
|
||||||
|
|
||||||
|
def test_needs_rehash_returns_false_for_fresh_hash() -> None:
|
||||||
|
h = hash_password("supersecret")
|
||||||
|
assert needs_rehash(h) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── refresh JWT ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_and_verify_refresh_token() -> None:
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
jti = uuid.uuid4().hex
|
||||||
|
token = create_refresh_token(user_id, jti)
|
||||||
|
claims = verify_refresh_token(token)
|
||||||
|
assert claims.sub == user_id
|
||||||
|
assert claims.jti == jti
|
||||||
|
assert claims.type == "refresh"
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_refresh_rejects_access_token() -> None:
|
||||||
|
from contract_check.core.auth import create_access_token
|
||||||
|
|
||||||
|
access = create_access_token(uuid.uuid4(), 42)
|
||||||
|
with pytest.raises(TokenInvalidError):
|
||||||
|
verify_refresh_token(access)
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_refresh_rejects_tampered_token() -> None:
|
||||||
|
token = create_refresh_token(uuid.uuid4(), uuid.uuid4().hex)
|
||||||
|
tampered = token[:-10] + ("A" if token[-10] != "A" else "B") + token[-9:]
|
||||||
|
with pytest.raises(TokenInvalidError):
|
||||||
|
verify_refresh_token(tampered)
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_refresh_rejects_expired(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setenv("JWT_REFRESH_TTL_DAYS", "-1")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
token = create_refresh_token(uuid.uuid4(), uuid.uuid4().hex)
|
||||||
|
with pytest.raises(TokenExpiredError):
|
||||||
|
verify_refresh_token(token)
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_claims_roundtrip() -> None:
|
||||||
|
c = RefreshTokenClaims(sub=uuid.uuid4(), jti="abc", type="refresh", exp=123)
|
||||||
|
d = c.to_dict()
|
||||||
|
c2 = RefreshTokenClaims.from_dict(d)
|
||||||
|
assert c2.sub == c.sub
|
||||||
|
assert c2.jti == c.jti
|
||||||
|
assert c2.exp == c.exp
|
||||||
|
|
||||||
|
|
||||||
|
# ── refresh-token Redis store ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeRedis:
|
||||||
|
"""In-memory async stand-in for redis.asyncio.Redis (SET/GET/DELETE/EXISTS/SCAN)."""
|
||||||
|
|
||||||
|
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 exists(self, key: str) -> int:
|
||||||
|
if key in self._data and self._unexpired(key):
|
||||||
|
return 1
|
||||||
|
return 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
|
||||||
|
|
||||||
|
async def scan(
|
||||||
|
self, cursor: int = 0, match: str | None = None, count: int = 100
|
||||||
|
) -> tuple[int, list[str]]:
|
||||||
|
import fnmatch
|
||||||
|
|
||||||
|
all_keys = [k for k in self._data if self._unexpired(k)]
|
||||||
|
if match:
|
||||||
|
all_keys = [k for k in all_keys if fnmatch.fnmatch(k, match)]
|
||||||
|
return 0, all_keys
|
||||||
|
|
||||||
|
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]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def store_factory() -> Callable[[], tuple[RefreshTokenStore, _FakeRedis]]:
|
||||||
|
def _make() -> tuple[RefreshTokenStore, _FakeRedis]:
|
||||||
|
redis = _FakeRedis()
|
||||||
|
return RefreshTokenStore(redis, ttl_seconds=3600), redis
|
||||||
|
|
||||||
|
return _make
|
||||||
|
|
||||||
|
|
||||||
|
async def test_refresh_store_issue_and_validate(
|
||||||
|
store_factory: Callable[[], tuple[RefreshTokenStore, _FakeRedis]],
|
||||||
|
) -> None:
|
||||||
|
store, redis = store_factory()
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
|
jti = await store.issue(user_id)
|
||||||
|
|
||||||
|
assert isinstance(jti, str)
|
||||||
|
assert len(jti) == 32 # uuid4().hex
|
||||||
|
assert await store.is_valid(user_id, jti) is True
|
||||||
|
assert refresh_key(user_id, jti) in redis._data
|
||||||
|
|
||||||
|
|
||||||
|
async def test_refresh_store_revoke(
|
||||||
|
store_factory: Callable[[], tuple[RefreshTokenStore, _FakeRedis]],
|
||||||
|
) -> None:
|
||||||
|
store, _ = store_factory()
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
|
jti = await store.issue(user_id)
|
||||||
|
assert await store.revoke(user_id, jti) is True
|
||||||
|
assert await store.is_valid(user_id, jti) is False
|
||||||
|
# Idempotent revoke.
|
||||||
|
assert await store.revoke(user_id, jti) is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_refresh_store_revoke_all(
|
||||||
|
store_factory: Callable[[], tuple[RefreshTokenStore, _FakeRedis]],
|
||||||
|
) -> None:
|
||||||
|
store, _ = store_factory()
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
other = uuid.uuid4()
|
||||||
|
|
||||||
|
jti1 = await store.issue(user_id)
|
||||||
|
jti2 = await store.issue(user_id)
|
||||||
|
jti_other = await store.issue(other)
|
||||||
|
|
||||||
|
removed = await store.revoke_all(user_id)
|
||||||
|
|
||||||
|
assert removed == 2
|
||||||
|
assert await store.is_valid(user_id, jti1) is False
|
||||||
|
assert await store.is_valid(user_id, jti2) is False
|
||||||
|
# Other user unaffected.
|
||||||
|
assert await store.is_valid(other, jti_other) is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_refresh_store_is_valid_rejects_empty_jti(
|
||||||
|
store_factory: Callable[[], tuple[RefreshTokenStore, _FakeRedis]],
|
||||||
|
) -> None:
|
||||||
|
store, _ = store_factory()
|
||||||
|
assert await store.is_valid(uuid.uuid4(), "") is False
|
||||||
63
uv.lock
generated
63
uv.lock
generated
|
|
@ -161,6 +161,15 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
|
{ url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aiosmtplib"
|
||||||
|
version = "5.1.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b9/25/d36d056e62a1dc3dd51ce76e7647c62966702193dc91eafa7fd4e1006a91/aiosmtplib-5.1.2.tar.gz", hash = "sha256:04a0ea3c678f5b719f998f290dce010ca512e1385836d3944206299df03b060f", size = 71031, upload-time = "2026-06-20T15:00:48.269Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/ec/c5a415cd1309eaac28ad3c599458194d25ba07189bb07a1bac2c6713c17e/aiosmtplib-5.1.2-py3-none-any.whl", hash = "sha256:070d467cc329dafd0af59108ba5d217d973cba10309910fed359a2a7bfb52d7a", size = 28394, upload-time = "2026-06-20T15:00:47.299Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aiosqlite"
|
name = "aiosqlite"
|
||||||
version = "0.22.1"
|
version = "0.22.1"
|
||||||
|
|
@ -571,7 +580,9 @@ analyze = [
|
||||||
api = [
|
api = [
|
||||||
{ name = "aio-pika" },
|
{ name = "aio-pika" },
|
||||||
{ name = "alembic" },
|
{ name = "alembic" },
|
||||||
|
{ name = "argon2-cffi" },
|
||||||
{ name = "asyncpg" },
|
{ name = "asyncpg" },
|
||||||
|
{ name = "email-validator" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "minio" },
|
{ name = "minio" },
|
||||||
{ name = "opentelemetry-exporter-otlp" },
|
{ name = "opentelemetry-exporter-otlp" },
|
||||||
|
|
@ -598,11 +609,14 @@ db = [
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "aio-pika" },
|
{ name = "aio-pika" },
|
||||||
{ name = "aiogram" },
|
{ name = "aiogram" },
|
||||||
|
{ name = "aiosmtplib" },
|
||||||
{ name = "aiosqlite" },
|
{ name = "aiosqlite" },
|
||||||
{ name = "alembic" },
|
{ name = "alembic" },
|
||||||
{ name = "anyio" },
|
{ name = "anyio" },
|
||||||
|
{ name = "argon2-cffi" },
|
||||||
{ name = "asgi-lifespan" },
|
{ name = "asgi-lifespan" },
|
||||||
{ name = "asyncpg" },
|
{ name = "asyncpg" },
|
||||||
|
{ name = "email-validator" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "minio" },
|
{ name = "minio" },
|
||||||
{ name = "mypy" },
|
{ name = "mypy" },
|
||||||
|
|
@ -646,6 +660,17 @@ extract = [
|
||||||
mq = [
|
mq = [
|
||||||
{ name = "aio-pika" },
|
{ name = "aio-pika" },
|
||||||
]
|
]
|
||||||
|
notify = [
|
||||||
|
{ name = "aio-pika" },
|
||||||
|
{ name = "aiosmtplib" },
|
||||||
|
{ name = "alembic" },
|
||||||
|
{ name = "asyncpg" },
|
||||||
|
{ name = "opentelemetry-exporter-otlp" },
|
||||||
|
{ name = "opentelemetry-sdk" },
|
||||||
|
{ name = "prometheus-client" },
|
||||||
|
{ name = "sentry-sdk" },
|
||||||
|
{ name = "sqlalchemy" },
|
||||||
|
]
|
||||||
obs = [
|
obs = [
|
||||||
{ name = "opentelemetry-exporter-otlp" },
|
{ name = "opentelemetry-exporter-otlp" },
|
||||||
{ name = "opentelemetry-sdk" },
|
{ name = "opentelemetry-sdk" },
|
||||||
|
|
@ -685,7 +710,9 @@ analyze = [
|
||||||
api = [
|
api = [
|
||||||
{ name = "aio-pika", specifier = ">=9.4" },
|
{ name = "aio-pika", specifier = ">=9.4" },
|
||||||
{ name = "alembic", specifier = ">=1.13" },
|
{ name = "alembic", specifier = ">=1.13" },
|
||||||
|
{ name = "argon2-cffi", specifier = ">=23.1" },
|
||||||
{ name = "asyncpg", specifier = ">=0.29" },
|
{ name = "asyncpg", specifier = ">=0.29" },
|
||||||
|
{ name = "email-validator", specifier = ">=2.1" },
|
||||||
{ name = "fastapi", specifier = ">=0.110" },
|
{ name = "fastapi", specifier = ">=0.110" },
|
||||||
{ name = "minio", specifier = ">=7.2" },
|
{ name = "minio", specifier = ">=7.2" },
|
||||||
{ name = "opentelemetry-exporter-otlp", specifier = ">=1.24" },
|
{ name = "opentelemetry-exporter-otlp", specifier = ">=1.24" },
|
||||||
|
|
@ -710,11 +737,14 @@ db = [
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "aio-pika", specifier = ">=9.4" },
|
{ name = "aio-pika", specifier = ">=9.4" },
|
||||||
{ name = "aiogram", specifier = ">=3.4" },
|
{ name = "aiogram", specifier = ">=3.4" },
|
||||||
|
{ name = "aiosmtplib", specifier = ">=3.0" },
|
||||||
{ name = "aiosqlite", specifier = ">=0.20" },
|
{ name = "aiosqlite", specifier = ">=0.20" },
|
||||||
{ name = "alembic", specifier = ">=1.13" },
|
{ name = "alembic", specifier = ">=1.13" },
|
||||||
{ name = "anyio", specifier = ">=4" },
|
{ name = "anyio", specifier = ">=4" },
|
||||||
|
{ name = "argon2-cffi", specifier = ">=23.1" },
|
||||||
{ name = "asgi-lifespan", specifier = ">=2.1.0" },
|
{ name = "asgi-lifespan", specifier = ">=2.1.0" },
|
||||||
{ name = "asyncpg", specifier = ">=0.29" },
|
{ name = "asyncpg", specifier = ">=0.29" },
|
||||||
|
{ name = "email-validator", specifier = ">=2.1" },
|
||||||
{ name = "fastapi", specifier = ">=0.110" },
|
{ name = "fastapi", specifier = ">=0.110" },
|
||||||
{ name = "minio", specifier = ">=7.2" },
|
{ name = "minio", specifier = ">=7.2" },
|
||||||
{ name = "mypy", specifier = ">=1.10" },
|
{ name = "mypy", specifier = ">=1.10" },
|
||||||
|
|
@ -756,6 +786,17 @@ extract = [
|
||||||
{ name = "sqlalchemy", specifier = ">=2.0" },
|
{ name = "sqlalchemy", specifier = ">=2.0" },
|
||||||
]
|
]
|
||||||
mq = [{ name = "aio-pika", specifier = ">=9.4" }]
|
mq = [{ name = "aio-pika", specifier = ">=9.4" }]
|
||||||
|
notify = [
|
||||||
|
{ name = "aio-pika", specifier = ">=9.4" },
|
||||||
|
{ name = "aiosmtplib", specifier = ">=3.0" },
|
||||||
|
{ name = "alembic", specifier = ">=1.13" },
|
||||||
|
{ name = "asyncpg", specifier = ">=0.29" },
|
||||||
|
{ name = "opentelemetry-exporter-otlp", specifier = ">=1.24" },
|
||||||
|
{ name = "opentelemetry-sdk", specifier = ">=1.24" },
|
||||||
|
{ name = "prometheus-client", specifier = ">=0.20" },
|
||||||
|
{ name = "sentry-sdk", specifier = ">=2" },
|
||||||
|
{ name = "sqlalchemy", specifier = ">=2.0" },
|
||||||
|
]
|
||||||
obs = [
|
obs = [
|
||||||
{ name = "opentelemetry-exporter-otlp", specifier = ">=1.24" },
|
{ name = "opentelemetry-exporter-otlp", specifier = ">=1.24" },
|
||||||
{ name = "opentelemetry-sdk", specifier = ">=1.24" },
|
{ name = "opentelemetry-sdk", specifier = ">=1.24" },
|
||||||
|
|
@ -827,6 +868,15 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" },
|
{ url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dnspython"
|
||||||
|
version = "2.8.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "docker"
|
name = "docker"
|
||||||
version = "7.2.0"
|
version = "7.2.0"
|
||||||
|
|
@ -841,6 +891,19 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/75/23/529140fe1aab80fc6992f93a706deec709140a6397439139a054e1515c45/docker-7.2.0-py3-none-any.whl", hash = "sha256:a3f45fdeb9165e2d25d9a1d02ddf3bc70fb572cf5ebbf9b58558c22caf29b71f", size = 148775, upload-time = "2026-07-09T14:53:45.224Z" },
|
{ url = "https://files.pythonhosted.org/packages/75/23/529140fe1aab80fc6992f93a706deec709140a6397439139a054e1515c45/docker-7.2.0-py3-none-any.whl", hash = "sha256:a3f45fdeb9165e2d25d9a1d02ddf3bc70fb572cf5ebbf9b58558c22caf29b71f", size = 148775, upload-time = "2026-07-09T14:53:45.224Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "email-validator"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "dnspython" },
|
||||||
|
{ name = "idna" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi"
|
name = "fastapi"
|
||||||
version = "0.141.1"
|
version = "0.141.1"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue