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