SPB Git forge

spb/hfmarketdata

Public

Open high-frequency market data platform — FirstRate full-history downloader, DuckDB/Parquet lake, open REST API and React docs platform (www.hfmarketdata.io)

127commits 1branches 0releases
24.7 MBsize
maindefault branch
11 days agolast push
JavaScript 53.7% Python 38.3% CSS 4.6% TypeScript 3.1%

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 18 days ago (Sep 6, 2026) parent af8d6fa

2 changed files +387 −64

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 8 Author: Simon-Pierre Boucher <contact@spboucher.ai>
4 9 """
@@ -14,15 +19,29 @@ from core.errors import ApiError
14 19 from . import security
15 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 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`)."""
20 − uid = security.read_session(request)
21 − if uid is None:
27 + """Session cookie first (checked against `session_version`), then the API key resolved by the rate-limit
28 + middleware (`request.state.user_id`)."""
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 40 uid = getattr(request.state, "user_id", None)
23 − if uid is None:
24 − return None
25 − u = s.get(User, int(uid))
41 + if uid is not None:
42 + u = s.get(User, int(uid))
43 + if u is not None:
44 + request.state.auth_kind = "key"
26 45 if u is None:
27 46 return None
28 47 request.state.actor = f"user:{u.id}"
@@ -33,12 +52,19 @@ def current_user(u: User | None = Depends(current_user_optional)) -> User:
33 52 if u is None:
34 53 raise ApiError(401, "AUTH_REQUIRED", "Sign in (session cookie) or pass your API key as "
35 54 "`Authorization: Bearer hfmd_live_…` to access your account.")
36 − if u.status == "disabled":
55 + if u.status in ("disabled", "deleted"):
37 56 raise ApiError(403, "ACCOUNT_DISABLED", f"This account is disabled. Contact {settings.contact_email}.")
38 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 68 if u.role != "admin":
43 69 raise ApiError(403, "FORBIDDEN", "This endpoint requires the admin role.")
44 70 return u
@@ -48,6 +74,16 @@ def csrf(request: Request) -> None:
48 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 87 def debug_links_enabled() -> bool:
52 88 """Action links are echoed in API responses only outside production AND without a mail provider."""
53 89 return settings.is_dev and not settings.resend_api_key
modified hfmarketdata/api/accounts/service.py +342 −55
@@ -1,8 +1,17 @@
1 1 """Account operations shared by the HTTP routes, the admin API and the CLI.
2 2
3 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.
4 +context manager commits). E-mails are *queued* on the session and sent by `mailer` after the commit, so a
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 16 Author: Simon-Pierre Boucher <contact@spboucher.ai>
8 17 """
@@ -13,18 +22,27 @@ from dataclasses import dataclass
13 22 from datetime import datetime, timedelta, timezone
14 23
15 24 from sqlalchemy import func, select
25 +from sqlalchemy.exc import IntegrityError
16 26 from sqlalchemy.orm import Session
17 27
18 28 from core.config import settings
19 29 from core.errors import ApiError
20 30
21 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 34 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"}
35 +MAX_KEY_EXPIRY_DAYS = 365
36 +TOKEN_TTL = {"verify": timedelta(hours=48), "invite": timedelta(days=7), "reset": timedelta(hours=1),
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 48 def now() -> datetime:
@@ -37,7 +55,7 @@ def iso(dt: datetime | None) -> str | None:
37 55
38 56 def normalize_email(email: str) -> str:
39 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 59 raise ApiError(400, "INVALID_PARAMETER", "A valid e-mail address is required.")
42 60 return e
43 61
@@ -63,24 +81,49 @@ def action_link(kind: str, raw_token: str) -> str:
63 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:
67 − """Create a single-use token, invalidate older ones of the same kind, and e-mail the action link."""
84 +def _token_meta(tok: EmailToken) -> dict:
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 107 if kind not in TOKEN_TTL:
69 108 raise ValueError(kind)
70 109 for old in s.execute(select(EmailToken).where(EmailToken.user_id == user.id, EmailToken.kind == kind,
71 110 EmailToken.used_at.is_(None))).scalars():
72 111 old.used_at = now()
73 112 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])
113 + tok = EmailToken(user_id=user.id, kind=kind, token_hash=security.hash_token(raw), expires_at=now() + TOKEN_TTL[kind],
114 + meta=json.dumps(meta) if meta else None)
76 115 s.add(tok)
116 + s.flush()
77 117 link = action_link(kind, raw)
78 118 delivery = mailer.Delivery(False, error="not_sent")
79 119 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)
120 + delivery = mailer.queue(s, kind, to or user.email, name=user.name, link=link, expires=TOKEN_TTL_LABEL[kind],
121 + on_failure=_store_link_later(tok.id, link), **mail_ctx)
122 + if not delivery.delivered and not delivery.queued:
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 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 132 tok = s.execute(select(EmailToken).where(EmailToken.user_id == user.id, EmailToken.kind == kind,
90 133 EmailToken.used_at.is_(None), EmailToken.expires_at > now())
91 134 .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
135 + return _token_meta(tok).get("link") if tok else 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 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 142 raise ApiError(400, "INVALID_TOKEN", "This link is invalid, expired or already used. Request a new one.")
104 143 tok.used_at = now()
105 144 user = s.get(User, tok.user_id)
106 − if user is None:
145 + if user is None or user.status == "deleted":
107 146 raise ApiError(400, "INVALID_TOKEN", "This link is invalid.")
108 147 return tok, user
109 148
@@ -121,13 +160,24 @@ def require_user(s: Session, user_id: int) -> User:
121 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 174 def create_user(s: Session, email: str, name: str, *, password: str | None = None, tier: str = "free",
125 175 role: str = "user", status: str | None = None, actor: str = "system") -> User:
126 176 email = normalize_email(email)
127 177 if get_user_by_email(s, email):
128 178 raise ApiError(409, "EMAIL_TAKEN", "An account with this e-mail already exists. Sign in or reset your password.")
129 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 181 if role not in ("user", "admin"):
132 182 raise ApiError(400, "INVALID_PARAMETER", "role must be user or admin")
133 183 if password is not None:
@@ -141,21 +191,36 @@ def create_user(s: Session, email: str, name: str, *, password: str | None = Non
141 191 return u
142 192
143 193
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."""
194 +def signup(s: Session, email: str, name: str, password: str, actor: str = "self") -> IssuedToken | None:
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 205 email = normalize_email(email)
147 206 security.validate_password(password)
148 207 existing = get_user_by_email(s, email)
149 208 if existing is not None:
150 209 if existing.status == "invited":
151 210 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]
211 + if existing.status == "active" and existing.email_verified_at is None:
156 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.")
158 − u = create_user(s, email, name, password=password, actor=actor)
213 + if existing.status == "active":
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 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 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 248 def accept_invite(s: Session, raw_token: str, password: str) -> User:
179 249 security.validate_password(password)
180 250 _, u = consume_token(s, raw_token, "invite")
251 + _refuse_if_disabled(u)
181 252 u.password_hash = security.hash_password(password)
182 253 u.email_verified_at = u.email_verified_at or now()
183 254 if u.status == "invited":
184 255 u.status = "active"
185 256 u.last_login_at = now()
257 + u.failed_logins, u.locked_until = 0, None
186 258 audit(s, f"user:{u.id}", "user.accept_invite", f"user:{u.id}")
187 259 return u
188 260
189 261
190 262 def verify_email(s: Session, raw_token: str) -> User:
191 263 _, u = consume_token(s, raw_token, "verify")
264 + _refuse_if_disabled(u)
192 265 u.email_verified_at = u.email_verified_at or now()
193 266 if u.status == "invited":
194 267 u.status = "active"
@@ -197,9 +270,71 @@ def verify_email(s: Session, raw_token: str) -> User:
197 270 return u
198 271
199 272
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):
273 +def verify_any(s: Session, raw_token: str) -> tuple[str, User]:
274 + """`POST /v1/auth/verify`: the same page handles account verification and e-mail-change confirmation."""
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 338 raise ApiError(401, "INVALID_CREDENTIALS", "Unknown e-mail or wrong password.")
204 339 if u.status == "disabled":
205 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 342 raise ApiError(403, "EMAIL_NOT_VERIFIED", "Verify your e-mail first — check your inbox for the link, "
208 343 "or sign up again to receive a new one.")
209 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 347 return u
211 348
212 349
@@ -215,20 +352,115 @@ def forgot(s: Session, email: str, actor: str = "self") -> IssuedToken | None:
215 352 u = get_user_by_email(s, email)
216 353 except ApiError:
217 354 return None
218 − if u is None or u.status == "disabled":
355 + if u is None or u.status in ("disabled", "deleted"):
219 356 return None
220 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 364 security.validate_password(password)
225 365 _, u = consume_token(s, raw_token, "reset")
366 + _refuse_if_disabled(u)
226 367 u.password_hash = security.hash_password(password)
227 368 u.email_verified_at = u.email_verified_at or now()
228 369 if u.status == "invited":
229 370 u.status = "active"
230 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 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 471 if name is not None:
240 472 changes["name"] = name.strip()[:200]
241 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 475 continue
244 476 if value not in allowed[field]:
245 477 raise ApiError(400, "INVALID_PARAMETER", f"{field} must be one of {', '.join(allowed[field])}")
246 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 484 for field, value in changes.items():
248 485 setattr(u, field, value)
486 + if "role" in changes or changes.get("status") == "disabled":
487 + _bump_session(u)
249 488 if changes:
250 489 audit(s, actor, "user.update", f"user:{u.id}", **changes)
251 490 _invalidate_cache()
@@ -263,19 +502,53 @@ def list_keys(s: Session, u: User) -> list[ApiKey]:
263 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]:
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:
505 +def _count_active(s: Session, user_id: int) -> int:
506 + return int(s.execute(select(func.count()).select_from(ApiKey)
507 + .where(ApiKey.user_id == user_id, ApiKey.status == "active")).scalar_one())
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 540 raise ApiError(409, "KEY_LIMIT_REACHED", f"You already have {MAX_ACTIVE_KEYS} active keys. Revoke one first.")
271 541 raw = security.generate_api_key()
272 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 545 s.add(k)
275 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 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 552 return raw, k
280 553
281 554
@@ -286,19 +559,27 @@ def get_key(s: Session, u: User, key_id: int) -> ApiKey:
286 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 563 if k.status == "active":
291 564 k.status = "revoked"
292 565 k.revoked_at = now()
293 566 audit(s, actor, "key.revoke", f"key:{k.id}", prefix=k.prefix)
294 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 571 return k
296 572
297 573
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)
574 +def rotate_key(s: Session, u: User, k: ApiKey, *, actor: str, notify: bool = True) -> tuple[str, ApiKey]:
575 + """Revoke `k` and create a fresh key with the same name, note, scopes, expiry and tier override."""
576 + revoke_key(s, k, 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 585 def _invalidate_cache(key_hash: str | None = None) -> None:
@@ -314,13 +595,19 @@ def _invalidate_cache(key_hash: str | None = None) -> None:
314 595 def user_public(u: User, *, keys_count: int | None = None) -> dict:
315 596 d = {"id": u.id, "email": u.email, "name": u.name, "role": u.role, "tier": u.tier, "status": u.status,
316 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 600 if keys_count is not None:
319 601 d["keys_active"] = keys_count
320 602 return d
321 603
322 604
323 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 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),
326 − "principal": k.principal}
611 + "created_at": iso(k.created_at), "last_used_at": iso(k.last_used_at), "last_used_ip": k.last_used_ip,
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