quart-security 1.3.0__tar.gz → 1.4.1__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-1.3.0/README.md → quart_security-1.4.1/PKG-INFO +45 -2
- quart_security-1.3.0/PKG-INFO → quart_security-1.4.1/README.md +16 -29
- {quart_security-1.3.0 → quart_security-1.4.1}/pyproject.toml +5 -3
- {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/__init__.py +8 -1
- quart_security-1.4.1/quart_security/breach.py +61 -0
- {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/core.py +14 -5
- {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/datastore.py +28 -2
- {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/decorators.py +13 -2
- {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/forms.py +0 -1
- quart_security-1.4.1/quart_security/password.py +90 -0
- quart_security-1.4.1/quart_security/py.typed +0 -0
- {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/templates/security/mf_recovery_codes.html +1 -1
- quart_security-1.4.1/quart_security/totp.py +110 -0
- {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/views.py +182 -53
- quart_security-1.3.0/.github/workflows/ci.yml +0 -34
- quart_security-1.3.0/.github/workflows/publish.yml +0 -37
- quart_security-1.3.0/.gitignore +0 -8
- quart_security-1.3.0/quart_security/password.py +0 -43
- quart_security-1.3.0/quart_security/totp.py +0 -61
- quart_security-1.3.0/tests/conftest.py +0 -241
- quart_security-1.3.0/tests/test_auth_flow.py +0 -98
- quart_security-1.3.0/tests/test_core_flows.py +0 -129
- quart_security-1.3.0/tests/test_password.py +0 -14
- quart_security-1.3.0/tests/test_two_factor.py +0 -108
- quart_security-1.3.0/tests/test_webauthn.py +0 -243
- {quart_security-1.3.0 → quart_security-1.4.1}/LICENSE +0 -0
- {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/email.py +0 -0
- {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/models.py +0 -0
- {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/proxies.py +0 -0
- {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/signals.py +0 -0
- {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/templates/security/_macros.html +0 -0
- {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/templates/security/change_password.html +0 -0
- {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/templates/security/login_user.html +0 -0
- {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/templates/security/mf_recovery.html +0 -0
- {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/templates/security/register_user.html +0 -0
- {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/templates/security/two_factor_select.html +0 -0
- {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/templates/security/two_factor_setup.html +0 -0
- {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/templates/security/two_factor_verify_code.html +0 -0
- {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/templates/security/wan_register.html +0 -0
- {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/templates/security/wan_signin.html +0 -0
- {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/templates/security/wan_verify.html +0 -0
- {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/utils.py +0 -0
- {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/webauthn.py +0 -0
|
@@ -1,3 +1,31 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: quart-security
|
|
3
|
+
Version: 1.4.1
|
|
4
|
+
Summary: Native async auth for Quart
|
|
5
|
+
Author: quart-security maintainers
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: quart>=0.20.0
|
|
10
|
+
Requires-Dist: sqlalchemy>=2.0
|
|
11
|
+
Requires-Dist: libpass>=1.9
|
|
12
|
+
Requires-Dist: pyotp>=2.9
|
|
13
|
+
Requires-Dist: qrcode>=8.0
|
|
14
|
+
Requires-Dist: webauthn>=2.7
|
|
15
|
+
Requires-Dist: wtforms>=3.1
|
|
16
|
+
Requires-Dist: aiosmtplib>=3.0
|
|
17
|
+
Requires-Dist: blinker>=1.7
|
|
18
|
+
Requires-Dist: email-validator>=2.0
|
|
19
|
+
Requires-Dist: argon2-cffi>=23.1
|
|
20
|
+
Requires-Dist: httpx>=0.27
|
|
21
|
+
Requires-Dist: pytest>=8.0 ; extra == "dev"
|
|
22
|
+
Requires-Dist: pytest-asyncio>=0.23 ; extra == "dev"
|
|
23
|
+
Requires-Dist: ruff>=0.6 ; extra == "dev"
|
|
24
|
+
Project-URL: Homepage, https://github.com/level09/quart-security
|
|
25
|
+
Project-URL: Issues, https://github.com/level09/quart-security/issues
|
|
26
|
+
Project-URL: Repository, https://github.com/level09/quart-security
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
|
|
1
29
|
# quart-security
|
|
2
30
|
|
|
3
31
|
`quart-security` is a native async authentication extension for Quart.
|
|
@@ -108,20 +136,30 @@ Optional for lockout support:
|
|
|
108
136
|
- `failed_login_count`
|
|
109
137
|
- `locked_until`
|
|
110
138
|
|
|
139
|
+
These fields are required when login or MFA lockout is enabled. The built-in
|
|
140
|
+
counter limits account-level guesses. Deploy an application or edge rate limiter
|
|
141
|
+
for source IP and distributed abuse controls.
|
|
142
|
+
|
|
111
143
|
## Key Configuration
|
|
112
144
|
|
|
113
145
|
The extension uses `SECURITY_*` keys for migration-friendly configuration.
|
|
114
146
|
|
|
115
147
|
Core:
|
|
116
|
-
- `SECURITY_PASSWORD_HASH` (default: `pbkdf2_sha512`)
|
|
148
|
+
- `SECURITY_PASSWORD_HASH` (default: `argon2` / argon2id; set to `pbkdf2_sha512` to keep old behavior)
|
|
117
149
|
- `SECURITY_PASSWORD_SALT` (recommended)
|
|
118
150
|
- `SECURITY_PASSWORD_LENGTH_MIN` (default: `12`)
|
|
151
|
+
- `SECURITY_PASSWORD_BREACH_CHECK` (default: `True`) - HIBP k-anonymity check on register/change
|
|
152
|
+
- `SECURITY_PASSWORD_BREACH_COUNT_MIN` (default: `1`) - minimum breach count to reject
|
|
153
|
+
- `SECURITY_ARGON2_MEMORY_COST` / `SECURITY_ARGON2_TIME_COST` / `SECURITY_ARGON2_PARALLELISM` - override argon2 params (production defaults: 19456/2/1)
|
|
119
154
|
- `SECURITY_LOGIN_MAX_ATTEMPTS` (default: `5`)
|
|
120
155
|
- `SECURITY_LOCKOUT_MINUTES` (default: `15`)
|
|
121
156
|
- `SECURITY_REGISTERABLE`
|
|
122
157
|
- `SECURITY_CHANGEABLE`
|
|
123
158
|
- `SECURITY_TRACKABLE`
|
|
124
159
|
- `SECURITY_CSRF_PROTECT` (default: `True`)
|
|
160
|
+
- `SECURITY_FRESHNESS` (default: `60` minutes)
|
|
161
|
+
|
|
162
|
+
Existing pbkdf2_sha512 and bcrypt hashes continue to verify after the argon2 default change. They are transparently rehashed to argon2id on the user's next successful login.
|
|
125
163
|
|
|
126
164
|
2FA:
|
|
127
165
|
- `SECURITY_TWO_FACTOR`
|
|
@@ -129,6 +167,10 @@ Core:
|
|
|
129
167
|
- `SECURITY_MULTI_FACTOR_RECOVERY_CODES`
|
|
130
168
|
- `SECURITY_MULTI_FACTOR_RECOVERY_CODES_N`
|
|
131
169
|
|
|
170
|
+
Recovery codes are displayed only when generated and are stored as keyed hashes.
|
|
171
|
+
Existing plaintext codes remain valid until their next successful use, when the
|
|
172
|
+
remaining codes are migrated.
|
|
173
|
+
|
|
132
174
|
WebAuthn:
|
|
133
175
|
- `SECURITY_WEBAUTHN`
|
|
134
176
|
- `SECURITY_WAN_ALLOW_AS_FIRST_FACTOR`
|
|
@@ -147,7 +189,7 @@ Routing:
|
|
|
147
189
|
Core:
|
|
148
190
|
- `/login`
|
|
149
191
|
- `/register`
|
|
150
|
-
- `/logout`
|
|
192
|
+
- `/logout` (POST only)
|
|
151
193
|
- `/change`
|
|
152
194
|
|
|
153
195
|
2FA:
|
|
@@ -225,3 +267,4 @@ Run this in a HTTPS staging environment with production-like hostnames and real
|
|
|
225
267
|
- Run behind HTTPS for WebAuthn in non-local environments.
|
|
226
268
|
- Set explicit WebAuthn RP values (`SECURITY_WAN_RP_ID`, `SECURITY_WAN_EXPECTED_ORIGIN`) when behind proxies or multiple domains.
|
|
227
269
|
- Keep CSRF protection enabled unless you have a deliberate replacement.
|
|
270
|
+
|
|
@@ -1,29 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.1
|
|
2
|
-
Name: quart-security
|
|
3
|
-
Version: 1.3.0
|
|
4
|
-
Summary: Native async auth for Quart
|
|
5
|
-
Author: quart-security maintainers
|
|
6
|
-
Requires-Python: >=3.11
|
|
7
|
-
Description-Content-Type: text/markdown
|
|
8
|
-
Requires-Dist: quart>=0.20.0
|
|
9
|
-
Requires-Dist: sqlalchemy>=2.0
|
|
10
|
-
Requires-Dist: libpass>=1.9
|
|
11
|
-
Requires-Dist: pyotp>=2.9
|
|
12
|
-
Requires-Dist: qrcode>=8.0
|
|
13
|
-
Requires-Dist: webauthn>=2.0
|
|
14
|
-
Requires-Dist: wtforms>=3.1
|
|
15
|
-
Requires-Dist: aiosmtplib>=3.0
|
|
16
|
-
Requires-Dist: blinker>=1.7
|
|
17
|
-
Requires-Dist: itsdangerous>=2.1
|
|
18
|
-
Requires-Dist: email-validator>=2.0
|
|
19
|
-
Requires-Dist: pytest>=8.0 ; extra == "dev"
|
|
20
|
-
Requires-Dist: pytest-asyncio>=0.23 ; extra == "dev"
|
|
21
|
-
Requires-Dist: ruff>=0.6 ; extra == "dev"
|
|
22
|
-
Project-URL: Homepage, https://github.com/level09/quart-security
|
|
23
|
-
Project-URL: Issues, https://github.com/level09/quart-security/issues
|
|
24
|
-
Project-URL: Repository, https://github.com/level09/quart-security
|
|
25
|
-
Provides-Extra: dev
|
|
26
|
-
|
|
27
1
|
# quart-security
|
|
28
2
|
|
|
29
3
|
`quart-security` is a native async authentication extension for Quart.
|
|
@@ -134,20 +108,30 @@ Optional for lockout support:
|
|
|
134
108
|
- `failed_login_count`
|
|
135
109
|
- `locked_until`
|
|
136
110
|
|
|
111
|
+
These fields are required when login or MFA lockout is enabled. The built-in
|
|
112
|
+
counter limits account-level guesses. Deploy an application or edge rate limiter
|
|
113
|
+
for source IP and distributed abuse controls.
|
|
114
|
+
|
|
137
115
|
## Key Configuration
|
|
138
116
|
|
|
139
117
|
The extension uses `SECURITY_*` keys for migration-friendly configuration.
|
|
140
118
|
|
|
141
119
|
Core:
|
|
142
|
-
- `SECURITY_PASSWORD_HASH` (default: `pbkdf2_sha512`)
|
|
120
|
+
- `SECURITY_PASSWORD_HASH` (default: `argon2` / argon2id; set to `pbkdf2_sha512` to keep old behavior)
|
|
143
121
|
- `SECURITY_PASSWORD_SALT` (recommended)
|
|
144
122
|
- `SECURITY_PASSWORD_LENGTH_MIN` (default: `12`)
|
|
123
|
+
- `SECURITY_PASSWORD_BREACH_CHECK` (default: `True`) - HIBP k-anonymity check on register/change
|
|
124
|
+
- `SECURITY_PASSWORD_BREACH_COUNT_MIN` (default: `1`) - minimum breach count to reject
|
|
125
|
+
- `SECURITY_ARGON2_MEMORY_COST` / `SECURITY_ARGON2_TIME_COST` / `SECURITY_ARGON2_PARALLELISM` - override argon2 params (production defaults: 19456/2/1)
|
|
145
126
|
- `SECURITY_LOGIN_MAX_ATTEMPTS` (default: `5`)
|
|
146
127
|
- `SECURITY_LOCKOUT_MINUTES` (default: `15`)
|
|
147
128
|
- `SECURITY_REGISTERABLE`
|
|
148
129
|
- `SECURITY_CHANGEABLE`
|
|
149
130
|
- `SECURITY_TRACKABLE`
|
|
150
131
|
- `SECURITY_CSRF_PROTECT` (default: `True`)
|
|
132
|
+
- `SECURITY_FRESHNESS` (default: `60` minutes)
|
|
133
|
+
|
|
134
|
+
Existing pbkdf2_sha512 and bcrypt hashes continue to verify after the argon2 default change. They are transparently rehashed to argon2id on the user's next successful login.
|
|
151
135
|
|
|
152
136
|
2FA:
|
|
153
137
|
- `SECURITY_TWO_FACTOR`
|
|
@@ -155,6 +139,10 @@ Core:
|
|
|
155
139
|
- `SECURITY_MULTI_FACTOR_RECOVERY_CODES`
|
|
156
140
|
- `SECURITY_MULTI_FACTOR_RECOVERY_CODES_N`
|
|
157
141
|
|
|
142
|
+
Recovery codes are displayed only when generated and are stored as keyed hashes.
|
|
143
|
+
Existing plaintext codes remain valid until their next successful use, when the
|
|
144
|
+
remaining codes are migrated.
|
|
145
|
+
|
|
158
146
|
WebAuthn:
|
|
159
147
|
- `SECURITY_WEBAUTHN`
|
|
160
148
|
- `SECURITY_WAN_ALLOW_AS_FIRST_FACTOR`
|
|
@@ -173,7 +161,7 @@ Routing:
|
|
|
173
161
|
Core:
|
|
174
162
|
- `/login`
|
|
175
163
|
- `/register`
|
|
176
|
-
- `/logout`
|
|
164
|
+
- `/logout` (POST only)
|
|
177
165
|
- `/change`
|
|
178
166
|
|
|
179
167
|
2FA:
|
|
@@ -251,4 +239,3 @@ Run this in a HTTPS staging environment with production-like hostnames and real
|
|
|
251
239
|
- Run behind HTTPS for WebAuthn in non-local environments.
|
|
252
240
|
- Set explicit WebAuthn RP values (`SECURITY_WAN_RP_ID`, `SECURITY_WAN_EXPECTED_ORIGIN`) when behind proxies or multiple domains.
|
|
253
241
|
- Keep CSRF protection enabled unless you have a deliberate replacement.
|
|
254
|
-
|
|
@@ -4,7 +4,7 @@ build-backend = "flit_core.buildapi"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "quart-security"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.4.1"
|
|
8
8
|
description = "Native async auth for Quart"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -18,12 +18,13 @@ dependencies = [
|
|
|
18
18
|
"libpass>=1.9",
|
|
19
19
|
"pyotp>=2.9",
|
|
20
20
|
"qrcode>=8.0",
|
|
21
|
-
"webauthn>=2.
|
|
21
|
+
"webauthn>=2.7",
|
|
22
22
|
"wtforms>=3.1",
|
|
23
23
|
"aiosmtplib>=3.0",
|
|
24
24
|
"blinker>=1.7",
|
|
25
|
-
"itsdangerous>=2.1",
|
|
26
25
|
"email-validator>=2.0",
|
|
26
|
+
"argon2-cffi>=23.1",
|
|
27
|
+
"httpx>=0.27",
|
|
27
28
|
]
|
|
28
29
|
|
|
29
30
|
[project.optional-dependencies]
|
|
@@ -45,6 +46,7 @@ name = "quart_security"
|
|
|
45
46
|
dev = [
|
|
46
47
|
"pytest>=8.0",
|
|
47
48
|
"pytest-asyncio>=0.23",
|
|
49
|
+
"respx>=0.23.1",
|
|
48
50
|
"ruff>=0.6",
|
|
49
51
|
]
|
|
50
52
|
|
|
@@ -4,7 +4,12 @@ from .core import Security
|
|
|
4
4
|
from .datastore import SQLAlchemyUserDatastore
|
|
5
5
|
from .decorators import auth_required, roles_required
|
|
6
6
|
from .models import RoleMixin, UserMixin
|
|
7
|
-
from .password import
|
|
7
|
+
from .password import (
|
|
8
|
+
hash_password,
|
|
9
|
+
hash_password_async,
|
|
10
|
+
verify_password,
|
|
11
|
+
verify_password_async,
|
|
12
|
+
)
|
|
8
13
|
from .proxies import current_user
|
|
9
14
|
from .signals import (
|
|
10
15
|
password_changed,
|
|
@@ -23,7 +28,9 @@ __all__ = [
|
|
|
23
28
|
"UserMixin",
|
|
24
29
|
"RoleMixin",
|
|
25
30
|
"hash_password",
|
|
31
|
+
"hash_password_async",
|
|
26
32
|
"verify_password",
|
|
33
|
+
"verify_password_async",
|
|
27
34
|
"current_user",
|
|
28
35
|
"user_authenticated",
|
|
29
36
|
"user_logged_out",
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""HIBP k-anonymity breach check (NIST 800-63B-4 SHALL)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
_HIBP_URL = "https://api.pwnedpasswords.com/range/{prefix}"
|
|
13
|
+
_TIMEOUT = 3.0
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def password_is_breached(password: str, *, count_min: int = 1) -> bool | None:
|
|
17
|
+
"""Check whether *password* appears in the HIBP breach corpus.
|
|
18
|
+
|
|
19
|
+
Uses the k-anonymity range API: sends only the first 5 hex chars of the
|
|
20
|
+
SHA-1 digest. The full digest never leaves the process.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
True - password appears >= count_min times.
|
|
24
|
+
False - password not found in response.
|
|
25
|
+
None - network/HTTP error; caller should fail-open (allow the password).
|
|
26
|
+
"""
|
|
27
|
+
sha1 = (
|
|
28
|
+
hashlib.sha1(password.encode("utf-8"), usedforsecurity=False)
|
|
29
|
+
.hexdigest()
|
|
30
|
+
.upper()
|
|
31
|
+
)
|
|
32
|
+
prefix, suffix = sha1[:5], sha1[5:]
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
|
36
|
+
response = await client.get(
|
|
37
|
+
_HIBP_URL.format(prefix=prefix),
|
|
38
|
+
headers={"Add-Padding": "true"},
|
|
39
|
+
)
|
|
40
|
+
response.raise_for_status()
|
|
41
|
+
except Exception as exc:
|
|
42
|
+
logger.warning(
|
|
43
|
+
"HIBP breach check failed (%s: %s); failing open",
|
|
44
|
+
type(exc).__name__,
|
|
45
|
+
exc,
|
|
46
|
+
)
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
for line in response.text.splitlines():
|
|
50
|
+
parts = line.split(":", 1)
|
|
51
|
+
if len(parts) != 2:
|
|
52
|
+
continue
|
|
53
|
+
candidate, raw_count = parts[0].strip(), parts[1].strip()
|
|
54
|
+
if candidate == suffix:
|
|
55
|
+
try:
|
|
56
|
+
count = int(raw_count)
|
|
57
|
+
except ValueError:
|
|
58
|
+
continue
|
|
59
|
+
return count >= count_min
|
|
60
|
+
|
|
61
|
+
return False
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import secrets
|
|
6
|
+
import time
|
|
6
7
|
from datetime import timedelta
|
|
7
8
|
|
|
8
9
|
from quart import current_app, g, request, session
|
|
@@ -111,14 +112,15 @@ class Security:
|
|
|
111
112
|
@staticmethod
|
|
112
113
|
def _load_defaults(app):
|
|
113
114
|
defaults = {
|
|
114
|
-
"SECURITY_PASSWORD_HASH": "
|
|
115
|
+
"SECURITY_PASSWORD_HASH": "argon2",
|
|
116
|
+
"SECURITY_PASSWORD_BREACH_CHECK": True,
|
|
117
|
+
"SECURITY_PASSWORD_BREACH_COUNT_MIN": 1,
|
|
115
118
|
"SECURITY_PASSWORD_LENGTH_MIN": 12,
|
|
116
119
|
"SECURITY_REGISTERABLE": True,
|
|
117
120
|
"SECURITY_CHANGEABLE": True,
|
|
118
121
|
"SECURITY_TRACKABLE": True,
|
|
119
122
|
"SECURITY_TWO_FACTOR": True,
|
|
120
123
|
"SECURITY_WEBAUTHN": True,
|
|
121
|
-
"SECURITY_TWO_FACTOR_ENABLED_METHODS": ["authenticator"],
|
|
122
124
|
"SECURITY_TOTP_ISSUER": "Quart",
|
|
123
125
|
"SECURITY_MULTI_FACTOR_RECOVERY_CODES": True,
|
|
124
126
|
"SECURITY_MULTI_FACTOR_RECOVERY_CODES_N": 3,
|
|
@@ -135,9 +137,7 @@ class Security:
|
|
|
135
137
|
"SECURITY_POST_LOGIN_VIEW": "/",
|
|
136
138
|
"SECURITY_POST_REGISTER_VIEW": "/login",
|
|
137
139
|
"SECURITY_EMAIL_SENDER": "noreply@example.com",
|
|
138
|
-
"SECURITY_SEND_PASSWORD_CHANGE_EMAIL": False,
|
|
139
140
|
"SECURITY_CSRF_PROTECT": True,
|
|
140
|
-
"SECURITY_API_ENABLED_METHODS": ["session"],
|
|
141
141
|
}
|
|
142
142
|
|
|
143
143
|
for key, value in defaults.items():
|
|
@@ -150,7 +150,11 @@ class Security:
|
|
|
150
150
|
return
|
|
151
151
|
|
|
152
152
|
user = await maybe_await(self.datastore.find_user(fs_uniquifier=user_id))
|
|
153
|
-
|
|
153
|
+
if user is None or not getattr(user, "active", True):
|
|
154
|
+
session.clear()
|
|
155
|
+
g._current_user = AnonymousUser()
|
|
156
|
+
return
|
|
157
|
+
g._current_user = user
|
|
154
158
|
|
|
155
159
|
async def login_user(self, user, fresh=True):
|
|
156
160
|
app = current_app._get_current_object()
|
|
@@ -161,9 +165,14 @@ class Security:
|
|
|
161
165
|
if not user_id:
|
|
162
166
|
raise RuntimeError("User must have fs_uniquifier/get_id for session login")
|
|
163
167
|
|
|
168
|
+
# Regenerate session to prevent fixation: discard pre-login state,
|
|
169
|
+
# then write only the login keys into a clean session.
|
|
170
|
+
session.clear()
|
|
164
171
|
session["_user_id"] = user_id
|
|
165
172
|
session["_fresh"] = bool(fresh)
|
|
173
|
+
session["_auth_at"] = int(time.time())
|
|
166
174
|
session["_id"] = secrets.token_hex(16)
|
|
175
|
+
session.modified = True
|
|
167
176
|
g._current_user = user
|
|
168
177
|
|
|
169
178
|
if app.config.get("SECURITY_TRACKABLE", True):
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from contextvars import ContextVar
|
|
5
6
|
from uuid import uuid4
|
|
6
7
|
|
|
7
8
|
from sqlalchemy import select
|
|
@@ -15,10 +16,19 @@ class SQLAlchemyUserDatastore:
|
|
|
15
16
|
self.user_model = user_model
|
|
16
17
|
self.role_model = role_model
|
|
17
18
|
self.webauthn_model = webauthn_model
|
|
19
|
+
self._active_session = ContextVar("quart_security_session", default=None)
|
|
18
20
|
|
|
19
21
|
@property
|
|
20
22
|
def session(self):
|
|
21
|
-
|
|
23
|
+
source = self.session_factory
|
|
24
|
+
configured = getattr(source, "session", None)
|
|
25
|
+
if configured is not None:
|
|
26
|
+
return configured
|
|
27
|
+
active = self._active_session.get()
|
|
28
|
+
if active is None:
|
|
29
|
+
active = source() if callable(source) else source
|
|
30
|
+
self._active_session.set(active)
|
|
31
|
+
return active
|
|
22
32
|
|
|
23
33
|
async def _first(self, model, **kwargs):
|
|
24
34
|
stmt = select(model).filter_by(**kwargs)
|
|
@@ -130,6 +140,18 @@ class SQLAlchemyUserDatastore:
|
|
|
130
140
|
return credential
|
|
131
141
|
return None
|
|
132
142
|
|
|
143
|
+
async def find_user_for_webauthn_credential(self, credential):
|
|
144
|
+
related_user = getattr(credential, "user", None)
|
|
145
|
+
if related_user is not None:
|
|
146
|
+
return related_user
|
|
147
|
+
owner_id = getattr(credential, "user_id", None)
|
|
148
|
+
if owner_id is None:
|
|
149
|
+
return None
|
|
150
|
+
user = await self.find_user(fs_webauthn_user_handle=owner_id)
|
|
151
|
+
if user is None and hasattr(self.user_model, "id"):
|
|
152
|
+
user = await self.find_user(id=owner_id)
|
|
153
|
+
return user
|
|
154
|
+
|
|
133
155
|
async def create_webauthn_credential(self, user, **kwargs):
|
|
134
156
|
if self.webauthn_model is None:
|
|
135
157
|
raise RuntimeError("webauthn_model is required for WebAuthn credentials")
|
|
@@ -168,4 +190,8 @@ class SQLAlchemyUserDatastore:
|
|
|
168
190
|
return True
|
|
169
191
|
|
|
170
192
|
async def commit(self):
|
|
171
|
-
|
|
193
|
+
active = self.session
|
|
194
|
+
try:
|
|
195
|
+
await active.commit()
|
|
196
|
+
finally:
|
|
197
|
+
self._active_session.set(None)
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
"""Authentication and authorization decorators."""
|
|
2
2
|
|
|
3
|
+
import datetime
|
|
4
|
+
import time
|
|
3
5
|
from functools import wraps
|
|
4
6
|
|
|
5
|
-
from quart import abort, current_app, redirect, request
|
|
7
|
+
from quart import abort, current_app, redirect, request, session
|
|
6
8
|
|
|
7
9
|
from .proxies import current_user
|
|
8
10
|
from .utils import url_for_security
|
|
9
11
|
|
|
10
12
|
|
|
11
|
-
def auth_required(*methods):
|
|
13
|
+
def auth_required(*methods, fresh=False):
|
|
12
14
|
"""Require an authenticated user for a route."""
|
|
13
15
|
|
|
14
16
|
allowed_methods = methods or ("session",)
|
|
@@ -22,6 +24,15 @@ def auth_required(*methods):
|
|
|
22
24
|
if request.is_json:
|
|
23
25
|
abort(401)
|
|
24
26
|
return redirect(url_for_security("login", next=request.url))
|
|
27
|
+
if fresh:
|
|
28
|
+
freshness = current_app.config.get(
|
|
29
|
+
"SECURITY_FRESHNESS", datetime.timedelta(minutes=60)
|
|
30
|
+
)
|
|
31
|
+
max_age = freshness.total_seconds()
|
|
32
|
+
authenticated_at = session.get("_auth_at", 0)
|
|
33
|
+
is_stale = time.time() - authenticated_at > max_age
|
|
34
|
+
if not session.get("_fresh") or is_stale:
|
|
35
|
+
abort(401)
|
|
25
36
|
return await current_app.ensure_async(func)(*args, **kwargs)
|
|
26
37
|
|
|
27
38
|
return wrapper
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Password hashing and validation helpers."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
from passlib.context import CryptContext
|
|
6
|
+
|
|
7
|
+
_pwd_context: CryptContext | None = None
|
|
8
|
+
_password_salt: str | None = None
|
|
9
|
+
|
|
10
|
+
# OWASP 2025 minimum argon2id parameters
|
|
11
|
+
_ARGON2_MEMORY_COST = 19456 # KiB
|
|
12
|
+
_ARGON2_TIME_COST = 2
|
|
13
|
+
_ARGON2_PARALLELISM = 1
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def init_password_context(app):
|
|
17
|
+
"""Initialize passlib context from app config.
|
|
18
|
+
|
|
19
|
+
Default scheme is argon2id (OWASP 2025). Existing pbkdf2_sha512 and bcrypt
|
|
20
|
+
hashes continue to verify and are transparently rehashed on next login when
|
|
21
|
+
password_needs_rehash() returns True.
|
|
22
|
+
|
|
23
|
+
Set SECURITY_PASSWORD_HASH to override the default scheme.
|
|
24
|
+
Set SECURITY_ARGON2_MEMORY_COST / _TIME_COST / _PARALLELISM to override
|
|
25
|
+
argon2 parameters (useful for test environments).
|
|
26
|
+
"""
|
|
27
|
+
global _pwd_context, _password_salt
|
|
28
|
+
|
|
29
|
+
scheme = app.config.get("SECURITY_PASSWORD_HASH", "argon2")
|
|
30
|
+
_password_salt = app.config.get("SECURITY_PASSWORD_SALT")
|
|
31
|
+
|
|
32
|
+
memory_cost = app.config.get("SECURITY_ARGON2_MEMORY_COST", _ARGON2_MEMORY_COST)
|
|
33
|
+
time_cost = app.config.get("SECURITY_ARGON2_TIME_COST", _ARGON2_TIME_COST)
|
|
34
|
+
parallelism = app.config.get("SECURITY_ARGON2_PARALLELISM", _ARGON2_PARALLELISM)
|
|
35
|
+
|
|
36
|
+
# Build context: argon2 with OWASP params, legacy schemes deprecated.
|
|
37
|
+
# CryptContext handles per-scheme kwargs via <scheme>__<param> notation.
|
|
38
|
+
_pwd_context = CryptContext(
|
|
39
|
+
schemes=["argon2", "pbkdf2_sha512", "bcrypt"],
|
|
40
|
+
default=scheme,
|
|
41
|
+
deprecated="auto",
|
|
42
|
+
argon2__memory_cost=memory_cost,
|
|
43
|
+
argon2__time_cost=time_cost,
|
|
44
|
+
argon2__parallelism=parallelism,
|
|
45
|
+
argon2__type="id",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _ensure_context() -> CryptContext:
|
|
50
|
+
if _pwd_context is None:
|
|
51
|
+
raise RuntimeError("Password context is not initialized")
|
|
52
|
+
return _pwd_context
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def hash_password(password: str) -> str:
|
|
56
|
+
return _ensure_context().hash(password)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def verify_password(password: str, password_hash: str) -> bool:
|
|
60
|
+
context = _ensure_context()
|
|
61
|
+
if context.verify(password, password_hash):
|
|
62
|
+
return True
|
|
63
|
+
# Optional fallback for deployments that previously mixed in app salt.
|
|
64
|
+
if _password_salt:
|
|
65
|
+
return context.verify(f"{password}{_password_salt}", password_hash)
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def password_needs_rehash(password_hash: str) -> bool:
|
|
70
|
+
"""Return True if the hash was made with a deprecated/weaker scheme.
|
|
71
|
+
|
|
72
|
+
Call this after a successful verify to decide whether to upgrade the
|
|
73
|
+
stored hash transparently on login.
|
|
74
|
+
"""
|
|
75
|
+
return _ensure_context().needs_update(password_hash)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def hash_password_async(password: str) -> str:
|
|
79
|
+
return await asyncio.to_thread(hash_password, password)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def verify_password_async(password: str, password_hash: str) -> bool:
|
|
83
|
+
return await asyncio.to_thread(verify_password, password, password_hash)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def validate_password(password: str, min_length: int = 12) -> list[str]:
|
|
87
|
+
errors: list[str] = []
|
|
88
|
+
if len(password) < min_length:
|
|
89
|
+
errors.append(f"Password must be at least {min_length} characters")
|
|
90
|
+
return errors
|
|
File without changes
|
|
@@ -8,6 +8,6 @@
|
|
|
8
8
|
{% endfor %}
|
|
9
9
|
</ul>
|
|
10
10
|
<form method="post" action="{{ url_for_security('mf_recovery_codes') }}">
|
|
11
|
-
|
|
11
|
+
{{ mf_recovery_codes_form.hidden_tag() }}
|
|
12
12
|
<button type="submit">Regenerate codes</button>
|
|
13
13
|
</form>
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""TOTP and recovery-code helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import hmac
|
|
8
|
+
import io
|
|
9
|
+
import secrets
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _require_pyotp():
|
|
13
|
+
try:
|
|
14
|
+
import pyotp
|
|
15
|
+
except ImportError as exc:
|
|
16
|
+
raise RuntimeError("pyotp is required for TOTP support") from exc
|
|
17
|
+
return pyotp
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def generate_totp_secret() -> str:
|
|
21
|
+
pyotp = _require_pyotp()
|
|
22
|
+
return pyotp.random_base32()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_totp_uri(secret: str, email: str, issuer: str) -> str:
|
|
26
|
+
pyotp = _require_pyotp()
|
|
27
|
+
return pyotp.totp.TOTP(secret).provisioning_uri(name=email, issuer_name=issuer)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def generate_qr_code(uri: str) -> str:
|
|
31
|
+
try:
|
|
32
|
+
import qrcode
|
|
33
|
+
except ImportError as exc:
|
|
34
|
+
raise RuntimeError("qrcode is required for QR generation") from exc
|
|
35
|
+
|
|
36
|
+
image = qrcode.make(uri)
|
|
37
|
+
buffer = io.BytesIO()
|
|
38
|
+
image.save(buffer, format="PNG")
|
|
39
|
+
b64 = base64.b64encode(buffer.getvalue()).decode("utf-8")
|
|
40
|
+
return f"data:image/png;base64,{b64}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def verify_totp(secret: str, token: str) -> bool:
|
|
44
|
+
pyotp = _require_pyotp()
|
|
45
|
+
totp = pyotp.TOTP(secret)
|
|
46
|
+
return bool(totp.verify(token, valid_window=1))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def generate_recovery_codes(n: int = 3) -> list[str]:
|
|
50
|
+
codes = []
|
|
51
|
+
for _ in range(n):
|
|
52
|
+
raw = secrets.token_hex(5)
|
|
53
|
+
codes.append(f"{raw[:5]}-{raw[5:]}")
|
|
54
|
+
return codes
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_RECOVERY_HASH_PREFIX = "hmac-sha256$"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _normalize_recovery_code(code: str) -> str:
|
|
61
|
+
return code.strip().lower().replace("-", "").replace(" ", "")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _recovery_digest(code: str, secret_key: bytes | str) -> str:
|
|
65
|
+
if isinstance(secret_key, str):
|
|
66
|
+
secret_key = secret_key.encode()
|
|
67
|
+
digest = hmac.new(
|
|
68
|
+
secret_key, _normalize_recovery_code(code).encode(), hashlib.sha256
|
|
69
|
+
).hexdigest()
|
|
70
|
+
return f"{_RECOVERY_HASH_PREFIX}{digest}"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def hash_recovery_codes(codes: list[str], secret_key: bytes | str) -> list[str]:
|
|
74
|
+
return [_recovery_digest(code, secret_key) for code in codes]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def ensure_hashed_recovery_codes(
|
|
78
|
+
codes: list[str], secret_key: bytes | str
|
|
79
|
+
) -> list[str]:
|
|
80
|
+
return [
|
|
81
|
+
code if is_hashed_recovery_code(code) else _recovery_digest(code, secret_key)
|
|
82
|
+
for code in codes
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def is_hashed_recovery_code(code: str) -> bool:
|
|
87
|
+
return code.startswith(_RECOVERY_HASH_PREFIX)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def verify_recovery_code(
|
|
91
|
+
code: str, stored_codes: list[str], *, secret_key: bytes | str = b""
|
|
92
|
+
) -> tuple[bool, list[str]]:
|
|
93
|
+
normalized = code.strip().lower().replace("-", "").replace(" ", "")
|
|
94
|
+
submitted_hash = _recovery_digest(code, secret_key)
|
|
95
|
+
matched_index = -1
|
|
96
|
+
for i, stored in enumerate(stored_codes):
|
|
97
|
+
stored_normalized = _normalize_recovery_code(stored)
|
|
98
|
+
# compare_digest on every entry — no early exit
|
|
99
|
+
matches_plaintext = not is_hashed_recovery_code(
|
|
100
|
+
stored
|
|
101
|
+
) and secrets.compare_digest(normalized, stored_normalized)
|
|
102
|
+
matches_hash = is_hashed_recovery_code(stored) and secrets.compare_digest(
|
|
103
|
+
submitted_hash, stored
|
|
104
|
+
)
|
|
105
|
+
if matches_plaintext or matches_hash:
|
|
106
|
+
matched_index = i
|
|
107
|
+
if matched_index >= 0:
|
|
108
|
+
remaining = [item for i, item in enumerate(stored_codes) if i != matched_index]
|
|
109
|
+
return True, remaining
|
|
110
|
+
return False, stored_codes
|