SPB Git

Repository Public

Commit

accounts: service — signup sans pré-hijack et uniforme, verrouillage progressif (423), révocation des sessions au reset, garde dernier admin, changement mdp/e-mail, suppression douce, clés avec expiration/note/scopes, audit enrichi ; Bearer lecture seule (require_session)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed · Sep 6, 2026 parent af8d6fa
2 changed files +387 −64 Browse files
modified hfmarketdata/api/accounts/deps.py +45 −9
@@ -1,4 +1,9 @@
1−"""FastAPI dependencies: current user (session cookie or API key), admin role, CSRF guard, debug links. 1+"""FastAPI dependencies: current user (session cookie or API key), session-only guard, admin role, CSRF, debug links.
2+
3+`request.state.auth_kind` is "session" (cookie) or "key" (Bearer / ?api_key=). An API key is a *data*
4+credential: it may read `/v1/me`, `/v1/me/usage`, `/v1/me/limits`, but every mutation of the account and the
5+whole `/v1/admin/*` area require a browser session (`require_session` → 403 SESSION_REQUIRED). Cookies embed
6+`users.session_version`; a stale version (password reset, "sign out everywhere", …) is simply anonymous.
2 7
3 Author: Simon-Pierre Boucher <contact@spboucher.ai> 8 Author: Simon-Pierre Boucher <contact@spboucher.ai>
4 """ 9 """
@@ -14,15 +19,29 @@ from core.errors import ApiError
14 from . import security 19 from . import security
15 from .models import User 20 from .models import User
16 21
22+SESSION_REQUIRED_MSG = ("This action requires a signed-in browser session (cookie). API keys can only read "
23+ "GET /v1/me, /v1/me/usage and /v1/me/limits — sign in at {url}/signin to manage your account.")
24+
17 25
18 def current_user_optional(request: Request, s: Session = Depends(get_session)) -> User | None: 26 def current_user_optional(request: Request, s: Session = Depends(get_session)) -> User | None:
19− """Session cookie first, then the API key resolved by the rate-limit middleware (`request.state.user_id`).""" 27+ """Session cookie first (checked against `session_version`), then the API key resolved by the rate-limit
20− uid = security.read_session(request) 28+ middleware (`request.state.user_id`)."""
21− if uid is None: 29+ claims = security.read_session(request)
30+ u: User | None = None
31+ if claims is not None:
32+ u = s.get(User, claims.user_id)
33+ if u is not None and int(u.session_version or 1) != claims.version:
34+ u = None # cookie issued before a reset / revoke-all → anonymous
35+ if u is not None and u.status == "deleted":
36+ u = None
37+ if u is not None:
38+ request.state.auth_kind = "session"
39+ if u is None:
22 uid = getattr(request.state, "user_id", None) 40 uid = getattr(request.state, "user_id", None)
23− if uid is None: 41+ if uid is not None:
24− return None 42+ u = s.get(User, int(uid))
25− u = s.get(User, int(uid)) 43+ if u is not None:
44+ request.state.auth_kind = "key"
26 if u is None: 45 if u is None:
27 return None 46 return None
28 request.state.actor = f"user:{u.id}" 47 request.state.actor = f"user:{u.id}"
@@ -33,12 +52,19 @@ def current_user(u: User | None = Depends(current_user_optional)) -> User:
33 if u is None: 52 if u is None:
34 raise ApiError(401, "AUTH_REQUIRED", "Sign in (session cookie) or pass your API key as " 53 raise ApiError(401, "AUTH_REQUIRED", "Sign in (session cookie) or pass your API key as "
35 "`Authorization: Bearer hfmd_live_…` to access your account.") 54 "`Authorization: Bearer hfmd_live_…` to access your account.")
36− if u.status == "disabled": 55+ if u.status in ("disabled", "deleted"):
37 raise ApiError(403, "ACCOUNT_DISABLED", f"This account is disabled. Contact {settings.contact_email}.") 56 raise ApiError(403, "ACCOUNT_DISABLED", f"This account is disabled. Contact {settings.contact_email}.")
38 return u 57 return u
39 58
40 59
41−def require_admin(u: User = Depends(current_user)) -> User: 60+def require_session(request: Request, u: User = Depends(current_user)) -> User:
61+ """Mutations of the account and the admin area: a cookie session is mandatory, an API key is refused."""
62+ if getattr(request.state, "auth_kind", None) != "session":
63+ raise ApiError(403, "SESSION_REQUIRED", SESSION_REQUIRED_MSG.format(url=settings.public_url))
64+ return u
65+
66+
67+def require_admin(u: User = Depends(require_session)) -> User:
42 if u.role != "admin": 68 if u.role != "admin":
43 raise ApiError(403, "FORBIDDEN", "This endpoint requires the admin role.") 69 raise ApiError(403, "FORBIDDEN", "This endpoint requires the admin role.")
44 return u 70 return u
@@ -48,6 +74,16 @@ def csrf(request: Request) -> None:
48 security.csrf_guard(request) 74 security.csrf_guard(request)
49 75
50 76
77+def client_ip_hash(request: Request) -> str | None:
78+ """Hashed client IP (same rules as the rate-limit middleware: trusted proxy hops of X-Forwarded-For)."""
79+ try:
80+ from ratelimit.middleware import client_ip
81+ ip = client_ip(request.scope, request.headers)
82+ return security.hash_ip(ip) if ip and ip != "unknown" else None
83+ except Exception: # pragma: no cover
84+ return None
85+
86+
51 def debug_links_enabled() -> bool: 87 def debug_links_enabled() -> bool:
52 """Action links are echoed in API responses only outside production AND without a mail provider.""" 88 """Action links are echoed in API responses only outside production AND without a mail provider."""
53 return settings.is_dev and not settings.resend_api_key 89 return settings.is_dev and not settings.resend_api_key
modified hfmarketdata/api/accounts/service.py +342 −55
@@ -1,8 +1,17 @@
1 """Account operations shared by the HTTP routes, the admin API and the CLI. 1 """Account operations shared by the HTTP routes, the admin API and the CLI.
2 2
3 Every function takes an open SQLAlchemy `Session` and never commits itself (the caller's `session()` 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 4+context manager commits). E-mails are *queued* on the session and sent by `mailer` after the commit, so a
5−action link when no mail provider is configured. 5+rolled-back transaction sends nothing and the Resend call never runs inside a SQLite write lock. When no
6+mail provider is configured the action link is stored in `email_tokens.meta` so admins / the CLI can print it.
7+
8+Security rules implemented here (see docs/accounts-ratelimit.md §9):
9+* signup never modifies an existing account (anti pre-hijack) and answers uniformly;
10+* login costs the same for unknown and known e-mails (argon2 on a dummy hash) and locks the account after
11+ 5 failures with a progressive delay (30 s → 2 min → 10 min → 1 h), 423 ACCOUNT_LOCKED + Retry-After;
12+* `users.session_version` is embedded in the cookie and bumped on password reset/change, e-mail change,
13+ disable, role change, "sign out everywhere" and account deletion → every cookie dies at once;
14+* the last active administrator can never be demoted, disabled or deleted.
6 15
7 Author: Simon-Pierre Boucher <contact@spboucher.ai> 16 Author: Simon-Pierre Boucher <contact@spboucher.ai>
8 """ 17 """
@@ -13,18 +22,27 @@ from dataclasses import dataclass
13 from datetime import datetime, timedelta, timezone 22 from datetime import datetime, timedelta, timezone
14 23
15 from sqlalchemy import func, select 24 from sqlalchemy import func, select
25+from sqlalchemy.exc import IntegrityError
16 from sqlalchemy.orm import Session 26 from sqlalchemy.orm import Session
17 27
18 from core.config import settings 28 from core.config import settings
19 from core.errors import ApiError 29 from core.errors import ApiError
20 30
21 from . import mailer, security 31 from . import mailer, security
22−from .models import ApiKey, AuditLog, EmailToken, User, utcnow 32+from .models import KEY_SCOPES, ApiKey, AuditLog, EmailToken, User, utcnow
23 33
24 MAX_ACTIVE_KEYS = 10 34 MAX_ACTIVE_KEYS = 10
25−TOKEN_TTL = {"verify": timedelta(hours=48), "invite": timedelta(days=7), "reset": timedelta(hours=1)} 35+MAX_KEY_EXPIRY_DAYS = 365
26−TOKEN_TTL_LABEL = {"verify": "48 hours", "invite": "7 days", "reset": "1 hour"} 36+TOKEN_TTL = {"verify": timedelta(hours=48), "invite": timedelta(days=7), "reset": timedelta(hours=1),
27−TOKEN_PATH = {"verify": "/verify", "invite": "/accept-invite", "reset": "/reset-password"} 37+ "email_change": timedelta(hours=48)}
38+TOKEN_TTL_LABEL = {"verify": "48 hours", "invite": "7 days", "reset": "1 hour", "email_change": "48 hours"}
39+TOKEN_PATH = {"verify": "/verify", "invite": "/accept-invite", "reset": "/reset-password", "email_change": "/verify"}
40+# failed sign-ins → lock duration (seconds). Below LOCK_AFTER failures nothing happens.
41+LOCK_AFTER = 5
42+LOCK_STEPS_S = {5: 30, 6: 120, 7: 600}
43+LOCK_MAX_S = 3600
44+SUSPICIOUS_AT = 10
45+DELETED_DOMAIN = "deleted.invalid"
28 46
29 47
30 def now() -> datetime: 48 def now() -> datetime:
@@ -37,7 +55,7 @@ def iso(dt: datetime | None) -> str | None:
37 55
38 def normalize_email(email: str) -> str: 56 def normalize_email(email: str) -> str:
39 e = (email or "").strip().lower() 57 e = (email or "").strip().lower()
40− if "@" not in e or "." not in e.split("@")[-1] or len(e) > 254: 58+ if "@" not in e or "." not in e.split("@")[-1] or len(e) > 254 or e.endswith("@" + DELETED_DOMAIN):
41 raise ApiError(400, "INVALID_PARAMETER", "A valid e-mail address is required.") 59 raise ApiError(400, "INVALID_PARAMETER", "A valid e-mail address is required.")
42 return e 60 return e
43 61
@@ -63,24 +81,49 @@ def action_link(kind: str, raw_token: str) -> str:
63 return f"{settings.public_url}{TOKEN_PATH[kind]}?token={raw_token}" 81 return f"{settings.public_url}{TOKEN_PATH[kind]}?token={raw_token}"
64 82
65 83
66−def issue_token(s: Session, user: User, kind: str, *, send_mail: bool = True, actor: str = "system") -> IssuedToken: 84+def _token_meta(tok: EmailToken) -> dict:
67− """Create a single-use token, invalidate older ones of the same kind, and e-mail the action link.""" 85+ try:
86+ return json.loads(tok.meta) if tok.meta else {}
87+ except ValueError:
88+ return {}
89+
90+
91+def _store_link_later(token_id: int, link: str):
92+ """Callback for the mailer: the provider refused the message → keep the link for admins / CLI."""
93+ def _cb(kind: str, to: str, ctx: dict, d: mailer.Delivery) -> None:
94+ from core.db import session as _session
95+ with _session() as s2:
96+ tok = s2.get(EmailToken, token_id)
97+ if tok is not None and tok.used_at is None:
98+ meta = _token_meta(tok)
99+ meta.update(link=link, reason=d.error)
100+ tok.meta = json.dumps(meta)
101+ return _cb
102+
103+
104+def issue_token(s: Session, user: User, kind: str, *, send_mail: bool = True, actor: str = "system",
105+ to: str | None = None, meta: dict | None = None, **mail_ctx) -> IssuedToken:
106+ """Create a single-use token, invalidate older ones of the same kind, and queue the action e-mail."""
68 if kind not in TOKEN_TTL: 107 if kind not in TOKEN_TTL:
69 raise ValueError(kind) 108 raise ValueError(kind)
70 for old in s.execute(select(EmailToken).where(EmailToken.user_id == user.id, EmailToken.kind == kind, 109 for old in s.execute(select(EmailToken).where(EmailToken.user_id == user.id, EmailToken.kind == kind,
71 EmailToken.used_at.is_(None))).scalars(): 110 EmailToken.used_at.is_(None))).scalars():
72 old.used_at = now() 111 old.used_at = now()
73 raw = security.generate_token() 112 raw = security.generate_token()
74− tok = EmailToken(user_id=user.id, kind=kind, token_hash=security.hash_token(raw), 113+ tok = EmailToken(user_id=user.id, kind=kind, token_hash=security.hash_token(raw), expires_at=now() + TOKEN_TTL[kind],
75− expires_at=now() + TOKEN_TTL[kind]) 114+ meta=json.dumps(meta) if meta else None)
76 s.add(tok) 115 s.add(tok)
116+ s.flush()
77 link = action_link(kind, raw) 117 link = action_link(kind, raw)
78 delivery = mailer.Delivery(False, error="not_sent") 118 delivery = mailer.Delivery(False, error="not_sent")
79 if send_mail: 119 if send_mail:
80− delivery = mailer.send(kind, user.email, name=user.name, link=link, expires=TOKEN_TTL_LABEL[kind]) 120+ delivery = mailer.queue(s, kind, to or user.email, name=user.name, link=link, expires=TOKEN_TTL_LABEL[kind],
81− if not delivery.delivered: 121+ on_failure=_store_link_later(tok.id, link), **mail_ctx)
82− tok.meta = json.dumps({"link": link, "reason": delivery.error}) 122+ if not delivery.delivered and not delivery.queued:
83− audit(s, actor, f"token.{kind}", f"user:{user.id}", delivered=delivery.delivered) 123+ m = meta.copy() if meta else {}
124+ m.update(link=link, reason=delivery.error)
125+ tok.meta = json.dumps(m)
126+ audit(s, actor, f"token.{kind}", f"user:{user.id}", delivery=delivery.error or "delivered")
84 return IssuedToken(tok, user, raw, link, delivery) 127 return IssuedToken(tok, user, raw, link, delivery)
85 128
86 129
@@ -89,21 +132,17 @@ def pending_link(s: Session, user: User, kind: str) -> str | None:
89 tok = s.execute(select(EmailToken).where(EmailToken.user_id == user.id, EmailToken.kind == kind, 132 tok = s.execute(select(EmailToken).where(EmailToken.user_id == user.id, EmailToken.kind == kind,
90 EmailToken.used_at.is_(None), EmailToken.expires_at > now()) 133 EmailToken.used_at.is_(None), EmailToken.expires_at > now())
91 .order_by(EmailToken.id.desc())).scalars().first() 134 .order_by(EmailToken.id.desc())).scalars().first()
92− if tok and tok.meta: 135+ return _token_meta(tok).get("link") if tok else None
93− try:
94− return json.loads(tok.meta).get("link")
95− except ValueError:
96− return None
97− return None
98 136
99 137
100−def consume_token(s: Session, raw: str, kind: str) -> tuple[EmailToken, User]: 138+def consume_token(s: Session, raw: str, kind: str | tuple[str, ...] | None) -> tuple[EmailToken, User]:
139+ kinds = (kind,) if isinstance(kind, str) else kind
101 tok = s.execute(select(EmailToken).where(EmailToken.token_hash == security.hash_token(raw or ""))).scalars().first() 140 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(): 141+ if tok is None or (kinds and tok.kind not in kinds) 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.") 142 raise ApiError(400, "INVALID_TOKEN", "This link is invalid, expired or already used. Request a new one.")
104 tok.used_at = now() 143 tok.used_at = now()
105 user = s.get(User, tok.user_id) 144 user = s.get(User, tok.user_id)
106− if user is None: 145+ if user is None or user.status == "deleted":
107 raise ApiError(400, "INVALID_TOKEN", "This link is invalid.") 146 raise ApiError(400, "INVALID_TOKEN", "This link is invalid.")
108 return tok, user 147 return tok, user
109 148
@@ -121,13 +160,24 @@ def require_user(s: Session, user_id: int) -> User:
121 return u 160 return u
122 161
123 162
163+def active_admins(s: Session, *, excluding: int | None = None) -> int:
164+ q = select(func.count()).select_from(User).where(User.role == "admin", User.status == "active")
165+ if excluding is not None:
166+ q = q.where(User.id != excluding)
167+ return int(s.execute(q).scalar_one())
168+
169+
170+def _bump_session(u: User) -> None:
171+ u.session_version = int(u.session_version or 1) + 1
172+
173+
124 def create_user(s: Session, email: str, name: str, *, password: str | None = None, tier: str = "free", 174 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: 175 role: str = "user", status: str | None = None, actor: str = "system") -> User:
126 email = normalize_email(email) 176 email = normalize_email(email)
127 if get_user_by_email(s, email): 177 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.") 178 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", "unlimited"): 179 if tier not in ("free", "high_usage", "unlimited"):
130− raise ApiError(400, "INVALID_PARAMETER", "tier must be free or high_usage") 180+ raise ApiError(400, "INVALID_PARAMETER", "tier must be one of free, high_usage, unlimited")
131 if role not in ("user", "admin"): 181 if role not in ("user", "admin"):
132 raise ApiError(400, "INVALID_PARAMETER", "role must be user or admin") 182 raise ApiError(400, "INVALID_PARAMETER", "role must be user or admin")
133 if password is not None: 183 if password is not None:
@@ -141,21 +191,36 @@ def create_user(s: Session, email: str, name: str, *, password: str | None = Non
141 return u 191 return u
142 192
143 193
144−def signup(s: Session, email: str, name: str, password: str, actor: str = "self") -> IssuedToken: 194+def signup(s: Session, email: str, name: str, password: str, actor: str = "self") -> IssuedToken | None:
145− """Self-service signup: active-but-unverified user + verification mail. Re-sends for invited users.""" 195+ """Self-service signup. NEVER modifies an existing account (anti pre-hijack) and gives the caller no
196+ way to tell whether the address existed: the response is always "verification sent".
197+
198+ * new address → user (active, unverified) + verification mail;
199+ * invited address → the invitation is re-sent (password untouched);
200+ * signed-up but unverified → a fresh verification mail, password/name untouched;
201+ * verified account → an "you already have an account" notice is e-mailed, nothing returned;
202+ * disabled/deleted → nothing.
203+ Returns the issued token (for dev `debug_link`) or None.
204+ """
146 email = normalize_email(email) 205 email = normalize_email(email)
147 security.validate_password(password) 206 security.validate_password(password)
148 existing = get_user_by_email(s, email) 207 existing = get_user_by_email(s, email)
149 if existing is not None: 208 if existing is not None:
150 if existing.status == "invited": 209 if existing.status == "invited":
151 return issue_token(s, existing, "invite", actor=actor) 210 return issue_token(s, existing, "invite", actor=actor)
152− if existing.email_verified_at is None and existing.status == "active": 211+ if existing.status == "active" and existing.email_verified_at is None:
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) 212 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.") 213+ if existing.status == "active":
158− u = create_user(s, email, name, password=password, actor=actor) 214+ mailer.queue(s, "already_registered", existing.email, name=existing.name)
215+ audit(s, actor, "user.signup_existing", f"user:{existing.id}")
216+ return None
217+ nested = s.begin_nested()
218+ try:
219+ u = create_user(s, email, name, password=password, actor=actor)
220+ nested.commit()
221+ except IntegrityError: # concurrent signup with the same address: behave like "existing"
222+ nested.rollback()
223+ return None
159 return issue_token(s, u, "verify", actor=actor) 224 return issue_token(s, u, "verify", actor=actor)
160 225
161 226
@@ -175,20 +240,28 @@ def invite(s: Session, email: str, name: str, *, tier: str = "free", role: str =
175 return u, raw_key, issued 240 return u, raw_key, issued
176 241
177 242
243+def _refuse_if_disabled(u: User) -> None:
244+ if u.status in ("disabled", "deleted"):
245+ raise ApiError(403, "ACCOUNT_DISABLED", f"This account is disabled. Contact {settings.contact_email}.")
246+
247+
178 def accept_invite(s: Session, raw_token: str, password: str) -> User: 248 def accept_invite(s: Session, raw_token: str, password: str) -> User:
179 security.validate_password(password) 249 security.validate_password(password)
180 _, u = consume_token(s, raw_token, "invite") 250 _, u = consume_token(s, raw_token, "invite")
251+ _refuse_if_disabled(u)
181 u.password_hash = security.hash_password(password) 252 u.password_hash = security.hash_password(password)
182 u.email_verified_at = u.email_verified_at or now() 253 u.email_verified_at = u.email_verified_at or now()
183 if u.status == "invited": 254 if u.status == "invited":
184 u.status = "active" 255 u.status = "active"
185 u.last_login_at = now() 256 u.last_login_at = now()
257+ u.failed_logins, u.locked_until = 0, None
186 audit(s, f"user:{u.id}", "user.accept_invite", f"user:{u.id}") 258 audit(s, f"user:{u.id}", "user.accept_invite", f"user:{u.id}")
187 return u 259 return u
188 260
189 261
190 def verify_email(s: Session, raw_token: str) -> User: 262 def verify_email(s: Session, raw_token: str) -> User:
191 _, u = consume_token(s, raw_token, "verify") 263 _, u = consume_token(s, raw_token, "verify")
264+ _refuse_if_disabled(u)
192 u.email_verified_at = u.email_verified_at or now() 265 u.email_verified_at = u.email_verified_at or now()
193 if u.status == "invited": 266 if u.status == "invited":
194 u.status = "active" 267 u.status = "active"
@@ -197,9 +270,71 @@ def verify_email(s: Session, raw_token: str) -> User:
197 return u 270 return u
198 271
199 272
200−def login(s: Session, email: str, password: str) -> User: 273+def verify_any(s: Session, raw_token: str) -> tuple[str, User]:
201− u = get_user_by_email(s, email) 274+ """`POST /v1/auth/verify`: the same page handles account verification and e-mail-change confirmation."""
202− if u is None or not security.verify_password(password, u.password_hash): 275+ tok = s.execute(select(EmailToken).where(EmailToken.token_hash == security.hash_token(raw_token or ""))).scalars().first()
276+ kind = tok.kind if tok is not None else "verify"
277+ if kind == "email_change":
278+ return kind, confirm_email_change(s, raw_token)
279+ return "verify", verify_email(s, raw_token)
280+
281+
282+def _lock_seconds(failures: int) -> int:
283+ if failures < LOCK_AFTER:
284+ return 0
285+ return LOCK_STEPS_S.get(failures, LOCK_MAX_S)
286+
287+
288+def _record_failed_login(user_id: int, *, ip_hash: str | None, user_agent: str | None) -> tuple[int, int]:
289+ """Persist a failed attempt in its OWN transaction (the caller's session is rolled back by the 401).
290+ Returns (failures, lock_seconds)."""
291+ from core.db import session as _session
292+ with _session() as s2:
293+ u = s2.get(User, user_id)
294+ if u is None:
295+ return 0, 0
296+ u.failed_logins = int(u.failed_logins or 0) + 1
297+ lock_s = _lock_seconds(u.failed_logins)
298+ if lock_s:
299+ u.locked_until = now() + timedelta(seconds=lock_s)
300+ audit(s2, f"user:{u.id}", "user.login_failed", f"user:{u.id}", failures=u.failed_logins, ip=ip_hash,
301+ ua=(user_agent or "")[:120] or None, locked_s=lock_s or None)
302+ if u.failed_logins == SUSPICIOUS_AT and u.email_verified_at is not None:
303+ mailer.queue(s2, "suspicious_login", u.email, name=u.name, attempts=SUSPICIOUS_AT)
304+ failures = u.failed_logins
305+ _mirror_failures(user_id, failures)
306+ return failures, lock_s
307+
308+
309+def _mirror_failures(user_id: int, failures: int) -> None:
310+ """Best-effort copy of the counter in Redis (`auth:fail:<uid>`, 1 h TTL) for cross-worker observability."""
311+ try:
312+ from ratelimit import redis_limiter as rl
313+ rl._call(lambda: rl.client().set(f"auth:fail:{user_id}", failures, ex=3600))
314+ except Exception: # pragma: no cover
315+ pass
316+
317+
318+def _locked_error(locked_until: datetime) -> ApiError:
319+ retry = max(1, int((locked_until - now()).total_seconds()))
320+ return ApiError(423, "ACCOUNT_LOCKED", f"Too many failed sign-in attempts. Try again in {retry} s, or reset "
321+ "your password.", headers={"Retry-After": str(retry)}, details={"retry_after": retry})
322+
323+
324+def login(s: Session, email: str, password: str, *, ip_hash: str | None = None, user_agent: str | None = None) -> User:
325+ try:
326+ u = get_user_by_email(s, email)
327+ except ApiError:
328+ u = None
329+ if u is None or u.status == "deleted":
330+ security.verify_password(password, None) # same argon2 cost as a real account
331+ raise ApiError(401, "INVALID_CREDENTIALS", "Unknown e-mail or wrong password.")
332+ if u.locked_until is not None and u.locked_until > now():
333+ raise _locked_error(u.locked_until)
334+ if not security.verify_password(password, u.password_hash):
335+ _, lock_s = _record_failed_login(u.id, ip_hash=ip_hash, user_agent=user_agent)
336+ if lock_s:
337+ raise _locked_error(now() + timedelta(seconds=lock_s))
203 raise ApiError(401, "INVALID_CREDENTIALS", "Unknown e-mail or wrong password.") 338 raise ApiError(401, "INVALID_CREDENTIALS", "Unknown e-mail or wrong password.")
204 if u.status == "disabled": 339 if u.status == "disabled":
205 raise ApiError(403, "ACCOUNT_DISABLED", f"This account is disabled. Contact {settings.contact_email}.") 340 raise ApiError(403, "ACCOUNT_DISABLED", f"This account is disabled. Contact {settings.contact_email}.")
@@ -207,6 +342,8 @@ def login(s: Session, email: str, password: str) -> User:
207 raise ApiError(403, "EMAIL_NOT_VERIFIED", "Verify your e-mail first — check your inbox for the link, " 342 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.") 343 "or sign up again to receive a new one.")
209 u.last_login_at = now() 344 u.last_login_at = now()
345+ u.failed_logins, u.locked_until = 0, None
346+ audit(s, f"user:{u.id}", "user.login", f"user:{u.id}", ip=ip_hash)
210 return u 347 return u
211 348
212 349
@@ -215,20 +352,115 @@ def forgot(s: Session, email: str, actor: str = "self") -> IssuedToken | None:
215 u = get_user_by_email(s, email) 352 u = get_user_by_email(s, email)
216 except ApiError: 353 except ApiError:
217 return None 354 return None
218− if u is None or u.status == "disabled": 355+ if u is None or u.status in ("disabled", "deleted"):
219 return None 356 return None
220 return issue_token(s, u, "reset", actor=actor) 357 return issue_token(s, u, "reset", actor=actor)
221 358
222 359
223−def reset_password(s: Session, raw_token: str, password: str) -> User: 360+def reset_password(s: Session, raw_token: str, password: str, *, revoke_keys: bool = True) -> tuple[User, int]:
361+ """Set a new password from a reset link. Every session is signed out; API keys are revoked unless the
362+ user opted out (`revoke_keys=false`) — a reset usually means the credentials may have leaked.
363+ Returns (user, number of keys revoked)."""
224 security.validate_password(password) 364 security.validate_password(password)
225 _, u = consume_token(s, raw_token, "reset") 365 _, u = consume_token(s, raw_token, "reset")
366+ _refuse_if_disabled(u)
226 u.password_hash = security.hash_password(password) 367 u.password_hash = security.hash_password(password)
227 u.email_verified_at = u.email_verified_at or now() 368 u.email_verified_at = u.email_verified_at or now()
228 if u.status == "invited": 369 if u.status == "invited":
229 u.status = "active" 370 u.status = "active"
230 u.last_login_at = now() 371 u.last_login_at = now()
231− audit(s, f"user:{u.id}", "user.reset_password", f"user:{u.id}") 372+ u.failed_logins, u.locked_until = 0, None
373+ _bump_session(u)
374+ revoked = 0
375+ if revoke_keys:
376+ for k in active_keys(s, u):
377+ revoke_key(s, k, actor=f"user:{u.id}", notify=False)
378+ revoked += 1
379+ audit(s, f"user:{u.id}", "user.reset_password", f"user:{u.id}", keys_revoked=revoked)
380+ return u, revoked
381+
382+
383+def change_password(s: Session, u: User, current: str, new: str) -> User:
384+ if not security.verify_password(current, u.password_hash):
385+ raise ApiError(401, "INVALID_CREDENTIALS", "The current password is wrong.")
386+ security.validate_password(new)
387+ u.password_hash = security.hash_password(new)
388+ _bump_session(u)
389+ audit(s, f"user:{u.id}", "account.password_change", f"user:{u.id}")
390+ mailer.queue(s, "password_changed", u.email, name=u.name)
391+ return u
392+
393+
394+def request_email_change(s: Session, u: User, new_email: str, password: str) -> IssuedToken | None:
395+ """Mail a confirmation link to the NEW address; the change is applied by `confirm_email_change`.
396+ Uniform answer whether or not the new address already belongs to someone (that account gets a notice)."""
397+ if not security.verify_password(password, u.password_hash):
398+ raise ApiError(401, "INVALID_CREDENTIALS", "The current password is wrong.")
399+ new_email = normalize_email(new_email)
400+ if new_email == u.email:
401+ raise ApiError(400, "INVALID_PARAMETER", "This is already the e-mail of your account.")
402+ other = get_user_by_email(s, new_email)
403+ audit(s, f"user:{u.id}", "account.email_change_requested", f"user:{u.id}")
404+ if other is not None:
405+ mailer.queue(s, "already_registered", other.email, name=other.name)
406+ return None
407+ return issue_token(s, u, "email_change", actor=f"user:{u.id}", to=new_email, meta={"new_email": new_email},
408+ new_email=new_email)
409+
410+
411+def confirm_email_change(s: Session, raw_token: str) -> User:
412+ tok, u = consume_token(s, raw_token, "email_change")
413+ _refuse_if_disabled(u)
414+ new_email = _token_meta(tok).get("new_email")
415+ if not new_email:
416+ raise ApiError(400, "INVALID_TOKEN", "This link is invalid.")
417+ if get_user_by_email(s, new_email) is not None:
418+ raise ApiError(409, "EMAIL_TAKEN", "This e-mail address is now used by another account.")
419+ old = u.email
420+ u.email = new_email
421+ u.email_verified_at = now()
422+ _bump_session(u)
423+ audit(s, f"user:{u.id}", "account.email_change", f"user:{u.id}")
424+ mailer.queue(s, "email_changed", old, name=u.name, new_email=new_email)
425+ _invalidate_cache()
426+ return u
427+
428+
429+def revoke_all_sessions(s: Session, u: User, *, actor: str | None = None) -> User:
430+ _bump_session(u)
431+ audit(s, actor or f"user:{u.id}", "session.revoke_all", f"user:{u.id}")
432+ return u
433+
434+
435+def delete_account(s: Session, u: User, *, actor: str, notify: bool = True) -> User:
436+ """Soft delete: keys revoked, tokens voided, sessions signed out, e-mail/name anonymised. Usage tables
437+ only reference `key:<id>` principals, so aggregated counters stay but can no longer be tied to a person."""
438+ if u.status == "deleted":
439+ return u
440+ if u.role == "admin" and u.status == "active" and active_admins(s, excluding=u.id) == 0:
441+ raise ApiError(409, "LAST_ADMIN", "The last active administrator cannot be deleted. Promote someone first.")
442+ old_email, old_name = u.email, u.name
443+ for k in active_keys(s, u):
444+ revoke_key(s, k, actor=actor, notify=False)
445+ for tok in s.execute(select(EmailToken).where(EmailToken.user_id == u.id, EmailToken.used_at.is_(None))).scalars():
446+ tok.used_at = now()
447+ u.email = f"deleted-{u.id}@{DELETED_DOMAIN}"
448+ u.name = ""
449+ u.password_hash = None
450+ u.status = "deleted"
451+ u.deleted_at = now()
452+ u.quota_alerts = 0
453+ _bump_session(u)
454+ audit(s, actor, "account.delete", f"user:{u.id}")
455+ if notify:
456+ mailer.queue(s, "account_deleted", old_email, name=old_name)
457+ _invalidate_cache()
458+ return u
459+
460+
461+def set_quota_alerts(s: Session, u: User, enabled: bool) -> User:
462+ u.quota_alerts = 1 if enabled else 0
463+ audit(s, f"user:{u.id}", "account.quota_alerts", f"user:{u.id}", enabled=enabled)
232 return u 464 return u
233 465
234 466
@@ -239,13 +471,20 @@ def update_user(s: Session, u: User, *, actor: str, name: str | None = None, tie
239 if name is not None: 471 if name is not None:
240 changes["name"] = name.strip()[:200] 472 changes["name"] = name.strip()[:200]
241 for field, value in (("tier", tier), ("role", role), ("status", status)): 473 for field, value in (("tier", tier), ("role", role), ("status", status)):
242− if value is None: 474+ if value is None or value == getattr(u, field):
243 continue 475 continue
244 if value not in allowed[field]: 476 if value not in allowed[field]:
245 raise ApiError(400, "INVALID_PARAMETER", f"{field} must be one of {', '.join(allowed[field])}") 477 raise ApiError(400, "INVALID_PARAMETER", f"{field} must be one of {', '.join(allowed[field])}")
246 changes[field] = value 478 changes[field] = value
479+ if u.status == "deleted" and changes:
480+ raise ApiError(409, "CONFLICT", "Deleted accounts cannot be modified.")
481+ demoting = changes.get("role") == "user" or changes.get("status") == "disabled"
482+ if demoting and u.role == "admin" and u.status == "active" and active_admins(s, excluding=u.id) == 0:
483+ raise ApiError(409, "LAST_ADMIN", "The last active administrator cannot be demoted or disabled. Promote someone first.")
247 for field, value in changes.items(): 484 for field, value in changes.items():
248 setattr(u, field, value) 485 setattr(u, field, value)
486+ if "role" in changes or changes.get("status") == "disabled":
487+ _bump_session(u)
249 if changes: 488 if changes:
250 audit(s, actor, "user.update", f"user:{u.id}", **changes) 489 audit(s, actor, "user.update", f"user:{u.id}", **changes)
251 _invalidate_cache() 490 _invalidate_cache()
@@ -263,19 +502,53 @@ 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()) 502 return list(s.execute(select(ApiKey).where(ApiKey.user_id == u.id).order_by(ApiKey.id)).scalars())
264 503
265 504
266−def create_key(s: Session, u: User, name: str = "default", *, actor: str, notify: bool = True) -> tuple[str, ApiKey]: 505+def _count_active(s: Session, user_id: int) -> int:
267− """Create an active key. Returns (raw_key, row) — the raw key is shown exactly once.""" 506+ return int(s.execute(select(func.count()).select_from(ApiKey)
268− n_active = s.execute(select(func.count()).select_from(ApiKey).where(ApiKey.user_id == u.id, ApiKey.status == "active")).scalar_one() 507+ .where(ApiKey.user_id == user_id, ApiKey.status == "active")).scalar_one())
269− if n_active >= MAX_ACTIVE_KEYS: 508+
509+
510+def validate_scopes(scopes: list[str] | None) -> list[str]:
511+ scopes = list(dict.fromkeys(scopes or ["data"]))
512+ bad = [x for x in scopes if x not in KEY_SCOPES]
513+ if bad or not scopes:
514+ raise ApiError(400, "INVALID_PARAMETER", f"scopes must be a non-empty subset of {list(KEY_SCOPES)}")
515+ return scopes
516+
517+
518+def validate_expiry(expires_at: datetime | None = None, expires_in_days: int | None = None) -> datetime | None:
519+ if expires_at is None and expires_in_days is None:
520+ return None
521+ if expires_in_days is not None:
522+ if not 1 <= int(expires_in_days) <= MAX_KEY_EXPIRY_DAYS:
523+ raise ApiError(400, "INVALID_PARAMETER", f"expires_in_days must be between 1 and {MAX_KEY_EXPIRY_DAYS}")
524+ return now() + timedelta(days=int(expires_in_days))
525+ exp = expires_at.astimezone(timezone.utc).replace(tzinfo=None) if expires_at.tzinfo else expires_at
526+ if exp <= now() or exp > now() + timedelta(days=MAX_KEY_EXPIRY_DAYS):
527+ raise ApiError(400, "INVALID_PARAMETER", f"expires_at must be in the future and within {MAX_KEY_EXPIRY_DAYS} days")
528+ return exp
529+
530+
531+def create_key(s: Session, u: User, name: str = "default", *, actor: str, notify: bool = True,
532+ expires_at: datetime | None = None, note: str | None = None, scopes: list[str] | None = None,
533+ tier_override: str | None = None) -> tuple[str, ApiKey]:
534+ """Create an active key. Returns (raw_key, row) — the raw key is shown exactly once.
535+
536+ The active-key cap is checked before AND after the insert (same transaction): under SQLite's serialised
537+ writes a concurrent creator sees the extra row and rolls back instead of exceeding the cap."""
538+ scopes_json = json.dumps(validate_scopes(scopes))
539+ if _count_active(s, u.id) >= MAX_ACTIVE_KEYS:
270 raise ApiError(409, "KEY_LIMIT_REACHED", f"You already have {MAX_ACTIVE_KEYS} active keys. Revoke one first.") 540 raise ApiError(409, "KEY_LIMIT_REACHED", f"You already have {MAX_ACTIVE_KEYS} active keys. Revoke one first.")
271 raw = security.generate_api_key() 541 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), 542 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") 543+ key_hash=security.hash_key(raw), status="active", expires_at=expires_at,
544+ note=(note or "").strip()[:500] or None, scopes=scopes_json, tier_override=tier_override)
274 s.add(k) 545 s.add(k)
275 s.flush() 546 s.flush()
276− audit(s, actor, "key.create", f"key:{k.id}", user=u.id, prefix=k.prefix) 547+ if _count_active(s, u.id) > MAX_ACTIVE_KEYS:
548+ raise ApiError(409, "KEY_LIMIT_REACHED", f"You already have {MAX_ACTIVE_KEYS} active keys. Revoke one first.")
549+ audit(s, actor, "key.create", f"key:{k.id}", user=u.id, prefix=k.prefix, expires_at=iso(k.expires_at))
277 if notify and u.email_verified_at is not None: 550 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) 551+ mailer.queue(s, "key_created", u.email, name=u.name, key_name=k.name, key_prefix=k.prefix)
279 return raw, k 552 return raw, k
280 553
281 554
@@ -286,19 +559,27 @@ def get_key(s: Session, u: User, key_id: int) -> ApiKey:
286 return k 559 return k
287 560
288 561
289−def revoke_key(s: Session, k: ApiKey, *, actor: str) -> ApiKey: 562+def revoke_key(s: Session, k: ApiKey, *, actor: str, notify: bool = True) -> ApiKey:
290 if k.status == "active": 563 if k.status == "active":
291 k.status = "revoked" 564 k.status = "revoked"
292 k.revoked_at = now() 565 k.revoked_at = now()
293 audit(s, actor, "key.revoke", f"key:{k.id}", prefix=k.prefix) 566 audit(s, actor, "key.revoke", f"key:{k.id}", prefix=k.prefix)
294 _invalidate_cache(k.key_hash) 567 _invalidate_cache(k.key_hash)
568+ owner = s.get(User, k.user_id)
569+ if notify and owner is not None and owner.email_verified_at is not None and owner.status != "deleted":
570+ mailer.queue(s, "key_revoked", owner.email, name=owner.name, key_name=k.name, key_prefix=k.prefix)
295 return k 571 return k
296 572
297 573
298−def rotate_key(s: Session, u: User, k: ApiKey, *, actor: str) -> tuple[str, ApiKey]: 574+def rotate_key(s: Session, u: User, k: ApiKey, *, actor: str, notify: bool = True) -> tuple[str, ApiKey]:
299− """Revoke `k` and create a fresh key with the same name.""" 575+ """Revoke `k` and create a fresh key with the same name, note, scopes, expiry and tier override."""
300− revoke_key(s, k, actor=actor) 576+ revoke_key(s, k, actor=actor, notify=False)
301− return create_key(s, u, k.name, actor=actor, notify=False) 577+ raw, nk = create_key(s, u, k.name, actor=actor, notify=False, expires_at=k.expires_at, note=k.note,
578+ scopes=json.loads(k.scopes or "[]") or None, tier_override=k.tier_override)
579+ audit(s, actor, "key.rotate", f"key:{k.id}", new_key=nk.id)
580+ if notify and u.email_verified_at is not None:
581+ mailer.queue(s, "key_rotated", u.email, name=u.name, key_name=k.name, key_prefix=k.prefix, new_prefix=nk.prefix)
582+ return raw, nk
302 583
303 584
304 def _invalidate_cache(key_hash: str | None = None) -> None: 585 def _invalidate_cache(key_hash: str | None = None) -> None:
@@ -314,13 +595,19 @@ def _invalidate_cache(key_hash: str | None = None) -> None:
314 def user_public(u: User, *, keys_count: int | None = None) -> dict: 595 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, 596 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), 597 "email_verified": u.email_verified_at is not None, "created_at": iso(u.created_at),
317− "last_login_at": iso(u.last_login_at)} 598+ "last_login_at": iso(u.last_login_at), "quota_alerts": bool(u.quota_alerts),
599+ "locked_until": iso(u.locked_until) if u.locked_until and u.locked_until > now() else None}
318 if keys_count is not None: 600 if keys_count is not None:
319 d["keys_active"] = keys_count 601 d["keys_active"] = keys_count
320 return d 602 return d
321 603
322 604
323 def key_public(k: ApiKey) -> dict: 605 def key_public(k: ApiKey) -> dict:
606+ try:
607+ scopes = json.loads(k.scopes) if k.scopes else ["data"]
608+ except ValueError:
609+ scopes = ["data"]
324 return {"id": k.id, "name": k.name, "prefix": k.prefix, "status": k.status, "tier_override": k.tier_override, 610 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), 611+ "created_at": iso(k.created_at), "last_used_at": iso(k.last_used_at), "last_used_ip": k.last_used_ip,
326− "principal": k.principal} 612+ "revoked_at": iso(k.revoked_at), "expires_at": iso(k.expires_at), "expired": k.is_expired(),
613+ "note": k.note, "scopes": scopes, "principal": k.principal}
327 614
Menu

Theme