quart-security 1.2.1__tar.gz → 1.4.0__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 (33) hide show
  1. {quart_security-1.2.1 → quart_security-1.4.0}/PKG-INFO +10 -4
  2. {quart_security-1.2.1 → quart_security-1.4.0}/README.md +6 -1
  3. {quart_security-1.2.1 → quart_security-1.4.0}/pyproject.toml +5 -3
  4. {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/__init__.py +8 -1
  5. quart_security-1.4.0/quart_security/breach.py +61 -0
  6. {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/core.py +7 -4
  7. quart_security-1.4.0/quart_security/password.py +90 -0
  8. quart_security-1.4.0/quart_security/py.typed +0 -0
  9. {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/templates/security/login_user.html +3 -0
  10. {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/templates/security/wan_signin.html +3 -6
  11. {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/totp.py +9 -4
  12. {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/views.py +102 -52
  13. {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/webauthn.py +27 -10
  14. quart_security-1.2.1/quart_security/password.py +0 -43
  15. {quart_security-1.2.1 → quart_security-1.4.0}/LICENSE +0 -0
  16. {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/datastore.py +0 -0
  17. {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/decorators.py +0 -0
  18. {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/email.py +0 -0
  19. {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/forms.py +0 -0
  20. {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/models.py +0 -0
  21. {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/proxies.py +0 -0
  22. {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/signals.py +0 -0
  23. {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/templates/security/_macros.html +0 -0
  24. {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/templates/security/change_password.html +0 -0
  25. {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/templates/security/mf_recovery.html +0 -0
  26. {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/templates/security/mf_recovery_codes.html +0 -0
  27. {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/templates/security/register_user.html +0 -0
  28. {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/templates/security/two_factor_select.html +0 -0
  29. {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/templates/security/two_factor_setup.html +0 -0
  30. {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/templates/security/two_factor_verify_code.html +0 -0
  31. {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/templates/security/wan_register.html +0 -0
  32. {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/templates/security/wan_verify.html +0 -0
  33. {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quart-security
3
- Version: 1.2.1
3
+ Version: 1.4.0
4
4
  Summary: Native async auth for Quart
5
5
  Author: quart-security maintainers
6
6
  Requires-Python: >=3.11
@@ -11,12 +11,13 @@ Requires-Dist: sqlalchemy>=2.0
11
11
  Requires-Dist: libpass>=1.9
12
12
  Requires-Dist: pyotp>=2.9
13
13
  Requires-Dist: qrcode>=8.0
14
- Requires-Dist: webauthn>=2.0
14
+ Requires-Dist: webauthn>=2.7
15
15
  Requires-Dist: wtforms>=3.1
16
16
  Requires-Dist: aiosmtplib>=3.0
17
17
  Requires-Dist: blinker>=1.7
18
- Requires-Dist: itsdangerous>=2.1
19
18
  Requires-Dist: email-validator>=2.0
19
+ Requires-Dist: argon2-cffi>=23.1
20
+ Requires-Dist: httpx>=0.27
20
21
  Requires-Dist: pytest>=8.0 ; extra == "dev"
21
22
  Requires-Dist: pytest-asyncio>=0.23 ; extra == "dev"
22
23
  Requires-Dist: ruff>=0.6 ; extra == "dev"
@@ -140,9 +141,12 @@ Optional for lockout support:
140
141
  The extension uses `SECURITY_*` keys for migration-friendly configuration.
141
142
 
142
143
  Core:
143
- - `SECURITY_PASSWORD_HASH` (default: `pbkdf2_sha512`)
144
+ - `SECURITY_PASSWORD_HASH` (default: `argon2` / argon2id; set to `pbkdf2_sha512` to keep old behavior)
144
145
  - `SECURITY_PASSWORD_SALT` (recommended)
145
146
  - `SECURITY_PASSWORD_LENGTH_MIN` (default: `12`)
147
+ - `SECURITY_PASSWORD_BREACH_CHECK` (default: `True`) - HIBP k-anonymity check on register/change
148
+ - `SECURITY_PASSWORD_BREACH_COUNT_MIN` (default: `1`) - minimum breach count to reject
149
+ - `SECURITY_ARGON2_MEMORY_COST` / `SECURITY_ARGON2_TIME_COST` / `SECURITY_ARGON2_PARALLELISM` - override argon2 params (production defaults: 19456/2/1)
146
150
  - `SECURITY_LOGIN_MAX_ATTEMPTS` (default: `5`)
147
151
  - `SECURITY_LOCKOUT_MINUTES` (default: `15`)
148
152
  - `SECURITY_REGISTERABLE`
@@ -150,6 +154,8 @@ Core:
150
154
  - `SECURITY_TRACKABLE`
151
155
  - `SECURITY_CSRF_PROTECT` (default: `True`)
152
156
 
157
+ 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.
158
+
153
159
  2FA:
154
160
  - `SECURITY_TWO_FACTOR`
155
161
  - `SECURITY_TOTP_ISSUER`
@@ -113,9 +113,12 @@ Optional for lockout support:
113
113
  The extension uses `SECURITY_*` keys for migration-friendly configuration.
114
114
 
115
115
  Core:
116
- - `SECURITY_PASSWORD_HASH` (default: `pbkdf2_sha512`)
116
+ - `SECURITY_PASSWORD_HASH` (default: `argon2` / argon2id; set to `pbkdf2_sha512` to keep old behavior)
117
117
  - `SECURITY_PASSWORD_SALT` (recommended)
118
118
  - `SECURITY_PASSWORD_LENGTH_MIN` (default: `12`)
119
+ - `SECURITY_PASSWORD_BREACH_CHECK` (default: `True`) - HIBP k-anonymity check on register/change
120
+ - `SECURITY_PASSWORD_BREACH_COUNT_MIN` (default: `1`) - minimum breach count to reject
121
+ - `SECURITY_ARGON2_MEMORY_COST` / `SECURITY_ARGON2_TIME_COST` / `SECURITY_ARGON2_PARALLELISM` - override argon2 params (production defaults: 19456/2/1)
119
122
  - `SECURITY_LOGIN_MAX_ATTEMPTS` (default: `5`)
120
123
  - `SECURITY_LOCKOUT_MINUTES` (default: `15`)
121
124
  - `SECURITY_REGISTERABLE`
@@ -123,6 +126,8 @@ Core:
123
126
  - `SECURITY_TRACKABLE`
124
127
  - `SECURITY_CSRF_PROTECT` (default: `True`)
125
128
 
129
+ 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.
130
+
126
131
  2FA:
127
132
  - `SECURITY_TWO_FACTOR`
128
133
  - `SECURITY_TOTP_ISSUER`
@@ -4,7 +4,7 @@ build-backend = "flit_core.buildapi"
4
4
 
5
5
  [project]
6
6
  name = "quart-security"
7
- version = "1.2.1"
7
+ version = "1.4.0"
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
@@ -111,14 +111,15 @@ class Security:
111
111
  @staticmethod
112
112
  def _load_defaults(app):
113
113
  defaults = {
114
- "SECURITY_PASSWORD_HASH": "pbkdf2_sha512",
114
+ "SECURITY_PASSWORD_HASH": "argon2",
115
+ "SECURITY_PASSWORD_BREACH_CHECK": True,
116
+ "SECURITY_PASSWORD_BREACH_COUNT_MIN": 1,
115
117
  "SECURITY_PASSWORD_LENGTH_MIN": 12,
116
118
  "SECURITY_REGISTERABLE": True,
117
119
  "SECURITY_CHANGEABLE": True,
118
120
  "SECURITY_TRACKABLE": True,
119
121
  "SECURITY_TWO_FACTOR": True,
120
122
  "SECURITY_WEBAUTHN": True,
121
- "SECURITY_TWO_FACTOR_ENABLED_METHODS": ["authenticator"],
122
123
  "SECURITY_TOTP_ISSUER": "Quart",
123
124
  "SECURITY_MULTI_FACTOR_RECOVERY_CODES": True,
124
125
  "SECURITY_MULTI_FACTOR_RECOVERY_CODES_N": 3,
@@ -135,9 +136,7 @@ class Security:
135
136
  "SECURITY_POST_LOGIN_VIEW": "/",
136
137
  "SECURITY_POST_REGISTER_VIEW": "/login",
137
138
  "SECURITY_EMAIL_SENDER": "noreply@example.com",
138
- "SECURITY_SEND_PASSWORD_CHANGE_EMAIL": False,
139
139
  "SECURITY_CSRF_PROTECT": True,
140
- "SECURITY_API_ENABLED_METHODS": ["session"],
141
140
  }
142
141
 
143
142
  for key, value in defaults.items():
@@ -161,9 +160,13 @@ class Security:
161
160
  if not user_id:
162
161
  raise RuntimeError("User must have fs_uniquifier/get_id for session login")
163
162
 
163
+ # Regenerate session to prevent fixation: discard pre-login state,
164
+ # then write only the login keys into a clean session.
165
+ session.clear()
164
166
  session["_user_id"] = user_id
165
167
  session["_fresh"] = bool(fresh)
166
168
  session["_id"] = secrets.token_hex(16)
169
+ session.modified = True
167
170
  g._current_user = user
168
171
 
169
172
  if app.config.get("SECURITY_TRACKABLE", True):
@@ -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,4 +8,7 @@
8
8
  {{ render_input(login_user_form.password, 'password') }}
9
9
  <button type="submit">Sign in</button>
10
10
  </form>
11
+ {% if security.webauthn and security.wan_allow_as_first_factor %}
12
+ <p><a href="{{ url_for_security('wan_signin') }}">Sign in with passkey</a></p>
13
+ {% endif %}
11
14
  <p><a href="{{ url_for_security('register') }}">Create account</a></p>
@@ -4,13 +4,10 @@
4
4
  <h1>Sign In with Passkey</h1>
5
5
 
6
6
  {% if not credential_options %}
7
- <form method="post" action="{{ url_for_security('wan_signin') }}" id="wan-signin-form">
7
+ <form method="post" action="{{ url_for_security('wan_signin') }}" id="wan-discoverable-form">
8
8
  {{ wan_signin_form.hidden_tag() }}
9
- {{ render_input(wan_signin_form.identity) }}
10
- <label style="display: block; margin-bottom: 0.75rem;">
11
- {{ wan_signin_form.remember }} {{ wan_signin_form.remember.label.text }}
12
- </label>
13
- <button type="submit">Continue</button>
9
+ <input type="hidden" name="identity" value="">
10
+ <button type="submit">Sign in with passkey</button>
14
11
  </form>
15
12
  <p><a href="{{ url_for_security('login') }}">Back to password login</a></p>
16
13
  {% else %}
@@ -54,8 +54,13 @@ def generate_recovery_codes(n: int = 3) -> list[str]:
54
54
 
55
55
  def verify_recovery_code(code: str, stored_codes: list[str]) -> tuple[bool, list[str]]:
56
56
  normalized = code.strip().lower().replace("-", "").replace(" ", "")
57
- for stored in stored_codes:
58
- if normalized == stored.replace("-", ""):
59
- remaining = [item for item in stored_codes if item != stored]
60
- return True, remaining
57
+ matched_index = -1
58
+ for i, stored in enumerate(stored_codes):
59
+ stored_normalized = stored.strip().lower().replace("-", "").replace(" ", "")
60
+ # compare_digest on every entry — no early exit
61
+ if secrets.compare_digest(normalized, stored_normalized):
62
+ matched_index = i
63
+ if matched_index >= 0:
64
+ remaining = [item for i, item in enumerate(stored_codes) if i != matched_index]
65
+ return True, remaining
61
66
  return False, stored_codes
@@ -20,6 +20,7 @@ from quart import (
20
20
  from werkzeug.routing import BuildError
21
21
 
22
22
  from . import webauthn as wan
23
+ from .breach import password_is_breached
23
24
  from .decorators import auth_required
24
25
  from .forms import (
25
26
  QuartForm,
@@ -29,7 +30,12 @@ from .forms import (
29
30
  WebAuthnRegisterForm,
30
31
  WebAuthnVerifyForm,
31
32
  )
32
- from .password import hash_password, validate_password, verify_password
33
+ from .password import (
34
+ hash_password_async,
35
+ password_needs_rehash,
36
+ validate_password,
37
+ verify_password_async,
38
+ )
33
39
  from .proxies import _security, current_user
34
40
  from .signals import password_changed, tf_profile_changed, user_registered
35
41
  from .utils import maybe_await, naive_utcnow, url_for_security
@@ -96,6 +102,13 @@ def _ensure_csrf_token() -> str:
96
102
  def _safe_redirect_target(candidate: str | None, fallback: str = "/") -> str:
97
103
  if not candidate:
98
104
  return fallback
105
+ # Reject control characters (ASCII 0-31)
106
+ if any(ord(c) < 32 for c in candidate):
107
+ return fallback
108
+ # Reject backslash variants before any query string
109
+ path_part = candidate.split("?", 1)[0]
110
+ if "\\" in path_part:
111
+ return fallback
99
112
  parts = urlsplit(candidate)
100
113
  if parts.scheme or parts.netloc:
101
114
  return fallback
@@ -200,6 +213,21 @@ def _extract_webauthn_credential_id(credential_payload: dict | None) -> bytes |
200
213
  return None
201
214
 
202
215
 
216
+ def _extract_webauthn_user_handle(credential_payload: dict | None) -> str | None:
217
+ if not isinstance(credential_payload, dict):
218
+ return None
219
+ response = credential_payload.get("response")
220
+ if not isinstance(response, dict):
221
+ return None
222
+ user_handle = response.get("userHandle")
223
+ if not isinstance(user_handle, str) or not user_handle:
224
+ return None
225
+ try:
226
+ return wan.base64url_to_bytes(user_handle).decode("utf-8")
227
+ except Exception:
228
+ return None
229
+
230
+
203
231
  async def _extract_webauthn_credential_payload():
204
232
  payload = await request.get_json(silent=True)
205
233
  if isinstance(payload, dict):
@@ -256,7 +284,7 @@ async def login():
256
284
 
257
285
  # Account lockout check
258
286
  if user and hasattr(user, "locked_until") and user.locked_until:
259
- if datetime.datetime.now() < user.locked_until:
287
+ if naive_utcnow() < user.locked_until:
260
288
  await flash("Account temporarily locked. Try again later.", "error")
261
289
  return await render_template(
262
290
  "security/login_user.html", login_user_form=form
@@ -269,8 +297,13 @@ async def login():
269
297
  if (
270
298
  user
271
299
  and getattr(user, "active", True)
272
- and verify_password(form.password.data, user.password)
300
+ and await verify_password_async(form.password.data, user.password)
273
301
  ):
302
+ # Transparent rehash: upgrade weak/legacy hash on successful login
303
+ if password_needs_rehash(user.password):
304
+ user.password = await hash_password_async(form.password.data)
305
+ await _commit()
306
+
274
307
  # Reset failed attempts on success
275
308
  if (
276
309
  hasattr(user, "failed_login_count")
@@ -295,7 +328,7 @@ async def login():
295
328
  max_attempts = current_app.config.get("SECURITY_LOGIN_MAX_ATTEMPTS", 5)
296
329
  lockout_minutes = current_app.config.get("SECURITY_LOCKOUT_MINUTES", 15)
297
330
  if user.failed_login_count >= max_attempts:
298
- user.locked_until = datetime.datetime.now() + datetime.timedelta(
331
+ user.locked_until = naive_utcnow() + datetime.timedelta(
299
332
  minutes=lockout_minutes
300
333
  )
301
334
  await _commit()
@@ -332,9 +365,24 @@ async def register():
332
365
  "security/register_user.html", register_user_form=form
333
366
  )
334
367
 
368
+ if current_app.config.get("SECURITY_PASSWORD_BREACH_CHECK", True):
369
+ count_min = current_app.config.get("SECURITY_PASSWORD_BREACH_COUNT_MIN", 1)
370
+ breached = await password_is_breached(
371
+ form.password.data, count_min=count_min
372
+ )
373
+ if breached:
374
+ await flash(
375
+ "This password has appeared in a data breach."
376
+ " Choose a different one.",
377
+ "error",
378
+ )
379
+ return await render_template(
380
+ "security/register_user.html", register_user_form=form
381
+ )
382
+
335
383
  user_kwargs = {
336
384
  "email": email,
337
- "password": hash_password(form.password.data),
385
+ "password": await hash_password_async(form.password.data),
338
386
  "active": True,
339
387
  }
340
388
  if hasattr(form, "name") and form.name.data:
@@ -380,7 +428,7 @@ async def change_password():
380
428
  return await render_template(
381
429
  "security/change_password.html", change_password_form=form
382
430
  )
383
- if not verify_password(current_password, user.password):
431
+ if not await verify_password_async(current_password, user.password):
384
432
  await flash("Invalid current password", "error")
385
433
  return await render_template(
386
434
  "security/change_password.html", change_password_form=form
@@ -402,7 +450,22 @@ async def change_password():
402
450
  "security/change_password.html", change_password_form=form
403
451
  )
404
452
 
405
- user.password = hash_password(form.new_password.data)
453
+ if current_app.config.get("SECURITY_PASSWORD_BREACH_CHECK", True):
454
+ count_min = current_app.config.get("SECURITY_PASSWORD_BREACH_COUNT_MIN", 1)
455
+ breached = await password_is_breached(
456
+ form.new_password.data, count_min=count_min
457
+ )
458
+ if breached:
459
+ await flash(
460
+ "This password has appeared in a data breach."
461
+ " Choose a different one.",
462
+ "error",
463
+ )
464
+ return await render_template(
465
+ "security/change_password.html", change_password_form=form
466
+ )
467
+
468
+ user.password = await hash_password_async(form.new_password.data)
406
469
  if hasattr(user, "password_set"):
407
470
  user.password_set = True
408
471
 
@@ -689,6 +752,7 @@ async def wan_register():
689
752
  rp_name=_webauthn_rp_name(),
690
753
  challenge=challenge,
691
754
  existing_credentials=registered_credentials,
755
+ discoverable=(requested_usage == "primary"),
692
756
  )
693
757
  credential_options = wan.options_to_json_dict(options)
694
758
 
@@ -737,7 +801,10 @@ async def wan_register_response():
737
801
  "SECURITY_WAN_REQUIRE_USER_VERIFICATION", True
738
802
  ),
739
803
  )
740
- except Exception:
804
+ except Exception as exc:
805
+ current_app.logger.warning(
806
+ "WebAuthn registration failed: %s: %s", type(exc).__name__, exc
807
+ )
741
808
  await flash("Passkey registration failed.", "error")
742
809
  return redirect(url_for_security("wan_register"))
743
810
 
@@ -776,46 +843,13 @@ async def wan_signin():
776
843
  response_form = await QuartForm.from_formdata()
777
844
 
778
845
  credential_options = None
846
+
779
847
  if _is_post():
780
848
  await _enforce_csrf(getattr(form, "_submitted_csrf", None))
781
- identity = _normalize_email(form.identity.data)
782
- if not identity:
783
- await flash("Email is required.", "error")
784
- return await render_template(
785
- "security/wan_signin.html",
786
- wan_signin_form=form,
787
- wan_signin_response_form=response_form,
788
- credential_options=None,
789
- )
790
-
791
- user = await _find_user(email=identity)
792
- if user is None or not getattr(user, "active", True):
793
- await flash("Unable to authenticate with passkey.", "error")
794
- return await render_template(
795
- "security/wan_signin.html",
796
- wan_signin_form=form,
797
- wan_signin_response_form=response_form,
798
- credential_options=None,
799
- )
800
-
801
- credentials = await _list_webauthn_credentials(user)
802
- credentials = [
803
- credential
804
- for credential in credentials
805
- if getattr(credential, "usage", "secondary") in {"primary", "first"}
806
- ]
807
- if not credentials:
808
- await flash("No passkey is configured for passwordless sign-in.", "error")
809
- return await render_template(
810
- "security/wan_signin.html",
811
- wan_signin_form=form,
812
- wan_signin_response_form=response_form,
813
- credential_options=None,
814
- )
815
849
 
816
850
  challenge = secrets.token_bytes(32)
817
851
  options = await wan.begin_authentication(
818
- credentials,
852
+ [],
819
853
  rp_id=_webauthn_rp_id(),
820
854
  challenge=challenge,
821
855
  )
@@ -823,7 +857,6 @@ async def wan_signin():
823
857
  _set_wan_state(
824
858
  "wan_signin_state",
825
859
  challenge=wan.bytes_to_base64url(challenge),
826
- user_id=user.get_id(),
827
860
  remember=bool(form.remember.data),
828
861
  )
829
862
 
@@ -848,18 +881,29 @@ async def wan_signin_response():
848
881
  await flash("Passkey sign-in expired. Please try again.", "error")
849
882
  return redirect(url_for_security("wan_signin"))
850
883
 
851
- user = await _find_user(fs_uniquifier=state.get("user_id"))
852
- if user is None:
853
- await flash("Unable to find user for passkey sign-in.", "error")
854
- return redirect(url_for_security("wan_signin"))
855
-
856
884
  credential_payload = await _extract_webauthn_credential_payload()
857
885
  credential_id = _extract_webauthn_credential_id(credential_payload)
858
886
  if not credential_payload or credential_id is None:
859
887
  await flash("Invalid passkey response payload.", "error")
860
888
  return redirect(url_for_security("wan_signin"))
861
889
 
862
- stored_credential = await _find_webauthn_credential(credential_id, user=user)
890
+ stored_credential = await _find_webauthn_credential(credential_id, user=None)
891
+ if stored_credential is None:
892
+ await flash("Passkey not recognized.", "error")
893
+ return redirect(url_for_security("wan_signin"))
894
+
895
+ credential_user_id = getattr(stored_credential, "user_id", None)
896
+ user_handle = _extract_webauthn_user_handle(credential_payload)
897
+ lookup_handle = credential_user_id or user_handle
898
+ if not lookup_handle:
899
+ await flash("Unable to identify user from passkey.", "error")
900
+ return redirect(url_for_security("wan_signin"))
901
+
902
+ user = await _find_user(fs_webauthn_user_handle=lookup_handle)
903
+ if user is None or not getattr(user, "active", True):
904
+ await flash("Unable to find user for passkey sign-in.", "error")
905
+ return redirect(url_for_security("wan_signin"))
906
+
863
907
  if stored_credential is None:
864
908
  await flash("Passkey not recognized.", "error")
865
909
  return redirect(url_for_security("wan_signin"))
@@ -875,7 +919,10 @@ async def wan_signin_response():
875
919
  "SECURITY_WAN_REQUIRE_USER_VERIFICATION", True
876
920
  ),
877
921
  )
878
- except Exception:
922
+ except Exception as exc:
923
+ current_app.logger.warning(
924
+ "WebAuthn sign-in failed: %s: %s", type(exc).__name__, exc
925
+ )
879
926
  await flash("Passkey verification failed.", "error")
880
927
  return redirect(url_for_security("wan_signin"))
881
928
 
@@ -981,7 +1028,10 @@ async def wan_verify_response():
981
1028
  "SECURITY_WAN_REQUIRE_USER_VERIFICATION", True
982
1029
  ),
983
1030
  )
984
- except Exception:
1031
+ except Exception as exc:
1032
+ current_app.logger.warning(
1033
+ "WebAuthn verification failed: %s: %s", type(exc).__name__, exc
1034
+ )
985
1035
  await flash("Passkey verification failed.", "error")
986
1036
  return redirect(url_for_security("wan_verify"))
987
1037
 
@@ -26,7 +26,12 @@ def _require_webauthn():
26
26
  verify_authentication_response,
27
27
  verify_registration_response,
28
28
  )
29
- from webauthn.helpers.structs import PublicKeyCredentialDescriptor
29
+ from webauthn.helpers.structs import (
30
+ AuthenticatorSelectionCriteria,
31
+ PublicKeyCredentialDescriptor,
32
+ ResidentKeyRequirement,
33
+ UserVerificationRequirement,
34
+ )
30
35
  except ImportError as exc:
31
36
  raise RuntimeError("webauthn package is required for WebAuthn support") from exc
32
37
 
@@ -37,6 +42,9 @@ def _require_webauthn():
37
42
  "verify_authentication_response": verify_authentication_response,
38
43
  "verify_registration_response": verify_registration_response,
39
44
  "PublicKeyCredentialDescriptor": PublicKeyCredentialDescriptor,
45
+ "AuthenticatorSelectionCriteria": AuthenticatorSelectionCriteria,
46
+ "ResidentKeyRequirement": ResidentKeyRequirement,
47
+ "UserVerificationRequirement": UserVerificationRequirement,
40
48
  }
41
49
 
42
50
 
@@ -59,6 +67,7 @@ async def begin_registration(
59
67
  rp_name,
60
68
  challenge: bytes,
61
69
  existing_credentials=None,
70
+ discoverable: bool = False,
62
71
  ):
63
72
  api = _require_webauthn()
64
73
 
@@ -72,15 +81,23 @@ async def begin_registration(
72
81
  api["PublicKeyCredentialDescriptor"](id=cred.credential_id)
73
82
  )
74
83
 
75
- return api["generate_registration_options"](
76
- rp_id=rp_id,
77
- rp_name=rp_name,
78
- challenge=challenge,
79
- user_id=user_id,
80
- user_name=getattr(user, "email", ""),
81
- user_display_name=getattr(user, "name", None) or getattr(user, "email", ""),
82
- exclude_credentials=exclude_credentials,
83
- )
84
+ kwargs = {
85
+ "rp_id": rp_id,
86
+ "rp_name": rp_name,
87
+ "challenge": challenge,
88
+ "user_id": user_id,
89
+ "user_name": getattr(user, "email", ""),
90
+ "user_display_name": getattr(user, "name", None) or getattr(user, "email", ""),
91
+ "exclude_credentials": exclude_credentials,
92
+ }
93
+
94
+ if discoverable:
95
+ kwargs["authenticator_selection"] = api["AuthenticatorSelectionCriteria"](
96
+ resident_key=api["ResidentKeyRequirement"].REQUIRED,
97
+ user_verification=api["UserVerificationRequirement"].REQUIRED,
98
+ )
99
+
100
+ return api["generate_registration_options"](**kwargs)
84
101
 
85
102
 
86
103
  async def complete_registration(
@@ -1,43 +0,0 @@
1
- """Password hashing and validation helpers."""
2
-
3
- from passlib.context import CryptContext
4
-
5
- _pwd_context: CryptContext | None = None
6
- _password_salt: str | None = None
7
-
8
-
9
- def init_password_context(app):
10
- """Initialize passlib context from app config."""
11
- global _pwd_context, _password_salt
12
-
13
- scheme = app.config.get("SECURITY_PASSWORD_HASH", "pbkdf2_sha512")
14
- _password_salt = app.config.get("SECURITY_PASSWORD_SALT")
15
- _pwd_context = CryptContext(schemes=[scheme], deprecated="auto")
16
-
17
-
18
- def _ensure_context() -> CryptContext:
19
- if _pwd_context is None:
20
- raise RuntimeError("Password context is not initialized")
21
- return _pwd_context
22
-
23
-
24
- def hash_password(password: str) -> str:
25
- # Keep default behavior compatible with existing Flask-Security hashes.
26
- return _ensure_context().hash(password)
27
-
28
-
29
- def verify_password(password: str, password_hash: str) -> bool:
30
- context = _ensure_context()
31
- if context.verify(password, password_hash):
32
- return True
33
- # Optional fallback for deployments that previously mixed in app salt.
34
- if _password_salt:
35
- return context.verify(f"{password}{_password_salt}", password_hash)
36
- return False
37
-
38
-
39
- def validate_password(password: str, min_length: int = 12) -> list[str]:
40
- errors: list[str] = []
41
- if len(password) < min_length:
42
- errors.append(f"Password must be at least {min_length} characters")
43
- return errors
File without changes