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.
- {quart_security-1.2.1 → quart_security-1.4.0}/PKG-INFO +10 -4
- {quart_security-1.2.1 → quart_security-1.4.0}/README.md +6 -1
- {quart_security-1.2.1 → quart_security-1.4.0}/pyproject.toml +5 -3
- {quart_security-1.2.1 → 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.2.1 → 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.2.1 → quart_security-1.4.0}/quart_security/templates/security/login_user.html +3 -0
- {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/templates/security/wan_signin.html +3 -6
- {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/totp.py +9 -4
- {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/views.py +102 -52
- {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/webauthn.py +27 -10
- quart_security-1.2.1/quart_security/password.py +0 -43
- {quart_security-1.2.1 → quart_security-1.4.0}/LICENSE +0 -0
- {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/datastore.py +0 -0
- {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/decorators.py +0 -0
- {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/email.py +0 -0
- {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/forms.py +0 -0
- {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/models.py +0 -0
- {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/proxies.py +0 -0
- {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/signals.py +0 -0
- {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/templates/security/_macros.html +0 -0
- {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/templates/security/change_password.html +0 -0
- {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/templates/security/mf_recovery.html +0 -0
- {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/templates/security/mf_recovery_codes.html +0 -0
- {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/templates/security/register_user.html +0 -0
- {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/templates/security/two_factor_select.html +0 -0
- {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/templates/security/two_factor_setup.html +0 -0
- {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/templates/security/two_factor_verify_code.html +0 -0
- {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/templates/security/wan_register.html +0 -0
- {quart_security-1.2.1 → quart_security-1.4.0}/quart_security/templates/security/wan_verify.html +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
{quart_security-1.2.1 → quart_security-1.4.0}/quart_security/templates/security/login_user.html
RENAMED
|
@@ -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>
|
{quart_security-1.2.1 → quart_security-1.4.0}/quart_security/templates/security/wan_signin.html
RENAMED
|
@@ -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-
|
|
7
|
+
<form method="post" action="{{ url_for_security('wan_signin') }}" id="wan-discoverable-form">
|
|
8
8
|
{{ wan_signin_form.hidden_tag() }}
|
|
9
|
-
|
|
10
|
-
<
|
|
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
|
-
|
|
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
|
|
@@ -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
|
|
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
|
|
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 =
|
|
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":
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
|
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
|
-
|
|
76
|
-
rp_id
|
|
77
|
-
rp_name
|
|
78
|
-
challenge
|
|
79
|
-
user_id
|
|
80
|
-
user_name
|
|
81
|
-
user_display_name
|
|
82
|
-
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{quart_security-1.2.1 → quart_security-1.4.0}/quart_security/templates/security/_macros.html
RENAMED
|
File without changes
|
{quart_security-1.2.1 → quart_security-1.4.0}/quart_security/templates/security/change_password.html
RENAMED
|
File without changes
|
{quart_security-1.2.1 → quart_security-1.4.0}/quart_security/templates/security/mf_recovery.html
RENAMED
|
File without changes
|
|
File without changes
|
{quart_security-1.2.1 → quart_security-1.4.0}/quart_security/templates/security/register_user.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{quart_security-1.2.1 → quart_security-1.4.0}/quart_security/templates/security/wan_register.html
RENAMED
|
File without changes
|
{quart_security-1.2.1 → quart_security-1.4.0}/quart_security/templates/security/wan_verify.html
RENAMED
|
File without changes
|
|
File without changes
|