pyweb-stack 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.
- pyweb/__init__.py +71 -0
- pyweb/app.py +37 -0
- pyweb/app_loader.py +139 -0
- pyweb/asgi.py +71 -0
- pyweb/auth.py +701 -0
- pyweb/bench.py +101 -0
- pyweb/browser.py +232 -0
- pyweb/build.py +295 -0
- pyweb/cache.py +201 -0
- pyweb/cli/__init__.py +465 -0
- pyweb/cli/__main__.py +3 -0
- pyweb/compiler/__init__.py +5 -0
- pyweb/compiler/ast.py +45 -0
- pyweb/compiler/codegen/__init__.py +9 -0
- pyweb/compiler/codegen/ir.py +50 -0
- pyweb/compiler/errors.py +29 -0
- pyweb/compiler/lower.py +733 -0
- pyweb/compiler/parser.py +532 -0
- pyweb/compiler/pipeline.py +158 -0
- pyweb/compiler/pyjs.py +1087 -0
- pyweb/compiler/rpc.py +35 -0
- pyweb/context.py +193 -0
- pyweb/css.py +115 -0
- pyweb/db/__init__.py +652 -0
- pyweb/db/migrate.py +143 -0
- pyweb/decorators.py +49 -0
- pyweb/deploy.py +102 -0
- pyweb/forms.py +108 -0
- pyweb/hosting.py +139 -0
- pyweb/jobs.py +248 -0
- pyweb/live.py +88 -0
- pyweb/lsp.py +214 -0
- pyweb/models.py +269 -0
- pyweb/npm.py +270 -0
- pyweb/observability.py +313 -0
- pyweb/platform.py +69 -0
- pyweb/plugins.py +112 -0
- pyweb/py.typed +0 -0
- pyweb/realtime.py +189 -0
- pyweb/rpc.py +137 -0
- pyweb/runtime/browser/runtime.js +1151 -0
- pyweb/runtime/server/__init__.py +445 -0
- pyweb/security.py +162 -0
- pyweb/serve.py +335 -0
- pyweb/ssr.py +260 -0
- pyweb/sync.py +118 -0
- pyweb/testing.py +154 -0
- pyweb/uploads.py +94 -0
- pyweb_stack-0.1.0.dist-info/METADATA +198 -0
- pyweb_stack-0.1.0.dist-info/RECORD +54 -0
- pyweb_stack-0.1.0.dist-info/WHEEL +5 -0
- pyweb_stack-0.1.0.dist-info/entry_points.txt +2 -0
- pyweb_stack-0.1.0.dist-info/licenses/LICENSE +21 -0
- pyweb_stack-0.1.0.dist-info/top_level.txt +1 -0
pyweb/auth.py
ADDED
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
"""Auth: password hashing, signed sessions, RBAC/permissions, CSRF,
|
|
2
|
+
OAuth, magic links, TOTP."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import base64
|
|
7
|
+
import functools
|
|
8
|
+
import hashlib
|
|
9
|
+
import hmac
|
|
10
|
+
import json
|
|
11
|
+
import secrets
|
|
12
|
+
import time
|
|
13
|
+
import urllib.parse
|
|
14
|
+
import urllib.request
|
|
15
|
+
|
|
16
|
+
_HASH_ALGO = "pbkdf2_sha256"
|
|
17
|
+
_ITERATIONS = 260_000
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AuthError(Exception):
|
|
21
|
+
"""Base class for auth failures."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class NotAuthenticated(AuthError):
|
|
25
|
+
"""Raised when a request has no usable session."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Forbidden(AuthError):
|
|
29
|
+
"""Raised when a session lacks the required role/permission."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _b64e(raw: bytes) -> str:
|
|
33
|
+
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _b64d(data: str) -> bytes:
|
|
37
|
+
return base64.urlsafe_b64decode(data + "=" * (-len(data) % 4))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def hash_password(password, *, salt=None, rounds=None, iterations=_ITERATIONS):
|
|
41
|
+
if salt is None:
|
|
42
|
+
salt_bytes = secrets.token_bytes(16)
|
|
43
|
+
dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt_bytes, iterations)
|
|
44
|
+
return f"{_HASH_ALGO}${iterations}${_b64e(salt_bytes)}${_b64e(dk)}"
|
|
45
|
+
salt_bytes = salt if isinstance(salt, bytes) else bytes.fromhex(salt)
|
|
46
|
+
n = rounds if rounds is not None else iterations
|
|
47
|
+
dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt_bytes, n)
|
|
48
|
+
return f"pbkdf2${n}${salt_bytes.hex()}${dk.hex()}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def verify_password(password, stored):
|
|
52
|
+
try:
|
|
53
|
+
parts = stored.split("$")
|
|
54
|
+
if len(parts) != 4:
|
|
55
|
+
return False
|
|
56
|
+
algo, iters, salt_part, dk_part = parts
|
|
57
|
+
iters_i = int(iters)
|
|
58
|
+
if algo == _HASH_ALGO:
|
|
59
|
+
salt = _b64d(salt_part)
|
|
60
|
+
expected = _b64d(dk_part)
|
|
61
|
+
else:
|
|
62
|
+
salt = bytes.fromhex(salt_part)
|
|
63
|
+
expected = bytes.fromhex(dk_part)
|
|
64
|
+
except (ValueError, base64.binascii.Error):
|
|
65
|
+
return False
|
|
66
|
+
candidate = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, iters_i)
|
|
67
|
+
if algo == _HASH_ALGO:
|
|
68
|
+
return hmac.compare_digest(candidate, expected)
|
|
69
|
+
return hmac.compare_digest(candidate.hex(), expected.hex() if isinstance(expected, bytes) else dk_part)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class SessionStore:
|
|
73
|
+
def __init__(self, secret, *, ttl=86400):
|
|
74
|
+
self.secret = secret.encode() if isinstance(secret, str) else secret
|
|
75
|
+
self.ttl = ttl
|
|
76
|
+
self._data: dict[str, dict] = {}
|
|
77
|
+
|
|
78
|
+
def _sign(self, sid):
|
|
79
|
+
return hmac.new(self.secret, sid.encode(), hashlib.sha256).hexdigest()
|
|
80
|
+
|
|
81
|
+
def create(self, user_id, extra=None):
|
|
82
|
+
sid = secrets.token_urlsafe(24)
|
|
83
|
+
self._data[sid] = {"user_id": user_id, "created": time.time(), **(extra or {})}
|
|
84
|
+
return f"{sid}.{self._sign(sid)}"
|
|
85
|
+
|
|
86
|
+
def get(self, token):
|
|
87
|
+
try:
|
|
88
|
+
sid, sig = token.split(".")
|
|
89
|
+
except (ValueError, AttributeError):
|
|
90
|
+
return None
|
|
91
|
+
if not hmac.compare_digest(sig, self._sign(sid)):
|
|
92
|
+
return None
|
|
93
|
+
sess = self._data.get(sid)
|
|
94
|
+
if not sess or time.time() - sess["created"] > self.ttl:
|
|
95
|
+
self._data.pop(sid, None)
|
|
96
|
+
return None
|
|
97
|
+
return sess
|
|
98
|
+
|
|
99
|
+
def destroy(self, token):
|
|
100
|
+
sid = (token or "").split(".")[0]
|
|
101
|
+
self._data.pop(sid, None)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _session_of(args, kwargs):
|
|
105
|
+
if "session" in kwargs:
|
|
106
|
+
return kwargs["session"]
|
|
107
|
+
return args[0] if args and isinstance(args[0], dict) else None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def required(fn):
|
|
111
|
+
fn.__pyweb_auth__ = "required"
|
|
112
|
+
|
|
113
|
+
@functools.wraps(fn)
|
|
114
|
+
def wrapper(*args, **kwargs):
|
|
115
|
+
session = _session_of(args, kwargs)
|
|
116
|
+
if session is None and not getattr(fn, "__pyweb_auth_enforce__", True):
|
|
117
|
+
return fn(*args, **kwargs)
|
|
118
|
+
if not isinstance(session, dict) or "sub" not in session:
|
|
119
|
+
if "session" in kwargs or args:
|
|
120
|
+
raise NotAuthenticated("login required")
|
|
121
|
+
return fn(*args, **kwargs)
|
|
122
|
+
return fn(*args, **kwargs)
|
|
123
|
+
|
|
124
|
+
wrapper.__pyweb_auth__ = "required"
|
|
125
|
+
return wrapper
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def permission(name):
|
|
129
|
+
def deco(fn):
|
|
130
|
+
perms = getattr(fn, "__pyweb_permissions__", [])
|
|
131
|
+
fn.__pyweb_permissions__ = [*perms, name]
|
|
132
|
+
|
|
133
|
+
@functools.wraps(fn)
|
|
134
|
+
def wrapper(*args, **kwargs):
|
|
135
|
+
session = _session_of(args, kwargs)
|
|
136
|
+
if session is not None:
|
|
137
|
+
if not isinstance(session, dict) or "sub" not in session:
|
|
138
|
+
raise NotAuthenticated("login required")
|
|
139
|
+
if name not in session.get("roles", []):
|
|
140
|
+
raise Forbidden(f"requires role {name!r}")
|
|
141
|
+
return fn(*args, **kwargs)
|
|
142
|
+
|
|
143
|
+
wrapper.__pyweb_permissions__ = fn.__pyweb_permissions__
|
|
144
|
+
return wrapper
|
|
145
|
+
return deco
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def can(user_roles, fn):
|
|
149
|
+
need = getattr(fn, "__pyweb_permissions__", [])
|
|
150
|
+
return all(p in (user_roles or []) for p in need)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
SESSION_COOKIE = "pyweb_session"
|
|
154
|
+
LOGIN_URL = "/login"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def session_from_request(req, secret: str, max_age: int = 3600):
|
|
158
|
+
"""Extract + verify the session from a ``Request``'s cookies/headers.
|
|
159
|
+
|
|
160
|
+
Accepts the ``pyweb_session`` cookie or ``Authorization: Bearer``.
|
|
161
|
+
Returns the payload dict, or ``None`` when absent/invalid/expired.
|
|
162
|
+
"""
|
|
163
|
+
raw = ""
|
|
164
|
+
if getattr(req, "cookies", None) and req.cookies.get(SESSION_COOKIE):
|
|
165
|
+
raw = req.cookies[SESSION_COOKIE]
|
|
166
|
+
else:
|
|
167
|
+
auth = (getattr(req, "headers", None) or {}).get("Authorization", "")
|
|
168
|
+
if auth.startswith("Bearer "):
|
|
169
|
+
raw = auth[len("Bearer "):]
|
|
170
|
+
if not raw:
|
|
171
|
+
return None
|
|
172
|
+
return verify_session(raw, secret, max_age)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def require_session(req, secret: str, max_age: int = 3600):
|
|
176
|
+
"""Return ``(session, None)`` when authenticated, else ``(None, redirect)``.
|
|
177
|
+
|
|
178
|
+
The redirect is a ``302`` to ``/login?next=<path>`` per the QA-006
|
|
179
|
+
contract: unauthenticated page access redirects; API/RPC callers
|
|
180
|
+
distinguish the ``Location: /login`` response instead of a 200.
|
|
181
|
+
"""
|
|
182
|
+
from pyweb.runtime.server import Response
|
|
183
|
+
session = session_from_request(req, secret, max_age)
|
|
184
|
+
if session is not None and "sub" in session:
|
|
185
|
+
return session, None
|
|
186
|
+
return None, Response(302, "", {"Location": f"{LOGIN_URL}?next={req.path}"})
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def login_response(user_id, secret: str, *, extra=None, next_url="/",
|
|
190
|
+
max_age: int = 3600, secure: bool = False):
|
|
191
|
+
"""``302`` to ``next_url`` with a ``Set-Cookie: pyweb_session=...``.
|
|
192
|
+
|
|
193
|
+
The cookie carries ``Max-Age`` matching the server-side session TTL so
|
|
194
|
+
browsers discard stale tokens instead of hoarding them. ``secure=True``
|
|
195
|
+
adds ``Secure`` — always set it in production behind HTTPS; it stays
|
|
196
|
+
off by default only so localhost dev over plain HTTP keeps working.
|
|
197
|
+
"""
|
|
198
|
+
from pyweb.runtime.server import Response
|
|
199
|
+
token = issue_session({"sub": user_id, **(extra or {})}, secret, max_age)
|
|
200
|
+
flags = (f"HttpOnly; Path=/; Max-Age={int(max_age)}; SameSite=Lax"
|
|
201
|
+
+ ("; Secure" if secure else ""))
|
|
202
|
+
return Response(302, "", {
|
|
203
|
+
"Location": next_url,
|
|
204
|
+
"Set-Cookie": f"{SESSION_COOKIE}={token}; {flags}",
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def warn_if_insecure_cookies(secure: bool, *, host: str = "") -> str | None:
|
|
209
|
+
"""Return a warning when session cookies would go out without ``Secure``
|
|
210
|
+
on a non-local host. ``pyweb serve`` logs it at startup."""
|
|
211
|
+
if secure:
|
|
212
|
+
return None
|
|
213
|
+
if (host or "").split(":")[0] in ("127.0.0.1", "localhost", "::1", ""):
|
|
214
|
+
return None
|
|
215
|
+
return ("auth cookies lack the Secure flag on a non-local host "
|
|
216
|
+
f"({host!r}): pass secure=True (login_response) and serve "
|
|
217
|
+
"behind HTTPS, or sessions are exposed to network sniffing")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def logout_response(next_url="/", *, secure: bool = False):
|
|
221
|
+
"""Clear the session cookie and redirect to ``next_url``."""
|
|
222
|
+
from pyweb.runtime.server import Response
|
|
223
|
+
flags = ("HttpOnly; Path=/; Max-Age=0; SameSite=Lax"
|
|
224
|
+
+ ("; Secure" if secure else ""))
|
|
225
|
+
return Response(302, "", {
|
|
226
|
+
"Location": next_url,
|
|
227
|
+
"Set-Cookie": f"{SESSION_COOKIE}=; {flags}",
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _sign(payload_b64: str, secret: str) -> str:
|
|
232
|
+
return hmac.new(secret.encode(), payload_b64.encode(), hashlib.sha256).hexdigest()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def issue_session(data: dict, secret: str, max_age: int = 3600) -> str:
|
|
236
|
+
"""Issue a ``base64url(payload).hexhmac`` stateless session cookie value."""
|
|
237
|
+
payload = dict(data)
|
|
238
|
+
payload.setdefault("iat", int(time.time()))
|
|
239
|
+
payload_b64 = _b64e(json.dumps(payload, separators=(",", ":")).encode())
|
|
240
|
+
return f"{payload_b64}.{_sign(payload_b64, secret)}"
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def verify_session(cookie: str, secret: str, max_age: int = 3600):
|
|
244
|
+
"""Verify a stateless session cookie; return payload dict or ``None``."""
|
|
245
|
+
try:
|
|
246
|
+
payload_b64, sig = cookie.rsplit(".", 1)
|
|
247
|
+
except ValueError:
|
|
248
|
+
return None
|
|
249
|
+
if not hmac.compare_digest(_sign(payload_b64, secret), sig):
|
|
250
|
+
return None
|
|
251
|
+
try:
|
|
252
|
+
payload = json.loads(_b64d(payload_b64))
|
|
253
|
+
except (ValueError, base64.binascii.Error, json.JSONDecodeError):
|
|
254
|
+
return None
|
|
255
|
+
if not isinstance(payload, dict):
|
|
256
|
+
return None
|
|
257
|
+
iat = payload.get("iat")
|
|
258
|
+
if not isinstance(iat, int) or time.time() - iat > max_age:
|
|
259
|
+
return None
|
|
260
|
+
return payload
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
_PROVIDERS = {
|
|
264
|
+
"google": {
|
|
265
|
+
"authorize": "https://accounts.google.com/o/oauth2/v2/auth",
|
|
266
|
+
"token": "https://oauth2.googleapis.com/token",
|
|
267
|
+
},
|
|
268
|
+
"github": {
|
|
269
|
+
"authorize": "https://github.com/login/oauth/authorize",
|
|
270
|
+
"token": "https://github.com/login/oauth/access_token",
|
|
271
|
+
},
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class OAuthClient:
|
|
276
|
+
"""OAuth authorization-code client using only stdlib urllib."""
|
|
277
|
+
|
|
278
|
+
def __init__(self, provider, client_id, client_secret, redirect_uri, scope=None):
|
|
279
|
+
if provider not in _PROVIDERS:
|
|
280
|
+
raise ValueError(f"unknown provider {provider!r}")
|
|
281
|
+
self.provider = provider
|
|
282
|
+
self.client_id = client_id
|
|
283
|
+
self.client_secret = client_secret
|
|
284
|
+
self.redirect_uri = redirect_uri
|
|
285
|
+
self.scope = scope or (["openid", "email", "profile"] if provider == "google" else ["read:user"])
|
|
286
|
+
|
|
287
|
+
def authorize_url(self, state):
|
|
288
|
+
params = urllib.parse.urlencode({
|
|
289
|
+
"client_id": self.client_id,
|
|
290
|
+
"redirect_uri": self.redirect_uri,
|
|
291
|
+
"response_type": "code",
|
|
292
|
+
"code": "code",
|
|
293
|
+
"scope": " ".join(self.scope),
|
|
294
|
+
"state": state,
|
|
295
|
+
})
|
|
296
|
+
return f"{_PROVIDERS[self.provider]['authorize']}?{params}"
|
|
297
|
+
|
|
298
|
+
def exchange_code(self, code, timeout=10):
|
|
299
|
+
data = urllib.parse.urlencode({
|
|
300
|
+
"client_id": self.client_id,
|
|
301
|
+
"client_secret": self.client_secret,
|
|
302
|
+
"code": code,
|
|
303
|
+
"grant_type": "authorization_code",
|
|
304
|
+
"redirect_uri": self.redirect_uri,
|
|
305
|
+
}).encode()
|
|
306
|
+
req = urllib.request.Request(_PROVIDERS[self.provider]["token"], data=data,
|
|
307
|
+
headers={"Accept": "application/json"})
|
|
308
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
309
|
+
return json.loads(resp.read().decode())
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def issue_magic_token(secret: str, email: str, ttl: int = 900) -> str:
|
|
313
|
+
payload = {"email": email, "exp": int(time.time()) + ttl}
|
|
314
|
+
payload_b64 = _b64e(json.dumps(payload, separators=(",", ":")).encode())
|
|
315
|
+
return f"{payload_b64}.{_sign(payload_b64, secret)}"
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def verify_magic_token(secret: str, token: str, max_age: int = 900):
|
|
319
|
+
try:
|
|
320
|
+
payload_b64, sig = token.rsplit(".", 1)
|
|
321
|
+
except ValueError:
|
|
322
|
+
return None
|
|
323
|
+
if not hmac.compare_digest(_sign(payload_b64, secret), sig):
|
|
324
|
+
return None
|
|
325
|
+
try:
|
|
326
|
+
payload = json.loads(_b64d(payload_b64))
|
|
327
|
+
except (ValueError, base64.binascii.Error, json.JSONDecodeError):
|
|
328
|
+
return None
|
|
329
|
+
if time.time() > payload.get("exp", 0):
|
|
330
|
+
return None
|
|
331
|
+
return payload.get("email")
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
_TOTP_STEP = 30
|
|
335
|
+
_TOTP_DIGITS = 6
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _totp_counter(for_time: int) -> int:
|
|
339
|
+
return int(for_time) // _TOTP_STEP
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def totp(secret: bytes, for_time: int | None = None, digits=_TOTP_DIGITS) -> str:
|
|
343
|
+
import struct
|
|
344
|
+
counter = _totp_counter(int(time.time()) if for_time is None else for_time)
|
|
345
|
+
mac = hmac.new(secret, struct.pack(">Q", counter), hashlib.sha1).digest()
|
|
346
|
+
offset = mac[-1] & 0x0F
|
|
347
|
+
code = struct.unpack(">I", mac[offset:offset + 4])[0] & 0x7FFFFFFF
|
|
348
|
+
return str(code % (10 ** digits)).zfill(digits)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def verify_totp(secret: bytes, code: str, for_time: int | None = None, window: int = 1) -> bool:
|
|
352
|
+
now = int(time.time()) if for_time is None else for_time
|
|
353
|
+
for skew in range(-window, window + 1):
|
|
354
|
+
if hmac.compare_digest(totp(secret, now + skew * _TOTP_STEP), str(code)):
|
|
355
|
+
return True
|
|
356
|
+
return False
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def csrf_token(secret, session_id):
|
|
360
|
+
return hmac.new(secret.encode() if isinstance(secret, str) else secret,
|
|
361
|
+
session_id.encode(), hashlib.sha256).hexdigest()
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def verify_csrf(secret, session_id, token):
|
|
365
|
+
return hmac.compare_digest(csrf_token(secret, session_id), token or "")
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# ---------------------------------------------------------------------------
|
|
369
|
+
# Policy-based authorization (RBAC v1)
|
|
370
|
+
#
|
|
371
|
+
# Why policies, not just role lists: ``@permission("admin")`` checks a
|
|
372
|
+
# single role, but real apps need rules like "authors can edit their own
|
|
373
|
+
# posts". :class:`Policy` lets teams declare named rules once and reuse
|
|
374
|
+
# them from both server functions and RPC gates.
|
|
375
|
+
# ---------------------------------------------------------------------------
|
|
376
|
+
|
|
377
|
+
class Policy:
|
|
378
|
+
"""Named authorization rule set.
|
|
379
|
+
|
|
380
|
+
``Policy("post:edit").allow("admin").allow("author", owner_field="author_id")``
|
|
381
|
+
"""
|
|
382
|
+
|
|
383
|
+
def __init__(self, name):
|
|
384
|
+
self.name = name
|
|
385
|
+
self._rules: list[tuple] = []
|
|
386
|
+
|
|
387
|
+
def allow(self, role, *, owner_field=None):
|
|
388
|
+
self._rules.append((role, owner_field))
|
|
389
|
+
return self
|
|
390
|
+
|
|
391
|
+
def check(self, session, resource=None):
|
|
392
|
+
if not isinstance(session, dict) or "sub" not in session:
|
|
393
|
+
raise NotAuthenticated("login required")
|
|
394
|
+
roles = set(session.get("roles", []))
|
|
395
|
+
for role, owner_field in self._rules:
|
|
396
|
+
if role not in roles:
|
|
397
|
+
continue
|
|
398
|
+
if owner_field is None:
|
|
399
|
+
return True
|
|
400
|
+
if resource is not None:
|
|
401
|
+
owner = (resource.get(owner_field)
|
|
402
|
+
if isinstance(resource, dict)
|
|
403
|
+
else getattr(resource, owner_field, None))
|
|
404
|
+
if owner == session.get("sub"):
|
|
405
|
+
return True
|
|
406
|
+
raise Forbidden(f"policy {self.name!r} denied")
|
|
407
|
+
|
|
408
|
+
def __call__(self, fn):
|
|
409
|
+
policy = self
|
|
410
|
+
|
|
411
|
+
@functools.wraps(fn)
|
|
412
|
+
def wrapper(*args, **kwargs):
|
|
413
|
+
session = _session_of(args, kwargs)
|
|
414
|
+
resource = kwargs.get("resource", args[1] if len(args) > 1 else None)
|
|
415
|
+
policy.check(session, resource)
|
|
416
|
+
return fn(*args, **kwargs)
|
|
417
|
+
|
|
418
|
+
wrapper.__pyweb_permissions__ = [*getattr(fn, "__pyweb_permissions__", []),
|
|
419
|
+
f"policy:{self.name}"]
|
|
420
|
+
return wrapper
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def rotate_session(store: SessionStore, old_token, *, extra=None):
|
|
424
|
+
"""Sliding expiration: destroy the old token, issue a fresh one.
|
|
425
|
+
|
|
426
|
+
Call on each authenticated request (or every N minutes) to bound the
|
|
427
|
+
lifetime of a stolen cookie. Preserves user_id + extra claims.
|
|
428
|
+
"""
|
|
429
|
+
sess = store.get(old_token)
|
|
430
|
+
if sess is None:
|
|
431
|
+
return None
|
|
432
|
+
store.destroy(old_token)
|
|
433
|
+
user_id = sess.pop("user_id", None)
|
|
434
|
+
merged = {**sess, **(extra or {})}
|
|
435
|
+
return store.create(user_id, extra=merged)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
# ---------------------------------------------------------------------------
|
|
439
|
+
# WebAuthn (passkeys) — verification side, stdlib-only.
|
|
440
|
+
#
|
|
441
|
+
# Scope: verify assertion responses (login). Registration (attestation)
|
|
442
|
+
# verification is intentionally out of scope for v1 — teams enroll via
|
|
443
|
+
# the platform authenticator and import the credential public key.
|
|
444
|
+
# ES256 (COSE -7) over SHA-256, matching what iCloud/Google password
|
|
445
|
+
# managers and platform authenticators emit.
|
|
446
|
+
# ---------------------------------------------------------------------------
|
|
447
|
+
|
|
448
|
+
def _cbor_first(data: bytes):
|
|
449
|
+
"""Minimal CBOR head decode: returns (value, rest) for int/bytes/text.
|
|
450
|
+
|
|
451
|
+
Only what WebAuthn authData parsing needs — not a general decoder.
|
|
452
|
+
"""
|
|
453
|
+
if not data:
|
|
454
|
+
raise ValueError("empty cbor")
|
|
455
|
+
ib, rest = data[0], data[1:]
|
|
456
|
+
major, info = ib >> 5, ib & 0x1F
|
|
457
|
+
if info < 24:
|
|
458
|
+
num, tail = info, rest
|
|
459
|
+
elif info == 24:
|
|
460
|
+
num, tail = rest[0], rest[1:]
|
|
461
|
+
elif info == 25:
|
|
462
|
+
num = int.from_bytes(rest[:2], "big")
|
|
463
|
+
tail = rest[2:]
|
|
464
|
+
elif info == 26:
|
|
465
|
+
num = int.from_bytes(rest[:4], "big")
|
|
466
|
+
tail = rest[4:]
|
|
467
|
+
else:
|
|
468
|
+
raise ValueError(f"unsupported cbor info {info}")
|
|
469
|
+
if major == 0:
|
|
470
|
+
return num, tail
|
|
471
|
+
if major == 1:
|
|
472
|
+
return -1 - num, tail
|
|
473
|
+
if major == 2:
|
|
474
|
+
return tail[:num], tail[num:]
|
|
475
|
+
if major == 3:
|
|
476
|
+
return tail[:num].decode("utf-8"), tail[num:]
|
|
477
|
+
raise ValueError(f"unsupported cbor major {major}")
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def parse_auth_data(auth_data: bytes) -> dict:
|
|
481
|
+
"""Parse WebAuthn ``authenticatorData`` into its fields."""
|
|
482
|
+
if len(auth_data) < 37:
|
|
483
|
+
raise ValueError("authenticatorData too short")
|
|
484
|
+
return {
|
|
485
|
+
"rp_id_hash": auth_data[:32],
|
|
486
|
+
"flags": auth_data[32],
|
|
487
|
+
"sign_count": int.from_bytes(auth_data[33:37], "big"),
|
|
488
|
+
"attested": auth_data[37:],
|
|
489
|
+
"user_present": bool(auth_data[32] & 0x01),
|
|
490
|
+
"user_verified": bool(auth_data[32] & 0x04),
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _b64url_decode(data) -> bytes:
|
|
495
|
+
if isinstance(data, bytes):
|
|
496
|
+
return data
|
|
497
|
+
return base64.urlsafe_b64decode(data + "=" * (-len(data) % 4))
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _der_to_rs(sig: bytes):
|
|
501
|
+
"""Parse an ASN.1 DER ``ECDSA-Sig-Value`` into ``(r, s)``."""
|
|
502
|
+
def read_len(buf, i):
|
|
503
|
+
n = buf[i]
|
|
504
|
+
i += 1
|
|
505
|
+
if n < 0x80:
|
|
506
|
+
return n, i
|
|
507
|
+
k = n & 0x7F
|
|
508
|
+
if k == 0 or k > 2:
|
|
509
|
+
raise ValueError("bad DER length")
|
|
510
|
+
return int.from_bytes(buf[i:i + k], "big"), i + k
|
|
511
|
+
|
|
512
|
+
if len(sig) < 8 or sig[0] != 0x30:
|
|
513
|
+
raise ValueError("not a DER sequence")
|
|
514
|
+
total, i = read_len(sig, 1)
|
|
515
|
+
if i + total != len(sig):
|
|
516
|
+
raise ValueError("DER length mismatch")
|
|
517
|
+
out = []
|
|
518
|
+
for _ in range(2):
|
|
519
|
+
if sig[i] != 0x02:
|
|
520
|
+
raise ValueError("expected DER integer")
|
|
521
|
+
n, i = read_len(sig, i + 1)
|
|
522
|
+
out.append(int.from_bytes(sig[i:i + n], "big"))
|
|
523
|
+
i += n
|
|
524
|
+
if i != len(sig):
|
|
525
|
+
raise ValueError("trailing DER bytes")
|
|
526
|
+
return out[0], out[1]
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def verify_webauthn_assertion(*, credential_public_key: bytes,
|
|
530
|
+
auth_data: bytes, client_data_json: bytes,
|
|
531
|
+
signature: bytes, rp_id: str,
|
|
532
|
+
expected_challenge, expected_origin,
|
|
533
|
+
require_user_verification=False,
|
|
534
|
+
prior_sign_count=None) -> dict:
|
|
535
|
+
"""Verify a WebAuthn (passkey) login assertion. ES256 / P-256 only.
|
|
536
|
+
|
|
537
|
+
* ``credential_public_key``: raw uncompressed P-256 point
|
|
538
|
+
(``0x04 || x || y``) stored at registration
|
|
539
|
+
(:func:`cose_to_raw_point` converts COSE keys).
|
|
540
|
+
* ``signature``: as sent by the browser (ASN.1 DER); raw 64-byte
|
|
541
|
+
``r || s`` is accepted too.
|
|
542
|
+
* ``expected_challenge``: the challenge you issued for this login
|
|
543
|
+
(bytes, or base64url text) — prevents replaying old assertions.
|
|
544
|
+
* ``expected_origin``: e.g. ``"https://example.com"`` (or a list).
|
|
545
|
+
* ``prior_sign_count``: the stored counter; a non-increasing counter
|
|
546
|
+
signals a cloned authenticator and is rejected.
|
|
547
|
+
|
|
548
|
+
Returns the parsed authenticator data; raises :class:`AuthError`.
|
|
549
|
+
The signature is checked with the ``cryptography`` package when it is
|
|
550
|
+
installed (``pip install "pyweb-stack[crypto]"``) and with a pure-Python
|
|
551
|
+
fallback otherwise.
|
|
552
|
+
"""
|
|
553
|
+
parsed = parse_auth_data(auth_data)
|
|
554
|
+
if not hmac.compare_digest(parsed["rp_id_hash"], hashlib.sha256(rp_id.encode()).digest()):
|
|
555
|
+
raise AuthError("rpId hash mismatch")
|
|
556
|
+
if not parsed["user_present"]:
|
|
557
|
+
raise AuthError("user presence flag not set")
|
|
558
|
+
if require_user_verification and not parsed["user_verified"]:
|
|
559
|
+
raise AuthError("user verification required")
|
|
560
|
+
if len(credential_public_key) != 65 or credential_public_key[0] != 0x04:
|
|
561
|
+
raise AuthError("unsupported credential key (need raw P-256)")
|
|
562
|
+
try:
|
|
563
|
+
client = json.loads(client_data_json)
|
|
564
|
+
except ValueError as exc:
|
|
565
|
+
raise AuthError("invalid clientDataJSON") from exc
|
|
566
|
+
if not isinstance(client, dict) or client.get("type") != "webauthn.get":
|
|
567
|
+
raise AuthError("wrong ceremony type")
|
|
568
|
+
try:
|
|
569
|
+
got_challenge = _b64url_decode(client.get("challenge") or "")
|
|
570
|
+
except (ValueError, TypeError) as exc:
|
|
571
|
+
raise AuthError("malformed challenge") from exc
|
|
572
|
+
if not expected_challenge or not hmac.compare_digest(got_challenge, _b64url_decode(expected_challenge)):
|
|
573
|
+
raise AuthError("challenge mismatch")
|
|
574
|
+
origins = [expected_origin] if isinstance(expected_origin, str) else list(expected_origin or [])
|
|
575
|
+
if client.get("origin") not in origins:
|
|
576
|
+
raise AuthError("origin mismatch")
|
|
577
|
+
if prior_sign_count and parsed["sign_count"] and parsed["sign_count"] <= prior_sign_count:
|
|
578
|
+
raise AuthError("sign count did not increase (possible cloned authenticator)")
|
|
579
|
+
msg = auth_data + hashlib.sha256(client_data_json).digest()
|
|
580
|
+
try:
|
|
581
|
+
if len(signature) == 64:
|
|
582
|
+
r, s_ = int.from_bytes(signature[:32], "big"), int.from_bytes(signature[32:], "big")
|
|
583
|
+
else:
|
|
584
|
+
r, s_ = _der_to_rs(signature)
|
|
585
|
+
except (ValueError, IndexError) as exc:
|
|
586
|
+
raise AuthError("malformed signature") from exc
|
|
587
|
+
x = int.from_bytes(credential_public_key[1:33], "big")
|
|
588
|
+
y = int.from_bytes(credential_public_key[33:65], "big")
|
|
589
|
+
if not _ecdsa_p256_verify_rs(x, y, msg, r, s_):
|
|
590
|
+
raise AuthError("invalid assertion signature")
|
|
591
|
+
return parsed
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def cose_to_raw_point(cose_key: bytes) -> bytes:
|
|
595
|
+
"""Convert a COSE_Key (ES256, kty EC2) to a raw 65-byte P-256 point."""
|
|
596
|
+
if not cose_key or cose_key[0] != 0xA5:
|
|
597
|
+
raise ValueError("expected COSE map(5)")
|
|
598
|
+
rest = cose_key[1:]
|
|
599
|
+
vals = {}
|
|
600
|
+
for _ in range(5):
|
|
601
|
+
k, rest = _cbor_first(rest)
|
|
602
|
+
v, rest = _cbor_first(rest)
|
|
603
|
+
vals[k] = v
|
|
604
|
+
if vals.get(1) != 2 or vals.get(3) != -7:
|
|
605
|
+
raise ValueError("only COSE EC2/ES256 supported")
|
|
606
|
+
x, y = vals.get(-2), vals.get(-3)
|
|
607
|
+
if not (isinstance(x, bytes) and isinstance(y, bytes)
|
|
608
|
+
and len(x) == 32 and len(y) == 32):
|
|
609
|
+
raise ValueError("bad COSE x/y coordinates")
|
|
610
|
+
return b"\x04" + x + y
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
_P256 = 0xFFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF
|
|
614
|
+
_P256_B = 0x5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B
|
|
615
|
+
_P256_GX = 0x6B17D1F2E12C4247F8BCE6E563A440F277037D812DEB33A0F4A13945D898C296
|
|
616
|
+
_P256_GY = 0x4FE342E2FE1A7F9B8EE7EB4A7C0F9E162BCE33576B315ECECBB6406837BF51F5
|
|
617
|
+
_P256_N = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _p256_point_add(p, q):
|
|
621
|
+
# Curve: y^2 = x^3 - 3x + b (NIST P-256, a = -3).
|
|
622
|
+
if p is None:
|
|
623
|
+
return q
|
|
624
|
+
if q is None:
|
|
625
|
+
return p
|
|
626
|
+
x1, y1 = p
|
|
627
|
+
x2, y2 = q
|
|
628
|
+
if x1 == x2 and (y1 + y2) % _P256 == 0:
|
|
629
|
+
return None
|
|
630
|
+
if p == q:
|
|
631
|
+
lam = (3 * x1 * x1 - 3) * pow(2 * y1, -1, _P256) % _P256
|
|
632
|
+
else:
|
|
633
|
+
lam = ((y2 - y1) * pow((x2 - x1) % _P256, -1, _P256)) % _P256
|
|
634
|
+
x3 = (lam * lam - x1 - x2) % _P256
|
|
635
|
+
return (x3, (lam * (x1 - x3) - y1) % _P256)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _p256_on_curve(x, y) -> bool:
|
|
639
|
+
return (y * y - (x * x * x - 3 * x + _P256_B)) % _P256 == 0
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def _p256_scalar_mult(k, point):
|
|
643
|
+
res = None
|
|
644
|
+
add = point
|
|
645
|
+
while k:
|
|
646
|
+
if k & 1:
|
|
647
|
+
res = _p256_point_add(res, add)
|
|
648
|
+
add = _p256_point_add(add, add)
|
|
649
|
+
k >>= 1
|
|
650
|
+
return res
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _ecdsa_p256_verify(x, y, msg, signature: bytes) -> bool:
|
|
654
|
+
"""Verify a raw 64-byte ``r || s`` ECDSA P-256/SHA-256 signature."""
|
|
655
|
+
if len(signature) != 64:
|
|
656
|
+
return False
|
|
657
|
+
return _ecdsa_p256_verify_rs(x, y, msg, int.from_bytes(signature[:32], "big"),
|
|
658
|
+
int.from_bytes(signature[32:], "big"))
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _ecdsa_p256_verify_rs(x, y, msg, r, s) -> bool:
|
|
662
|
+
if not (0 < x < _P256 and 0 < y < _P256) or not _p256_on_curve(x, y):
|
|
663
|
+
return False
|
|
664
|
+
if not (0 < r < _P256_N and 0 < s < _P256_N):
|
|
665
|
+
return False
|
|
666
|
+
try:
|
|
667
|
+
from cryptography.exceptions import InvalidSignature
|
|
668
|
+
from cryptography.hazmat.primitives import hashes
|
|
669
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
670
|
+
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
|
|
671
|
+
except ImportError:
|
|
672
|
+
return _ecdsa_p256_verify_pure(x, y, msg, r, s)
|
|
673
|
+
key = ec.EllipticCurvePublicNumbers(x, y, ec.SECP256R1()).public_key()
|
|
674
|
+
try:
|
|
675
|
+
key.verify(encode_dss_signature(r, s), msg, ec.ECDSA(hashes.SHA256()))
|
|
676
|
+
return True
|
|
677
|
+
except InvalidSignature:
|
|
678
|
+
return False
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _ecdsa_p256_verify_pure(x, y, msg, r, s) -> bool:
|
|
682
|
+
"""Pure-Python fallback. Verification uses only public data, so timing
|
|
683
|
+
side channels do not leak secrets; it is slow (~50/s), fine for logins."""
|
|
684
|
+
e = int.from_bytes(hashlib.sha256(msg).digest(), "big")
|
|
685
|
+
w = pow(s, -1, _P256_N)
|
|
686
|
+
u1 = (e * w) % _P256_N
|
|
687
|
+
u2 = (r * w) % _P256_N
|
|
688
|
+
pt = _p256_point_add(_p256_scalar_mult(u1, (_P256_GX, _P256_GY)),
|
|
689
|
+
_p256_scalar_mult(u2, (x, y)))
|
|
690
|
+
if pt is None:
|
|
691
|
+
return False
|
|
692
|
+
return pt[0] % _P256_N == r
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def oidc_userinfo(endpoint: str, access_token: str, timeout=10) -> dict:
|
|
696
|
+
"""Fetch OIDC ``userinfo`` claims with a bearer token (stdlib)."""
|
|
697
|
+
req = urllib.request.Request(endpoint, headers={
|
|
698
|
+
"Authorization": f"Bearer {access_token}",
|
|
699
|
+
"Accept": "application/json"})
|
|
700
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
701
|
+
return json.loads(resp.read().decode())
|