accounts: modèles SQLite (§1.4), sécurité (argon2, clés hfmd_live_ hachées, sessions signées, garde CSRF), mailer Resend, service métier
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
5 changed files +669 −0
added
hfmarketdata/api/accounts/__init__.py
+1 −0
@@ -0,0 +1 @@ | ||
| 1 | +"""Accounts: users, e-mail tokens, API keys, usage aggregates, audit log (SQLite via core.db).""" | |
added
hfmarketdata/api/accounts/mailer.py
+98 −0
@@ -0,0 +1,98 @@ | ||
| 1 | +"""Transactional e-mail through the Resend HTTP API (httpx). | |
| 2 | + | |
| 3 | +`send(kind, to, **ctx)` renders one of the English templates (plain text + minimal HTML) and posts it to | |
| 4 | +Resend when `settings.resend_api_key` is set. Without a key the message is logged and `delivered=False` is | |
| 5 | +returned, so the caller stores the action link in `email_tokens.meta` for the admin UI / CLI to print. | |
| 6 | +API keys are NEVER included in an e-mail. | |
| 7 | + | |
| 8 | +Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 9 | +""" | |
| 10 | +from __future__ import annotations | |
| 11 | + | |
| 12 | +import html | |
| 13 | +import logging | |
| 14 | +from dataclasses import dataclass | |
| 15 | + | |
| 16 | +import httpx | |
| 17 | + | |
| 18 | +from core.config import settings | |
| 19 | + | |
| 20 | +log = logging.getLogger("hfmarketdata.mailer") | |
| 21 | + | |
| 22 | +RESEND_URL = "https://api.resend.com/emails" | |
| 23 | +TIMEOUT_S = 10.0 | |
| 24 | + | |
| 25 | + | |
| 26 | +@dataclass(frozen=True) | |
| 27 | +class Delivery: | |
| 28 | + delivered: bool | |
| 29 | + provider_id: str | None = None | |
| 30 | + error: str | None = None | |
| 31 | + | |
| 32 | + | |
| 33 | +def _template(kind: str, ctx: dict) -> tuple[str, str]: | |
| 34 | + """Return (subject, body_text) for a template. `ctx` keys: name, link, key_name, key_prefix, expires.""" | |
| 35 | + name = ctx.get("name") or "there" | |
| 36 | + link = ctx.get("link", "") | |
| 37 | + brand = "HF Market Data" | |
| 38 | + footer = (f"\n\n— {brand} · {settings.public_url}\nQuestions? Reply to this e-mail or write to " | |
| 39 | + f"{settings.contact_email}.") | |
| 40 | + if kind == "verify": | |
| 41 | + return (f"Verify your e-mail for {brand}", | |
| 42 | + f"Hi {name},\n\nConfirm your e-mail address to activate your {brand} account and create your " | |
| 43 | + f"API key:\n\n{link}\n\nThe link is valid for {ctx.get('expires', '48 hours')}. If you did not " | |
| 44 | + f"sign up, you can ignore this message.{footer}") | |
| 45 | + if kind == "invite": | |
| 46 | + return (f"You are invited to {brand}", | |
| 47 | + f"Hi {name},\n\nAn account has been created for you on {brand} (open high-frequency market data " | |
| 48 | + f"API — 1-minute to daily bars since 2010, futures contracts, options chains).\n\nSet your " | |
| 49 | + f"password to accept the invitation:\n\n{link}\n\nThe link is valid for " | |
| 50 | + f"{ctx.get('expires', '7 days')}. Your API key is waiting in the dashboard once you are signed " | |
| 51 | + f"in (keys are shown once, at creation — you can create a new one anytime).{footer}") | |
| 52 | + if kind == "reset": | |
| 53 | + return (f"Reset your {brand} password", | |
| 54 | + f"Hi {name},\n\nSomeone asked to reset the password of your {brand} account. If it was you, " | |
| 55 | + f"choose a new password here:\n\n{link}\n\nThe link is valid for {ctx.get('expires', '1 hour')}. " | |
| 56 | + f"Otherwise ignore this e-mail — your password stays unchanged.{footer}") | |
| 57 | + if kind == "key_created": | |
| 58 | + return (f"New API key on your {brand} account", | |
| 59 | + f"Hi {name},\n\nA new API key named “{ctx.get('key_name', 'default')}” " | |
| 60 | + f"(prefix {ctx.get('key_prefix', '')}…) was created on your account. For security the key " | |
| 61 | + f"itself is only shown once, in the dashboard.\n\nIf this was not you, revoke it now: " | |
| 62 | + f"{settings.public_url}/dashboard/keys{footer}") | |
| 63 | + raise ValueError(f"unknown mail template {kind!r}") | |
| 64 | + | |
| 65 | + | |
| 66 | +def render(kind: str, **ctx) -> tuple[str, str, str]: | |
| 67 | + """(subject, text, html)""" | |
| 68 | + subject, text = _template(kind, ctx) | |
| 69 | + paragraphs = "".join(f"<p>{html.escape(p).replace(chr(10), '<br>')}</p>" for p in text.split("\n\n")) | |
| 70 | + link = ctx.get("link") | |
| 71 | + if link: | |
| 72 | + esc = html.escape(link, quote=True) | |
| 73 | + paragraphs = paragraphs.replace(html.escape(link), f'<a href="{esc}">{esc}</a>') | |
| 74 | + body_html = ( | |
| 75 | + '<!doctype html><html><body style="font-family:-apple-system,Segoe UI,Helvetica,Arial,sans-serif;' | |
| 76 | + 'background:#0b0f14;color:#e6edf3;padding:24px"><div style="max-width:560px;margin:auto;background:#111823;' | |
| 77 | + 'border:1px solid #1f2a37;border-radius:12px;padding:28px;line-height:1.55">' | |
| 78 | + f'<h2 style="margin:0 0 16px;color:#7ee787">HF Market Data</h2>{paragraphs}</div></body></html>') | |
| 79 | + return subject, text, body_html | |
| 80 | + | |
| 81 | + | |
| 82 | +def send(kind: str, to: str, **ctx) -> Delivery: | |
| 83 | + subject, text, body_html = render(kind, **ctx) | |
| 84 | + if not settings.resend_api_key: | |
| 85 | + log.info("mail not delivered (no HFMD_RESEND_API_KEY) kind=%s to=%s subject=%r link=%s", | |
| 86 | + kind, to, subject, ctx.get("link")) | |
| 87 | + return Delivery(False, error="no_provider") | |
| 88 | + payload = {"from": settings.mail_from, "to": [to], "subject": subject, "text": text, "html": body_html} | |
| 89 | + try: | |
| 90 | + r = httpx.post(RESEND_URL, json=payload, timeout=TIMEOUT_S, | |
| 91 | + headers={"Authorization": f"Bearer {settings.resend_api_key}"}) | |
| 92 | + if r.status_code >= 300: | |
| 93 | + log.warning("resend error %s for %s: %s", r.status_code, to, r.text[:300]) | |
| 94 | + return Delivery(False, error=f"resend_{r.status_code}") | |
| 95 | + return Delivery(True, provider_id=(r.json() or {}).get("id")) | |
| 96 | + except Exception as exc: | |
| 97 | + log.warning("resend unreachable for %s: %s", to, exc) | |
| 98 | + return Delivery(False, error=str(exc)[:200]) | |
added
hfmarketdata/api/accounts/models.py
+119 −0
@@ -0,0 +1,119 @@ | ||
| 1 | +"""SQLAlchemy models — UPGRADE-PLAN §1.4 (users · email_tokens · api_keys · usage_daily · usage_minute · audit_log). | |
| 2 | + | |
| 3 | +Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 4 | +""" | |
| 5 | +from __future__ import annotations | |
| 6 | + | |
| 7 | +from datetime import date, datetime, timezone | |
| 8 | + | |
| 9 | +from sqlalchemy import Date, DateTime, ForeignKey, Index, Integer, String, Text | |
| 10 | +from sqlalchemy.orm import Mapped, mapped_column, relationship | |
| 11 | + | |
| 12 | +from core.db import Base, create_all | |
| 13 | + | |
| 14 | +ROLES = ("user", "admin") | |
| 15 | +TIERS = ("free", "high_usage") | |
| 16 | +USER_STATUSES = ("invited", "active", "disabled") | |
| 17 | +KEY_STATUSES = ("active", "revoked") | |
| 18 | +TOKEN_KINDS = ("verify", "reset", "invite") | |
| 19 | + | |
| 20 | + | |
| 21 | +def utcnow() -> datetime: | |
| 22 | + return datetime.now(timezone.utc).replace(tzinfo=None) | |
| 23 | + | |
| 24 | + | |
| 25 | +class User(Base): | |
| 26 | + __tablename__ = "users" | |
| 27 | + | |
| 28 | + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) | |
| 29 | + email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) | |
| 30 | + name: Mapped[str] = mapped_column(String(200), nullable=False, default="") | |
| 31 | + password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True) # argon2; NULL until invite accepted | |
| 32 | + email_verified_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) | |
| 33 | + role: Mapped[str] = mapped_column(String(16), nullable=False, default="user") # user | admin | |
| 34 | + tier: Mapped[str] = mapped_column(String(16), nullable=False, default="free") # free | high_usage | |
| 35 | + status: Mapped[str] = mapped_column(String(16), nullable=False, default="invited") # invited | active | disabled | |
| 36 | + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow) | |
| 37 | + last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) | |
| 38 | + | |
| 39 | + keys: Mapped[list[ApiKey]] = relationship(back_populates="user", cascade="all, delete-orphan") | |
| 40 | + tokens: Mapped[list[EmailToken]] = relationship(back_populates="user", cascade="all, delete-orphan") | |
| 41 | + | |
| 42 | + @property | |
| 43 | + def is_admin(self) -> bool: | |
| 44 | + return self.role == "admin" | |
| 45 | + | |
| 46 | + | |
| 47 | +class EmailToken(Base): | |
| 48 | + __tablename__ = "email_tokens" | |
| 49 | + | |
| 50 | + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) | |
| 51 | + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) | |
| 52 | + kind: Mapped[str] = mapped_column(String(16), nullable=False) # verify | reset | invite | |
| 53 | + token_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True) | |
| 54 | + expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) | |
| 55 | + used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) | |
| 56 | + # JSON: {"link": "…"} when the e-mail could not be delivered (no Resend key) so admins/CLI can print it | |
| 57 | + meta: Mapped[str | None] = mapped_column(Text, nullable=True) | |
| 58 | + | |
| 59 | + user: Mapped[User] = relationship(back_populates="tokens") | |
| 60 | + | |
| 61 | + | |
| 62 | +class ApiKey(Base): | |
| 63 | + __tablename__ = "api_keys" | |
| 64 | + | |
| 65 | + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) | |
| 66 | + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) | |
| 67 | + name: Mapped[str] = mapped_column(String(100), nullable=False, default="default") | |
| 68 | + prefix: Mapped[str] = mapped_column(String(24), nullable=False) # hfmd_live_ab12cd34 (display) | |
| 69 | + key_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True) # sha256(salt + key) | |
| 70 | + tier_override: Mapped[str | None] = mapped_column(String(16), nullable=True) | |
| 71 | + status: Mapped[str] = mapped_column(String(16), nullable=False, default="active") # active | revoked | |
| 72 | + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow) | |
| 73 | + last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) | |
| 74 | + revoked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) | |
| 75 | + | |
| 76 | + user: Mapped[User] = relationship(back_populates="keys") | |
| 77 | + | |
| 78 | + @property | |
| 79 | + def principal(self) -> str: | |
| 80 | + return f"key:{self.id}" | |
| 81 | + | |
| 82 | + | |
| 83 | +class UsageDaily(Base): | |
| 84 | + __tablename__ = "usage_daily" | |
| 85 | + | |
| 86 | + day: Mapped[date] = mapped_column(Date, primary_key=True) | |
| 87 | + principal: Mapped[str] = mapped_column(String(64), primary_key=True) # key:<id> | ip:<hash> | |
| 88 | + requests: Mapped[int] = mapped_column(Integer, nullable=False, default=0) | |
| 89 | + rows: Mapped[int] = mapped_column(Integer, nullable=False, default=0) | |
| 90 | + rows_parquet: Mapped[int] = mapped_column(Integer, nullable=False, default=0) | |
| 91 | + bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0) | |
| 92 | + status_2xx: Mapped[int] = mapped_column(Integer, nullable=False, default=0) | |
| 93 | + status_429: Mapped[int] = mapped_column(Integer, nullable=False, default=0) | |
| 94 | + | |
| 95 | + | |
| 96 | +class UsageMinute(Base): | |
| 97 | + __tablename__ = "usage_minute" | |
| 98 | + | |
| 99 | + minute: Mapped[datetime] = mapped_column(DateTime, primary_key=True) | |
| 100 | + principal: Mapped[str] = mapped_column(String(64), primary_key=True) | |
| 101 | + requests: Mapped[int] = mapped_column(Integer, nullable=False, default=0) | |
| 102 | + rows: Mapped[int] = mapped_column(Integer, nullable=False, default=0) | |
| 103 | + | |
| 104 | + | |
| 105 | +class AuditLog(Base): | |
| 106 | + __tablename__ = "audit_log" | |
| 107 | + | |
| 108 | + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) | |
| 109 | + ts: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow, index=True) | |
| 110 | + actor: Mapped[str] = mapped_column(String(64), nullable=False) # user:<id> | cli | system | |
| 111 | + action: Mapped[str] = mapped_column(String(64), nullable=False) | |
| 112 | + target: Mapped[str | None] = mapped_column(String(64), nullable=True) | |
| 113 | + meta: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON | |
| 114 | + | |
| 115 | + | |
| 116 | +Index("ix_usage_daily_principal", UsageDaily.principal) | |
| 117 | +Index("ix_usage_minute_principal", UsageMinute.principal) | |
| 118 | + | |
| 119 | +create_all() | |
added
hfmarketdata/api/accounts/security.py
+125 −0
@@ -0,0 +1,125 @@ | ||
| 1 | +"""Passwords (argon2), API keys (hfmd_live_ + 32 base62, sha256(salt+key) stored), e-mail tokens, | |
| 2 | +signed cookie sessions (itsdangerous) and the CSRF guard. | |
| 3 | + | |
| 4 | +Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 5 | +""" | |
| 6 | +from __future__ import annotations | |
| 7 | + | |
| 8 | +import hashlib | |
| 9 | +import secrets | |
| 10 | +import string | |
| 11 | + | |
| 12 | +from argon2 import PasswordHasher | |
| 13 | +from argon2.exceptions import VerifyMismatchError | |
| 14 | +from fastapi import Request, Response | |
| 15 | +from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer | |
| 16 | + | |
| 17 | +from core.config import settings | |
| 18 | +from core.errors import ApiError | |
| 19 | + | |
| 20 | +KEY_PREFIX = "hfmd_live_" | |
| 21 | +KEY_BODY_LEN = 32 | |
| 22 | +KEY_DISPLAY_CHARS = 8 | |
| 23 | +BASE62 = string.digits + string.ascii_letters | |
| 24 | +MIN_PASSWORD_LEN = 10 | |
| 25 | +SESSION_COOKIE = "hfmd_session" | |
| 26 | +SESSION_MAX_AGE_S = 30 * 86400 | |
| 27 | +MUTATING = {"POST", "PUT", "PATCH", "DELETE"} | |
| 28 | + | |
| 29 | +_ph = PasswordHasher() | |
| 30 | +_serializer = URLSafeTimedSerializer(settings.secret_key, salt="hfmd-session") | |
| 31 | + | |
| 32 | + | |
| 33 | +# ------------------------------------------------------------------------------------------ passwords | |
| 34 | + | |
| 35 | +def validate_password(password: str) -> None: | |
| 36 | + if not password or len(password) < MIN_PASSWORD_LEN: | |
| 37 | + raise ApiError(400, "WEAK_PASSWORD", f"Password must be at least {MIN_PASSWORD_LEN} characters.") | |
| 38 | + | |
| 39 | + | |
| 40 | +def hash_password(password: str) -> str: | |
| 41 | + return _ph.hash(password) | |
| 42 | + | |
| 43 | + | |
| 44 | +def verify_password(password: str, password_hash: str | None) -> bool: | |
| 45 | + if not password_hash: | |
| 46 | + return False | |
| 47 | + try: | |
| 48 | + return _ph.verify(password_hash, password) | |
| 49 | + except VerifyMismatchError: | |
| 50 | + return False | |
| 51 | + except Exception: | |
| 52 | + return False | |
| 53 | + | |
| 54 | + | |
| 55 | +# ------------------------------------------------------------------------------------------- API keys | |
| 56 | + | |
| 57 | +def generate_api_key() -> str: | |
| 58 | + return KEY_PREFIX + "".join(secrets.choice(BASE62) for _ in range(KEY_BODY_LEN)) | |
| 59 | + | |
| 60 | + | |
| 61 | +def key_prefix(raw_key: str) -> str: | |
| 62 | + """Display prefix: `hfmd_live_` + first 8 chars of the body.""" | |
| 63 | + return raw_key[: len(KEY_PREFIX) + KEY_DISPLAY_CHARS] | |
| 64 | + | |
| 65 | + | |
| 66 | +def hash_key(raw_key: str) -> str: | |
| 67 | + return hashlib.sha256((settings.key_hash_salt + raw_key).encode()).hexdigest() | |
| 68 | + | |
| 69 | + | |
| 70 | +def looks_like_key(value: str) -> bool: | |
| 71 | + body = value[len(KEY_PREFIX):] | |
| 72 | + return value.startswith(KEY_PREFIX) and len(body) == KEY_BODY_LEN and all(c in BASE62 for c in body) | |
| 73 | + | |
| 74 | + | |
| 75 | +# --------------------------------------------------------------------------------------- e-mail tokens | |
| 76 | + | |
| 77 | +def generate_token() -> str: | |
| 78 | + return secrets.token_urlsafe(32) | |
| 79 | + | |
| 80 | + | |
| 81 | +def hash_token(token: str) -> str: | |
| 82 | + return hashlib.sha256((settings.key_hash_salt + "|token|" + token).encode()).hexdigest() | |
| 83 | + | |
| 84 | + | |
| 85 | +# -------------------------------------------------------------------------------------------- sessions | |
| 86 | + | |
| 87 | +def session_value(user_id: int) -> str: | |
| 88 | + return _serializer.dumps({"uid": int(user_id)}) | |
| 89 | + | |
| 90 | + | |
| 91 | +def set_session_cookie(response: Response, user_id: int) -> None: | |
| 92 | + response.set_cookie(SESSION_COOKIE, session_value(user_id), max_age=SESSION_MAX_AGE_S, httponly=True, | |
| 93 | + samesite="lax", secure=not settings.is_dev, path="/") | |
| 94 | + | |
| 95 | + | |
| 96 | +def clear_session_cookie(response: Response) -> None: | |
| 97 | + response.delete_cookie(SESSION_COOKIE, path="/", httponly=True, samesite="lax", secure=not settings.is_dev) | |
| 98 | + | |
| 99 | + | |
| 100 | +def read_session(request: Request) -> int | None: | |
| 101 | + raw = request.cookies.get(SESSION_COOKIE) | |
| 102 | + if not raw: | |
| 103 | + return None | |
| 104 | + try: | |
| 105 | + data = _serializer.loads(raw, max_age=SESSION_MAX_AGE_S) | |
| 106 | + except (BadSignature, SignatureExpired): | |
| 107 | + return None | |
| 108 | + uid = data.get("uid") if isinstance(data, dict) else None | |
| 109 | + return int(uid) if isinstance(uid, int) else None | |
| 110 | + | |
| 111 | + | |
| 112 | +# ------------------------------------------------------------------------------------------------ CSRF | |
| 113 | + | |
| 114 | +def csrf_guard(request: Request) -> None: | |
| 115 | + """State-changing account requests must be JSON (`Content-Type: application/json`). | |
| 116 | + | |
| 117 | + Combined with SameSite=Lax HttpOnly cookies this defeats form-based CSRF: browsers cannot send a | |
| 118 | + cross-site request with that content type without a CORS preflight, and credentials are never | |
| 119 | + allowed cross-origin by our CORS policy. | |
| 120 | + """ | |
| 121 | + if request.method in MUTATING: | |
| 122 | + ctype = request.headers.get("content-type", "").split(";")[0].strip().lower() | |
| 123 | + if ctype != "application/json": | |
| 124 | + raise ApiError(415, "UNSUPPORTED_MEDIA_TYPE", | |
| 125 | + "Send `Content-Type: application/json` (use `{}` as body when there is nothing to send).") | |
added
hfmarketdata/api/accounts/service.py
+326 −0
@@ -0,0 +1,326 @@ | ||
| 1 | +"""Account operations shared by the HTTP routes, the admin API and the CLI. | |
| 2 | + | |
| 3 | +Every function takes an open SQLAlchemy `Session` and never commits itself (the caller's `session()` | |
| 4 | +context manager commits). Side effects (mail) return their delivery status so callers can surface the | |
| 5 | +action link when no mail provider is configured. | |
| 6 | + | |
| 7 | +Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 8 | +""" | |
| 9 | +from __future__ import annotations | |
| 10 | + | |
| 11 | +import json | |
| 12 | +from dataclasses import dataclass | |
| 13 | +from datetime import datetime, timedelta, timezone | |
| 14 | + | |
| 15 | +from sqlalchemy import func, select | |
| 16 | +from sqlalchemy.orm import Session | |
| 17 | + | |
| 18 | +from core.config import settings | |
| 19 | +from core.errors import ApiError | |
| 20 | + | |
| 21 | +from . import mailer, security | |
| 22 | +from .models import ApiKey, AuditLog, EmailToken, User, utcnow | |
| 23 | + | |
| 24 | +MAX_ACTIVE_KEYS = 10 | |
| 25 | +TOKEN_TTL = {"verify": timedelta(hours=48), "invite": timedelta(days=7), "reset": timedelta(hours=1)} | |
| 26 | +TOKEN_TTL_LABEL = {"verify": "48 hours", "invite": "7 days", "reset": "1 hour"} | |
| 27 | +TOKEN_PATH = {"verify": "/verify", "invite": "/accept-invite", "reset": "/reset-password"} | |
| 28 | + | |
| 29 | + | |
| 30 | +def now() -> datetime: | |
| 31 | + return utcnow() | |
| 32 | + | |
| 33 | + | |
| 34 | +def iso(dt: datetime | None) -> str | None: | |
| 35 | + return dt.replace(tzinfo=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") if dt else None | |
| 36 | + | |
| 37 | + | |
| 38 | +def normalize_email(email: str) -> str: | |
| 39 | + e = (email or "").strip().lower() | |
| 40 | + if "@" not in e or "." not in e.split("@")[-1] or len(e) > 254: | |
| 41 | + raise ApiError(400, "INVALID_PARAMETER", "A valid e-mail address is required.") | |
| 42 | + return e | |
| 43 | + | |
| 44 | + | |
| 45 | +# ------------------------------------------------------------------------------------------------- audit | |
| 46 | + | |
| 47 | +def audit(s: Session, actor: str, action: str, target: str | None = None, **meta) -> None: | |
| 48 | + s.add(AuditLog(actor=actor, action=action, target=target, meta=json.dumps(meta, default=str) if meta else None)) | |
| 49 | + | |
| 50 | + | |
| 51 | +# ------------------------------------------------------------------------------------------------ tokens | |
| 52 | + | |
| 53 | +@dataclass(frozen=True) | |
| 54 | +class IssuedToken: | |
| 55 | + token: EmailToken | |
| 56 | + user: User | |
| 57 | + raw: str | |
| 58 | + link: str | |
| 59 | + delivery: mailer.Delivery | |
| 60 | + | |
| 61 | + | |
| 62 | +def action_link(kind: str, raw_token: str) -> str: | |
| 63 | + return f"{settings.public_url}{TOKEN_PATH[kind]}?token={raw_token}" | |
| 64 | + | |
| 65 | + | |
| 66 | +def issue_token(s: Session, user: User, kind: str, *, send_mail: bool = True, actor: str = "system") -> IssuedToken: | |
| 67 | + """Create a single-use token, invalidate older ones of the same kind, and e-mail the action link.""" | |
| 68 | + if kind not in TOKEN_TTL: | |
| 69 | + raise ValueError(kind) | |
| 70 | + for old in s.execute(select(EmailToken).where(EmailToken.user_id == user.id, EmailToken.kind == kind, | |
| 71 | + EmailToken.used_at.is_(None))).scalars(): | |
| 72 | + old.used_at = now() | |
| 73 | + raw = security.generate_token() | |
| 74 | + tok = EmailToken(user_id=user.id, kind=kind, token_hash=security.hash_token(raw), | |
| 75 | + expires_at=now() + TOKEN_TTL[kind]) | |
| 76 | + s.add(tok) | |
| 77 | + link = action_link(kind, raw) | |
| 78 | + delivery = mailer.Delivery(False, error="not_sent") | |
| 79 | + if send_mail: | |
| 80 | + delivery = mailer.send(kind, user.email, name=user.name, link=link, expires=TOKEN_TTL_LABEL[kind]) | |
| 81 | + if not delivery.delivered: | |
| 82 | + tok.meta = json.dumps({"link": link, "reason": delivery.error}) | |
| 83 | + audit(s, actor, f"token.{kind}", f"user:{user.id}", delivered=delivery.delivered) | |
| 84 | + return IssuedToken(tok, user, raw, link, delivery) | |
| 85 | + | |
| 86 | + | |
| 87 | +def pending_link(s: Session, user: User, kind: str) -> str | None: | |
| 88 | + """Undelivered, still valid action link stored in email_tokens.meta (for admins / CLI).""" | |
| 89 | + tok = s.execute(select(EmailToken).where(EmailToken.user_id == user.id, EmailToken.kind == kind, | |
| 90 | + EmailToken.used_at.is_(None), EmailToken.expires_at > now()) | |
| 91 | + .order_by(EmailToken.id.desc())).scalars().first() | |
| 92 | + if tok and tok.meta: | |
| 93 | + try: | |
| 94 | + return json.loads(tok.meta).get("link") | |
| 95 | + except ValueError: | |
| 96 | + return None | |
| 97 | + return None | |
| 98 | + | |
| 99 | + | |
| 100 | +def consume_token(s: Session, raw: str, kind: str) -> tuple[EmailToken, User]: | |
| 101 | + tok = s.execute(select(EmailToken).where(EmailToken.token_hash == security.hash_token(raw or ""))).scalars().first() | |
| 102 | + if tok is None or tok.kind != kind or tok.used_at is not None or tok.expires_at <= now(): | |
| 103 | + raise ApiError(400, "INVALID_TOKEN", "This link is invalid, expired or already used. Request a new one.") | |
| 104 | + tok.used_at = now() | |
| 105 | + user = s.get(User, tok.user_id) | |
| 106 | + if user is None: | |
| 107 | + raise ApiError(400, "INVALID_TOKEN", "This link is invalid.") | |
| 108 | + return tok, user | |
| 109 | + | |
| 110 | + | |
| 111 | +# ------------------------------------------------------------------------------------------------- users | |
| 112 | + | |
| 113 | +def get_user_by_email(s: Session, email: str) -> User | None: | |
| 114 | + return s.execute(select(User).where(User.email == normalize_email(email))).scalars().first() | |
| 115 | + | |
| 116 | + | |
| 117 | +def require_user(s: Session, user_id: int) -> User: | |
| 118 | + u = s.get(User, user_id) | |
| 119 | + if u is None: | |
| 120 | + raise ApiError(404, "USER_NOT_FOUND", f"No user with id {user_id}.") | |
| 121 | + return u | |
| 122 | + | |
| 123 | + | |
| 124 | +def create_user(s: Session, email: str, name: str, *, password: str | None = None, tier: str = "free", | |
| 125 | + role: str = "user", status: str | None = None, actor: str = "system") -> User: | |
| 126 | + email = normalize_email(email) | |
| 127 | + if get_user_by_email(s, email): | |
| 128 | + raise ApiError(409, "EMAIL_TAKEN", "An account with this e-mail already exists. Sign in or reset your password.") | |
| 129 | + if tier not in ("free", "high_usage"): | |
| 130 | + raise ApiError(400, "INVALID_PARAMETER", "tier must be free or high_usage") | |
| 131 | + if role not in ("user", "admin"): | |
| 132 | + raise ApiError(400, "INVALID_PARAMETER", "role must be user or admin") | |
| 133 | + if password is not None: | |
| 134 | + security.validate_password(password) | |
| 135 | + u = User(email=email, name=(name or "").strip()[:200], tier=tier, role=role, | |
| 136 | + password_hash=security.hash_password(password) if password else None, | |
| 137 | + status=status or ("active" if password else "invited")) | |
| 138 | + s.add(u) | |
| 139 | + s.flush() | |
| 140 | + audit(s, actor, "user.create", f"user:{u.id}", email=email, tier=tier, role=role) | |
| 141 | + return u | |
| 142 | + | |
| 143 | + | |
| 144 | +def signup(s: Session, email: str, name: str, password: str, actor: str = "self") -> IssuedToken: | |
| 145 | + """Self-service signup: active-but-unverified user + verification mail. Re-sends for invited users.""" | |
| 146 | + email = normalize_email(email) | |
| 147 | + security.validate_password(password) | |
| 148 | + existing = get_user_by_email(s, email) | |
| 149 | + if existing is not None: | |
| 150 | + if existing.status == "invited": | |
| 151 | + return issue_token(s, existing, "invite", actor=actor) | |
| 152 | + if existing.email_verified_at is None and existing.status == "active": | |
| 153 | + # signed up but never verified: refresh password and re-send verification | |
| 154 | + existing.password_hash = security.hash_password(password) | |
| 155 | + existing.name = (name or existing.name).strip()[:200] | |
| 156 | + return issue_token(s, existing, "verify", actor=actor) | |
| 157 | + raise ApiError(409, "EMAIL_TAKEN", "An account with this e-mail already exists. Sign in or reset your password.") | |
| 158 | + u = create_user(s, email, name, password=password, actor=actor) | |
| 159 | + return issue_token(s, u, "verify", actor=actor) | |
| 160 | + | |
| 161 | + | |
| 162 | +def invite(s: Session, email: str, name: str, *, tier: str = "free", role: str = "user", actor: str = "admin", | |
| 163 | + key_name: str = "default", send_mail: bool = True) -> tuple[User, str | None, IssuedToken]: | |
| 164 | + """Admin/CLI invitation: user (status invited) + one active key + invitation mail. | |
| 165 | + | |
| 166 | + Returns (user, raw_key or None if the user already had an active key, issued token).""" | |
| 167 | + email = normalize_email(email) | |
| 168 | + u = get_user_by_email(s, email) | |
| 169 | + if u is None: | |
| 170 | + u = create_user(s, email, name, tier=tier, role=role, status="invited", actor=actor) | |
| 171 | + raw_key = None | |
| 172 | + if not active_keys(s, u): | |
| 173 | + raw_key, _ = create_key(s, u, key_name, actor=actor, notify=False) | |
| 174 | + issued = issue_token(s, u, "invite", send_mail=send_mail and u.status == "invited", actor=actor) | |
| 175 | + return u, raw_key, issued | |
| 176 | + | |
| 177 | + | |
| 178 | +def accept_invite(s: Session, raw_token: str, password: str) -> User: | |
| 179 | + security.validate_password(password) | |
| 180 | + _, u = consume_token(s, raw_token, "invite") | |
| 181 | + u.password_hash = security.hash_password(password) | |
| 182 | + u.email_verified_at = u.email_verified_at or now() | |
| 183 | + if u.status == "invited": | |
| 184 | + u.status = "active" | |
| 185 | + u.last_login_at = now() | |
| 186 | + audit(s, f"user:{u.id}", "user.accept_invite", f"user:{u.id}") | |
| 187 | + return u | |
| 188 | + | |
| 189 | + | |
| 190 | +def verify_email(s: Session, raw_token: str) -> User: | |
| 191 | + _, u = consume_token(s, raw_token, "verify") | |
| 192 | + u.email_verified_at = u.email_verified_at or now() | |
| 193 | + if u.status == "invited": | |
| 194 | + u.status = "active" | |
| 195 | + u.last_login_at = now() | |
| 196 | + audit(s, f"user:{u.id}", "user.verify", f"user:{u.id}") | |
| 197 | + return u | |
| 198 | + | |
| 199 | + | |
| 200 | +def login(s: Session, email: str, password: str) -> User: | |
| 201 | + u = get_user_by_email(s, email) | |
| 202 | + if u is None or not security.verify_password(password, u.password_hash): | |
| 203 | + raise ApiError(401, "INVALID_CREDENTIALS", "Unknown e-mail or wrong password.") | |
| 204 | + if u.status == "disabled": | |
| 205 | + raise ApiError(403, "ACCOUNT_DISABLED", f"This account is disabled. Contact {settings.contact_email}.") | |
| 206 | + if u.email_verified_at is None: | |
| 207 | + raise ApiError(403, "EMAIL_NOT_VERIFIED", "Verify your e-mail first — check your inbox for the link, " | |
| 208 | + "or sign up again to receive a new one.") | |
| 209 | + u.last_login_at = now() | |
| 210 | + return u | |
| 211 | + | |
| 212 | + | |
| 213 | +def forgot(s: Session, email: str, actor: str = "self") -> IssuedToken | None: | |
| 214 | + try: | |
| 215 | + u = get_user_by_email(s, email) | |
| 216 | + except ApiError: | |
| 217 | + return None | |
| 218 | + if u is None or u.status == "disabled": | |
| 219 | + return None | |
| 220 | + return issue_token(s, u, "reset", actor=actor) | |
| 221 | + | |
| 222 | + | |
| 223 | +def reset_password(s: Session, raw_token: str, password: str) -> User: | |
| 224 | + security.validate_password(password) | |
| 225 | + _, u = consume_token(s, raw_token, "reset") | |
| 226 | + u.password_hash = security.hash_password(password) | |
| 227 | + u.email_verified_at = u.email_verified_at or now() | |
| 228 | + if u.status == "invited": | |
| 229 | + u.status = "active" | |
| 230 | + u.last_login_at = now() | |
| 231 | + audit(s, f"user:{u.id}", "user.reset_password", f"user:{u.id}") | |
| 232 | + return u | |
| 233 | + | |
| 234 | + | |
| 235 | +def update_user(s: Session, u: User, *, actor: str, name: str | None = None, tier: str | None = None, | |
| 236 | + role: str | None = None, status: str | None = None) -> User: | |
| 237 | + allowed = {"tier": ("free", "high_usage"), "role": ("user", "admin"), "status": ("invited", "active", "disabled")} | |
| 238 | + changes: dict = {} | |
| 239 | + if name is not None: | |
| 240 | + changes["name"] = name.strip()[:200] | |
| 241 | + for field, value in (("tier", tier), ("role", role), ("status", status)): | |
| 242 | + if value is None: | |
| 243 | + continue | |
| 244 | + if value not in allowed[field]: | |
| 245 | + raise ApiError(400, "INVALID_PARAMETER", f"{field} must be one of {', '.join(allowed[field])}") | |
| 246 | + changes[field] = value | |
| 247 | + for field, value in changes.items(): | |
| 248 | + setattr(u, field, value) | |
| 249 | + if changes: | |
| 250 | + audit(s, actor, "user.update", f"user:{u.id}", **changes) | |
| 251 | + _invalidate_cache() | |
| 252 | + return u | |
| 253 | + | |
| 254 | + | |
| 255 | +# -------------------------------------------------------------------------------------------------- keys | |
| 256 | + | |
| 257 | +def active_keys(s: Session, u: User) -> list[ApiKey]: | |
| 258 | + return list(s.execute(select(ApiKey).where(ApiKey.user_id == u.id, ApiKey.status == "active") | |
| 259 | + .order_by(ApiKey.id)).scalars()) | |
| 260 | + | |
| 261 | + | |
| 262 | +def list_keys(s: Session, u: User) -> list[ApiKey]: | |
| 263 | + return list(s.execute(select(ApiKey).where(ApiKey.user_id == u.id).order_by(ApiKey.id)).scalars()) | |
| 264 | + | |
| 265 | + | |
| 266 | +def create_key(s: Session, u: User, name: str = "default", *, actor: str, notify: bool = True) -> tuple[str, ApiKey]: | |
| 267 | + """Create an active key. Returns (raw_key, row) — the raw key is shown exactly once.""" | |
| 268 | + n_active = s.execute(select(func.count()).select_from(ApiKey).where(ApiKey.user_id == u.id, ApiKey.status == "active")).scalar_one() | |
| 269 | + if n_active >= MAX_ACTIVE_KEYS: | |
| 270 | + raise ApiError(409, "KEY_LIMIT_REACHED", f"You already have {MAX_ACTIVE_KEYS} active keys. Revoke one first.") | |
| 271 | + raw = security.generate_api_key() | |
| 272 | + k = ApiKey(user_id=u.id, name=(name or "default").strip()[:100] or "default", prefix=security.key_prefix(raw), | |
| 273 | + key_hash=security.hash_key(raw), status="active") | |
| 274 | + s.add(k) | |
| 275 | + s.flush() | |
| 276 | + audit(s, actor, "key.create", f"key:{k.id}", user=u.id, prefix=k.prefix) | |
| 277 | + if notify and u.email_verified_at is not None: | |
| 278 | + mailer.send("key_created", u.email, name=u.name, key_name=k.name, key_prefix=k.prefix) | |
| 279 | + return raw, k | |
| 280 | + | |
| 281 | + | |
| 282 | +def get_key(s: Session, u: User, key_id: int) -> ApiKey: | |
| 283 | + k = s.get(ApiKey, key_id) | |
| 284 | + if k is None or k.user_id != u.id: | |
| 285 | + raise ApiError(404, "KEY_NOT_FOUND", f"No API key with id {key_id} on this account.") | |
| 286 | + return k | |
| 287 | + | |
| 288 | + | |
| 289 | +def revoke_key(s: Session, k: ApiKey, *, actor: str) -> ApiKey: | |
| 290 | + if k.status == "active": | |
| 291 | + k.status = "revoked" | |
| 292 | + k.revoked_at = now() | |
| 293 | + audit(s, actor, "key.revoke", f"key:{k.id}", prefix=k.prefix) | |
| 294 | + _invalidate_cache(k.key_hash) | |
| 295 | + return k | |
| 296 | + | |
| 297 | + | |
| 298 | +def rotate_key(s: Session, u: User, k: ApiKey, *, actor: str) -> tuple[str, ApiKey]: | |
| 299 | + """Revoke `k` and create a fresh key with the same name.""" | |
| 300 | + revoke_key(s, k, actor=actor) | |
| 301 | + return create_key(s, u, k.name, actor=actor, notify=False) | |
| 302 | + | |
| 303 | + | |
| 304 | +def _invalidate_cache(key_hash: str | None = None) -> None: | |
| 305 | + try: | |
| 306 | + from ratelimit.middleware import invalidate_key_cache | |
| 307 | + invalidate_key_cache(key_hash) | |
| 308 | + except Exception: # pragma: no cover | |
| 309 | + pass | |
| 310 | + | |
| 311 | + | |
| 312 | +# -------------------------------------------------------------------------------------------- serialise | |
| 313 | + | |
| 314 | +def user_public(u: User, *, keys_count: int | None = None) -> dict: | |
| 315 | + d = {"id": u.id, "email": u.email, "name": u.name, "role": u.role, "tier": u.tier, "status": u.status, | |
| 316 | + "email_verified": u.email_verified_at is not None, "created_at": iso(u.created_at), | |
| 317 | + "last_login_at": iso(u.last_login_at)} | |
| 318 | + if keys_count is not None: | |
| 319 | + d["keys_active"] = keys_count | |
| 320 | + return d | |
| 321 | + | |
| 322 | + | |
| 323 | +def key_public(k: ApiKey) -> dict: | |
| 324 | + return {"id": k.id, "name": k.name, "prefix": k.prefix, "status": k.status, "tier_override": k.tier_override, | |
| 325 | + "created_at": iso(k.created_at), "last_used_at": iso(k.last_used_at), "revoked_at": iso(k.revoked_at), | |
| 326 | + "principal": k.principal} | |
| 327 | ||