quart-security 2.0.0__tar.gz → 2.0.2__tar.gz
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.
- {quart_security-2.0.0 → quart_security-2.0.2}/PKG-INFO +9 -2
- {quart_security-2.0.0 → quart_security-2.0.2}/README.md +7 -0
- {quart_security-2.0.0 → quart_security-2.0.2}/pyproject.toml +2 -3
- {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/breach.py +2 -2
- {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/core.py +11 -0
- {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/totp.py +49 -0
- {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/views.py +14 -4
- {quart_security-2.0.0 → quart_security-2.0.2}/LICENSE +0 -0
- {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/__init__.py +0 -0
- {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/datastore.py +0 -0
- {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/decorators.py +0 -0
- {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/email.py +0 -0
- {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/forms.py +0 -0
- {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/models.py +0 -0
- {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/password.py +0 -0
- {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/proxies.py +0 -0
- {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/py.typed +0 -0
- {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/signals.py +0 -0
- {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/state.py +0 -0
- {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/templates/security/_macros.html +0 -0
- {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/templates/security/change_password.html +0 -0
- {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/templates/security/login_user.html +0 -0
- {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/templates/security/mf_recovery.html +0 -0
- {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/templates/security/mf_recovery_codes.html +0 -0
- {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/templates/security/register_user.html +0 -0
- {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/templates/security/two_factor_select.html +0 -0
- {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/templates/security/two_factor_setup.html +0 -0
- {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/templates/security/two_factor_verify_code.html +0 -0
- {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/templates/security/wan_register.html +0 -0
- {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/templates/security/wan_signin.html +0 -0
- {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/templates/security/wan_verify.html +0 -0
- {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/utils.py +0 -0
- {quart_security-2.0.0 → quart_security-2.0.2}/quart_security/webauthn.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: quart-security
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.2
|
|
4
4
|
Summary: Native async auth for Quart
|
|
5
5
|
Author: quart-security maintainers
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -19,7 +19,7 @@ Requires-Dist: aiosmtplib>=5.1.2
|
|
|
19
19
|
Requires-Dist: blinker>=1.7
|
|
20
20
|
Requires-Dist: email-validator>=2.0
|
|
21
21
|
Requires-Dist: argon2-cffi>=23.1
|
|
22
|
-
Requires-Dist:
|
|
22
|
+
Requires-Dist: httpx2>=2.16
|
|
23
23
|
Requires-Dist: pytest>=8.0 ; extra == "dev"
|
|
24
24
|
Requires-Dist: pytest-asyncio>=0.23 ; extra == "dev"
|
|
25
25
|
Requires-Dist: ruff>=0.6 ; extra == "dev"
|
|
@@ -358,3 +358,10 @@ Dependencies now include the Pillow, bcrypt, and SQLAlchemy asyncio extras and p
|
|
|
358
358
|
minimum versions of aiosmtplib, cryptography, and cbor2. The release workflow runs
|
|
359
359
|
lint and tests before building and publishing. CI tests Python 3.11 through 3.14.
|
|
360
360
|
|
|
361
|
+
|
|
362
|
+
## TOTP encryption and key rotation
|
|
363
|
+
|
|
364
|
+
Version 2.0.1 encrypts enrolled TOTP seeds and pending setup secrets with authenticated Fernet encryption. Legacy plaintext seeds remain readable and are encrypted on successful login or an authenticated request. Hosts should encrypt dormant records during upgrade using `encrypt_totp_secret(value, app=app)`. This requires no schema change for a 255-character seed column.
|
|
365
|
+
|
|
366
|
+
By default, the key is derived from `SECRET_KEY` with a separate purpose label. Keep that key stable. For independent rotation, configure `SECURITY_TOTP_ENCRYPTION_KEYS` as a nonempty list of Fernet keys. The first key encrypts new data; all keys can decrypt existing data. Retain old keys while rotating existing seeds with `encrypt_totp_secret`, including active pending setup states until their five-minute expiry. Remove old keys only after all data and required backups have been handled. Never rotate `SECRET_KEY` without preserving the derived MFA key if using the default. `derive_totp_encryption_key(old_secret_key)` supplies that key for an explicit keyring. Protect the keyring separately from database backups.
|
|
367
|
+
|
|
@@ -327,3 +327,10 @@ or SQLAlchemy model fields.
|
|
|
327
327
|
Dependencies now include the Pillow, bcrypt, and SQLAlchemy asyncio extras and patched
|
|
328
328
|
minimum versions of aiosmtplib, cryptography, and cbor2. The release workflow runs
|
|
329
329
|
lint and tests before building and publishing. CI tests Python 3.11 through 3.14.
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
## TOTP encryption and key rotation
|
|
333
|
+
|
|
334
|
+
Version 2.0.1 encrypts enrolled TOTP seeds and pending setup secrets with authenticated Fernet encryption. Legacy plaintext seeds remain readable and are encrypted on successful login or an authenticated request. Hosts should encrypt dormant records during upgrade using `encrypt_totp_secret(value, app=app)`. This requires no schema change for a 255-character seed column.
|
|
335
|
+
|
|
336
|
+
By default, the key is derived from `SECRET_KEY` with a separate purpose label. Keep that key stable. For independent rotation, configure `SECURITY_TOTP_ENCRYPTION_KEYS` as a nonempty list of Fernet keys. The first key encrypts new data; all keys can decrypt existing data. Retain old keys while rotating existing seeds with `encrypt_totp_secret`, including active pending setup states until their five-minute expiry. Remove old keys only after all data and required backups have been handled. Never rotate `SECRET_KEY` without preserving the derived MFA key if using the default. `derive_totp_encryption_key(old_secret_key)` supplies that key for an explicit keyring. Protect the keyring separately from database backups.
|
|
@@ -4,7 +4,7 @@ build-backend = "flit_core.buildapi"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "quart-security"
|
|
7
|
-
version = "2.0.
|
|
7
|
+
version = "2.0.2"
|
|
8
8
|
description = "Native async auth for Quart"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -26,7 +26,7 @@ dependencies = [
|
|
|
26
26
|
"blinker>=1.7",
|
|
27
27
|
"email-validator>=2.0",
|
|
28
28
|
"argon2-cffi>=23.1",
|
|
29
|
-
"
|
|
29
|
+
"httpx2>=2.16",
|
|
30
30
|
]
|
|
31
31
|
|
|
32
32
|
[project.optional-dependencies]
|
|
@@ -49,7 +49,6 @@ dev = [
|
|
|
49
49
|
"aiosqlite>=0.20",
|
|
50
50
|
"pytest>=8.0",
|
|
51
51
|
"pytest-asyncio>=0.23",
|
|
52
|
-
"respx>=0.23.1",
|
|
53
52
|
"ruff>=0.6",
|
|
54
53
|
]
|
|
55
54
|
|
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import hashlib
|
|
6
6
|
import logging
|
|
7
7
|
|
|
8
|
-
import
|
|
8
|
+
import httpx2
|
|
9
9
|
|
|
10
10
|
logger = logging.getLogger(__name__)
|
|
11
11
|
|
|
@@ -32,7 +32,7 @@ async def password_is_breached(password: str, *, count_min: int = 1) -> bool | N
|
|
|
32
32
|
prefix, suffix = sha1[:5], sha1[5:]
|
|
33
33
|
|
|
34
34
|
try:
|
|
35
|
-
async with
|
|
35
|
+
async with httpx2.AsyncClient(timeout=_TIMEOUT) as client:
|
|
36
36
|
response = await client.get(
|
|
37
37
|
_HIBP_URL.format(prefix=prefix),
|
|
38
38
|
headers={"Add-Padding": "true"},
|
|
@@ -14,6 +14,7 @@ from .password import init_password_context
|
|
|
14
14
|
from .proxies import AnonymousUser, current_user
|
|
15
15
|
from .signals import user_authenticated, user_logged_out
|
|
16
16
|
from .state import SQLAlchemyStateStore
|
|
17
|
+
from .totp import encrypt_totp_secret, init_totp_encryption
|
|
17
18
|
from .utils import maybe_await, naive_utcnow, url_for_security
|
|
18
19
|
|
|
19
20
|
|
|
@@ -119,6 +120,7 @@ class Security:
|
|
|
119
120
|
app.config.get("SESSION_COOKIE_SAMESITE") or "Lax"
|
|
120
121
|
)
|
|
121
122
|
init_password_context(app)
|
|
123
|
+
init_totp_encryption(app)
|
|
122
124
|
|
|
123
125
|
from .views import security_bp
|
|
124
126
|
|
|
@@ -228,6 +230,11 @@ class Security:
|
|
|
228
230
|
session.clear()
|
|
229
231
|
g._current_user = AnonymousUser()
|
|
230
232
|
return
|
|
233
|
+
if getattr(user, "tf_totp_secret", None) and not user.tf_totp_secret.startswith(
|
|
234
|
+
"fernet$"
|
|
235
|
+
):
|
|
236
|
+
user.tf_totp_secret = encrypt_totp_secret(user.tf_totp_secret)
|
|
237
|
+
await maybe_await(self.datastore.commit())
|
|
231
238
|
g._current_user = user
|
|
232
239
|
|
|
233
240
|
async def login_user(self, user, fresh=True):
|
|
@@ -248,6 +255,10 @@ class Security:
|
|
|
248
255
|
user.current_login_ip = request.remote_addr
|
|
249
256
|
user.login_count = (getattr(user, "login_count", None) or 0) + 1
|
|
250
257
|
|
|
258
|
+
if getattr(user, "tf_totp_secret", None) and not user.tf_totp_secret.startswith(
|
|
259
|
+
"fernet$"
|
|
260
|
+
):
|
|
261
|
+
user.tf_totp_secret = encrypt_totp_secret(user.tf_totp_secret)
|
|
251
262
|
await maybe_await(self.datastore.commit())
|
|
252
263
|
token = await self.state_store.put(
|
|
253
264
|
{"user_id": user_id},
|
|
@@ -9,6 +9,53 @@ import io
|
|
|
9
9
|
import secrets
|
|
10
10
|
import time
|
|
11
11
|
|
|
12
|
+
_TOTP_CIPHER_PREFIX = "fernet$"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def derive_totp_encryption_key(secret_key):
|
|
16
|
+
if isinstance(secret_key, str):
|
|
17
|
+
secret_key = secret_key.encode()
|
|
18
|
+
if not secret_key:
|
|
19
|
+
raise RuntimeError("TOTP encryption requires SECRET_KEY")
|
|
20
|
+
return base64.urlsafe_b64encode(
|
|
21
|
+
hmac.digest(secret_key, b"quart-security/totp-encryption/v1", "sha256")
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def init_totp_encryption(app):
|
|
26
|
+
from cryptography.fernet import Fernet, MultiFernet
|
|
27
|
+
|
|
28
|
+
keys = app.config.get("SECURITY_TOTP_ENCRYPTION_KEYS")
|
|
29
|
+
if keys is None:
|
|
30
|
+
keys = [derive_totp_encryption_key(app.secret_key)]
|
|
31
|
+
if not isinstance(keys, (list, tuple)) or not keys:
|
|
32
|
+
raise ValueError("SECURITY_TOTP_ENCRYPTION_KEYS must be a nonempty key list")
|
|
33
|
+
app.extensions["quart_security_totp"] = MultiFernet([Fernet(key) for key in keys])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _totp_cipher(app=None):
|
|
37
|
+
from quart import current_app
|
|
38
|
+
|
|
39
|
+
return (app or current_app).extensions["quart_security_totp"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def encrypt_totp_secret(secret, *, app=None):
|
|
43
|
+
cipher = _totp_cipher(app)
|
|
44
|
+
if secret.startswith(_TOTP_CIPHER_PREFIX):
|
|
45
|
+
token = cipher.rotate(secret[len(_TOTP_CIPHER_PREFIX) :].encode())
|
|
46
|
+
else:
|
|
47
|
+
token = cipher.encrypt(secret.encode())
|
|
48
|
+
return _TOTP_CIPHER_PREFIX + token.decode()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def decrypt_totp_secret(secret, *, app=None):
|
|
52
|
+
# Legacy seeds remain readable until the host encrypts existing records.
|
|
53
|
+
if not secret.startswith(_TOTP_CIPHER_PREFIX):
|
|
54
|
+
return secret
|
|
55
|
+
return (
|
|
56
|
+
_totp_cipher(app).decrypt(secret[len(_TOTP_CIPHER_PREFIX) :].encode()).decode()
|
|
57
|
+
)
|
|
58
|
+
|
|
12
59
|
|
|
13
60
|
def _require_pyotp():
|
|
14
61
|
try:
|
|
@@ -24,6 +71,7 @@ def generate_totp_secret() -> str:
|
|
|
24
71
|
|
|
25
72
|
|
|
26
73
|
def get_totp_uri(secret: str, email: str, issuer: str) -> str:
|
|
74
|
+
secret = decrypt_totp_secret(secret)
|
|
27
75
|
pyotp = _require_pyotp()
|
|
28
76
|
return pyotp.totp.TOTP(secret).provisioning_uri(name=email, issuer_name=issuer)
|
|
29
77
|
|
|
@@ -46,6 +94,7 @@ def verify_totp(secret: str, token: str) -> bool:
|
|
|
46
94
|
|
|
47
95
|
|
|
48
96
|
def matching_totp_step(secret: str, token: str) -> int | None:
|
|
97
|
+
secret = decrypt_totp_secret(secret)
|
|
49
98
|
pyotp = _require_pyotp()
|
|
50
99
|
totp = pyotp.TOTP(secret)
|
|
51
100
|
step = int(time.time()) // totp.interval
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import asyncio
|
|
5
6
|
import datetime
|
|
6
7
|
import hashlib
|
|
7
8
|
import hmac
|
|
@@ -165,6 +166,10 @@ def _webauthn_rp_name() -> str:
|
|
|
165
166
|
|
|
166
167
|
async def _set_wan_state(key: str, **payload):
|
|
167
168
|
await _pop_wan_state(key)
|
|
169
|
+
if key == "tf_setup_state":
|
|
170
|
+
from .totp import encrypt_totp_secret
|
|
171
|
+
|
|
172
|
+
payload["secret"] = encrypt_totp_secret(payload["secret"])
|
|
168
173
|
session[key] = await _security.state_store.put(payload, ttl=300)
|
|
169
174
|
|
|
170
175
|
|
|
@@ -174,10 +179,11 @@ async def _pop_wan_state(key: str) -> dict | None:
|
|
|
174
179
|
|
|
175
180
|
|
|
176
181
|
async def _verify_totp_once(secret, token):
|
|
177
|
-
from .totp import matching_totp_step
|
|
182
|
+
from .totp import decrypt_totp_secret, matching_totp_step
|
|
178
183
|
|
|
179
184
|
if not secret or not token:
|
|
180
185
|
return False
|
|
186
|
+
secret = decrypt_totp_secret(secret)
|
|
181
187
|
step = matching_totp_step(secret, token)
|
|
182
188
|
if step is None:
|
|
183
189
|
return False
|
|
@@ -592,7 +598,9 @@ async def two_factor_setup():
|
|
|
592
598
|
and token
|
|
593
599
|
and await _verify_totp_once(pending_secret, token)
|
|
594
600
|
):
|
|
595
|
-
|
|
601
|
+
from .totp import encrypt_totp_secret
|
|
602
|
+
|
|
603
|
+
current_user.tf_totp_secret = encrypt_totp_secret(pending_secret)
|
|
596
604
|
current_user.tf_primary_method = "authenticator"
|
|
597
605
|
|
|
598
606
|
raw_codes = []
|
|
@@ -642,11 +650,13 @@ async def two_factor_setup():
|
|
|
642
650
|
"tf_setup_state", secret=pending_secret, user_id=current_user.get_id()
|
|
643
651
|
)
|
|
644
652
|
else:
|
|
645
|
-
|
|
653
|
+
from .totp import decrypt_totp_secret
|
|
654
|
+
|
|
655
|
+
pending_secret = decrypt_totp_secret(pending["secret"])
|
|
646
656
|
authr_key = pending_secret
|
|
647
657
|
issuer = current_app.config.get("SECURITY_TOTP_ISSUER", "Quart")
|
|
648
658
|
uri = get_totp_uri(pending_secret, current_user.email, issuer)
|
|
649
|
-
authr_qrcode = generate_qr_code
|
|
659
|
+
authr_qrcode = await asyncio.to_thread(generate_qr_code, uri)
|
|
650
660
|
|
|
651
661
|
return await render_template(
|
|
652
662
|
"security/two_factor_setup.html",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{quart_security-2.0.0 → quart_security-2.0.2}/quart_security/templates/security/_macros.html
RENAMED
|
File without changes
|
{quart_security-2.0.0 → quart_security-2.0.2}/quart_security/templates/security/change_password.html
RENAMED
|
File without changes
|
{quart_security-2.0.0 → quart_security-2.0.2}/quart_security/templates/security/login_user.html
RENAMED
|
File without changes
|
{quart_security-2.0.0 → quart_security-2.0.2}/quart_security/templates/security/mf_recovery.html
RENAMED
|
File without changes
|
|
File without changes
|
{quart_security-2.0.0 → quart_security-2.0.2}/quart_security/templates/security/register_user.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{quart_security-2.0.0 → quart_security-2.0.2}/quart_security/templates/security/wan_register.html
RENAMED
|
File without changes
|
{quart_security-2.0.0 → quart_security-2.0.2}/quart_security/templates/security/wan_signin.html
RENAMED
|
File without changes
|
{quart_security-2.0.0 → quart_security-2.0.2}/quart_security/templates/security/wan_verify.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|