quart-httpauth 0.0.1__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.
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
"""Basic, Digest and Token HTTP authentication for Quart routes.
|
|
2
|
+
|
|
3
|
+
``quart_httpauth`` is an async-native port of Flask-HTTPAuth_. The public API —
|
|
4
|
+
class names, constructor arguments, decorator names, and callback contracts — is
|
|
5
|
+
preserved, so existing Flask-HTTPAuth code transfers by switching the ``flask``
|
|
6
|
+
imports to ``quart`` and the ``flask_httpauth`` import to ``quart_httpauth``. The
|
|
7
|
+
only intentional behavioural change is that the request pipeline and every user
|
|
8
|
+
callback are awaited; callbacks may be written as either ``def`` or
|
|
9
|
+
``async def``.
|
|
10
|
+
|
|
11
|
+
.. _Flask-HTTPAuth: https://github.com/miguelgrinberg/flask-httpauth
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hmac
|
|
17
|
+
from base64 import b64decode
|
|
18
|
+
from collections.abc import Awaitable
|
|
19
|
+
from functools import wraps
|
|
20
|
+
from hashlib import md5
|
|
21
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
22
|
+
from random import Random, SystemRandom
|
|
23
|
+
from typing import Any, Callable, cast
|
|
24
|
+
|
|
25
|
+
from quart import Response, current_app, g, make_response, request, session
|
|
26
|
+
from werkzeug.datastructures import Authorization, Headers
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"HTTPAuth",
|
|
30
|
+
"HTTPBasicAuth",
|
|
31
|
+
"HTTPDigestAuth",
|
|
32
|
+
"HTTPTokenAuth",
|
|
33
|
+
"HTTPMultiAuth",
|
|
34
|
+
"MultiAuth",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
__version__ = version("quart-httpauth")
|
|
39
|
+
except PackageNotFoundError: # pragma: no cover - running from an uninstalled tree
|
|
40
|
+
__version__ = "0.0.0"
|
|
41
|
+
|
|
42
|
+
#: A user callback, which may be synchronous or a coroutine function.
|
|
43
|
+
Callback = Callable[..., Any]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class HTTPAuth:
|
|
47
|
+
"""Base class for the HTTP authentication schemes.
|
|
48
|
+
|
|
49
|
+
This class is effectively abstract: it owns the shared machinery (the
|
|
50
|
+
``login_required`` decorator, error handling, header parsing, and role
|
|
51
|
+
authorization) but delegates credential checking to :meth:`authenticate`,
|
|
52
|
+
which each concrete subclass implements.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
scheme: str | None = None,
|
|
58
|
+
realm: str | None = None,
|
|
59
|
+
header: str | None = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
self.scheme = scheme
|
|
62
|
+
self.realm = realm or "Authentication Required"
|
|
63
|
+
self.header = header
|
|
64
|
+
self.get_password_callback: Callback | None = None
|
|
65
|
+
self.get_user_roles_callback: Callback | None = None
|
|
66
|
+
self.auth_error_callback: Callable[..., Awaitable[Response]]
|
|
67
|
+
|
|
68
|
+
def default_get_password(username: str) -> None:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
def default_auth_error(status: int) -> tuple[str, int]:
|
|
72
|
+
return "Unauthorized Access", status
|
|
73
|
+
|
|
74
|
+
self.get_password(default_get_password)
|
|
75
|
+
self.error_handler(default_auth_error)
|
|
76
|
+
|
|
77
|
+
def is_compatible_auth(self, headers: Headers) -> bool:
|
|
78
|
+
"""Return whether *headers* carry credentials for this scheme."""
|
|
79
|
+
if self.header is None or self.header == "Authorization":
|
|
80
|
+
try:
|
|
81
|
+
scheme, _ = headers.get("Authorization", "").split(None, 1)
|
|
82
|
+
except ValueError:
|
|
83
|
+
# malformed or absent Authorization header
|
|
84
|
+
return False
|
|
85
|
+
return scheme == self.scheme
|
|
86
|
+
return self.header in headers
|
|
87
|
+
|
|
88
|
+
def get_password(self, f: Callback) -> Callback:
|
|
89
|
+
"""Register the callback used to obtain a user's stored password.
|
|
90
|
+
|
|
91
|
+
This is the primary mechanism used by :class:`HTTPDigestAuth`; it is not
|
|
92
|
+
a credential path for Basic authentication (use
|
|
93
|
+
:meth:`HTTPBasicAuth.verify_password` instead). Example::
|
|
94
|
+
|
|
95
|
+
@auth.get_password
|
|
96
|
+
def get_password(username):
|
|
97
|
+
return db.get_user_password(username)
|
|
98
|
+
"""
|
|
99
|
+
self.get_password_callback = f
|
|
100
|
+
return f
|
|
101
|
+
|
|
102
|
+
def get_user_roles(self, f: Callback) -> Callback:
|
|
103
|
+
"""Register the callback that returns the roles assigned to a user.
|
|
104
|
+
|
|
105
|
+
The callback receives the user returned by the verify callback (or, when
|
|
106
|
+
that callback returned ``True``, the ``Authorization`` object) and
|
|
107
|
+
returns a role or list of roles. Example::
|
|
108
|
+
|
|
109
|
+
@auth.get_user_roles
|
|
110
|
+
def get_user_roles(user):
|
|
111
|
+
return user.get_roles()
|
|
112
|
+
"""
|
|
113
|
+
self.get_user_roles_callback = f
|
|
114
|
+
return f
|
|
115
|
+
|
|
116
|
+
def error_handler(self, f: Callback) -> Callable[..., Awaitable[Response]]:
|
|
117
|
+
"""Register the callback that builds authentication error responses.
|
|
118
|
+
|
|
119
|
+
The callback may take one argument (the status code, ``401`` or ``403``)
|
|
120
|
+
or, for backward compatibility, no arguments. Its return value may be
|
|
121
|
+
any response type accepted by Quart routes; the framework guarantees a
|
|
122
|
+
``WWW-Authenticate`` header is present. Example::
|
|
123
|
+
|
|
124
|
+
@auth.error_handler
|
|
125
|
+
def auth_error(status):
|
|
126
|
+
return "Access Denied", status
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
@wraps(f)
|
|
130
|
+
async def decorated(*args: Any, **kwargs: Any) -> Response:
|
|
131
|
+
result = await self.ensure_async(f)(*args, **kwargs)
|
|
132
|
+
explicit_status = isinstance(result, (tuple, Response))
|
|
133
|
+
response = cast(Response, await make_response(result))
|
|
134
|
+
if not explicit_status and response.status_code == 200:
|
|
135
|
+
# the callback did not set a status code, so default to 401
|
|
136
|
+
response.status_code = 401
|
|
137
|
+
if "WWW-Authenticate" not in response.headers:
|
|
138
|
+
response.headers["WWW-Authenticate"] = await self.authenticate_header()
|
|
139
|
+
return response
|
|
140
|
+
|
|
141
|
+
self.auth_error_callback = decorated
|
|
142
|
+
return decorated
|
|
143
|
+
|
|
144
|
+
async def authenticate_header(self) -> str:
|
|
145
|
+
"""Return the value of the ``WWW-Authenticate`` challenge header."""
|
|
146
|
+
return f'{self.scheme} realm="{self.realm}"'
|
|
147
|
+
|
|
148
|
+
def get_auth(self) -> Authorization | None:
|
|
149
|
+
"""Parse the request's credentials into an ``Authorization`` object."""
|
|
150
|
+
auth: Authorization | None = None
|
|
151
|
+
if self.header is None or self.header == "Authorization":
|
|
152
|
+
auth = request.authorization
|
|
153
|
+
# Werkzeug < 2.3 only recognizes Basic and Digest, so on older
|
|
154
|
+
# versions the header is parsed by hand here.
|
|
155
|
+
if auth is None and "Authorization" in request.headers: # pragma: no cover
|
|
156
|
+
try:
|
|
157
|
+
auth_type, token = request.headers["Authorization"].split(None, 1)
|
|
158
|
+
auth = Authorization(auth_type)
|
|
159
|
+
auth.token = token
|
|
160
|
+
except (ValueError, KeyError):
|
|
161
|
+
pass
|
|
162
|
+
elif self.header in request.headers:
|
|
163
|
+
# a custom header is used, so its entire value is the token
|
|
164
|
+
auth = Authorization(self.scheme or "")
|
|
165
|
+
auth.token = request.headers[self.header]
|
|
166
|
+
|
|
167
|
+
# If the scheme does not match we behave as if there were no auth at
|
|
168
|
+
# all. This is friendlier than failing outright, as it lets callbacks
|
|
169
|
+
# handle special cases such as supporting multiple schemes.
|
|
170
|
+
if auth is not None and auth.type.lower() != (self.scheme or "").lower():
|
|
171
|
+
auth = None
|
|
172
|
+
|
|
173
|
+
return auth
|
|
174
|
+
|
|
175
|
+
async def get_auth_password(self, auth: Authorization | None) -> Any:
|
|
176
|
+
"""Look up the stored password for the authenticated username."""
|
|
177
|
+
if auth and auth.username and self.get_password_callback is not None:
|
|
178
|
+
return await self.ensure_async(self.get_password_callback)(auth.username)
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
async def authorize(
|
|
182
|
+
self,
|
|
183
|
+
role: Any,
|
|
184
|
+
user: Any,
|
|
185
|
+
auth: Authorization | None,
|
|
186
|
+
) -> bool | None:
|
|
187
|
+
"""Return whether *user* satisfies the required *role*."""
|
|
188
|
+
if role is None:
|
|
189
|
+
return True
|
|
190
|
+
roles = list(role) if isinstance(role, (list, tuple)) else [role]
|
|
191
|
+
if user is True:
|
|
192
|
+
user = auth
|
|
193
|
+
if self.get_user_roles_callback is None: # pragma: no cover
|
|
194
|
+
raise ValueError("get_user_roles callback is not defined")
|
|
195
|
+
|
|
196
|
+
user_roles = await self.ensure_async(self.get_user_roles_callback)(user)
|
|
197
|
+
if user_roles is None:
|
|
198
|
+
user_roles = set()
|
|
199
|
+
elif not isinstance(user_roles, (list, tuple)):
|
|
200
|
+
user_roles = {user_roles}
|
|
201
|
+
else:
|
|
202
|
+
user_roles = set(user_roles)
|
|
203
|
+
|
|
204
|
+
for required in roles:
|
|
205
|
+
if isinstance(required, (list, tuple)):
|
|
206
|
+
required = set(required)
|
|
207
|
+
if required & user_roles == required:
|
|
208
|
+
return True
|
|
209
|
+
elif required in user_roles:
|
|
210
|
+
return True
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
async def authenticate(
|
|
214
|
+
self, auth: Authorization | None, stored_password: Any
|
|
215
|
+
) -> Any:
|
|
216
|
+
"""Verify the request's credentials (implemented by subclasses)."""
|
|
217
|
+
raise NotImplementedError # pragma: no cover
|
|
218
|
+
|
|
219
|
+
def login_required(
|
|
220
|
+
self,
|
|
221
|
+
f: Callback | None = None,
|
|
222
|
+
role: Any = None,
|
|
223
|
+
optional: bool | None = None,
|
|
224
|
+
) -> Any:
|
|
225
|
+
"""Protect a view so it only runs after successful authentication.
|
|
226
|
+
|
|
227
|
+
Example::
|
|
228
|
+
|
|
229
|
+
@app.route("/private")
|
|
230
|
+
@auth.login_required
|
|
231
|
+
async def private_page():
|
|
232
|
+
return "Only for authorized people!"
|
|
233
|
+
|
|
234
|
+
Pass ``role`` to require one or more roles, or ``optional=True`` to allow
|
|
235
|
+
anonymous access (in which case :meth:`current_user` returns ``None``).
|
|
236
|
+
"""
|
|
237
|
+
if f is not None and (role is not None or optional is not None):
|
|
238
|
+
raise ValueError( # pragma: no cover
|
|
239
|
+
"role and optional are the only supported arguments"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
def login_required_internal(f: Callback) -> Callback:
|
|
243
|
+
@wraps(f)
|
|
244
|
+
async def decorated(*args: Any, **kwargs: Any) -> Any:
|
|
245
|
+
auth = self.get_auth()
|
|
246
|
+
|
|
247
|
+
# Quart normally handles OPTIONS requests itself, but if it is
|
|
248
|
+
# configured to forward them we must ignore the authentication
|
|
249
|
+
# headers and let the request through, so CORS preflight is
|
|
250
|
+
# never challenged.
|
|
251
|
+
if request.method != "OPTIONS": # pragma: no cover
|
|
252
|
+
password = await self.get_auth_password(auth)
|
|
253
|
+
|
|
254
|
+
status = None
|
|
255
|
+
user = await self.authenticate(auth, password)
|
|
256
|
+
if user in (False, None):
|
|
257
|
+
status = 401
|
|
258
|
+
elif not await self.authorize(role, user, auth):
|
|
259
|
+
status = 403
|
|
260
|
+
if not optional and status:
|
|
261
|
+
try:
|
|
262
|
+
return await self.auth_error_callback(status)
|
|
263
|
+
except TypeError:
|
|
264
|
+
return await self.auth_error_callback()
|
|
265
|
+
|
|
266
|
+
if user is True:
|
|
267
|
+
g.quart_httpauth_user = auth.username if auth else None
|
|
268
|
+
else:
|
|
269
|
+
g.quart_httpauth_user = user
|
|
270
|
+
return await self.ensure_async(f)(*args, **kwargs)
|
|
271
|
+
|
|
272
|
+
return decorated
|
|
273
|
+
|
|
274
|
+
if f:
|
|
275
|
+
return login_required_internal(f)
|
|
276
|
+
return login_required_internal
|
|
277
|
+
|
|
278
|
+
def current_user(self) -> Any:
|
|
279
|
+
"""Return the user resolved by the verify callback, or ``None``.
|
|
280
|
+
|
|
281
|
+
When the verify callback returned ``True`` instead of a user object,
|
|
282
|
+
this is the username (or token) sent by the client. Example::
|
|
283
|
+
|
|
284
|
+
@app.route("/")
|
|
285
|
+
@auth.login_required
|
|
286
|
+
async def index():
|
|
287
|
+
return f"Hello, {auth.current_user()}!"
|
|
288
|
+
"""
|
|
289
|
+
return getattr(g, "quart_httpauth_user", None)
|
|
290
|
+
|
|
291
|
+
def ensure_async(self, f: Callback) -> Callable[..., Awaitable[Any]]:
|
|
292
|
+
"""Adapt *f* into an awaitable, whether it is sync or async.
|
|
293
|
+
|
|
294
|
+
Sync callbacks are offloaded to Quart's executor so they do not block
|
|
295
|
+
the event loop; coroutine functions are returned unchanged.
|
|
296
|
+
"""
|
|
297
|
+
try:
|
|
298
|
+
return current_app.ensure_async(f)
|
|
299
|
+
except AttributeError: # pragma: no cover - very old Quart
|
|
300
|
+
return f
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class HTTPBasicAuth(HTTPAuth):
|
|
304
|
+
"""HTTP Basic authentication (RFC 7617).
|
|
305
|
+
|
|
306
|
+
If *scheme* is given it replaces the standard ``Basic`` scheme in the
|
|
307
|
+
``WWW-Authenticate`` response; a custom scheme is a common way to stop
|
|
308
|
+
browsers from showing their built-in login prompt. *realm* sets the
|
|
309
|
+
application-defined realm advertised in that header.
|
|
310
|
+
"""
|
|
311
|
+
|
|
312
|
+
def __init__(self, scheme: str | None = None, realm: str | None = None) -> None:
|
|
313
|
+
super().__init__(scheme or "Basic", realm)
|
|
314
|
+
self.verify_password_callback: Callback | None = None
|
|
315
|
+
|
|
316
|
+
def verify_password(self, f: Callback) -> Callback:
|
|
317
|
+
"""Register the callback that verifies a username and password.
|
|
318
|
+
|
|
319
|
+
The callback receives the username and password and returns the user
|
|
320
|
+
object on success, ``True`` when there is no user object, or
|
|
321
|
+
``None``/``False`` on failure. Example::
|
|
322
|
+
|
|
323
|
+
@auth.verify_password
|
|
324
|
+
async def verify_password(username, password):
|
|
325
|
+
user = await User.get(username)
|
|
326
|
+
if user and user.check_password(password):
|
|
327
|
+
return user
|
|
328
|
+
|
|
329
|
+
It is also called for requests without credentials, with both arguments
|
|
330
|
+
set to empty strings; returning ``True`` there allows anonymous access.
|
|
331
|
+
"""
|
|
332
|
+
self.verify_password_callback = f
|
|
333
|
+
return f
|
|
334
|
+
|
|
335
|
+
def get_auth(self) -> Authorization | None:
|
|
336
|
+
# This parser is more permissive than Werkzeug's: it accepts schemes
|
|
337
|
+
# other than "Basic" and falls back to latin-1 decoding.
|
|
338
|
+
header = self.header or "Authorization"
|
|
339
|
+
if header not in request.headers:
|
|
340
|
+
return None
|
|
341
|
+
value = request.headers[header].encode("utf-8")
|
|
342
|
+
try:
|
|
343
|
+
scheme, credentials = value.split(b" ", 1)
|
|
344
|
+
encoded_username, encoded_password = b64decode(credentials).split(b":", 1)
|
|
345
|
+
except (ValueError, TypeError):
|
|
346
|
+
return None
|
|
347
|
+
try:
|
|
348
|
+
username = encoded_username.decode("utf-8")
|
|
349
|
+
password = encoded_password.decode("utf-8")
|
|
350
|
+
except UnicodeDecodeError:
|
|
351
|
+
# latin-1 always succeeds, so it is a safe fallback
|
|
352
|
+
username = encoded_username.decode("latin1")
|
|
353
|
+
password = encoded_password.decode("latin1")
|
|
354
|
+
|
|
355
|
+
return Authorization(
|
|
356
|
+
scheme.decode("ascii"),
|
|
357
|
+
{"username": username, "password": password},
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
async def authenticate(
|
|
361
|
+
self, auth: Authorization | None, stored_password: Any
|
|
362
|
+
) -> Any:
|
|
363
|
+
if auth:
|
|
364
|
+
username = auth.username
|
|
365
|
+
client_password = auth.password
|
|
366
|
+
else:
|
|
367
|
+
username = ""
|
|
368
|
+
client_password = ""
|
|
369
|
+
if self.verify_password_callback is not None:
|
|
370
|
+
return await self.ensure_async(self.verify_password_callback)(
|
|
371
|
+
username, client_password
|
|
372
|
+
)
|
|
373
|
+
return None
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
class HTTPDigestAuth(HTTPAuth):
|
|
377
|
+
"""HTTP Digest authentication (RFC 7616).
|
|
378
|
+
|
|
379
|
+
If *scheme* is given it replaces the standard ``Digest`` scheme in the
|
|
380
|
+
challenge, and *realm* sets the advertised realm.
|
|
381
|
+
|
|
382
|
+
When *use_ha1_pw* is ``False`` the :meth:`get_password` callback returns the
|
|
383
|
+
plaintext password; when it is ``True`` the callback returns the HA1 hash,
|
|
384
|
+
allowing the application to avoid storing plaintext passwords.
|
|
385
|
+
|
|
386
|
+
*qop* configures the accepted quality-of-protection values (a comma-separated
|
|
387
|
+
string, a list, or ``None`` to disable); the default is ``auth`` and
|
|
388
|
+
``auth-int`` is not implemented. *algorithm* selects ``MD5`` (default) or
|
|
389
|
+
``MD5-Sess``.
|
|
390
|
+
|
|
391
|
+
.. note::
|
|
392
|
+
By default the ``nonce`` and ``opaque`` values are stored in the Quart
|
|
393
|
+
session. With the default cookie-based sessions these values can leak,
|
|
394
|
+
so a server-side session backend should be used in production.
|
|
395
|
+
"""
|
|
396
|
+
|
|
397
|
+
def __init__(
|
|
398
|
+
self,
|
|
399
|
+
scheme: str | None = None,
|
|
400
|
+
realm: str | None = None,
|
|
401
|
+
use_ha1_pw: bool = False,
|
|
402
|
+
qop: str | list[str] | None = "auth",
|
|
403
|
+
algorithm: str = "MD5",
|
|
404
|
+
) -> None:
|
|
405
|
+
super().__init__(scheme or "Digest", realm)
|
|
406
|
+
self.use_ha1_pw = use_ha1_pw
|
|
407
|
+
if isinstance(qop, str):
|
|
408
|
+
self.qop = [v.strip() for v in qop.split(",")]
|
|
409
|
+
else:
|
|
410
|
+
self.qop = qop or []
|
|
411
|
+
if algorithm.lower() == "md5":
|
|
412
|
+
self.algorithm = "MD5"
|
|
413
|
+
elif algorithm.lower() == "md5-sess":
|
|
414
|
+
self.algorithm = "MD5-Sess"
|
|
415
|
+
else:
|
|
416
|
+
raise ValueError(f"Algorithm {algorithm} is not supported")
|
|
417
|
+
|
|
418
|
+
self.random: SystemRandom | Random = SystemRandom()
|
|
419
|
+
try:
|
|
420
|
+
self.random.random()
|
|
421
|
+
except NotImplementedError: # pragma: no cover
|
|
422
|
+
self.random = Random()
|
|
423
|
+
|
|
424
|
+
self.generate_nonce_callback: Callback
|
|
425
|
+
self.verify_nonce_callback: Callback
|
|
426
|
+
self.generate_opaque_callback: Callback
|
|
427
|
+
self.verify_opaque_callback: Callback
|
|
428
|
+
|
|
429
|
+
def generate_random() -> str:
|
|
430
|
+
return md5(str(self.random.random()).encode("utf-8")).hexdigest()
|
|
431
|
+
|
|
432
|
+
def default_generate_nonce() -> str:
|
|
433
|
+
session["auth_nonce"] = generate_random()
|
|
434
|
+
return session["auth_nonce"]
|
|
435
|
+
|
|
436
|
+
def default_verify_nonce(nonce: str | None) -> bool:
|
|
437
|
+
session_nonce = session.get("auth_nonce")
|
|
438
|
+
if nonce is None or session_nonce is None:
|
|
439
|
+
return False
|
|
440
|
+
return hmac.compare_digest(nonce, session_nonce)
|
|
441
|
+
|
|
442
|
+
def default_generate_opaque() -> str:
|
|
443
|
+
session["auth_opaque"] = generate_random()
|
|
444
|
+
return session["auth_opaque"]
|
|
445
|
+
|
|
446
|
+
def default_verify_opaque(opaque: str | None) -> bool:
|
|
447
|
+
session_opaque = session.get("auth_opaque")
|
|
448
|
+
if opaque is None or session_opaque is None: # pragma: no cover
|
|
449
|
+
return False
|
|
450
|
+
return hmac.compare_digest(opaque, session_opaque)
|
|
451
|
+
|
|
452
|
+
self.generate_nonce(default_generate_nonce)
|
|
453
|
+
self.generate_opaque(default_generate_opaque)
|
|
454
|
+
self.verify_nonce(default_verify_nonce)
|
|
455
|
+
self.verify_opaque(default_verify_opaque)
|
|
456
|
+
|
|
457
|
+
def generate_nonce(self, f: Callback) -> Callback:
|
|
458
|
+
"""Register the callback that generates a nonce.
|
|
459
|
+
|
|
460
|
+
Defining this (together with :meth:`verify_nonce`) lets the application
|
|
461
|
+
use a state store other than the session.
|
|
462
|
+
"""
|
|
463
|
+
self.generate_nonce_callback = f
|
|
464
|
+
return f
|
|
465
|
+
|
|
466
|
+
def verify_nonce(self, f: Callback) -> Callback:
|
|
467
|
+
"""Register the callback that verifies a nonce."""
|
|
468
|
+
self.verify_nonce_callback = f
|
|
469
|
+
return f
|
|
470
|
+
|
|
471
|
+
def generate_opaque(self, f: Callback) -> Callback:
|
|
472
|
+
"""Register the callback that generates an opaque value.
|
|
473
|
+
|
|
474
|
+
Defining this (together with :meth:`verify_opaque`) lets the application
|
|
475
|
+
use a state store other than the session.
|
|
476
|
+
"""
|
|
477
|
+
self.generate_opaque_callback = f
|
|
478
|
+
return f
|
|
479
|
+
|
|
480
|
+
def verify_opaque(self, f: Callback) -> Callback:
|
|
481
|
+
"""Register the callback that verifies an opaque value."""
|
|
482
|
+
self.verify_opaque_callback = f
|
|
483
|
+
return f
|
|
484
|
+
|
|
485
|
+
async def get_nonce(self) -> str:
|
|
486
|
+
return await self.ensure_async(self.generate_nonce_callback)()
|
|
487
|
+
|
|
488
|
+
async def get_opaque(self) -> str:
|
|
489
|
+
return await self.ensure_async(self.generate_opaque_callback)()
|
|
490
|
+
|
|
491
|
+
def generate_ha1(self, username: str, password: str) -> str:
|
|
492
|
+
"""Return the HA1 hash to store when ``use_ha1_pw`` is ``True``."""
|
|
493
|
+
a1 = f"{username}:{self.realm}:{password}".encode()
|
|
494
|
+
return md5(a1).hexdigest()
|
|
495
|
+
|
|
496
|
+
async def authenticate_header(self) -> str:
|
|
497
|
+
nonce = await self.get_nonce()
|
|
498
|
+
opaque = await self.get_opaque()
|
|
499
|
+
challenge = (
|
|
500
|
+
f'{self.scheme} realm="{self.realm}",nonce="{nonce}",' f'opaque="{opaque}"'
|
|
501
|
+
)
|
|
502
|
+
if self.qop:
|
|
503
|
+
qop_str = ",".join(self.qop)
|
|
504
|
+
challenge += f',algorithm="{self.algorithm}",qop="{qop_str}"'
|
|
505
|
+
return challenge
|
|
506
|
+
|
|
507
|
+
async def authenticate(
|
|
508
|
+
self, auth: Authorization | None, stored_password_or_ha1: Any
|
|
509
|
+
) -> bool:
|
|
510
|
+
if (
|
|
511
|
+
not auth
|
|
512
|
+
or not auth.username
|
|
513
|
+
or not auth.realm
|
|
514
|
+
or not auth.uri
|
|
515
|
+
or not auth.nonce
|
|
516
|
+
or not auth.response
|
|
517
|
+
or not stored_password_or_ha1
|
|
518
|
+
):
|
|
519
|
+
return False
|
|
520
|
+
|
|
521
|
+
nonce_ok = await self.ensure_async(self.verify_nonce_callback)(auth.nonce)
|
|
522
|
+
opaque_ok = await self.ensure_async(self.verify_opaque_callback)(auth.opaque)
|
|
523
|
+
if not nonce_ok or not opaque_ok:
|
|
524
|
+
return False
|
|
525
|
+
if auth.qop and auth.qop not in self.qop: # pragma: no cover
|
|
526
|
+
return False
|
|
527
|
+
|
|
528
|
+
if self.use_ha1_pw:
|
|
529
|
+
ha1 = stored_password_or_ha1
|
|
530
|
+
else:
|
|
531
|
+
a1 = f"{auth.username}:{auth.realm}:{stored_password_or_ha1}"
|
|
532
|
+
ha1 = md5(a1.encode("utf-8")).hexdigest()
|
|
533
|
+
if self.algorithm == "MD5-Sess":
|
|
534
|
+
ha1 = md5(f"{ha1}:{auth.nonce}:{auth.cnonce}".encode()).hexdigest()
|
|
535
|
+
|
|
536
|
+
a2 = f"{request.method}:{auth.uri}"
|
|
537
|
+
ha2 = md5(a2.encode("utf-8")).hexdigest()
|
|
538
|
+
if auth.qop == "auth":
|
|
539
|
+
a3 = f"{ha1}:{auth.nonce}:{auth.nc}:{auth.cnonce}:auth:{ha2}"
|
|
540
|
+
else:
|
|
541
|
+
a3 = f"{ha1}:{auth.nonce}:{ha2}"
|
|
542
|
+
response = md5(a3.encode("utf-8")).hexdigest()
|
|
543
|
+
return hmac.compare_digest(response, auth.response)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
class HTTPTokenAuth(HTTPAuth):
|
|
547
|
+
"""Bearer or custom-header token authentication.
|
|
548
|
+
|
|
549
|
+
*scheme* names the scheme used in the ``Authorization`` header and the
|
|
550
|
+
challenge (``Bearer`` by default)::
|
|
551
|
+
|
|
552
|
+
Authorization: Bearer this-is-my-token
|
|
553
|
+
|
|
554
|
+
*realm* sets the advertised realm. *header* selects a custom header to read
|
|
555
|
+
the token from instead of ``Authorization``; there the whole header value is
|
|
556
|
+
the token and no scheme prefix is expected::
|
|
557
|
+
|
|
558
|
+
X-API-Key: this-is-my-token
|
|
559
|
+
"""
|
|
560
|
+
|
|
561
|
+
def __init__(
|
|
562
|
+
self,
|
|
563
|
+
scheme: str = "Bearer",
|
|
564
|
+
realm: str | None = None,
|
|
565
|
+
header: str | None = None,
|
|
566
|
+
) -> None:
|
|
567
|
+
super().__init__(scheme, realm, header)
|
|
568
|
+
self.verify_token_callback: Callback | None = None
|
|
569
|
+
|
|
570
|
+
def verify_token(self, f: Callback) -> Callback:
|
|
571
|
+
"""Register the callback that verifies a token.
|
|
572
|
+
|
|
573
|
+
The callback receives the token and returns the user object on success,
|
|
574
|
+
``True`` when there is no user object, or ``None``/``False`` on failure.
|
|
575
|
+
A verify callback is required when using this class. Example::
|
|
576
|
+
|
|
577
|
+
@auth.verify_token
|
|
578
|
+
async def verify_token(token):
|
|
579
|
+
return await User.get_by_token(token)
|
|
580
|
+
"""
|
|
581
|
+
self.verify_token_callback = f
|
|
582
|
+
return f
|
|
583
|
+
|
|
584
|
+
async def authenticate(
|
|
585
|
+
self, auth: Authorization | None, stored_password: Any
|
|
586
|
+
) -> Any:
|
|
587
|
+
token = getattr(auth, "token", None)
|
|
588
|
+
if token and self.verify_token_callback is not None:
|
|
589
|
+
return await self.ensure_async(self.verify_token_callback)(token)
|
|
590
|
+
return None
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
class MultiAuth:
|
|
594
|
+
"""Try several authentication schemes in order on a single route.
|
|
595
|
+
|
|
596
|
+
Construct it with one or more of :class:`HTTPBasicAuth`,
|
|
597
|
+
:class:`HTTPDigestAuth` or :class:`HTTPTokenAuth`. A protected route uses the
|
|
598
|
+
first scheme whose header is compatible with the request, falling back to the
|
|
599
|
+
main scheme's challenge when none match.
|
|
600
|
+
"""
|
|
601
|
+
|
|
602
|
+
def __init__(self, main_auth: HTTPAuth, *additional_auths: HTTPAuth) -> None:
|
|
603
|
+
self.main_auth = main_auth
|
|
604
|
+
self.additional_auths = additional_auths
|
|
605
|
+
|
|
606
|
+
def login_required(
|
|
607
|
+
self,
|
|
608
|
+
f: Callback | None = None,
|
|
609
|
+
role: Any = None,
|
|
610
|
+
optional: bool | None = None,
|
|
611
|
+
) -> Any:
|
|
612
|
+
"""Protect a view, delegating to the first compatible scheme."""
|
|
613
|
+
if f is not None and (role is not None or optional is not None):
|
|
614
|
+
raise ValueError( # pragma: no cover
|
|
615
|
+
"role and optional are the only supported arguments"
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
def login_required_internal(f: Callback) -> Callback:
|
|
619
|
+
@wraps(f)
|
|
620
|
+
async def decorated(*args: Any, **kwargs: Any) -> Any:
|
|
621
|
+
selected_auth = self.main_auth
|
|
622
|
+
if not self.main_auth.is_compatible_auth(request.headers):
|
|
623
|
+
for auth in self.additional_auths:
|
|
624
|
+
if auth.is_compatible_auth(request.headers):
|
|
625
|
+
selected_auth = auth
|
|
626
|
+
break
|
|
627
|
+
guarded = selected_auth.login_required(role=role, optional=optional)(f)
|
|
628
|
+
return await guarded(*args, **kwargs)
|
|
629
|
+
|
|
630
|
+
return decorated
|
|
631
|
+
|
|
632
|
+
if f:
|
|
633
|
+
return login_required_internal(f)
|
|
634
|
+
return login_required_internal
|
|
635
|
+
|
|
636
|
+
def current_user(self) -> Any:
|
|
637
|
+
"""Return the authenticated user, or ``None``."""
|
|
638
|
+
return getattr(g, "quart_httpauth_user", None)
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
#: Friendlier alias. Upstream Flask-HTTPAuth exposes the class as ``MultiAuth``,
|
|
642
|
+
#: so both names are provided to maximise compatibility.
|
|
643
|
+
HTTPMultiAuth = MultiAuth
|
quart_httpauth/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: quart-httpauth
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Basic, Digest and Token HTTP authentication for Quart routes
|
|
5
|
+
Project-URL: Homepage, https://github.com/lymagics/quart_httpauth
|
|
6
|
+
Project-URL: Bug Tracker, https://github.com/lymagics/quart_httpauth/issues
|
|
7
|
+
Author: quart_httpauth contributors
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: async,authentication,basic,bearer,digest,http,quart,token
|
|
11
|
+
Classifier: Environment :: Web Environment
|
|
12
|
+
Classifier: Framework :: AsyncIO
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
|
|
22
|
+
Classifier: Topic :: Security
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Requires-Dist: quart>=0.19
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: black; extra == 'dev'
|
|
27
|
+
Requires-Dist: coverage; extra == 'dev'
|
|
28
|
+
Requires-Dist: hypercorn; extra == 'dev'
|
|
29
|
+
Requires-Dist: hypothesis; extra == 'dev'
|
|
30
|
+
Requires-Dist: language-tool-python; extra == 'dev'
|
|
31
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
32
|
+
Requires-Dist: pyhamcrest; extra == 'dev'
|
|
33
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
34
|
+
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
35
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
36
|
+
Requires-Dist: pytest-fail-slow; extra == 'dev'
|
|
37
|
+
Requires-Dist: pytest-randomly; extra == 'dev'
|
|
38
|
+
Requires-Dist: pytest-repeat; extra == 'dev'
|
|
39
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
40
|
+
Provides-Extra: docs
|
|
41
|
+
Requires-Dist: furo; extra == 'docs'
|
|
42
|
+
Requires-Dist: sphinx; extra == 'docs'
|
|
43
|
+
Description-Content-Type: text/markdown
|
|
44
|
+
|
|
45
|
+
# quart-httpauth
|
|
46
|
+
|
|
47
|
+
Basic, Digest and Token (Bearer) HTTP authentication for [Quart](https://github.com/pallets/quart)
|
|
48
|
+
routes — an **async-native port of [Flask-HTTPAuth](https://github.com/miguelgrinberg/flask-httpauth)**.
|
|
49
|
+
|
|
50
|
+
The public API mirrors Flask-HTTPAuth: class names, constructor arguments,
|
|
51
|
+
decorator names, and callback contracts are preserved. Existing Flask-HTTPAuth
|
|
52
|
+
code transfers by switching `flask` imports to `quart` and `flask_httpauth` to
|
|
53
|
+
`quart_httpauth`. The one intentional change is that the request pipeline and
|
|
54
|
+
every callback are awaited — your verify/role/error callbacks may be written as
|
|
55
|
+
either `def` **or** `async def`.
|
|
56
|
+
|
|
57
|
+
## Installation
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
pip install quart-httpauth
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The distribution name is `quart-httpauth`; the import name is `quart_httpauth`.
|
|
64
|
+
|
|
65
|
+
## Quickstart — Basic auth
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from quart import Quart
|
|
69
|
+
from quart_httpauth import HTTPBasicAuth
|
|
70
|
+
from werkzeug.security import check_password_hash, generate_password_hash
|
|
71
|
+
|
|
72
|
+
app = Quart(__name__)
|
|
73
|
+
auth = HTTPBasicAuth()
|
|
74
|
+
|
|
75
|
+
users = {
|
|
76
|
+
"susan": generate_password_hash("hunter2"),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@auth.verify_password
|
|
81
|
+
async def verify_password(username, password):
|
|
82
|
+
if username in users and check_password_hash(users[username], password):
|
|
83
|
+
return username
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@app.route("/")
|
|
87
|
+
@auth.login_required
|
|
88
|
+
async def index():
|
|
89
|
+
return f"Hello, {auth.current_user()}!"
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Token (Bearer) auth
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from quart import Quart
|
|
96
|
+
from quart_httpauth import HTTPTokenAuth
|
|
97
|
+
|
|
98
|
+
app = Quart(__name__)
|
|
99
|
+
auth = HTTPTokenAuth(scheme="Bearer")
|
|
100
|
+
|
|
101
|
+
tokens = {"secret-token-1": "john"}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@auth.verify_token
|
|
105
|
+
async def verify_token(token):
|
|
106
|
+
return tokens.get(token)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@app.route("/")
|
|
110
|
+
@auth.login_required
|
|
111
|
+
async def index():
|
|
112
|
+
return f"Hello, {auth.current_user()}!"
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
A custom header works too: `HTTPTokenAuth(header="X-API-Key")` — there the entire
|
|
116
|
+
header value is treated as the token with no scheme prefix.
|
|
117
|
+
|
|
118
|
+
## Roles and optional auth
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
@auth.get_user_roles
|
|
122
|
+
async def get_user_roles(user):
|
|
123
|
+
return user.roles
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@app.route("/admin")
|
|
127
|
+
@auth.login_required(role="admin")
|
|
128
|
+
async def admin():
|
|
129
|
+
return "for admins only"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@app.route("/maybe")
|
|
133
|
+
@auth.login_required(optional=True)
|
|
134
|
+
async def maybe():
|
|
135
|
+
user = auth.current_user()
|
|
136
|
+
return f"Hello, {user or 'anonymous'}!"
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Multiple schemes on one route
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
from quart_httpauth import HTTPBasicAuth, HTTPTokenAuth, MultiAuth
|
|
143
|
+
|
|
144
|
+
basic = HTTPBasicAuth()
|
|
145
|
+
token = HTTPTokenAuth()
|
|
146
|
+
multi = MultiAuth(basic, token) # HTTPMultiAuth is an alias
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@app.route("/")
|
|
150
|
+
@multi.login_required
|
|
151
|
+
async def index():
|
|
152
|
+
return f"Hello, {multi.current_user()}!"
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
See [`examples/`](examples/) for runnable apps.
|
|
156
|
+
|
|
157
|
+
## Differences from Flask-HTTPAuth
|
|
158
|
+
|
|
159
|
+
- The deprecated `hash_password` callback, the `username()` accessor, and the
|
|
160
|
+
legacy `get_password`-as-Basic-credential path are **not** ported. Use
|
|
161
|
+
`verify_password` for Basic auth. `get_password` is retained for
|
|
162
|
+
`HTTPDigestAuth`, where it is the primary, non-deprecated mechanism.
|
|
163
|
+
- The per-request user is stored on `g.quart_httpauth_user`.
|
|
164
|
+
|
|
165
|
+
## License
|
|
166
|
+
|
|
167
|
+
MIT. Ported from Flask-HTTPAuth (also MIT) by Miguel Grinberg.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
quart_httpauth/__init__.py,sha256=4Y8c3_Pb9n0-I99jveeTmt3VwlfDWnX-vavwo_ev9iM,24863
|
|
2
|
+
quart_httpauth/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
quart_httpauth-0.0.1.dist-info/METADATA,sha256=3cguDgakju7bZDZ2ZIDI6U13_5ilmojEoTzE9dEODnQ,4869
|
|
4
|
+
quart_httpauth-0.0.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
5
|
+
quart_httpauth-0.0.1.dist-info/licenses/LICENSE,sha256=EefOWiPKOOz3R1pJ5rnvxq0ATiIRSXNxwSlDzwNCw-M,1077
|
|
6
|
+
quart_httpauth-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Viacheslav Lymanskyi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|