wred-auth 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
wred_auth/__init__.py ADDED
@@ -0,0 +1,1102 @@
1
+ """Bearer authorization and an opt-in account service for Wred."""
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import inspect
6
+ import secrets
7
+ import time
8
+ import uuid
9
+ from dataclasses import dataclass
10
+ from functools import wraps
11
+ from urllib.parse import urlencode
12
+
13
+ import jwt
14
+ import pyotp
15
+ from argon2 import PasswordHasher
16
+ from argon2.exceptions import VerificationError
17
+ from cryptography.fernet import Fernet
18
+
19
+ from .storage import StoreUnavailable
20
+
21
+ __version__ = "0.1.0"
22
+
23
+
24
+ class AuthError(Exception):
25
+ def __init__(self, detail="Not authenticated", status=401):
26
+ self.detail, self.status = detail, status
27
+ super().__init__(detail)
28
+
29
+ def response(self):
30
+ headers = [("content-type", "application/json"), ("cache-control", "no-store")]
31
+ if self.status == 401:
32
+ headers.append(("www-authenticate", "Bearer"))
33
+ return {"detail": self.detail}, headers, self.status
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Principal:
38
+ id: str
39
+ roles: tuple = ()
40
+ session_id: str | None = None
41
+ auth_time: float = 0
42
+
43
+
44
+ def principal(request):
45
+ value = request.scope.get("wred.auth")
46
+ if value is None:
47
+ raise AuthError()
48
+ return value
49
+
50
+
51
+ def _hash(value):
52
+ return hashlib.sha256(value.encode()).hexdigest()
53
+
54
+
55
+ def _email(value):
56
+ if (
57
+ not isinstance(value, str)
58
+ or not 3 <= len(value) <= 320
59
+ or value.count("@") != 1
60
+ or value.startswith("@")
61
+ or value.endswith("@")
62
+ or any(c.isspace() for c in value)
63
+ ):
64
+ raise AuthError("Invalid email", 422)
65
+ return value.casefold()
66
+
67
+
68
+ def _password(value):
69
+ if not isinstance(value, str) or not 15 <= len(value) <= 128:
70
+ raise AuthError("Password must contain 15 to 128 characters", 422)
71
+ return value
72
+
73
+
74
+ class JWTVerifier:
75
+ """External bearer verifier with explicit algorithms and application role lookup."""
76
+
77
+ def __init__(self, key, *, algorithms, issuer, audience, lookup=None):
78
+ if not algorithms or any(a.lower() == "none" for a in algorithms):
79
+ raise ValueError("Explicit signed algorithms required")
80
+ self.key, self.algorithms, self.issuer, self.audience, self.lookup = (
81
+ key,
82
+ algorithms,
83
+ issuer,
84
+ audience,
85
+ lookup,
86
+ )
87
+
88
+ async def __call__(self, token):
89
+ try:
90
+ claims = jwt.decode(
91
+ token,
92
+ self.key,
93
+ algorithms=self.algorithms,
94
+ issuer=self.issuer,
95
+ audience=self.audience,
96
+ options={"require": ["sub", "exp", "iss", "aud"]},
97
+ )
98
+ except jwt.InvalidTokenError as exc:
99
+ raise AuthError() from exc
100
+ if self.lookup:
101
+ result = self.lookup(claims)
102
+ result = await result if inspect.isawaitable(result) else result
103
+ if not isinstance(result, Principal):
104
+ raise AuthError()
105
+ return result
106
+ return Principal(claims["sub"])
107
+
108
+
109
+ class Auth:
110
+ """Custom verifier alone, or full accounts with store, signing/encryption keys,
111
+ issuer, audience, public_url, and async send_email(kind, email, url) callback.
112
+ """
113
+
114
+ def __init__(
115
+ self,
116
+ *,
117
+ verifier=None,
118
+ store=None,
119
+ signing_key=None,
120
+ encryption_key=None,
121
+ issuer="wred-auth",
122
+ audience="wred-api",
123
+ public_url=None,
124
+ send_email=None,
125
+ audit=None,
126
+ access_ttl=900,
127
+ refresh_ttl=2592000,
128
+ providers=None,
129
+ limiter=None,
130
+ max_password_jobs=2,
131
+ ):
132
+ self.verifier, self.store = verifier, store
133
+ self.signing_key, self.issuer, self.audience = signing_key, issuer, audience
134
+ self.public_url, self.send_email, self.audit = public_url, send_email, audit
135
+ self.access_ttl, self.refresh_ttl = access_ttl, refresh_ttl
136
+ self.providers = providers or {}
137
+ self.limiter = limiter
138
+ if type(max_password_jobs) is not int or max_password_jobs < 1:
139
+ raise ValueError("max_password_jobs must be a positive integer")
140
+ self._password_slots = asyncio.Semaphore(max_password_jobs)
141
+ self._password_tasks = set()
142
+ if store is not None:
143
+ if (
144
+ not signing_key
145
+ or len(signing_key) < 32
146
+ or not encryption_key
147
+ or not public_url
148
+ or not send_email
149
+ ):
150
+ raise ValueError(
151
+ "Accounts require signing/encryption keys, public_url, and send_email"
152
+ )
153
+ if not public_url.startswith("https://"):
154
+ raise ValueError("Account public_url must use HTTPS")
155
+ if access_ttl <= 0 or refresh_ttl <= access_ttl:
156
+ raise ValueError("Invalid token lifetimes")
157
+ self.cipher = Fernet(encryption_key)
158
+ self.hasher = PasswordHasher()
159
+ self.dummy_hash = self.hasher.hash(secrets.token_urlsafe(32))
160
+ if store is None and verifier is None:
161
+ raise ValueError("Provide an account store or a verifier")
162
+
163
+ async def _event(self, name, user_id=None):
164
+ if self.audit:
165
+ await self.audit({"event": name, "user_id": user_id, "time": time.time()})
166
+
167
+ async def _run(self, callback, *, read_only=False):
168
+ try:
169
+ return await self.store.run(callback, read_only=read_only)
170
+ except StoreUnavailable as exc:
171
+ raise AuthError("Account service temporarily unavailable", 503) from exc
172
+
173
+ async def _password_work(self, function, *args):
174
+ # No unbounded work queue. Cancellation must not release a slot while
175
+ # the underlying thread is still using CPU and Argon2 memory.
176
+ if self._password_slots.locked():
177
+ raise AuthError("Password service busy; retry later", 503)
178
+ await self._password_slots.acquire()
179
+ task = asyncio.create_task(asyncio.to_thread(function, *args))
180
+ self._password_tasks.add(task)
181
+
182
+ def completed(task):
183
+ self._password_tasks.discard(task)
184
+ self._password_slots.release()
185
+ if not task.cancelled():
186
+ task.exception()
187
+
188
+ task.add_done_callback(completed)
189
+ return await asyncio.shield(task)
190
+
191
+ async def _hash_password(self, password):
192
+ return await self._password_work(self.hasher.hash, _password(password))
193
+
194
+ async def cleanup(self, *, batch_size=500):
195
+ """Delete at most batch_size expired tokens and sessions per call."""
196
+ try:
197
+ return await self.store.cleanup(now=time.time(), batch_size=batch_size)
198
+ except StoreUnavailable as exc:
199
+ raise AuthError("Account service temporarily unavailable", 503) from exc
200
+
201
+ async def _new_token(self, tx, kind, user_id=None, *, ttl=1800, data=None):
202
+ token = secrets.token_urlsafe(32)
203
+ await tx.insert(
204
+ "tokens",
205
+ {
206
+ "id": _hash(token),
207
+ "user_id": user_id,
208
+ "kind": kind,
209
+ "expires": time.time() + ttl,
210
+ "used": False,
211
+ "data": data or {},
212
+ },
213
+ )
214
+ return token
215
+
216
+ async def _token(self, tx, token, kind, *, consume=True):
217
+ if not isinstance(token, str):
218
+ raise AuthError()
219
+ row = await tx.get("tokens", _hash(token))
220
+ if (
221
+ not row
222
+ or row["kind"] != kind
223
+ or row["used"]
224
+ or row["expires"] <= time.time()
225
+ ):
226
+ raise AuthError("Invalid or expired token")
227
+ if consume:
228
+ await tx.update("tokens", row["id"], used=True)
229
+ return row
230
+
231
+ async def _verify_password(self, hashed, password):
232
+ if not isinstance(password, str) or len(password) > 128:
233
+ return False
234
+ try:
235
+ await self._password_work(
236
+ self.hasher.verify, hashed or self.dummy_hash, password
237
+ )
238
+ return hashed is not None
239
+ except VerificationError:
240
+ return False
241
+
242
+ def _new_user(self, email, password=None, verified=False):
243
+ return {
244
+ "id": uuid.uuid4().hex,
245
+ "email": email,
246
+ "password": password,
247
+ "verified": verified,
248
+ "active": True,
249
+ "roles": [],
250
+ "totp": None,
251
+ "totp_pending": None,
252
+ "totp_step": -1,
253
+ "recovery": [],
254
+ }
255
+
256
+ async def register(self, email, password):
257
+ email = _email(email)
258
+ hashed = await self._hash_password(password)
259
+
260
+ async def create(tx):
261
+ if not await tx.find("users", email=email):
262
+ user = self._new_user(email, hashed)
263
+ await tx.insert("users", user)
264
+ return await self._new_token(tx, "verify", user["id"])
265
+
266
+ token = await self._run(create)
267
+ if token:
268
+ await self._send("verify", email, token)
269
+ return {"detail": "If eligible, verification instructions will be sent"}
270
+
271
+ async def _send(self, kind, email, token):
272
+ # Application renders and sends email. The URL is a frontend landing page;
273
+ # it must POST the token to the corresponding API operation.
274
+ await self.send_email(
275
+ kind,
276
+ email,
277
+ self.public_url.rstrip("/")
278
+ + "/"
279
+ + kind
280
+ + "?"
281
+ + urlencode({"token": token}),
282
+ )
283
+
284
+ async def request_token(self, email, *, kind="reset"):
285
+ if kind not in ("reset", "verify"):
286
+ raise ValueError("Unsupported token purpose")
287
+ email = _email(email)
288
+
289
+ async def issue(tx):
290
+ users = await tx.find("users", email=email)
291
+ if users and users[0]["active"]:
292
+ return await self._new_token(tx, kind, users[0]["id"])
293
+
294
+ token = await self._run(issue)
295
+ if token:
296
+ await self._send(kind, email, token)
297
+ return {"detail": "If eligible, instructions will be sent"}
298
+
299
+ async def verify_email(self, token):
300
+ async def verify(tx):
301
+ row = await self._token(tx, token, "verify")
302
+ await tx.get("users", row["user_id"])
303
+ await tx.update("users", row["user_id"], verified=True)
304
+ return row["user_id"]
305
+
306
+ user_id = await self._run(verify)
307
+ await self._event("email_verified", user_id)
308
+ return {"detail": "Email verified"}
309
+
310
+ async def _revoke_all(self, tx, user_id, *, reset=False):
311
+ await tx.update_many(
312
+ "sessions", {"user_id": user_id, "revoked": False}, {"revoked": True}
313
+ )
314
+ for kind in ("mfa", "reset") if reset else ("mfa",):
315
+ await tx.update_many(
316
+ "tokens",
317
+ {
318
+ "user_id": user_id,
319
+ "kind": kind,
320
+ "used": False,
321
+ "expires__gt": time.time(),
322
+ },
323
+ {"used": True},
324
+ )
325
+
326
+ async def reset_password(self, token, password):
327
+ _password(password)
328
+
329
+ async def preflight(tx):
330
+ await self._token(tx, token, "reset", consume=False)
331
+
332
+ await self._run(preflight, read_only=True)
333
+ hashed = await self._hash_password(password)
334
+
335
+ async def reset(tx):
336
+ # Recheck after hashing: another request may have consumed or revoked
337
+ # this token in the meantime. All changes commit atomically.
338
+ row = await self._token(tx, token, "reset")
339
+ account = await tx.get("users", row["user_id"])
340
+ if not account or not account["active"]:
341
+ raise AuthError()
342
+ await tx.update("users", row["user_id"], password=hashed)
343
+ await self._revoke_all(tx, row["user_id"], reset=True)
344
+ return row["user_id"]
345
+
346
+ user_id = await self._run(reset)
347
+ await self._event("password_reset", user_id)
348
+ return {"detail": "Password changed; sign in again"}
349
+
350
+ def _access(self, user, session):
351
+ now = int(time.time())
352
+ return jwt.encode(
353
+ {
354
+ "sub": user["id"],
355
+ "sid": session["id"],
356
+ "iat": now,
357
+ "exp": now + self.access_ttl,
358
+ "iss": self.issuer,
359
+ "aud": self.audience,
360
+ },
361
+ self.signing_key,
362
+ algorithm="HS256",
363
+ )
364
+
365
+ async def _tokens(self, tx, user, session):
366
+ refresh = await self._new_token(
367
+ tx,
368
+ "refresh",
369
+ user["id"],
370
+ ttl=max(1, session["expires"] - time.time()),
371
+ data={"session_id": session["id"]},
372
+ )
373
+ return {
374
+ "access_token": self._access(user, session),
375
+ "refresh_token": refresh,
376
+ "token_type": "Bearer",
377
+ "expires_in": self.access_ttl,
378
+ }
379
+
380
+ async def _session(self, tx, user, *, mfa=False):
381
+ if not user["active"] or not user["verified"]:
382
+ raise AuthError()
383
+ if user["totp"] and not mfa:
384
+ challenge = await self._new_token(tx, "mfa", user["id"], ttl=300)
385
+ return {"mfa_required": True, "challenge": challenge}
386
+ now = time.time()
387
+ session = {
388
+ "id": uuid.uuid4().hex,
389
+ "user_id": user["id"],
390
+ "created": now,
391
+ "auth_time": now,
392
+ "expires": now + self.refresh_ttl,
393
+ "revoked": False,
394
+ }
395
+ await tx.insert("sessions", session)
396
+ return await self._tokens(tx, user, session)
397
+
398
+ async def login(self, email, password):
399
+ email = _email(email)
400
+
401
+ async def lookup(tx):
402
+ users = await tx.find("users", email=email)
403
+ return users[0] if users else None
404
+
405
+ user = await self._run(lookup, read_only=True)
406
+ valid = await self._verify_password(
407
+ user["password"] if user else None, password
408
+ )
409
+ if not user or not valid:
410
+ raise AuthError()
411
+
412
+ async def create(tx):
413
+ account = await tx.get("users", user["id"])
414
+ if not account or account["password"] != user["password"]:
415
+ raise AuthError()
416
+ return await self._session(tx, account)
417
+
418
+ result = await self._run(create)
419
+ await self._event("login", user["id"])
420
+ return result
421
+
422
+ async def refresh(self, token):
423
+ if not isinstance(token, str):
424
+ raise AuthError()
425
+
426
+ async def rotate(tx):
427
+ row = await tx.get("tokens", _hash(token))
428
+ if not row or row["kind"] != "refresh" or row["expires"] <= time.time():
429
+ raise AuthError()
430
+ session = await tx.get("sessions", row["data"]["session_id"])
431
+ if row["used"]:
432
+ if session:
433
+ await tx.update("sessions", session["id"], revoked=True)
434
+ return None, row["user_id"]
435
+ user = await tx.get("users", row["user_id"])
436
+ if (
437
+ not session
438
+ or session["revoked"]
439
+ or session["expires"] <= time.time()
440
+ or not user
441
+ or not user["active"]
442
+ or not user["verified"]
443
+ ):
444
+ raise AuthError()
445
+ await tx.update("tokens", row["id"], used=True)
446
+ return await self._tokens(tx, user, session), row["user_id"]
447
+
448
+ result, user_id = await self._run(rotate)
449
+ if result is None:
450
+ await self._event("refresh_replay", user_id)
451
+ raise AuthError("Session revoked")
452
+ return result
453
+
454
+ async def authenticate(self, token):
455
+ if self.verifier:
456
+ value = self.verifier(token)
457
+ value = await value if inspect.isawaitable(value) else value
458
+ if not isinstance(value, Principal):
459
+ raise AuthError()
460
+ return value
461
+ try:
462
+ claims = jwt.decode(
463
+ token,
464
+ self.signing_key,
465
+ algorithms=["HS256"],
466
+ issuer=self.issuer,
467
+ audience=self.audience,
468
+ options={"require": ["sub", "sid", "exp", "iat", "iss", "aud"]},
469
+ )
470
+ except jwt.InvalidTokenError as exc:
471
+ raise AuthError() from exc
472
+
473
+ async def lookup(tx):
474
+ session = await tx.get("sessions", claims["sid"])
475
+ user = await tx.get("users", claims["sub"])
476
+ if (
477
+ not session
478
+ or not user
479
+ or session["user_id"] != user["id"]
480
+ or session["revoked"]
481
+ or session["expires"] <= time.time()
482
+ or not user["active"]
483
+ or not user["verified"]
484
+ ):
485
+ raise AuthError()
486
+ return Principal(
487
+ user["id"], tuple(user["roles"]), session["id"], session["auth_time"]
488
+ )
489
+
490
+ return await self._run(lookup, read_only=True)
491
+
492
+ async def _request_principal(self, request):
493
+ scheme, _, token = request.headers.get("authorization", "").partition(" ")
494
+ if scheme.lower() != "bearer" or not token or " " in token:
495
+ raise AuthError()
496
+ value = await self.authenticate(token)
497
+ request.scope["wred.auth"] = value
498
+ return value
499
+
500
+ def require_auth(self):
501
+ def decorate(handler):
502
+ @wraps(handler)
503
+ async def wrapped(request, *args, **kwargs):
504
+ try:
505
+ await self._request_principal(request)
506
+ except AuthError as exc:
507
+ return exc.response()
508
+ return await handler(request, *args, **kwargs)
509
+
510
+ metadata = dict(getattr(handler, "__wred_openapi__", {}))
511
+ metadata.setdefault("security", [{"bearerAuth": []}])
512
+ wrapped.__wred_openapi__ = metadata
513
+ return wrapped
514
+
515
+ return decorate
516
+
517
+ def require_role(self, role):
518
+ def decorate(handler):
519
+ @wraps(handler)
520
+ async def wrapped(request, *args, **kwargs):
521
+ try:
522
+ value = request.scope.get(
523
+ "wred.auth"
524
+ ) or await self._request_principal(request)
525
+ if role not in value.roles:
526
+ raise AuthError("Forbidden", 403)
527
+ except AuthError as exc:
528
+ return exc.response()
529
+ return await handler(request, *args, **kwargs)
530
+
531
+ metadata = dict(getattr(handler, "__wred_openapi__", {}))
532
+ metadata.setdefault("security", [{"bearerAuth": []}])
533
+ wrapped.__wred_openapi__ = metadata
534
+ return wrapped
535
+
536
+ return decorate
537
+
538
+ def _recent(self, user):
539
+ if not user.session_id or time.time() - user.auth_time > 300:
540
+ raise AuthError("Recent authentication required", 403)
541
+
542
+ async def _account(self, tx, principal, *, recent=False):
543
+ if recent:
544
+ self._recent(principal)
545
+ session = await tx.get("sessions", principal.session_id)
546
+ account = await tx.get("users", principal.id)
547
+ if (
548
+ not account
549
+ or not account["active"]
550
+ or not account["verified"]
551
+ or not session
552
+ or session["user_id"] != principal.id
553
+ or session["revoked"]
554
+ or session["expires"] <= time.time()
555
+ ):
556
+ raise AuthError()
557
+ if recent and time.time() - session["auth_time"] > 300:
558
+ raise AuthError("Recent authentication required", 403)
559
+ return account
560
+
561
+ async def sessions(self, user, *, limit=100, after=None):
562
+ if type(limit) is not int or not 1 <= limit <= 1000:
563
+ raise AuthError("Invalid session page size", 422)
564
+
565
+ async def lookup(tx):
566
+ filters = {"user_id": user.id, "revoked": False, "expires__gt": time.time()}
567
+ if after is not None:
568
+ filters["id__gt"] = after
569
+ return [
570
+ {"id": s["id"], "created": s["created"], "expires": s["expires"]}
571
+ for s in await tx.find("sessions", limit=limit, **filters)
572
+ ]
573
+
574
+ return await self._run(lookup, read_only=True)
575
+
576
+ async def revoke(self, user, session_id=None):
577
+ async def revoke_session(tx):
578
+ session = await tx.get("sessions", session_id or user.session_id)
579
+ if not session or session["user_id"] != user.id:
580
+ raise AuthError("Session not found", 404)
581
+ await tx.update("sessions", session["id"], revoked=True)
582
+
583
+ await self._run(revoke_session)
584
+ await self._event("session_revoked", user.id)
585
+ return {"detail": "Session revoked"}
586
+
587
+ async def change_password(self, user, old_password, password):
588
+ self._recent(user)
589
+ _password(password)
590
+
591
+ async def lookup(tx):
592
+ return await self._account(tx, user, recent=True)
593
+
594
+ account = await self._run(lookup, read_only=True)
595
+ if not await self._verify_password(account["password"], old_password):
596
+ raise AuthError()
597
+ hashed = await self._hash_password(password)
598
+
599
+ async def change(tx):
600
+ current = await self._account(tx, user, recent=True)
601
+ if current["password"] != account["password"]:
602
+ raise AuthError()
603
+ await tx.update("users", user.id, password=hashed)
604
+ await self._revoke_all(tx, user.id, reset=True)
605
+
606
+ await self._run(change)
607
+ await self._event("password_changed", user.id)
608
+ return {"detail": "Password changed; sign in again"}
609
+
610
+ async def set_roles(self, user_id, roles):
611
+ """Administrative service method. Never exposed as a public account endpoint."""
612
+ roles = tuple(roles)
613
+ if not all(isinstance(role, str) and role for role in roles):
614
+ raise ValueError("Roles must be nonempty strings")
615
+
616
+ async def change(tx):
617
+ if not await tx.get("users", user_id):
618
+ raise AuthError("Account not found", 404)
619
+ await tx.update("users", user_id, roles=sorted(set(roles)))
620
+
621
+ await self._run(change)
622
+ await self._event("roles_changed", user_id)
623
+
624
+ async def set_active(self, user_id, active):
625
+ async def change(tx):
626
+ if not await tx.get("users", user_id):
627
+ raise AuthError("Account not found", 404)
628
+ await tx.update("users", user_id, active=bool(active))
629
+ if not active:
630
+ await self._revoke_all(tx, user_id, reset=True)
631
+
632
+ await self._run(change)
633
+ await self._event("account_status_changed", user_id)
634
+
635
+ async def setup_mfa(self, user):
636
+ self._recent(user)
637
+ secret = pyotp.random_base32()
638
+
639
+ async def setup(tx):
640
+ account = await self._account(tx, user, recent=True)
641
+ if account["totp"]:
642
+ raise AuthError("MFA is already enabled", 409)
643
+ await tx.update(
644
+ "users",
645
+ user.id,
646
+ totp_pending=self.cipher.encrypt(secret.encode()).decode(),
647
+ )
648
+ return account
649
+
650
+ account = await self._run(setup)
651
+ return {
652
+ "secret": secret,
653
+ "uri": pyotp.TOTP(secret).provisioning_uri(
654
+ account["email"], issuer_name=self.issuer
655
+ ),
656
+ }
657
+
658
+ def _totp_step(self, encrypted, code, previous=-1):
659
+ if not encrypted or not isinstance(code, str):
660
+ raise AuthError("Invalid MFA code")
661
+ secret = self.cipher.decrypt(encrypted.encode()).decode()
662
+ current = int(time.time()) // 30
663
+ totp = pyotp.TOTP(secret)
664
+ for step in (current, current - 1, current + 1):
665
+ if step > previous and secrets.compare_digest(totp.at(step * 30), code):
666
+ return step
667
+ raise AuthError("Invalid MFA code")
668
+
669
+ async def confirm_mfa(self, user, code):
670
+ self._recent(user)
671
+ recovery = [secrets.token_urlsafe(16) for _ in range(10)]
672
+
673
+ async def confirm(tx):
674
+ account = await self._account(tx, user, recent=True)
675
+ step = self._totp_step(account["totp_pending"], code)
676
+ await tx.update(
677
+ "users",
678
+ user.id,
679
+ totp=account["totp_pending"],
680
+ totp_pending=None,
681
+ totp_step=step,
682
+ recovery=[_hash(c) for c in recovery],
683
+ )
684
+ await self._revoke_all(tx, user.id)
685
+
686
+ await self._run(confirm)
687
+ await self._event("mfa_enabled", user.id)
688
+ return {"recovery_codes": recovery, "detail": "MFA enabled; sign in again"}
689
+
690
+ async def _consume_mfa(self, tx, account, code):
691
+ hashed = _hash(code)
692
+ if hashed in account["recovery"]:
693
+ await tx.update(
694
+ "users",
695
+ account["id"],
696
+ recovery=[c for c in account["recovery"] if c != hashed],
697
+ )
698
+ else:
699
+ step = self._totp_step(account["totp"], code, account["totp_step"])
700
+ await tx.update("users", account["id"], totp_step=step)
701
+
702
+ async def complete_mfa(self, challenge, code):
703
+ async def complete(tx):
704
+ row = await self._token(tx, challenge, "mfa")
705
+ account = await tx.get("users", row["user_id"])
706
+ await self._consume_mfa(tx, account, code)
707
+ result = await self._session(tx, account, mfa=True)
708
+ return (result, account)
709
+
710
+ result, account = await self._run(complete)
711
+ await self._event("mfa_login", account["id"])
712
+ return result
713
+
714
+ async def disable_mfa(self, user, code):
715
+ self._recent(user)
716
+
717
+ async def disable(tx):
718
+ account = await self._account(tx, user, recent=True)
719
+ await self._consume_mfa(tx, account, code)
720
+ await tx.update(
721
+ "users",
722
+ user.id,
723
+ totp=None,
724
+ totp_pending=None,
725
+ recovery=[],
726
+ totp_step=-1,
727
+ )
728
+ await self._revoke_all(tx, user.id)
729
+
730
+ await self._run(disable)
731
+ await self._event("mfa_disabled", user.id)
732
+ return {"detail": "MFA disabled; sign in again"}
733
+
734
+ async def oauth_start(self, provider, *, callback_url, user=None):
735
+ if provider not in self.providers:
736
+ raise AuthError("Unknown provider", 404)
737
+ if user:
738
+ self._recent(user)
739
+ browser = secrets.token_urlsafe(32)
740
+ verifier, nonce = secrets.token_urlsafe(48), secrets.token_urlsafe(32)
741
+
742
+ async def start(tx):
743
+ if user:
744
+ await self._account(tx, user, recent=True)
745
+ state = await self._new_token(
746
+ tx,
747
+ "oauth",
748
+ user.id if user else None,
749
+ ttl=300,
750
+ data={
751
+ "provider": provider,
752
+ "browser": _hash(browser),
753
+ "verifier": verifier,
754
+ "nonce": nonce,
755
+ "callback_url": callback_url,
756
+ "session_id": user.session_id if user else None,
757
+ },
758
+ )
759
+ return state
760
+
761
+ state = await self._run(start)
762
+ url = self.providers[provider].authorization_url(
763
+ state=state, nonce=nonce, verifier=verifier, redirect_uri=callback_url
764
+ )
765
+ return {"url": url, "browser": browser}
766
+
767
+ async def oauth_callback(self, provider, *, state, code, browser):
768
+ if provider not in self.providers:
769
+ raise AuthError("Unknown provider", 404)
770
+
771
+ # Consume before network exchange so the authorization code/state is single-use.
772
+ async def consume(tx):
773
+ row = await self._token(tx, state, "oauth")
774
+ data = row["data"]
775
+ if data["provider"] != provider or not secrets.compare_digest(
776
+ data["browser"], _hash(browser)
777
+ ):
778
+ raise AuthError("Invalid OAuth state")
779
+ return row
780
+
781
+ row = await self._run(consume)
782
+ data = row["data"]
783
+ identity = await self.providers[provider].exchange(
784
+ code=code,
785
+ verifier=data["verifier"],
786
+ nonce=data["nonce"],
787
+ redirect_uri=data["callback_url"],
788
+ )
789
+
790
+ async def link(tx):
791
+ existing = await tx.find(
792
+ "identities", provider=provider, subject=identity["subject"]
793
+ )
794
+ if row["user_id"]:
795
+ session = await tx.get("sessions", data["session_id"])
796
+ user = await tx.get("users", row["user_id"])
797
+ if (
798
+ not session
799
+ or session["revoked"]
800
+ or session["expires"] <= time.time()
801
+ or time.time() - session["auth_time"] > 300
802
+ or not user["active"]
803
+ ):
804
+ raise AuthError("Recent authentication required", 403)
805
+ if existing and existing[0]["user_id"] != user["id"]:
806
+ raise AuthError("Identity is already linked", 409)
807
+ elif existing:
808
+ user = await tx.get("users", existing[0]["user_id"])
809
+ else:
810
+ email = _email(identity["email"])
811
+ if not identity["email_verified"]:
812
+ raise AuthError("Provider email must be verified", 403)
813
+ if await tx.find("users", email=email):
814
+ raise AuthError(
815
+ "Sign in to the existing account to link this identity", 409
816
+ )
817
+ user = self._new_user(email, verified=True)
818
+ await tx.insert("users", user)
819
+ if not existing:
820
+ await tx.insert(
821
+ "identities",
822
+ {
823
+ "id": uuid.uuid4().hex,
824
+ "user_id": user["id"],
825
+ "provider": provider,
826
+ "subject": identity["subject"],
827
+ },
828
+ )
829
+ result = (
830
+ {"detail": "Identity linked"}
831
+ if row["user_id"]
832
+ else await self._session(tx, user)
833
+ )
834
+ return result, user["id"]
835
+
836
+ result, user_id = await self._run(link)
837
+ await self._event(
838
+ "identity_linked" if row["user_id"] else "provider_login", user_id
839
+ )
840
+ return result
841
+
842
+ def mount(
843
+ self,
844
+ app,
845
+ *,
846
+ prefix="/auth",
847
+ rule="10/minute",
848
+ account_rule="5/minute",
849
+ key=None,
850
+ ):
851
+ """Register JSON account endpoints with peer-IP and account-key throttles.
852
+
853
+ The caller must supply a configured Limiter; use Redis across workers.
854
+ """
855
+ from http.cookies import SimpleCookie
856
+
857
+ if self.store is None or self.limiter is None:
858
+ raise ValueError("Account routes require store and limiter")
859
+ prefix = "/" + prefix.strip("/")
860
+
861
+ async def dispatch(request, action, provider=None):
862
+ try:
863
+ if action.startswith("oauth_"):
864
+ callback = (
865
+ self.public_url.rstrip("/")
866
+ + prefix
867
+ + "/oauth/"
868
+ + provider
869
+ + "/callback"
870
+ )
871
+ if action in ("oauth_start", "oauth_link"):
872
+ user = (
873
+ await self._request_principal(request)
874
+ if action == "oauth_link"
875
+ else None
876
+ )
877
+ result = await self.oauth_start(
878
+ provider, callback_url=callback, user=user
879
+ )
880
+ return (
881
+ b"",
882
+ [
883
+ ("location", result["url"]),
884
+ ("cache-control", "no-store"),
885
+ (
886
+ "set-cookie",
887
+ "__Host-wred_oauth="
888
+ + result["browser"]
889
+ + "; Path=/; Secure; HttpOnly; SameSite=Lax",
890
+ ),
891
+ ],
892
+ 302,
893
+ )
894
+ cookie = SimpleCookie()
895
+ cookie.load(request.headers.get("cookie", ""))
896
+ browser = cookie.get("__Host-wred_oauth")
897
+ if not browser:
898
+ raise AuthError("OAuth browser binding required")
899
+ result = await self.oauth_callback(
900
+ provider,
901
+ state=request.query["state"],
902
+ code=request.query["code"],
903
+ browser=browser.value,
904
+ )
905
+ return (
906
+ result,
907
+ [
908
+ ("content-type", "application/json"),
909
+ ("cache-control", "no-store"),
910
+ (
911
+ "set-cookie",
912
+ "__Host-wred_oauth=; Max-Age=0; Path=/; Secure; HttpOnly; SameSite=Lax",
913
+ ),
914
+ ],
915
+ 200,
916
+ )
917
+ payload = {} if request.method == "GET" else await request.json()
918
+ if not isinstance(payload, dict):
919
+ raise AuthError("Expected a JSON object", 422)
920
+ # Bound inputs before parsing passwords/tokens and avoid logging them.
921
+ if any(
922
+ not isinstance(v, str) or len(v) > 8192 for v in payload.values()
923
+ ):
924
+ raise AuthError("Invalid account input", 422)
925
+ email = payload.get("email")
926
+ if email:
927
+ email = _email(email)
928
+ try:
929
+ allowed, retry = await self.limiter.check(
930
+ account_rule, email, bucket="auth:account:" + action
931
+ )
932
+ except Exception:
933
+ return AuthError(
934
+ "Rate limit service unavailable", 503
935
+ ).response()
936
+ if not allowed:
937
+ return (
938
+ {"detail": "Rate limit exceeded"},
939
+ [
940
+ ("content-type", "application/json"),
941
+ ("retry-after", str(retry)),
942
+ ],
943
+ 429,
944
+ )
945
+ if action == "mfa_login":
946
+ try:
947
+ allowed, retry = await self.limiter.check(
948
+ account_rule,
949
+ payload.get("challenge", ""),
950
+ bucket="auth:mfa-challenge",
951
+ )
952
+ except Exception:
953
+ return AuthError(
954
+ "Rate limit service unavailable", 503
955
+ ).response()
956
+ if not allowed:
957
+ return (
958
+ {"detail": "Rate limit exceeded"},
959
+ [
960
+ ("content-type", "application/json"),
961
+ ("retry-after", str(retry)),
962
+ ],
963
+ 429,
964
+ )
965
+ if action == "register":
966
+ result = await self.register(payload["email"], payload["password"])
967
+ elif action == "login":
968
+ result = await self.login(payload["email"], payload["password"])
969
+ elif action == "verify":
970
+ result = await self.verify_email(payload["token"])
971
+ elif action == "request_verification":
972
+ result = await self.request_token(payload["email"], kind="verify")
973
+ elif action == "forgot_password":
974
+ result = await self.request_token(payload["email"])
975
+ elif action == "reset_password":
976
+ result = await self.reset_password(
977
+ payload["token"], payload["password"]
978
+ )
979
+ elif action == "refresh":
980
+ result = await self.refresh(payload["refresh_token"])
981
+ elif action == "mfa_login":
982
+ result = await self.complete_mfa(
983
+ payload["challenge"], payload["code"]
984
+ )
985
+ else:
986
+ user = await self._request_principal(request)
987
+ if action == "sessions":
988
+ result = {
989
+ "sessions": await self.sessions(
990
+ user,
991
+ limit=int(request.query.get("limit", "100")),
992
+ after=request.query.get("after"),
993
+ )
994
+ }
995
+ elif action == "logout":
996
+ result = await self.revoke(user)
997
+ elif action == "revoke":
998
+ result = await self.revoke(user, payload["session_id"])
999
+ elif action == "change_password":
1000
+ result = await self.change_password(
1001
+ user, payload["old_password"], payload["password"]
1002
+ )
1003
+ elif action == "mfa_setup":
1004
+ result = await self.setup_mfa(user)
1005
+ elif action == "mfa_confirm":
1006
+ result = await self.confirm_mfa(user, payload["code"])
1007
+ elif action == "mfa_disable":
1008
+ result = await self.disable_mfa(user, payload["code"])
1009
+ else:
1010
+ raise AuthError("Not found", 404)
1011
+ return (
1012
+ result,
1013
+ [
1014
+ ("content-type", "application/json"),
1015
+ ("cache-control", "no-store"),
1016
+ ],
1017
+ 200,
1018
+ )
1019
+ except AuthError as exc:
1020
+ return exc.response()
1021
+ except (KeyError, ValueError, UnicodeError):
1022
+ return AuthError("Invalid account input", 422).response()
1023
+
1024
+ routes = []
1025
+ for action in (
1026
+ "register",
1027
+ "login",
1028
+ "verify",
1029
+ "request_verification",
1030
+ "forgot_password",
1031
+ "reset_password",
1032
+ "refresh",
1033
+ "logout",
1034
+ "revoke",
1035
+ "change_password",
1036
+ "sessions",
1037
+ "mfa_login",
1038
+ "mfa_setup",
1039
+ "mfa_confirm",
1040
+ "mfa_disable",
1041
+ ):
1042
+ routes.append(
1043
+ (
1044
+ prefix + "/" + action.replace("_", "-"),
1045
+ action,
1046
+ "GET" if action == "sessions" else "POST",
1047
+ None,
1048
+ )
1049
+ )
1050
+ for provider in self.providers:
1051
+ for action, method in (
1052
+ ("start", "GET"),
1053
+ ("callback", "GET"),
1054
+ ("link", "POST"),
1055
+ ):
1056
+ routes.append(
1057
+ (
1058
+ prefix + "/oauth/" + provider + "/" + action,
1059
+ "oauth_" + action,
1060
+ method,
1061
+ provider,
1062
+ )
1063
+ )
1064
+ if any(path in app.routes for path, *_ in routes):
1065
+ raise ValueError("An account route already exists")
1066
+
1067
+ def make_handler(action, provider):
1068
+ async def dispatch_action(request):
1069
+ return await dispatch(request, action, provider)
1070
+
1071
+ limited = self.limiter.limit(rule, key=key, bucket=prefix + ":" + action)(
1072
+ dispatch_action
1073
+ )
1074
+
1075
+ async def handler(request):
1076
+ payload, headers, status = await limited(request)
1077
+ headers = [
1078
+ (name, value)
1079
+ for name, value in headers
1080
+ if name.lower() != "cache-control"
1081
+ ]
1082
+ return payload, [*headers, ("cache-control", "no-store")], status
1083
+
1084
+ return handler
1085
+
1086
+ for path, action, method, provider in routes:
1087
+ app.route(path, methods=[method])(make_handler(action, provider))
1088
+ return self
1089
+
1090
+ async def aclose(self):
1091
+ if self._password_tasks:
1092
+ await asyncio.gather(*self._password_tasks, return_exceptions=True)
1093
+ if self.store:
1094
+ await self.store.aclose()
1095
+ if self.limiter:
1096
+ await self.limiter.aclose()
1097
+
1098
+ async def __aenter__(self):
1099
+ return self
1100
+
1101
+ async def __aexit__(self, *exc):
1102
+ await self.aclose()