| 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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
|