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 +1102 -0
- wred_auth/migrations/env.py +33 -0
- wred_auth/migrations/versions/0001_accounts.py +76 -0
- wred_auth/migrations/versions/0002_retention_indexes.py +33 -0
- wred_auth/mongodb.py +240 -0
- wred_auth/providers.py +116 -0
- wred_auth/sqlalchemy.py +271 -0
- wred_auth/storage.py +46 -0
- wred_auth-0.1.0.dist-info/METADATA +359 -0
- wred_auth-0.1.0.dist-info/RECORD +12 -0
- wred_auth-0.1.0.dist-info/WHEEL +4 -0
- wred_auth-0.1.0.dist-info/licenses/LICENSE +29 -0
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()
|