quart-security 1.3.0__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 (42) hide show
  1. {quart_security-1.3.0 → quart_security-1.4.0}/PKG-INFO +12 -5
  2. {quart_security-1.3.0 → quart_security-1.4.0}/README.md +6 -1
  3. {quart_security-1.3.0 → quart_security-1.4.0}/pyproject.toml +5 -3
  4. {quart_security-1.3.0 → 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.3.0 → 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.3.0 → quart_security-1.4.0}/quart_security/totp.py +9 -4
  10. {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/views.py +67 -10
  11. quart_security-1.3.0/.github/workflows/ci.yml +0 -34
  12. quart_security-1.3.0/.github/workflows/publish.yml +0 -37
  13. quart_security-1.3.0/.gitignore +0 -8
  14. quart_security-1.3.0/quart_security/password.py +0 -43
  15. quart_security-1.3.0/tests/conftest.py +0 -241
  16. quart_security-1.3.0/tests/test_auth_flow.py +0 -98
  17. quart_security-1.3.0/tests/test_core_flows.py +0 -129
  18. quart_security-1.3.0/tests/test_password.py +0 -14
  19. quart_security-1.3.0/tests/test_two_factor.py +0 -108
  20. quart_security-1.3.0/tests/test_webauthn.py +0 -243
  21. {quart_security-1.3.0 → quart_security-1.4.0}/LICENSE +0 -0
  22. {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/datastore.py +0 -0
  23. {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/decorators.py +0 -0
  24. {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/email.py +0 -0
  25. {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/forms.py +0 -0
  26. {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/models.py +0 -0
  27. {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/proxies.py +0 -0
  28. {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/signals.py +0 -0
  29. {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/templates/security/_macros.html +0 -0
  30. {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/templates/security/change_password.html +0 -0
  31. {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/templates/security/login_user.html +0 -0
  32. {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/templates/security/mf_recovery.html +0 -0
  33. {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/templates/security/mf_recovery_codes.html +0 -0
  34. {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/templates/security/register_user.html +0 -0
  35. {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/templates/security/two_factor_select.html +0 -0
  36. {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/templates/security/two_factor_setup.html +0 -0
  37. {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/templates/security/two_factor_verify_code.html +0 -0
  38. {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/templates/security/wan_register.html +0 -0
  39. {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/templates/security/wan_signin.html +0 -0
  40. {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/templates/security/wan_verify.html +0 -0
  41. {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/utils.py +0 -0
  42. {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/webauthn.py +0 -0
@@ -1,21 +1,23 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: quart-security
3
- Version: 1.3.0
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
7
7
  Description-Content-Type: text/markdown
8
+ License-File: LICENSE
8
9
  Requires-Dist: quart>=0.20.0
9
10
  Requires-Dist: sqlalchemy>=2.0
10
11
  Requires-Dist: libpass>=1.9
11
12
  Requires-Dist: pyotp>=2.9
12
13
  Requires-Dist: qrcode>=8.0
13
- Requires-Dist: webauthn>=2.0
14
+ Requires-Dist: webauthn>=2.7
14
15
  Requires-Dist: wtforms>=3.1
15
16
  Requires-Dist: aiosmtplib>=3.0
16
17
  Requires-Dist: blinker>=1.7
17
- Requires-Dist: itsdangerous>=2.1
18
18
  Requires-Dist: email-validator>=2.0
19
+ Requires-Dist: argon2-cffi>=23.1
20
+ Requires-Dist: httpx>=0.27
19
21
  Requires-Dist: pytest>=8.0 ; extra == "dev"
20
22
  Requires-Dist: pytest-asyncio>=0.23 ; extra == "dev"
21
23
  Requires-Dist: ruff>=0.6 ; extra == "dev"
@@ -139,9 +141,12 @@ Optional for lockout support:
139
141
  The extension uses `SECURITY_*` keys for migration-friendly configuration.
140
142
 
141
143
  Core:
142
- - `SECURITY_PASSWORD_HASH` (default: `pbkdf2_sha512`)
144
+ - `SECURITY_PASSWORD_HASH` (default: `argon2` / argon2id; set to `pbkdf2_sha512` to keep old behavior)
143
145
  - `SECURITY_PASSWORD_SALT` (recommended)
144
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)
145
150
  - `SECURITY_LOGIN_MAX_ATTEMPTS` (default: `5`)
146
151
  - `SECURITY_LOCKOUT_MINUTES` (default: `15`)
147
152
  - `SECURITY_REGISTERABLE`
@@ -149,6 +154,8 @@ Core:
149
154
  - `SECURITY_TRACKABLE`
150
155
  - `SECURITY_CSRF_PROTECT` (default: `True`)
151
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
+
152
159
  2FA:
153
160
  - `SECURITY_TWO_FACTOR`
154
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.3.0"
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
@@ -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
@@ -271,7 +284,7 @@ async def login():
271
284
 
272
285
  # Account lockout check
273
286
  if user and hasattr(user, "locked_until") and user.locked_until:
274
- if datetime.datetime.now() < user.locked_until:
287
+ if naive_utcnow() < user.locked_until:
275
288
  await flash("Account temporarily locked. Try again later.", "error")
276
289
  return await render_template(
277
290
  "security/login_user.html", login_user_form=form
@@ -284,8 +297,13 @@ async def login():
284
297
  if (
285
298
  user
286
299
  and getattr(user, "active", True)
287
- and verify_password(form.password.data, user.password)
300
+ and await verify_password_async(form.password.data, user.password)
288
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
+
289
307
  # Reset failed attempts on success
290
308
  if (
291
309
  hasattr(user, "failed_login_count")
@@ -310,7 +328,7 @@ async def login():
310
328
  max_attempts = current_app.config.get("SECURITY_LOGIN_MAX_ATTEMPTS", 5)
311
329
  lockout_minutes = current_app.config.get("SECURITY_LOCKOUT_MINUTES", 15)
312
330
  if user.failed_login_count >= max_attempts:
313
- user.locked_until = datetime.datetime.now() + datetime.timedelta(
331
+ user.locked_until = naive_utcnow() + datetime.timedelta(
314
332
  minutes=lockout_minutes
315
333
  )
316
334
  await _commit()
@@ -347,9 +365,24 @@ async def register():
347
365
  "security/register_user.html", register_user_form=form
348
366
  )
349
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
+
350
383
  user_kwargs = {
351
384
  "email": email,
352
- "password": hash_password(form.password.data),
385
+ "password": await hash_password_async(form.password.data),
353
386
  "active": True,
354
387
  }
355
388
  if hasattr(form, "name") and form.name.data:
@@ -395,7 +428,7 @@ async def change_password():
395
428
  return await render_template(
396
429
  "security/change_password.html", change_password_form=form
397
430
  )
398
- if not verify_password(current_password, user.password):
431
+ if not await verify_password_async(current_password, user.password):
399
432
  await flash("Invalid current password", "error")
400
433
  return await render_template(
401
434
  "security/change_password.html", change_password_form=form
@@ -417,7 +450,22 @@ async def change_password():
417
450
  "security/change_password.html", change_password_form=form
418
451
  )
419
452
 
420
- 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)
421
469
  if hasattr(user, "password_set"):
422
470
  user.password_set = True
423
471
 
@@ -753,7 +801,10 @@ async def wan_register_response():
753
801
  "SECURITY_WAN_REQUIRE_USER_VERIFICATION", True
754
802
  ),
755
803
  )
756
- except Exception:
804
+ except Exception as exc:
805
+ current_app.logger.warning(
806
+ "WebAuthn registration failed: %s: %s", type(exc).__name__, exc
807
+ )
757
808
  await flash("Passkey registration failed.", "error")
758
809
  return redirect(url_for_security("wan_register"))
759
810
 
@@ -868,7 +919,10 @@ async def wan_signin_response():
868
919
  "SECURITY_WAN_REQUIRE_USER_VERIFICATION", True
869
920
  ),
870
921
  )
871
- except Exception:
922
+ except Exception as exc:
923
+ current_app.logger.warning(
924
+ "WebAuthn sign-in failed: %s: %s", type(exc).__name__, exc
925
+ )
872
926
  await flash("Passkey verification failed.", "error")
873
927
  return redirect(url_for_security("wan_signin"))
874
928
 
@@ -974,7 +1028,10 @@ async def wan_verify_response():
974
1028
  "SECURITY_WAN_REQUIRE_USER_VERIFICATION", True
975
1029
  ),
976
1030
  )
977
- except Exception:
1031
+ except Exception as exc:
1032
+ current_app.logger.warning(
1033
+ "WebAuthn verification failed: %s: %s", type(exc).__name__, exc
1034
+ )
978
1035
  await flash("Passkey verification failed.", "error")
979
1036
  return redirect(url_for_security("wan_verify"))
980
1037
 
@@ -1,34 +0,0 @@
1
- name: CI
2
-
3
- on:
4
- push:
5
- branches:
6
- - main
7
- pull_request:
8
-
9
- jobs:
10
- test:
11
- runs-on: ubuntu-latest
12
-
13
- steps:
14
- - name: Checkout
15
- uses: actions/checkout@v4
16
-
17
- - name: Set up Python
18
- uses: actions/setup-python@v5
19
- with:
20
- python-version: "3.11"
21
-
22
- - name: Set up uv
23
- uses: astral-sh/setup-uv@v6
24
- with:
25
- enable-cache: true
26
-
27
- - name: Install dependencies
28
- run: uv sync --group dev
29
-
30
- - name: Run Ruff
31
- run: uv run ruff check .
32
-
33
- - name: Run tests
34
- run: uv run pytest -q
@@ -1,37 +0,0 @@
1
- name: Publish
2
-
3
- on:
4
- release:
5
- types:
6
- - published
7
- workflow_dispatch:
8
-
9
- permissions:
10
- contents: read
11
- id-token: write
12
-
13
- jobs:
14
- publish:
15
- if: github.repository == 'level09/quart-security'
16
- runs-on: ubuntu-latest
17
- environment:
18
- name: pypi
19
- url: https://pypi.org/p/quart-security
20
-
21
- steps:
22
- - name: Checkout
23
- uses: actions/checkout@v4
24
-
25
- - name: Set up Python
26
- uses: actions/setup-python@v5
27
- with:
28
- python-version: "3.12"
29
-
30
- - name: Set up uv
31
- uses: astral-sh/setup-uv@v6
32
-
33
- - name: Build distributions
34
- run: uv build
35
-
36
- - name: Publish to PyPI
37
- uses: pypa/gh-action-pypi-publish@release/v1
@@ -1,8 +0,0 @@
1
- __pycache__/
2
- *.pyc
3
- .pytest_cache/
4
- .ruff_cache/
5
- .uv-cache/
6
- .venv/
7
- dist/
8
- uv.lock
@@ -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