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.
Files changed (43) hide show
  1. quart_security-1.3.0/README.md → quart_security-1.4.1/PKG-INFO +45 -2
  2. quart_security-1.3.0/PKG-INFO → quart_security-1.4.1/README.md +16 -29
  3. {quart_security-1.3.0 → quart_security-1.4.1}/pyproject.toml +5 -3
  4. {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/__init__.py +8 -1
  5. quart_security-1.4.1/quart_security/breach.py +61 -0
  6. {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/core.py +14 -5
  7. {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/datastore.py +28 -2
  8. {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/decorators.py +13 -2
  9. {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/forms.py +0 -1
  10. quart_security-1.4.1/quart_security/password.py +90 -0
  11. quart_security-1.4.1/quart_security/py.typed +0 -0
  12. {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/templates/security/mf_recovery_codes.html +1 -1
  13. quart_security-1.4.1/quart_security/totp.py +110 -0
  14. {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/views.py +182 -53
  15. quart_security-1.3.0/.github/workflows/ci.yml +0 -34
  16. quart_security-1.3.0/.github/workflows/publish.yml +0 -37
  17. quart_security-1.3.0/.gitignore +0 -8
  18. quart_security-1.3.0/quart_security/password.py +0 -43
  19. quart_security-1.3.0/quart_security/totp.py +0 -61
  20. quart_security-1.3.0/tests/conftest.py +0 -241
  21. quart_security-1.3.0/tests/test_auth_flow.py +0 -98
  22. quart_security-1.3.0/tests/test_core_flows.py +0 -129
  23. quart_security-1.3.0/tests/test_password.py +0 -14
  24. quart_security-1.3.0/tests/test_two_factor.py +0 -108
  25. quart_security-1.3.0/tests/test_webauthn.py +0 -243
  26. {quart_security-1.3.0 → quart_security-1.4.1}/LICENSE +0 -0
  27. {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/email.py +0 -0
  28. {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/models.py +0 -0
  29. {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/proxies.py +0 -0
  30. {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/signals.py +0 -0
  31. {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/templates/security/_macros.html +0 -0
  32. {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/templates/security/change_password.html +0 -0
  33. {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/templates/security/login_user.html +0 -0
  34. {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/templates/security/mf_recovery.html +0 -0
  35. {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/templates/security/register_user.html +0 -0
  36. {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/templates/security/two_factor_select.html +0 -0
  37. {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/templates/security/two_factor_setup.html +0 -0
  38. {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/templates/security/two_factor_verify_code.html +0 -0
  39. {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/templates/security/wan_register.html +0 -0
  40. {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/templates/security/wan_signin.html +0 -0
  41. {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/templates/security/wan_verify.html +0 -0
  42. {quart_security-1.3.0 → quart_security-1.4.1}/quart_security/utils.py +0 -0
  43. {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.3.0"
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.0",
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 hash_password, verify_password
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": "pbkdf2_sha512",
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
- g._current_user = user or AnonymousUser()
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
- return self.session_factory()
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
- await self.session.commit()
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
@@ -109,5 +109,4 @@ class WebAuthnRegisterForm(QuartForm):
109
109
 
110
110
  class WebAuthnVerifyForm(QuartForm):
111
111
  identity = StringField("Email", validators=[Optional()])
112
- remember = BooleanField("Remember me")
113
112
  credential = StringField("Credential", validators=[Optional()])
@@ -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
- <input id="csrf_token" name="csrf_token" type="hidden" value="{{ csrf_token }}">
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