lenapi 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.
- lenapi/__init__.py +79 -0
- lenapi/application.py +636 -0
- lenapi/authorization.py +96 -0
- lenapi/background.py +395 -0
- lenapi/cli.py +731 -0
- lenapi/database.py +84 -0
- lenapi/dependencies.py +12 -0
- lenapi/logging.py +13 -0
- lenapi/middleware.py +144 -0
- lenapi/model.py +54 -0
- lenapi/models.py +1052 -0
- lenapi/mongodb.py +262 -0
- lenapi/openapi.py +181 -0
- lenapi/parameters.py +8 -0
- lenapi/payments.py +543 -0
- lenapi/request.py +177 -0
- lenapi/response.py +91 -0
- lenapi/security.py +665 -0
- lenapi/security_store.py +193 -0
- lenapi/service.py +350 -0
- lenapi-0.1.0.dist-info/METADATA +959 -0
- lenapi-0.1.0.dist-info/RECORD +25 -0
- lenapi-0.1.0.dist-info/WHEEL +5 -0
- lenapi-0.1.0.dist-info/entry_points.txt +2 -0
- lenapi-0.1.0.dist-info/top_level.txt +1 -0
lenapi/security.py
ADDED
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
"""Small HS256 JWT authentication helper for protected lenApi routes."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import binascii
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import inspect
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import secrets
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, Field
|
|
17
|
+
|
|
18
|
+
from .request import Request
|
|
19
|
+
from .response import HTTPException, Response
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
_B64URL = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _encode_segment(value: bytes) -> str:
|
|
26
|
+
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _decode_segment(value: str) -> bytes:
|
|
30
|
+
if not value or not _B64URL.fullmatch(value):
|
|
31
|
+
raise ValueError("Malformed token segment")
|
|
32
|
+
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def hash_password(password: str) -> str:
|
|
36
|
+
"""Hash a password in Django's ``pbkdf2_sha256`` encoded format."""
|
|
37
|
+
if not isinstance(password, str) or not password:
|
|
38
|
+
raise ValueError("password must be a non-empty string")
|
|
39
|
+
alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
|
40
|
+
salt = "".join(secrets.choice(alphabet) for _ in range(22))
|
|
41
|
+
iterations = 1_800_000
|
|
42
|
+
digest = hashlib.pbkdf2_hmac(
|
|
43
|
+
"sha256", password.encode("utf-8"), salt.encode("ascii"), iterations
|
|
44
|
+
)
|
|
45
|
+
encoded = base64.b64encode(digest).decode("ascii").strip()
|
|
46
|
+
return f"pbkdf2_sha256${iterations}${salt}${encoded}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def verify_password(password: str, encoded_password: str) -> bool:
|
|
50
|
+
"""Verify a password against lenApi or Django PBKDF2 encoded storage."""
|
|
51
|
+
if not isinstance(password, str) or not isinstance(encoded_password, str):
|
|
52
|
+
return False
|
|
53
|
+
try:
|
|
54
|
+
# Verify hashes from the earlier lenApi scrypt helper during upgrades.
|
|
55
|
+
if encoded_password.startswith("scrypt$"):
|
|
56
|
+
algorithm, n, r, p, salt_text, digest_text = encoded_password.split("$")
|
|
57
|
+
if (algorithm, int(n), int(r), int(p)) != ("scrypt", 16384, 8, 1):
|
|
58
|
+
return False
|
|
59
|
+
salt, expected = _decode_segment(salt_text), _decode_segment(digest_text)
|
|
60
|
+
if len(salt) != 16 or len(expected) != 64:
|
|
61
|
+
return False
|
|
62
|
+
actual = hashlib.scrypt(password.encode("utf-8"), salt=salt, n=16384, r=8, p=1, dklen=64)
|
|
63
|
+
return hmac.compare_digest(actual, expected)
|
|
64
|
+
algorithm, iterations_text, salt, expected = encoded_password.split("$", 3)
|
|
65
|
+
iterations = int(iterations_text)
|
|
66
|
+
if algorithm != "pbkdf2_sha256" or not 1 <= iterations <= 5_000_000:
|
|
67
|
+
return False
|
|
68
|
+
if not salt.isalnum():
|
|
69
|
+
return False
|
|
70
|
+
actual = hashlib.pbkdf2_hmac(
|
|
71
|
+
"sha256", password.encode("utf-8"), salt.encode("ascii"), iterations
|
|
72
|
+
)
|
|
73
|
+
actual_encoded = base64.b64encode(actual).decode("ascii").strip()
|
|
74
|
+
return hmac.compare_digest(actual_encoded, expected)
|
|
75
|
+
except (ValueError, TypeError, UnicodeEncodeError):
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def make_password(password: str) -> str:
|
|
80
|
+
"""Create a Django-compatible one-way password hash."""
|
|
81
|
+
return hash_password(password)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def check_password(password: str, encoded_password: str) -> bool:
|
|
85
|
+
"""Return whether a raw password matches its stored password hash."""
|
|
86
|
+
return verify_password(password, encoded_password)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def make_api_key() -> str:
|
|
90
|
+
"""Generate a hyphen-free UUIDv5 API key using a random UUIDv4 seed."""
|
|
91
|
+
return uuid.uuid5(uuid.NAMESPACE_DNS, str(uuid.uuid4())).hex
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def hash_api_key(api_key: str) -> str:
|
|
95
|
+
"""Return a deterministic SHA-256 digest for storing an API key safely."""
|
|
96
|
+
if not isinstance(api_key, str) or not api_key:
|
|
97
|
+
raise ValueError("api_key must be a non-empty string")
|
|
98
|
+
return hashlib.sha256(api_key.encode("utf-8")).hexdigest()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class JWTAuth:
|
|
102
|
+
"""Create HS256 access tokens and use the instance as a Bearer dependency.
|
|
103
|
+
|
|
104
|
+
``secret_key`` should be a high-entropy secret read from an environment
|
|
105
|
+
variable. This helper only supports HS256 and validates signatures and
|
|
106
|
+
expiration claims; it does not authenticate user credentials itself.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
algorithm = "HS256"
|
|
110
|
+
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
secret_key: str | bytes,
|
|
114
|
+
*,
|
|
115
|
+
token_lifetime_seconds: int = 3600,
|
|
116
|
+
refresh_token_lifetime_seconds: int = 60 * 60 * 24 * 30,
|
|
117
|
+
issuer: str | None = None,
|
|
118
|
+
) -> None:
|
|
119
|
+
key = secret_key.encode("utf-8") if isinstance(secret_key, str) else secret_key
|
|
120
|
+
if not isinstance(key, bytes) or len(key) < 32:
|
|
121
|
+
raise ValueError("HS256 secret_key must be at least 32 bytes")
|
|
122
|
+
if token_lifetime_seconds <= 0:
|
|
123
|
+
raise ValueError("token_lifetime_seconds must be positive")
|
|
124
|
+
if refresh_token_lifetime_seconds <= 0:
|
|
125
|
+
raise ValueError("refresh_token_lifetime_seconds must be positive")
|
|
126
|
+
self.secret_key = key
|
|
127
|
+
self.token_lifetime_seconds = token_lifetime_seconds
|
|
128
|
+
self.refresh_token_lifetime_seconds = refresh_token_lifetime_seconds
|
|
129
|
+
self.issuer = issuer
|
|
130
|
+
|
|
131
|
+
def create_access_token(
|
|
132
|
+
self,
|
|
133
|
+
subject: str,
|
|
134
|
+
*,
|
|
135
|
+
claims: dict[str, Any] | None = None,
|
|
136
|
+
expires_in: int | None = None,
|
|
137
|
+
) -> str:
|
|
138
|
+
"""Sign a token for a subject, adding issued-at and expiration claims."""
|
|
139
|
+
if not isinstance(subject, str) or not subject:
|
|
140
|
+
raise ValueError("subject must be a non-empty string")
|
|
141
|
+
lifetime = self.token_lifetime_seconds if expires_in is None else expires_in
|
|
142
|
+
if lifetime <= 0:
|
|
143
|
+
raise ValueError("expires_in must be positive")
|
|
144
|
+
payload = dict(claims or {})
|
|
145
|
+
reserved = {"sub", "iat", "exp", "iss", "token_use"}
|
|
146
|
+
if reserved.intersection(payload):
|
|
147
|
+
raise ValueError("claims cannot override reserved token claims")
|
|
148
|
+
return self._create_token(subject, payload, lifetime, token_use="access")
|
|
149
|
+
|
|
150
|
+
def create_refresh_token(
|
|
151
|
+
self,
|
|
152
|
+
subject: str,
|
|
153
|
+
*,
|
|
154
|
+
claims: dict[str, Any] | None = None,
|
|
155
|
+
expires_in: int | None = None,
|
|
156
|
+
) -> str:
|
|
157
|
+
"""Create a refresh token that cannot be used as a bearer access token."""
|
|
158
|
+
if not isinstance(subject, str) or not subject:
|
|
159
|
+
raise ValueError("subject must be a non-empty string")
|
|
160
|
+
lifetime = self.refresh_token_lifetime_seconds if expires_in is None else expires_in
|
|
161
|
+
if lifetime <= 0:
|
|
162
|
+
raise ValueError("expires_in must be positive")
|
|
163
|
+
payload = dict(claims or {})
|
|
164
|
+
reserved = {"sub", "iat", "exp", "iss", "token_use"}
|
|
165
|
+
if reserved.intersection(payload):
|
|
166
|
+
raise ValueError("claims cannot override reserved token claims")
|
|
167
|
+
return self._create_token(subject, payload, lifetime, token_use="refresh")
|
|
168
|
+
|
|
169
|
+
def _create_token(self, subject: str, payload: dict[str, Any], lifetime: int, *, token_use: str) -> str:
|
|
170
|
+
now = int(time.time())
|
|
171
|
+
payload.update({"sub": subject, "iat": now, "exp": now + lifetime, "token_use": token_use})
|
|
172
|
+
if self.issuer is not None:
|
|
173
|
+
payload["iss"] = self.issuer
|
|
174
|
+
header = {"alg": self.algorithm, "typ": "JWT"}
|
|
175
|
+
head = _encode_segment(json.dumps(header, separators=(",", ":")).encode("utf-8"))
|
|
176
|
+
body = _encode_segment(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
|
|
177
|
+
signing_input = f"{head}.{body}".encode("ascii")
|
|
178
|
+
signature = hmac.new(self.secret_key, signing_input, hashlib.sha256).digest()
|
|
179
|
+
return f"{head}.{body}.{_encode_segment(signature)}"
|
|
180
|
+
|
|
181
|
+
def refresh_access_token(self, refresh_token: str) -> str:
|
|
182
|
+
"""Verify a refresh token and issue a new access token for its subject."""
|
|
183
|
+
payload = self.decode_token(refresh_token, allow_refresh=True)
|
|
184
|
+
if payload.get("token_use") != "refresh":
|
|
185
|
+
raise ValueError("A refresh token is required")
|
|
186
|
+
claims = {key: value for key, value in payload.items()
|
|
187
|
+
if key not in {"sub", "iat", "exp", "iss", "token_use"}}
|
|
188
|
+
return self.create_access_token(payload["sub"], claims=claims)
|
|
189
|
+
|
|
190
|
+
def router(self, authenticate):
|
|
191
|
+
"""Build `/auth/token` and `/auth/refresh` routes for an app.
|
|
192
|
+
|
|
193
|
+
`authenticate(username, password)` must return the user's string ID, or
|
|
194
|
+
`(string_id, claims)`, when credentials are valid; otherwise return None.
|
|
195
|
+
"""
|
|
196
|
+
from .application import Router
|
|
197
|
+
|
|
198
|
+
router = Router(prefix="/auth")
|
|
199
|
+
|
|
200
|
+
@router.post("/token")
|
|
201
|
+
async def issue_token(credentials: _Credentials):
|
|
202
|
+
user = authenticate(credentials.username, credentials.password)
|
|
203
|
+
if inspect.isawaitable(user):
|
|
204
|
+
user = await user
|
|
205
|
+
if user is None or user is False:
|
|
206
|
+
raise HTTPException(401, "Invalid username or password")
|
|
207
|
+
if isinstance(user, tuple) and len(user) == 2:
|
|
208
|
+
subject, claims = user
|
|
209
|
+
else:
|
|
210
|
+
subject, claims = user, None
|
|
211
|
+
if not isinstance(subject, str) or not subject:
|
|
212
|
+
raise TypeError("authenticate must return a non-empty string user ID")
|
|
213
|
+
return {
|
|
214
|
+
"access_token": self.create_access_token(subject, claims=claims),
|
|
215
|
+
"refresh_token": self.create_refresh_token(subject, claims=claims),
|
|
216
|
+
"token_type": "bearer",
|
|
217
|
+
"expires_in": self.token_lifetime_seconds,
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
@router.post("/refresh")
|
|
221
|
+
def refresh_token(body: _RefreshRequest):
|
|
222
|
+
try:
|
|
223
|
+
access_token = self.refresh_access_token(body.refresh_token)
|
|
224
|
+
payload = self.decode_token(body.refresh_token, allow_refresh=True)
|
|
225
|
+
except ValueError as exc:
|
|
226
|
+
raise HTTPException(401, "Invalid or expired refresh token") from exc
|
|
227
|
+
claims = {key: value for key, value in payload.items()
|
|
228
|
+
if key not in {"sub", "iat", "exp", "iss", "token_use"}}
|
|
229
|
+
return {
|
|
230
|
+
"access_token": access_token,
|
|
231
|
+
"refresh_token": self.create_refresh_token(payload["sub"], claims=claims),
|
|
232
|
+
"token_type": "bearer",
|
|
233
|
+
"expires_in": self.token_lifetime_seconds,
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return router
|
|
237
|
+
|
|
238
|
+
def decode_token(self, token: str, *, allow_refresh: bool = False) -> dict[str, Any]:
|
|
239
|
+
"""Verify and decode a token, raising ValueError if it is invalid."""
|
|
240
|
+
try:
|
|
241
|
+
header_segment, payload_segment, signature_segment = token.split(".")
|
|
242
|
+
header = json.loads(_decode_segment(header_segment))
|
|
243
|
+
payload = json.loads(_decode_segment(payload_segment))
|
|
244
|
+
signature = _decode_segment(signature_segment)
|
|
245
|
+
if not isinstance(header, dict) or header.get("alg") != self.algorithm:
|
|
246
|
+
raise ValueError("Unsupported token algorithm")
|
|
247
|
+
if not isinstance(payload, dict):
|
|
248
|
+
raise ValueError("Token payload must be an object")
|
|
249
|
+
signing_input = f"{header_segment}.{payload_segment}".encode("ascii")
|
|
250
|
+
expected = hmac.new(self.secret_key, signing_input, hashlib.sha256).digest()
|
|
251
|
+
if not hmac.compare_digest(signature, expected):
|
|
252
|
+
raise ValueError("Invalid token signature")
|
|
253
|
+
expiration = payload.get("exp")
|
|
254
|
+
if isinstance(expiration, bool) or not isinstance(expiration, (int, float)):
|
|
255
|
+
raise ValueError("Token must have a numeric exp claim")
|
|
256
|
+
if expiration <= time.time():
|
|
257
|
+
raise ValueError("Token has expired")
|
|
258
|
+
not_before = payload.get("nbf")
|
|
259
|
+
if not_before is not None:
|
|
260
|
+
if isinstance(not_before, bool) or not isinstance(not_before, (int, float)):
|
|
261
|
+
raise ValueError("Token nbf claim must be numeric")
|
|
262
|
+
if not_before > time.time():
|
|
263
|
+
raise ValueError("Token is not active yet")
|
|
264
|
+
if self.issuer is not None and payload.get("iss") != self.issuer:
|
|
265
|
+
raise ValueError("Invalid token issuer")
|
|
266
|
+
if payload.get("token_use") == "refresh" and not allow_refresh:
|
|
267
|
+
raise ValueError("Refresh token cannot be used as an access token")
|
|
268
|
+
if not isinstance(payload.get("sub"), str) or not payload["sub"]:
|
|
269
|
+
raise ValueError("Token must have a non-empty sub claim")
|
|
270
|
+
return payload
|
|
271
|
+
except (KeyError, TypeError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
272
|
+
raise ValueError("Malformed token") from exc
|
|
273
|
+
|
|
274
|
+
def __call__(self, request: Request) -> dict[str, Any]:
|
|
275
|
+
"""Resolve a verified Bearer token for use with ``Depends``."""
|
|
276
|
+
authorization = request.headers.get("authorization", "")
|
|
277
|
+
scheme, separator, token = authorization.partition(" ")
|
|
278
|
+
if not separator or scheme.lower() != "bearer" or not token.strip():
|
|
279
|
+
raise HTTPException(401, "Bearer token required", headers={"WWW-Authenticate": "Bearer"})
|
|
280
|
+
try:
|
|
281
|
+
return self.decode_token(token.strip())
|
|
282
|
+
except ValueError as exc:
|
|
283
|
+
raise HTTPException(
|
|
284
|
+
401,
|
|
285
|
+
"Invalid or expired bearer token",
|
|
286
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
287
|
+
) from exc
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class _Credentials(BaseModel):
|
|
291
|
+
username: str = Field(min_length=1)
|
|
292
|
+
password: str = Field(min_length=1)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
class _RefreshRequest(BaseModel):
|
|
296
|
+
refresh_token: str = Field(min_length=1)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class APIKeyAuth:
|
|
300
|
+
"""Check a shared API key or resolve a user-specific key from a callback."""
|
|
301
|
+
|
|
302
|
+
def __init__(self, api_key: str | None = None, *, header_name: str = "x-api-key", authenticate=None) -> None:
|
|
303
|
+
if authenticate is None and (not isinstance(api_key, str) or len(api_key.encode("utf-8")) < 32):
|
|
304
|
+
raise ValueError("API key must be at least 32 bytes")
|
|
305
|
+
if not header_name or any(character.isspace() for character in header_name):
|
|
306
|
+
raise ValueError("header_name must be a valid header name")
|
|
307
|
+
self.api_key = api_key.encode("utf-8") if api_key is not None else None
|
|
308
|
+
self.authenticate = authenticate
|
|
309
|
+
self.header_name = header_name.lower()
|
|
310
|
+
|
|
311
|
+
async def __call__(self, request: Request) -> Any:
|
|
312
|
+
supplied = request.headers.get(self.header_name, "")
|
|
313
|
+
if self.authenticate is not None:
|
|
314
|
+
identity = self.authenticate(supplied)
|
|
315
|
+
if inspect.isawaitable(identity):
|
|
316
|
+
identity = await identity
|
|
317
|
+
if identity is not None and identity is not False:
|
|
318
|
+
return identity
|
|
319
|
+
elif supplied and hmac.compare_digest(supplied.encode("utf-8"), self.api_key):
|
|
320
|
+
return {"auth_method": "api_key"}
|
|
321
|
+
raise HTTPException(401, "Invalid or missing API key", headers={"WWW-Authenticate": "ApiKey"})
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class BasicAuth:
|
|
325
|
+
"""Verify HTTP Basic credentials with an app-provided callback."""
|
|
326
|
+
|
|
327
|
+
def __init__(self, authenticate) -> None:
|
|
328
|
+
self.authenticate = authenticate
|
|
329
|
+
|
|
330
|
+
async def __call__(self, request: Request) -> Any:
|
|
331
|
+
authorization = request.headers.get("authorization", "")
|
|
332
|
+
scheme, separator, value = authorization.partition(" ")
|
|
333
|
+
if not separator or scheme.lower() != "basic":
|
|
334
|
+
raise HTTPException(401, "Basic credentials required", headers={"WWW-Authenticate": 'Basic realm="lenApi"'})
|
|
335
|
+
try:
|
|
336
|
+
decoded = base64.b64decode(value, validate=True).decode("utf-8")
|
|
337
|
+
username, password = decoded.split(":", 1)
|
|
338
|
+
except (binascii.Error, UnicodeDecodeError, ValueError) as exc:
|
|
339
|
+
raise HTTPException(401, "Invalid Basic credentials", headers={"WWW-Authenticate": 'Basic realm="lenApi"'}) from exc
|
|
340
|
+
if not username or not password:
|
|
341
|
+
raise HTTPException(401, "Invalid Basic credentials", headers={"WWW-Authenticate": 'Basic realm="lenApi"'})
|
|
342
|
+
user = self.authenticate(username, password)
|
|
343
|
+
if inspect.isawaitable(user):
|
|
344
|
+
user = await user
|
|
345
|
+
if user is None or user is False:
|
|
346
|
+
raise HTTPException(401, "Invalid username or password", headers={"WWW-Authenticate": 'Basic realm="lenApi"'})
|
|
347
|
+
return user
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
class SessionAuth:
|
|
351
|
+
"""Resolve an HttpOnly session cookie with an application-provided store lookup."""
|
|
352
|
+
|
|
353
|
+
def __init__(self, lookup=None, *, store=None, cookie_name: str = "lenapi_session") -> None:
|
|
354
|
+
if lookup is None and store is None:
|
|
355
|
+
raise ValueError("SessionAuth requires a lookup callback or security store")
|
|
356
|
+
self.lookup = lookup
|
|
357
|
+
self.store = store
|
|
358
|
+
self.cookie_name = cookie_name
|
|
359
|
+
|
|
360
|
+
async def __call__(self, request: Request) -> Any:
|
|
361
|
+
session_id = request.cookies.get(self.cookie_name)
|
|
362
|
+
if session_id and self.store is not None:
|
|
363
|
+
serialized = self.store.get(f"session:{session_id}")
|
|
364
|
+
if inspect.isawaitable(serialized):
|
|
365
|
+
serialized = await serialized
|
|
366
|
+
try:
|
|
367
|
+
identity = json.loads(serialized) if serialized is not None else None
|
|
368
|
+
except (TypeError, json.JSONDecodeError):
|
|
369
|
+
identity = None
|
|
370
|
+
else:
|
|
371
|
+
identity = self.lookup(session_id) if session_id and self.lookup is not None else None
|
|
372
|
+
if inspect.isawaitable(identity):
|
|
373
|
+
identity = await identity
|
|
374
|
+
if identity is None or identity is False:
|
|
375
|
+
raise HTTPException(401, "Valid session required")
|
|
376
|
+
return identity
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
class OpaqueBearerAuth:
|
|
380
|
+
"""Resolve an opaque bearer token through an application-provided token store."""
|
|
381
|
+
|
|
382
|
+
def __init__(self, lookup=None, *, store=None) -> None:
|
|
383
|
+
if lookup is None and store is None:
|
|
384
|
+
raise ValueError("OpaqueBearerAuth requires a lookup callback or security store")
|
|
385
|
+
self.lookup = lookup
|
|
386
|
+
self.store = store
|
|
387
|
+
|
|
388
|
+
async def __call__(self, request: Request) -> Any:
|
|
389
|
+
authorization = request.headers.get("authorization", "")
|
|
390
|
+
scheme, separator, token = authorization.partition(" ")
|
|
391
|
+
identity = None
|
|
392
|
+
if separator and scheme.lower() == "bearer" and token.strip():
|
|
393
|
+
if self.store is not None:
|
|
394
|
+
serialized = self.store.get(f"opaque_bearer:{token.strip()}")
|
|
395
|
+
if inspect.isawaitable(serialized):
|
|
396
|
+
serialized = await serialized
|
|
397
|
+
try:
|
|
398
|
+
identity = json.loads(serialized) if serialized is not None else None
|
|
399
|
+
except (TypeError, json.JSONDecodeError):
|
|
400
|
+
identity = None
|
|
401
|
+
else:
|
|
402
|
+
identity = self.lookup(token.strip())
|
|
403
|
+
if inspect.isawaitable(identity):
|
|
404
|
+
identity = await identity
|
|
405
|
+
if identity is None or identity is False:
|
|
406
|
+
raise HTTPException(401, "Valid bearer token required", headers={"WWW-Authenticate": "Bearer"})
|
|
407
|
+
return identity
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
class OIDCAuth(OpaqueBearerAuth):
|
|
411
|
+
"""Validate an OIDC access token through a provider SDK or token introspector."""
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
class WebhookAuth:
|
|
415
|
+
"""Verify a raw-body HMAC webhook signature and optionally deduplicate events."""
|
|
416
|
+
|
|
417
|
+
def __init__(
|
|
418
|
+
self,
|
|
419
|
+
secret: str | bytes,
|
|
420
|
+
*,
|
|
421
|
+
security_store=None,
|
|
422
|
+
provider: str = "default",
|
|
423
|
+
signature_header: str = "x-webhook-signature",
|
|
424
|
+
event_id_header: str | None = "x-webhook-id",
|
|
425
|
+
timestamp_header: str | None = None,
|
|
426
|
+
tolerance_seconds: int = 300,
|
|
427
|
+
deduplication_ttl: int = 7 * 24 * 60 * 60,
|
|
428
|
+
) -> None:
|
|
429
|
+
self.secret = secret.encode("utf-8") if isinstance(secret, str) else secret
|
|
430
|
+
if len(self.secret) < 32:
|
|
431
|
+
raise ValueError("Webhook secret must be at least 32 bytes")
|
|
432
|
+
if tolerance_seconds <= 0 or deduplication_ttl <= 0:
|
|
433
|
+
raise ValueError("Webhook time limits must be positive")
|
|
434
|
+
self.security_store = security_store
|
|
435
|
+
self.provider = provider
|
|
436
|
+
self.signature_header = signature_header.lower()
|
|
437
|
+
self.event_id_header = event_id_header.lower() if event_id_header else None
|
|
438
|
+
self.timestamp_header = timestamp_header.lower() if timestamp_header else None
|
|
439
|
+
self.tolerance_seconds = tolerance_seconds
|
|
440
|
+
self.deduplication_ttl = deduplication_ttl
|
|
441
|
+
|
|
442
|
+
async def __call__(self, request: Request) -> dict[str, str]:
|
|
443
|
+
headers = request.headers
|
|
444
|
+
supplied = headers.get(self.signature_header, "")
|
|
445
|
+
signature = supplied.removeprefix("sha256=").strip()
|
|
446
|
+
body = await request.body()
|
|
447
|
+
signed_content = body
|
|
448
|
+
if self.timestamp_header:
|
|
449
|
+
timestamp = headers.get(self.timestamp_header, "")
|
|
450
|
+
if not timestamp.isascii():
|
|
451
|
+
raise HTTPException(401, "Invalid webhook timestamp")
|
|
452
|
+
try:
|
|
453
|
+
event_time = int(timestamp)
|
|
454
|
+
except ValueError as exc:
|
|
455
|
+
raise HTTPException(401, "Invalid webhook timestamp") from exc
|
|
456
|
+
if abs(time.time() - event_time) > self.tolerance_seconds:
|
|
457
|
+
raise HTTPException(401, "Webhook signature has expired")
|
|
458
|
+
signed_content = timestamp.encode("ascii") + b"." + body
|
|
459
|
+
expected = hmac.new(self.secret, signed_content, hashlib.sha256).hexdigest()
|
|
460
|
+
if not signature or not hmac.compare_digest(signature, expected):
|
|
461
|
+
raise HTTPException(401, "Invalid webhook signature")
|
|
462
|
+
|
|
463
|
+
event_id = headers.get(self.event_id_header, "") if self.event_id_header else ""
|
|
464
|
+
if self.security_store is not None and self.event_id_header:
|
|
465
|
+
if not event_id:
|
|
466
|
+
raise HTTPException(400, "Webhook event ID required")
|
|
467
|
+
duplicate = await self.security_store.claim(
|
|
468
|
+
f"webhook:{self.provider}:{event_id}", self.deduplication_ttl
|
|
469
|
+
)
|
|
470
|
+
if duplicate:
|
|
471
|
+
# Acknowledge retries so providers stop redelivering an event
|
|
472
|
+
# that this application has already accepted.
|
|
473
|
+
raise HTTPException(200, "Webhook event already received")
|
|
474
|
+
return {"auth_method": "webhook", "event_id": event_id}
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def session_auth(lookup=None, *, store=None, cookie_name: str = "lenapi_session") -> SessionAuth:
|
|
478
|
+
"""Create a session-cookie dependency with a callback or shared security store."""
|
|
479
|
+
return SessionAuth(lookup, store=store, cookie_name=cookie_name)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def opaque_bearer_auth(token_lookup=None, *, store=None) -> OpaqueBearerAuth:
|
|
483
|
+
"""Create an opaque bearer dependency with a callback or shared security store."""
|
|
484
|
+
return OpaqueBearerAuth(token_lookup, store=store)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def oidc_auth(token_lookup) -> OIDCAuth:
|
|
488
|
+
"""Create an optional per-route OIDC token authentication dependency."""
|
|
489
|
+
return OIDCAuth(token_lookup)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def webhook_auth(secret: str | bytes, *, security_store=None, **options) -> WebhookAuth:
|
|
493
|
+
"""Create a webhook signature and idempotency dependency."""
|
|
494
|
+
return WebhookAuth(secret, security_store=security_store, **options)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
class CSRFProtect:
|
|
498
|
+
"""Signed double-submit CSRF protection for cookie-authenticated routes."""
|
|
499
|
+
|
|
500
|
+
SAFE_METHODS = {"GET", "HEAD", "OPTIONS", "TRACE"}
|
|
501
|
+
|
|
502
|
+
def __init__(
|
|
503
|
+
self,
|
|
504
|
+
secret_key: str | bytes,
|
|
505
|
+
*,
|
|
506
|
+
cookie_name: str = "lenapi_csrf",
|
|
507
|
+
header_name: str = "x-csrf-token",
|
|
508
|
+
secure: bool = True,
|
|
509
|
+
samesite: str = "lax",
|
|
510
|
+
) -> None:
|
|
511
|
+
self.secret = secret_key.encode("utf-8") if isinstance(secret_key, str) else secret_key
|
|
512
|
+
if len(self.secret) < 32:
|
|
513
|
+
raise ValueError("CSRF secret_key must be at least 32 bytes")
|
|
514
|
+
self.cookie_name = cookie_name
|
|
515
|
+
self.header_name = header_name.lower()
|
|
516
|
+
self.secure = secure
|
|
517
|
+
self.samesite = samesite
|
|
518
|
+
|
|
519
|
+
def generate_token(self) -> str:
|
|
520
|
+
nonce = secrets.token_urlsafe(32)
|
|
521
|
+
signature = hmac.new(self.secret, nonce.encode("ascii"), hashlib.sha256).hexdigest()
|
|
522
|
+
return f"{nonce}.{signature}"
|
|
523
|
+
|
|
524
|
+
def set_cookie(self, response: Response, *, max_age: int = 3600) -> str:
|
|
525
|
+
"""Set a browser-readable CSRF cookie and return its token for clients."""
|
|
526
|
+
token = self.generate_token()
|
|
527
|
+
response.set_cookie(
|
|
528
|
+
self.cookie_name,
|
|
529
|
+
token,
|
|
530
|
+
max_age=max_age,
|
|
531
|
+
secure=self.secure,
|
|
532
|
+
httponly=False,
|
|
533
|
+
samesite=self.samesite,
|
|
534
|
+
path="/",
|
|
535
|
+
)
|
|
536
|
+
return token
|
|
537
|
+
|
|
538
|
+
async def __call__(self, request: Request) -> bool:
|
|
539
|
+
if request.method.upper() in self.SAFE_METHODS:
|
|
540
|
+
return True
|
|
541
|
+
cookie_token = request.cookies.get(self.cookie_name, "")
|
|
542
|
+
header_token = request.headers.get(self.header_name, "")
|
|
543
|
+
nonce, separator, supplied_signature = cookie_token.rpartition(".")
|
|
544
|
+
if not separator or not nonce or not header_token:
|
|
545
|
+
raise HTTPException(403, "CSRF token required")
|
|
546
|
+
expected_signature = hmac.new(self.secret, nonce.encode("ascii"), hashlib.sha256).hexdigest()
|
|
547
|
+
if (
|
|
548
|
+
not hmac.compare_digest(cookie_token, header_token)
|
|
549
|
+
or not hmac.compare_digest(supplied_signature, expected_signature)
|
|
550
|
+
):
|
|
551
|
+
raise HTTPException(403, "Invalid CSRF token")
|
|
552
|
+
return True
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def hmac_request_signature(
|
|
556
|
+
secret: str | bytes, method: str, path: str, timestamp: str, body: bytes, *, nonce: str = ""
|
|
557
|
+
) -> str:
|
|
558
|
+
"""Create a hex HMAC-SHA256 signature for a lenApi signed request."""
|
|
559
|
+
key = secret.encode("utf-8") if isinstance(secret, str) else secret
|
|
560
|
+
body_digest = hashlib.sha256(body).hexdigest()
|
|
561
|
+
message = f"{timestamp}\n{method.upper()}\n{path}\n{body_digest}\n{nonce}".encode("utf-8")
|
|
562
|
+
return hmac.new(key, message, hashlib.sha256).hexdigest()
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
class HMACAuth:
|
|
566
|
+
"""Authenticate timestamped, body-signed requests from trusted clients."""
|
|
567
|
+
|
|
568
|
+
def __init__(self, secret: str | bytes, *, tolerance_seconds: int = 300, nonce_store=None) -> None:
|
|
569
|
+
self.secret = secret.encode("utf-8") if isinstance(secret, str) else secret
|
|
570
|
+
if len(self.secret) < 32:
|
|
571
|
+
raise ValueError("HMAC secret must be at least 32 bytes")
|
|
572
|
+
if tolerance_seconds <= 0:
|
|
573
|
+
raise ValueError("tolerance_seconds must be positive")
|
|
574
|
+
self.tolerance_seconds = tolerance_seconds
|
|
575
|
+
self.nonce_store = nonce_store
|
|
576
|
+
self._seen_nonces: dict[str, int] = {}
|
|
577
|
+
self._nonce_lock = threading.Lock()
|
|
578
|
+
|
|
579
|
+
async def __call__(self, request: Request) -> dict[str, str]:
|
|
580
|
+
headers = request.headers
|
|
581
|
+
timestamp = headers.get("x-lenapi-timestamp", "")
|
|
582
|
+
supplied = headers.get("x-lenapi-signature", "")
|
|
583
|
+
nonce = headers.get("x-lenapi-nonce", "")
|
|
584
|
+
if not nonce or len(nonce) > 128:
|
|
585
|
+
raise HTTPException(401, "Invalid signed request", headers={"WWW-Authenticate": "HMAC"})
|
|
586
|
+
try:
|
|
587
|
+
request_time = int(timestamp)
|
|
588
|
+
except ValueError as exc:
|
|
589
|
+
raise HTTPException(401, "Invalid signed request", headers={"WWW-Authenticate": "HMAC"}) from exc
|
|
590
|
+
if abs(time.time() - request_time) > self.tolerance_seconds:
|
|
591
|
+
raise HTTPException(401, "Signed request has expired", headers={"WWW-Authenticate": "HMAC"})
|
|
592
|
+
body = await request.body()
|
|
593
|
+
query = request.scope.get("query_string", b"").decode("latin-1")
|
|
594
|
+
path = request.path + (f"?{query}" if query else "")
|
|
595
|
+
expected = hmac_request_signature(self.secret, request.method, path, timestamp, body, nonce=nonce)
|
|
596
|
+
if not hmac.compare_digest(supplied, expected):
|
|
597
|
+
raise HTTPException(401, "Invalid signed request", headers={"WWW-Authenticate": "HMAC"})
|
|
598
|
+
if self.nonce_store is not None:
|
|
599
|
+
used = self.nonce_store.claim(f"hmac_nonce:{nonce}", self.tolerance_seconds)
|
|
600
|
+
if inspect.isawaitable(used):
|
|
601
|
+
used = await used
|
|
602
|
+
if used:
|
|
603
|
+
raise HTTPException(401, "Signed request has already been used", headers={"WWW-Authenticate": "HMAC"})
|
|
604
|
+
else:
|
|
605
|
+
with self._nonce_lock:
|
|
606
|
+
self._seen_nonces = {
|
|
607
|
+
seen_nonce: seen_at for seen_nonce, seen_at in self._seen_nonces.items()
|
|
608
|
+
if request_time - seen_at <= self.tolerance_seconds
|
|
609
|
+
}
|
|
610
|
+
if nonce in self._seen_nonces:
|
|
611
|
+
raise HTTPException(401, "Signed request has already been used", headers={"WWW-Authenticate": "HMAC"})
|
|
612
|
+
self._seen_nonces[nonce] = request_time
|
|
613
|
+
return {"auth_method": "hmac"}
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def hmac_auth(secret: str | bytes, *, tolerance_seconds: int = 300, nonce_store=None) -> HMACAuth:
|
|
617
|
+
"""Create an optional per-route HMAC request authentication dependency."""
|
|
618
|
+
return HMACAuth(secret, tolerance_seconds=tolerance_seconds, nonce_store=nonce_store)
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def create_authenticator(
|
|
622
|
+
settings, *, authenticate=None, authenticate_api_key=None, session_lookup=None,
|
|
623
|
+
token_lookup=None, oidc_token_lookup=None, security_store=None,
|
|
624
|
+
):
|
|
625
|
+
"""Create the authentication dependency selected in a project settings module."""
|
|
626
|
+
mode = getattr(settings, "AUTHENTICATION", "jwt").lower()
|
|
627
|
+
if mode == "jwt":
|
|
628
|
+
token = settings.TOKEN
|
|
629
|
+
return JWTAuth(
|
|
630
|
+
token["SECRET_KEY"],
|
|
631
|
+
token_lifetime_seconds=token.get("LIFETIME_SECONDS", 3600),
|
|
632
|
+
refresh_token_lifetime_seconds=token.get("REFRESH_LIFETIME_SECONDS", 60 * 60 * 24 * 30),
|
|
633
|
+
issuer=token.get("ISSUER"),
|
|
634
|
+
)
|
|
635
|
+
if mode == "api_key":
|
|
636
|
+
return APIKeyAuth(getattr(settings, "API_KEY", None), authenticate=authenticate_api_key)
|
|
637
|
+
if mode == "basic_login":
|
|
638
|
+
if authenticate is None:
|
|
639
|
+
raise ValueError("basic_login requires an authenticate(username, password) callback")
|
|
640
|
+
return BasicAuth(authenticate)
|
|
641
|
+
if mode == "session":
|
|
642
|
+
session = getattr(settings, "SESSION", {})
|
|
643
|
+
lookup = session_lookup or getattr(settings, "SESSION_LOOKUP", None)
|
|
644
|
+
if lookup is None and security_store is None:
|
|
645
|
+
raise ValueError("session authentication requires security_store or session_lookup")
|
|
646
|
+
return SessionAuth(
|
|
647
|
+
lookup, store=security_store if lookup is None else None,
|
|
648
|
+
cookie_name=session.get("COOKIE_NAME", "lenapi_session"),
|
|
649
|
+
)
|
|
650
|
+
if mode == "opaque_bearer":
|
|
651
|
+
lookup = token_lookup or getattr(settings, "TOKEN_LOOKUP", None)
|
|
652
|
+
if lookup is None and security_store is None:
|
|
653
|
+
raise ValueError("opaque_bearer authentication requires security_store or token_lookup")
|
|
654
|
+
return OpaqueBearerAuth(lookup, store=security_store if lookup is None else None)
|
|
655
|
+
if mode == "hmac":
|
|
656
|
+
secret = getattr(settings, "HMAC_SECRET", None)
|
|
657
|
+
if not secret:
|
|
658
|
+
raise ValueError("hmac authentication requires HMAC_SECRET")
|
|
659
|
+
return HMACAuth(secret, tolerance_seconds=getattr(settings, "HMAC_TOLERANCE_SECONDS", 300))
|
|
660
|
+
if mode in {"oidc", "oauth2_oidc"}:
|
|
661
|
+
lookup = oidc_token_lookup or getattr(settings, "OIDC_TOKEN_LOOKUP", None)
|
|
662
|
+
if lookup is None:
|
|
663
|
+
raise ValueError("oidc authentication requires oidc_token_lookup")
|
|
664
|
+
return OIDCAuth(lookup)
|
|
665
|
+
raise ValueError("Unknown AUTHENTICATION mode")
|