veil-api-kit 0.10.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.
- veil_api_kit-0.10.0.dist-info/METADATA +103 -0
- veil_api_kit-0.10.0.dist-info/RECORD +17 -0
- veil_api_kit-0.10.0.dist-info/WHEEL +5 -0
- veil_api_kit-0.10.0.dist-info/top_level.txt +1 -0
- veil_kit/__init__.py +21 -0
- veil_kit/cookies.py +145 -0
- veil_kit/cors.py +36 -0
- veil_kit/di.py +362 -0
- veil_kit/graphql.py +215 -0
- veil_kit/grpc.py +525 -0
- veil_kit/py.typed +0 -0
- veil_kit/redirects.py +33 -0
- veil_kit/security.py +377 -0
- veil_kit/sessions.py +243 -0
- veil_kit/staticfiles.py +36 -0
- veil_kit/tasks.py +199 -0
- veil_kit/testing.py +24 -0
veil_kit/security.py
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""Authentication with the classes and behaviour of ``fastapi.security``.
|
|
2
|
+
|
|
3
|
+
Each scheme is a dependency (``Depends(scheme)`` or ``Security(scheme, scopes=[...])``) that reads the credentials
|
|
4
|
+
from the request and appears in OpenAPI ``securitySchemes``. With ``auto_error=False`` a missing credential gives
|
|
5
|
+
``None`` instead of a 401.
|
|
6
|
+
|
|
7
|
+
.. code-block:: python
|
|
8
|
+
|
|
9
|
+
from veil_kit.security import OAuth2PasswordBearer, JWT
|
|
10
|
+
|
|
11
|
+
oauth2 = OAuth2PasswordBearer(tokenUrl="/token")
|
|
12
|
+
tokens = JWT(secret="change-me", algorithms=["HS256"]) # needs "veil-api-kit[jwt]"
|
|
13
|
+
|
|
14
|
+
def current_user(token: str = veil.Depends(oauth2)):
|
|
15
|
+
return tokens.decode(token)["sub"]
|
|
16
|
+
|
|
17
|
+
@app.get("/me")
|
|
18
|
+
def me(user: str = veil.Depends(current_user)):
|
|
19
|
+
return {"user": user}
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
# F-268: fastapi.security と同じ名前・同じ振る舞い(401 と WWW-Authenticate)
|
|
23
|
+
|
|
24
|
+
import base64
|
|
25
|
+
import binascii
|
|
26
|
+
import hmac
|
|
27
|
+
|
|
28
|
+
import veil
|
|
29
|
+
from veil import HTTPException
|
|
30
|
+
|
|
31
|
+
from .cookies import _request_cookies
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"HTTPBasic",
|
|
35
|
+
"HTTPBasicCredentials",
|
|
36
|
+
"HTTPBearer",
|
|
37
|
+
"HTTPDigest",
|
|
38
|
+
"HTTPAuthorizationCredentials",
|
|
39
|
+
"APIKeyHeader",
|
|
40
|
+
"APIKeyCookie",
|
|
41
|
+
"APIKeyQuery",
|
|
42
|
+
"OAuth2PasswordBearer",
|
|
43
|
+
"OAuth2AuthorizationCodeBearer",
|
|
44
|
+
"OAuth2PasswordRequestForm",
|
|
45
|
+
"OAuth2PasswordRequestFormStrict",
|
|
46
|
+
"SecurityScopes",
|
|
47
|
+
"JWT",
|
|
48
|
+
"compare_digest",
|
|
49
|
+
"requires_scopes",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
SecurityScopes = veil.SecurityScopes
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def compare_digest(a, b):
|
|
56
|
+
"""Compare two strings in constant time."""
|
|
57
|
+
return hmac.compare_digest(a.encode() if isinstance(a, str) else a, b.encode() if isinstance(b, str) else b)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _unauthorized(detail="Not authenticated", scheme="Bearer"):
|
|
61
|
+
return HTTPException(401, detail, {"WWW-Authenticate": scheme})
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _Scheme:
|
|
65
|
+
# 要求ごとにスレッドを跨がず、その場で呼んでよい(軽い)
|
|
66
|
+
_veil_inline = True
|
|
67
|
+
scheme_name = None
|
|
68
|
+
|
|
69
|
+
def openapi_scheme(self):
|
|
70
|
+
"""``(name, Security Scheme Object)`` for OpenAPI."""
|
|
71
|
+
return self.scheme_name or type(self).__name__, self.model
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class HTTPAuthorizationCredentials:
|
|
75
|
+
"""``scheme`` and ``credentials`` of an ``Authorization`` header."""
|
|
76
|
+
|
|
77
|
+
def __init__(self, scheme, credentials):
|
|
78
|
+
self.scheme = scheme
|
|
79
|
+
self.credentials = credentials
|
|
80
|
+
|
|
81
|
+
def __repr__(self):
|
|
82
|
+
return "HTTPAuthorizationCredentials(scheme=%r)" % (self.scheme,)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class HTTPBasicCredentials:
|
|
86
|
+
"""``username`` and ``password`` of HTTP Basic authentication."""
|
|
87
|
+
|
|
88
|
+
def __init__(self, username, password):
|
|
89
|
+
self.username = username
|
|
90
|
+
self.password = password
|
|
91
|
+
|
|
92
|
+
def __repr__(self):
|
|
93
|
+
return "HTTPBasicCredentials(username=%r)" % (self.username,)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _authorization(request):
|
|
97
|
+
v = request.headers.get("authorization")
|
|
98
|
+
if not v:
|
|
99
|
+
return None, None
|
|
100
|
+
scheme, _, cred = v.partition(" ")
|
|
101
|
+
return scheme, cred.strip()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class HTTPBasic(_Scheme):
|
|
105
|
+
"""HTTP Basic authentication; gives :class:`HTTPBasicCredentials`."""
|
|
106
|
+
|
|
107
|
+
def __init__(self, *, scheme_name=None, realm=None, description=None, auto_error=True):
|
|
108
|
+
"""``realm`` goes to ``WWW-Authenticate``."""
|
|
109
|
+
self.scheme_name = scheme_name
|
|
110
|
+
self.realm = realm
|
|
111
|
+
self.auto_error = auto_error
|
|
112
|
+
self.model = {"type": "http", "scheme": "basic"}
|
|
113
|
+
if description:
|
|
114
|
+
self.model["description"] = description
|
|
115
|
+
|
|
116
|
+
def __call__(self, request: veil.Request):
|
|
117
|
+
"""Read the credentials (401 with ``WWW-Authenticate: Basic`` when absent or malformed)."""
|
|
118
|
+
challenge = 'Basic realm="%s"' % self.realm if self.realm else "Basic"
|
|
119
|
+
scheme, cred = _authorization(request)
|
|
120
|
+
if not scheme or scheme.lower() != "basic":
|
|
121
|
+
if self.auto_error:
|
|
122
|
+
raise _unauthorized(scheme=challenge)
|
|
123
|
+
return None
|
|
124
|
+
try:
|
|
125
|
+
user, sep, pw = base64.b64decode(cred).decode("utf-8").partition(":")
|
|
126
|
+
except (ValueError, UnicodeDecodeError, binascii.Error):
|
|
127
|
+
raise _unauthorized("Invalid authentication credentials", challenge)
|
|
128
|
+
if not sep:
|
|
129
|
+
raise _unauthorized("Invalid authentication credentials", challenge)
|
|
130
|
+
return HTTPBasicCredentials(user, pw)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class HTTPBearer(_Scheme):
|
|
134
|
+
"""``Authorization: Bearer <token>``; gives :class:`HTTPAuthorizationCredentials`."""
|
|
135
|
+
|
|
136
|
+
def __init__(self, *, bearerFormat=None, scheme_name=None, description=None, auto_error=True): # noqa: N803
|
|
137
|
+
"""``bearerFormat`` (such as ``"JWT"``) goes to OpenAPI."""
|
|
138
|
+
self.scheme_name = scheme_name
|
|
139
|
+
self.auto_error = auto_error
|
|
140
|
+
self.model = {"type": "http", "scheme": "bearer"}
|
|
141
|
+
if bearerFormat:
|
|
142
|
+
self.model["bearerFormat"] = bearerFormat
|
|
143
|
+
if description:
|
|
144
|
+
self.model["description"] = description
|
|
145
|
+
|
|
146
|
+
def __call__(self, request: veil.Request):
|
|
147
|
+
"""Read the token (401 with ``WWW-Authenticate: Bearer`` when absent)."""
|
|
148
|
+
scheme, cred = _authorization(request)
|
|
149
|
+
if not scheme or not cred or scheme.lower() != "bearer":
|
|
150
|
+
if self.auto_error:
|
|
151
|
+
raise _unauthorized()
|
|
152
|
+
return None
|
|
153
|
+
return HTTPAuthorizationCredentials(scheme, cred)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class HTTPDigest(_Scheme):
|
|
157
|
+
"""``Authorization: Digest ...`` (the header is passed through; checking it is up to you)."""
|
|
158
|
+
|
|
159
|
+
def __init__(self, *, scheme_name=None, description=None, auto_error=True):
|
|
160
|
+
"""Declare the scheme."""
|
|
161
|
+
self.scheme_name = scheme_name
|
|
162
|
+
self.auto_error = auto_error
|
|
163
|
+
self.model = {"type": "http", "scheme": "digest"}
|
|
164
|
+
if description:
|
|
165
|
+
self.model["description"] = description
|
|
166
|
+
|
|
167
|
+
def __call__(self, request: veil.Request):
|
|
168
|
+
"""Read the header (401 when absent)."""
|
|
169
|
+
scheme, cred = _authorization(request)
|
|
170
|
+
if not scheme or scheme.lower() != "digest":
|
|
171
|
+
if self.auto_error:
|
|
172
|
+
raise _unauthorized(scheme="Digest")
|
|
173
|
+
return None
|
|
174
|
+
return HTTPAuthorizationCredentials(scheme, cred)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class _APIKey(_Scheme):
|
|
178
|
+
where = None
|
|
179
|
+
|
|
180
|
+
def __init__(self, *, name, scheme_name=None, description=None, auto_error=True):
|
|
181
|
+
self.name = name
|
|
182
|
+
self.scheme_name = scheme_name
|
|
183
|
+
self.auto_error = auto_error
|
|
184
|
+
self.model = {"type": "apiKey", "in": self.where, "name": name}
|
|
185
|
+
if description:
|
|
186
|
+
self.model["description"] = description
|
|
187
|
+
|
|
188
|
+
def _value(self, request):
|
|
189
|
+
raise NotImplementedError
|
|
190
|
+
|
|
191
|
+
def __call__(self, request: veil.Request):
|
|
192
|
+
"""The key (401 when absent)."""
|
|
193
|
+
v = self._value(request)
|
|
194
|
+
if not v:
|
|
195
|
+
if self.auto_error:
|
|
196
|
+
raise _unauthorized(scheme="APIKey")
|
|
197
|
+
return None
|
|
198
|
+
return v
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class APIKeyHeader(_APIKey):
|
|
202
|
+
"""An API key in a header (``name`` is the header name)."""
|
|
203
|
+
|
|
204
|
+
where = "header"
|
|
205
|
+
|
|
206
|
+
def _value(self, request):
|
|
207
|
+
return request.headers.get(self.name)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class APIKeyCookie(_APIKey):
|
|
211
|
+
"""An API key in a cookie."""
|
|
212
|
+
|
|
213
|
+
where = "cookie"
|
|
214
|
+
|
|
215
|
+
def _value(self, request):
|
|
216
|
+
return _request_cookies(request).get(self.name)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class APIKeyQuery(_APIKey):
|
|
220
|
+
"""An API key in the query string."""
|
|
221
|
+
|
|
222
|
+
where = "query"
|
|
223
|
+
|
|
224
|
+
def _value(self, request):
|
|
225
|
+
return request.query_params.get(self.name)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class OAuth2PasswordBearer(_Scheme):
|
|
229
|
+
"""OAuth2 password flow: reads the bearer token; ``tokenUrl`` is where clients get it (for OpenAPI)."""
|
|
230
|
+
|
|
231
|
+
def __init__(self, tokenUrl, scheme_name=None, scopes=None, description=None, auto_error=True, # noqa: N803
|
|
232
|
+
refreshUrl=None): # noqa: N803
|
|
233
|
+
"""``scopes`` maps scope names to descriptions."""
|
|
234
|
+
self.scheme_name = scheme_name
|
|
235
|
+
self.auto_error = auto_error
|
|
236
|
+
flow = {"tokenUrl": tokenUrl, "scopes": dict(scopes or {})}
|
|
237
|
+
if refreshUrl:
|
|
238
|
+
flow["refreshUrl"] = refreshUrl
|
|
239
|
+
self.model = {"type": "oauth2", "flows": {"password": flow}}
|
|
240
|
+
if description:
|
|
241
|
+
self.model["description"] = description
|
|
242
|
+
|
|
243
|
+
def __call__(self, request: veil.Request):
|
|
244
|
+
"""The token (``str``; 401 with ``WWW-Authenticate: Bearer`` when absent)."""
|
|
245
|
+
scheme, cred = _authorization(request)
|
|
246
|
+
if not scheme or not cred or scheme.lower() != "bearer":
|
|
247
|
+
if self.auto_error:
|
|
248
|
+
raise _unauthorized()
|
|
249
|
+
return None
|
|
250
|
+
return cred
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class OAuth2AuthorizationCodeBearer(OAuth2PasswordBearer):
|
|
254
|
+
"""OAuth2 authorization code flow (``authorizationUrl`` and ``tokenUrl`` for OpenAPI)."""
|
|
255
|
+
|
|
256
|
+
def __init__(self, authorizationUrl, tokenUrl, refreshUrl=None, scheme_name=None, scopes=None, # noqa: N803
|
|
257
|
+
description=None, auto_error=True):
|
|
258
|
+
"""Declare the flow."""
|
|
259
|
+
super().__init__(tokenUrl, scheme_name, scopes, description, auto_error)
|
|
260
|
+
flow = {"authorizationUrl": authorizationUrl, "tokenUrl": tokenUrl, "scopes": dict(scopes or {})}
|
|
261
|
+
if refreshUrl:
|
|
262
|
+
flow["refreshUrl"] = refreshUrl
|
|
263
|
+
self.model = {"type": "oauth2", "flows": {"authorizationCode": flow}}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class OAuth2PasswordRequestForm:
|
|
267
|
+
"""The form of the OAuth2 password flow (``username``, ``password``, ``scope`` → ``scopes``, ``grant_type``,
|
|
268
|
+
``client_id``, ``client_secret``); use as ``form: OAuth2PasswordRequestForm = Depends()``."""
|
|
269
|
+
|
|
270
|
+
_veil_inline = True
|
|
271
|
+
|
|
272
|
+
def __init__(
|
|
273
|
+
self,
|
|
274
|
+
grant_type: str = veil.Form(None),
|
|
275
|
+
username: str = veil.Form(),
|
|
276
|
+
password: str = veil.Form(),
|
|
277
|
+
scope: str = veil.Form(""),
|
|
278
|
+
client_id: str = veil.Form(None),
|
|
279
|
+
client_secret: str = veil.Form(None),
|
|
280
|
+
):
|
|
281
|
+
self.grant_type = grant_type
|
|
282
|
+
self.username = username
|
|
283
|
+
self.password = password
|
|
284
|
+
self.scopes = scope.split()
|
|
285
|
+
self.client_id = client_id
|
|
286
|
+
self.client_secret = client_secret
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class OAuth2PasswordRequestFormStrict(OAuth2PasswordRequestForm):
|
|
290
|
+
"""Like :class:`OAuth2PasswordRequestForm`, with ``grant_type`` required to be ``"password"``."""
|
|
291
|
+
|
|
292
|
+
def __init__(
|
|
293
|
+
self,
|
|
294
|
+
grant_type: str = veil.Form(),
|
|
295
|
+
username: str = veil.Form(),
|
|
296
|
+
password: str = veil.Form(),
|
|
297
|
+
scope: str = veil.Form(""),
|
|
298
|
+
client_id: str = veil.Form(None),
|
|
299
|
+
client_secret: str = veil.Form(None),
|
|
300
|
+
):
|
|
301
|
+
if grant_type != "password":
|
|
302
|
+
raise HTTPException(422, "grant_type must be 'password'")
|
|
303
|
+
super().__init__(grant_type, username, password, scope, client_id, client_secret)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def requires_scopes(scheme, verify):
|
|
307
|
+
"""A dependency checking the scopes of a :class:`veil.Security` declaration.
|
|
308
|
+
|
|
309
|
+
``verify(token)`` returns the scopes the credential grants (an iterable) or raises; the dependency answers 401
|
|
310
|
+
when one of the required scopes is missing, with the scopes in ``WWW-Authenticate``.
|
|
311
|
+
"""
|
|
312
|
+
|
|
313
|
+
def check(security_scopes: veil.SecurityScopes, token=veil.Depends(scheme)):
|
|
314
|
+
granted = set(verify(token) or ())
|
|
315
|
+
missing = [s for s in security_scopes.scopes if s not in granted]
|
|
316
|
+
if missing:
|
|
317
|
+
raise HTTPException(401, "Not enough permissions",
|
|
318
|
+
{"WWW-Authenticate": 'Bearer scope="%s"' % security_scopes.scope_str})
|
|
319
|
+
return token
|
|
320
|
+
|
|
321
|
+
check._veil_inline = True
|
|
322
|
+
return check
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
class JWT:
|
|
326
|
+
"""Issue and verify JSON Web Tokens with PyJWT (``pip install "veil-api-kit[jwt]"``).
|
|
327
|
+
|
|
328
|
+
``key`` is the secret (HMAC) or the public key (RSA, EC, EdDSA) used to verify; ``signing_key`` signs (defaults to
|
|
329
|
+
``key``). ``audience``, ``issuer`` and ``leeway`` are checked on :meth:`decode`. As in PyJWT, a token with an
|
|
330
|
+
``aud`` claim is rejected when no ``audience`` is given (it was issued for another service);
|
|
331
|
+
``verify_audience=False`` turns the ``aud`` check off.
|
|
332
|
+
"""
|
|
333
|
+
|
|
334
|
+
# B-201: aud の検査は PyJWT の既定どおり(audience 無しで aud 付きのトークンは拒否)。外すときは明示する
|
|
335
|
+
def __init__(self, key=None, algorithms=("HS256",), *, secret=None, signing_key=None, audience=None, issuer=None,
|
|
336
|
+
leeway=0, jwks_url=None, verify_audience=True):
|
|
337
|
+
"""Create a verifier (``jwks_url`` fetches the keys from a JWK Set by ``kid``)."""
|
|
338
|
+
import jwt # noqa: PLC0415
|
|
339
|
+
|
|
340
|
+
self._jwt = jwt
|
|
341
|
+
self.key = key if key is not None else secret
|
|
342
|
+
self.signing_key = signing_key if signing_key is not None else self.key
|
|
343
|
+
self.algorithms = list(algorithms)
|
|
344
|
+
self.audience = audience
|
|
345
|
+
self.issuer = issuer
|
|
346
|
+
self.leeway = leeway
|
|
347
|
+
self.verify_audience = verify_audience
|
|
348
|
+
self._jwks = jwt.PyJWKClient(jwks_url) if jwks_url else None
|
|
349
|
+
|
|
350
|
+
def encode(self, claims, algorithm=None, headers=None):
|
|
351
|
+
"""Sign ``claims`` (a dict; ``exp`` may be a ``datetime``)."""
|
|
352
|
+
return self._jwt.encode(dict(claims), self.signing_key, algorithm=algorithm or self.algorithms[0],
|
|
353
|
+
headers=headers)
|
|
354
|
+
|
|
355
|
+
def decode(self, token, *, verify_exp=True):
|
|
356
|
+
"""Verify ``token`` and return its claims; raise ``HTTPException(401)`` when it is not valid."""
|
|
357
|
+
jwt = self._jwt
|
|
358
|
+
try:
|
|
359
|
+
key = self._jwks.get_signing_key_from_jwt(token).key if self._jwks is not None else self.key
|
|
360
|
+
return jwt.decode(token, key, algorithms=self.algorithms, audience=self.audience, issuer=self.issuer,
|
|
361
|
+
leeway=self.leeway, options={"verify_exp": verify_exp,
|
|
362
|
+
"verify_aud": self.verify_audience})
|
|
363
|
+
except jwt.PyJWTError as e:
|
|
364
|
+
raise HTTPException(401, "Could not validate credentials: %s" % e,
|
|
365
|
+
{"WWW-Authenticate": 'Bearer error="invalid_token"'})
|
|
366
|
+
|
|
367
|
+
def dependency(self, scheme=None):
|
|
368
|
+
"""A dependency returning the verified claims of the bearer token (``Depends(tokens.dependency())``)."""
|
|
369
|
+
scheme = scheme or HTTPBearer(bearerFormat="JWT")
|
|
370
|
+
me = self
|
|
371
|
+
|
|
372
|
+
def claims(credentials=veil.Depends(scheme)):
|
|
373
|
+
token = credentials.credentials if isinstance(credentials, HTTPAuthorizationCredentials) else credentials
|
|
374
|
+
return me.decode(token)
|
|
375
|
+
|
|
376
|
+
claims._veil_inline = True
|
|
377
|
+
return claims
|
veil_kit/sessions.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Sessions.
|
|
2
|
+
|
|
3
|
+
Without a store the session is a signed cookie in the format of Starlette's ``SessionMiddleware`` (base64 JSON signed
|
|
4
|
+
with itsdangerous' ``TimestampSigner``), so both can read each other's cookies. With a store (:class:`MemoryStore`,
|
|
5
|
+
:class:`RedisStore` or your own :class:`SessionStore`) the cookie holds a signed random id and the data stays on the
|
|
6
|
+
server.
|
|
7
|
+
|
|
8
|
+
A session is a dependency (veil has no ASGI middleware for native routes):
|
|
9
|
+
|
|
10
|
+
.. code-block:: python
|
|
11
|
+
|
|
12
|
+
from veil_kit.sessions import Session, Sessions
|
|
13
|
+
|
|
14
|
+
sessions = Sessions(secret_key="change-me")
|
|
15
|
+
|
|
16
|
+
@app.get("/count")
|
|
17
|
+
def count(session: Session = veil.Depends(sessions)):
|
|
18
|
+
session["n"] = session.get("n", 0) + 1
|
|
19
|
+
return {"n": session["n"]}
|
|
20
|
+
|
|
21
|
+
The cookie is written only when the session changed (``Set-Cookie`` on the response of that request).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
# F-268: Starlette の SessionMiddleware と同じ Cookie の形式。依存(Depends)として使う。
|
|
25
|
+
|
|
26
|
+
import base64
|
|
27
|
+
import json
|
|
28
|
+
import secrets
|
|
29
|
+
import threading
|
|
30
|
+
import time
|
|
31
|
+
|
|
32
|
+
import veil
|
|
33
|
+
|
|
34
|
+
from .cookies import BadSignature, Signer, _request_cookies
|
|
35
|
+
|
|
36
|
+
__all__ = ["Session", "Sessions", "SessionStore", "MemoryStore", "RedisStore"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Session(dict):
|
|
40
|
+
"""The data of one session (a ``dict``). ``clear()`` empties it; :meth:`regenerate` gives it a new id."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, data=None, sid=None):
|
|
43
|
+
super().__init__(data or {})
|
|
44
|
+
self.sid = sid
|
|
45
|
+
self.modified = False
|
|
46
|
+
self.new_id = False
|
|
47
|
+
|
|
48
|
+
def _touch(self):
|
|
49
|
+
self.modified = True
|
|
50
|
+
|
|
51
|
+
def __setitem__(self, k, v):
|
|
52
|
+
super().__setitem__(k, v)
|
|
53
|
+
self._touch()
|
|
54
|
+
|
|
55
|
+
def __delitem__(self, k):
|
|
56
|
+
super().__delitem__(k)
|
|
57
|
+
self._touch()
|
|
58
|
+
|
|
59
|
+
def clear(self):
|
|
60
|
+
"""Remove every key (the cookie is deleted at the end of the request)."""
|
|
61
|
+
super().clear()
|
|
62
|
+
self._touch()
|
|
63
|
+
|
|
64
|
+
def pop(self, *a):
|
|
65
|
+
"""Like ``dict.pop``."""
|
|
66
|
+
r = super().pop(*a)
|
|
67
|
+
self._touch()
|
|
68
|
+
return r
|
|
69
|
+
|
|
70
|
+
def popitem(self):
|
|
71
|
+
"""Like ``dict.popitem``."""
|
|
72
|
+
r = super().popitem()
|
|
73
|
+
self._touch()
|
|
74
|
+
return r
|
|
75
|
+
|
|
76
|
+
def setdefault(self, k, default=None):
|
|
77
|
+
"""Like ``dict.setdefault``."""
|
|
78
|
+
if k not in self:
|
|
79
|
+
self._touch()
|
|
80
|
+
return super().setdefault(k, default)
|
|
81
|
+
|
|
82
|
+
def update(self, *a, **kw):
|
|
83
|
+
"""Like ``dict.update``."""
|
|
84
|
+
super().update(*a, **kw)
|
|
85
|
+
self._touch()
|
|
86
|
+
|
|
87
|
+
def regenerate(self):
|
|
88
|
+
"""Give the session a new id (after a login; only with a store)."""
|
|
89
|
+
self.new_id = True
|
|
90
|
+
self._touch()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class SessionStore:
|
|
94
|
+
"""Server-side storage of session data (implement ``load``, ``save`` and ``delete``; they may be called from
|
|
95
|
+
several threads)."""
|
|
96
|
+
|
|
97
|
+
def load(self, sid):
|
|
98
|
+
"""Return the data of ``sid`` (a dict) or ``None``."""
|
|
99
|
+
raise NotImplementedError
|
|
100
|
+
|
|
101
|
+
def save(self, sid, data, max_age):
|
|
102
|
+
"""Store ``data`` for ``sid`` for ``max_age`` seconds."""
|
|
103
|
+
raise NotImplementedError
|
|
104
|
+
|
|
105
|
+
def delete(self, sid):
|
|
106
|
+
"""Forget ``sid``."""
|
|
107
|
+
raise NotImplementedError
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class MemoryStore(SessionStore):
|
|
111
|
+
"""Sessions in the process memory (one process; lost on restart)."""
|
|
112
|
+
|
|
113
|
+
def __init__(self):
|
|
114
|
+
"""Create an empty store."""
|
|
115
|
+
self._data = {}
|
|
116
|
+
self._lock = threading.Lock()
|
|
117
|
+
|
|
118
|
+
def load(self, sid):
|
|
119
|
+
"""Return the data of ``sid`` unless it expired."""
|
|
120
|
+
with self._lock:
|
|
121
|
+
v = self._data.get(sid)
|
|
122
|
+
if v is None:
|
|
123
|
+
return None
|
|
124
|
+
data, until = v
|
|
125
|
+
if until is not None and until < time.time():
|
|
126
|
+
del self._data[sid]
|
|
127
|
+
return None
|
|
128
|
+
return dict(data)
|
|
129
|
+
|
|
130
|
+
def save(self, sid, data, max_age):
|
|
131
|
+
"""Store a copy of ``data``."""
|
|
132
|
+
with self._lock:
|
|
133
|
+
self._data[sid] = (dict(data), time.time() + max_age if max_age else None)
|
|
134
|
+
if len(self._data) > 1024 and len(self._data) % 1024 == 0:
|
|
135
|
+
now = time.time()
|
|
136
|
+
for k in [k for k, (_, u) in self._data.items() if u is not None and u < now]:
|
|
137
|
+
del self._data[k]
|
|
138
|
+
|
|
139
|
+
def delete(self, sid):
|
|
140
|
+
"""Forget ``sid``."""
|
|
141
|
+
with self._lock:
|
|
142
|
+
self._data.pop(sid, None)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class RedisStore(SessionStore):
|
|
146
|
+
"""Sessions in Redis (``pip install "veil-api-kit[redis]"``); ``client`` is a ``redis.Redis``."""
|
|
147
|
+
|
|
148
|
+
def __init__(self, client=None, url="redis://localhost:6379/0", prefix="veil:session:"):
|
|
149
|
+
"""Use ``client``, or connect to ``url``."""
|
|
150
|
+
if client is None:
|
|
151
|
+
import redis # noqa: PLC0415
|
|
152
|
+
|
|
153
|
+
client = redis.Redis.from_url(url)
|
|
154
|
+
self.client = client
|
|
155
|
+
self.prefix = prefix
|
|
156
|
+
|
|
157
|
+
def load(self, sid):
|
|
158
|
+
"""Return the data of ``sid``."""
|
|
159
|
+
raw = self.client.get(self.prefix + sid)
|
|
160
|
+
return None if raw is None else json.loads(raw)
|
|
161
|
+
|
|
162
|
+
def save(self, sid, data, max_age):
|
|
163
|
+
"""Store ``data`` with an expiry of ``max_age`` seconds."""
|
|
164
|
+
self.client.set(self.prefix + sid, json.dumps(data), ex=int(max_age) if max_age else None)
|
|
165
|
+
|
|
166
|
+
def delete(self, sid):
|
|
167
|
+
"""Forget ``sid``."""
|
|
168
|
+
self.client.delete(self.prefix + sid)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class Sessions:
|
|
172
|
+
"""The session dependency: ``session: Session = Depends(sessions)``.
|
|
173
|
+
|
|
174
|
+
Arguments are those of Starlette's ``SessionMiddleware``: ``secret_key``, ``session_cookie`` (``"session"``),
|
|
175
|
+
``max_age`` (14 days; ``None`` makes a browser-session cookie), ``path``, ``same_site`` (``"lax"``),
|
|
176
|
+
``https_only`` (adds ``Secure``) and ``domain``; plus ``store`` for server-side data.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
_veil_inline = True
|
|
180
|
+
|
|
181
|
+
def __init__(self, secret_key, session_cookie="session", max_age=14 * 24 * 60 * 60, path="/", same_site="lax",
|
|
182
|
+
https_only=False, domain=None, store=None):
|
|
183
|
+
"""Create the dependency (see the class documentation)."""
|
|
184
|
+
self.signer = Signer(secret_key)
|
|
185
|
+
self.cookie = session_cookie
|
|
186
|
+
self.max_age = max_age
|
|
187
|
+
self.path = path
|
|
188
|
+
self.same_site = same_site
|
|
189
|
+
self.secure = https_only
|
|
190
|
+
self.domain = domain
|
|
191
|
+
self.store = store
|
|
192
|
+
|
|
193
|
+
def _load(self, request):
|
|
194
|
+
raw = _request_cookies(request).get(self.cookie)
|
|
195
|
+
if raw is None:
|
|
196
|
+
return Session()
|
|
197
|
+
try:
|
|
198
|
+
value = self.signer.unsign(raw, self.max_age)
|
|
199
|
+
except BadSignature:
|
|
200
|
+
return Session()
|
|
201
|
+
if self.store is None:
|
|
202
|
+
try:
|
|
203
|
+
return Session(json.loads(base64.b64decode(value)))
|
|
204
|
+
except ValueError:
|
|
205
|
+
return Session()
|
|
206
|
+
sid = value.decode("ascii", errors="replace")
|
|
207
|
+
data = self.store.load(sid)
|
|
208
|
+
return Session(data, sid) if data is not None else Session()
|
|
209
|
+
|
|
210
|
+
def _cookie_options(self):
|
|
211
|
+
opts = {"path": self.path, "httponly": True, "samesite": self.same_site, "secure": self.secure}
|
|
212
|
+
if self.domain:
|
|
213
|
+
opts["domain"] = self.domain
|
|
214
|
+
if self.max_age is not None:
|
|
215
|
+
opts["max_age"] = int(self.max_age)
|
|
216
|
+
return opts
|
|
217
|
+
|
|
218
|
+
def _write(self, session, response):
|
|
219
|
+
if not session.modified:
|
|
220
|
+
return
|
|
221
|
+
if self.store is None:
|
|
222
|
+
if not session:
|
|
223
|
+
response.delete_cookie(self.cookie, path=self.path, domain=self.domain)
|
|
224
|
+
return
|
|
225
|
+
value = base64.b64encode(json.dumps(dict(session)).encode("utf-8"))
|
|
226
|
+
response.set_cookie(self.cookie, self.signer.sign(value).decode("ascii"), **self._cookie_options())
|
|
227
|
+
return
|
|
228
|
+
if session.sid and (session.new_id or not session):
|
|
229
|
+
self.store.delete(session.sid)
|
|
230
|
+
session.sid = None
|
|
231
|
+
if not session:
|
|
232
|
+
response.delete_cookie(self.cookie, path=self.path, domain=self.domain)
|
|
233
|
+
return
|
|
234
|
+
if session.sid is None:
|
|
235
|
+
session.sid = secrets.token_urlsafe(32)
|
|
236
|
+
self.store.save(session.sid, dict(session), self.max_age)
|
|
237
|
+
response.set_cookie(self.cookie, self.signer.sign(session.sid).decode("ascii"), **self._cookie_options())
|
|
238
|
+
|
|
239
|
+
def __call__(self, request: veil.Request, response: veil.Response):
|
|
240
|
+
"""Load the session of ``request``; after the handler, write the cookie to ``response`` if it changed."""
|
|
241
|
+
session = self._load(request)
|
|
242
|
+
yield session
|
|
243
|
+
self._write(session, response)
|
veil_kit/staticfiles.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Static files with the arguments of Starlette's ``StaticFiles``.
|
|
2
|
+
|
|
3
|
+
.. code-block:: python
|
|
4
|
+
|
|
5
|
+
from veil_kit.staticfiles import StaticFiles
|
|
6
|
+
|
|
7
|
+
app.mount("/static", StaticFiles(directory="public"), name="static")
|
|
8
|
+
|
|
9
|
+
The directory is served by the server's file backend (Rust): ``sendfile``, kTLS, Range and ``ETag`` work as for the
|
|
10
|
+
proxy, and requests do not reach Python. Precompressed ``.br`` / ``.gz`` files are not used. ``html=True`` serves ``index.html`` for directories.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
# F-268: 本体の File バックエンドのルートを注入する(app.serve_dir と同じ)
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
|
|
17
|
+
__all__ = ["StaticFiles"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class StaticFiles:
|
|
21
|
+
"""A directory to mount with ``app.mount(path, StaticFiles(directory=...))``."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, *, directory, html=False, check_dir=True, follow_symlink=False, cache=True, **route_options):
|
|
24
|
+
"""``directory`` is served below the mount path; ``cache=False`` turns off the open-file and content caches;
|
|
25
|
+
``route_options`` are extra ``[[route]]`` keys."""
|
|
26
|
+
if check_dir and not os.path.isdir(directory):
|
|
27
|
+
raise RuntimeError("Directory '%s' does not exist" % directory)
|
|
28
|
+
self.directory = os.path.abspath(directory)
|
|
29
|
+
self.html = html
|
|
30
|
+
self.cache = cache
|
|
31
|
+
self.route_options = route_options
|
|
32
|
+
|
|
33
|
+
def _veil_mount(self, app, path):
|
|
34
|
+
"""Called by ``app.mount``."""
|
|
35
|
+
app.serve_dir(path, self.directory, index="index.html" if self.html else None, cache=self.cache,
|
|
36
|
+
**self.route_options)
|