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.
- {quart_security-1.3.0 → quart_security-1.4.0}/PKG-INFO +12 -5
- {quart_security-1.3.0 → quart_security-1.4.0}/README.md +6 -1
- {quart_security-1.3.0 → quart_security-1.4.0}/pyproject.toml +5 -3
- {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/__init__.py +8 -1
- quart_security-1.4.0/quart_security/breach.py +61 -0
- {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/core.py +7 -4
- quart_security-1.4.0/quart_security/password.py +90 -0
- quart_security-1.4.0/quart_security/py.typed +0 -0
- {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/totp.py +9 -4
- {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/views.py +67 -10
- 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/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.0}/LICENSE +0 -0
- {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/datastore.py +0 -0
- {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/decorators.py +0 -0
- {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/email.py +0 -0
- {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/forms.py +0 -0
- {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/models.py +0 -0
- {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/proxies.py +0 -0
- {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/signals.py +0 -0
- {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/templates/security/_macros.html +0 -0
- {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/templates/security/change_password.html +0 -0
- {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/templates/security/login_user.html +0 -0
- {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/templates/security/mf_recovery.html +0 -0
- {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/templates/security/mf_recovery_codes.html +0 -0
- {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/templates/security/register_user.html +0 -0
- {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/templates/security/two_factor_select.html +0 -0
- {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/templates/security/two_factor_setup.html +0 -0
- {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/templates/security/two_factor_verify_code.html +0 -0
- {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/templates/security/wan_register.html +0 -0
- {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/templates/security/wan_signin.html +0 -0
- {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/templates/security/wan_verify.html +0 -0
- {quart_security-1.3.0 → quart_security-1.4.0}/quart_security/utils.py +0 -0
- {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
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: quart-security
|
|
3
|
-
Version: 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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
@@ -111,14 +111,15 @@ class Security:
|
|
|
111
111
|
@staticmethod
|
|
112
112
|
def _load_defaults(app):
|
|
113
113
|
defaults = {
|
|
114
|
-
"SECURITY_PASSWORD_HASH": "
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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":
|
|
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
|
|
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
|
-
|
|
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
|
quart_security-1.3.0/.gitignore
DELETED
|
@@ -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
|