quart-security 1.4.0__tar.gz → 1.4.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {quart_security-1.4.0 → quart_security-1.4.1}/PKG-INFO +11 -2
- {quart_security-1.4.0 → quart_security-1.4.1}/README.md +10 -1
- {quart_security-1.4.0 → quart_security-1.4.1}/pyproject.toml +1 -1
- {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/core.py +7 -1
- {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/datastore.py +28 -2
- {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/decorators.py +13 -2
- {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/forms.py +0 -1
- {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/templates/security/mf_recovery_codes.html +1 -1
- {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/totp.py +47 -3
- {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/views.py +118 -46
- {quart_security-1.4.0 → quart_security-1.4.1}/LICENSE +0 -0
- {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/__init__.py +0 -0
- {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/breach.py +0 -0
- {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/email.py +0 -0
- {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/models.py +0 -0
- {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/password.py +0 -0
- {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/proxies.py +0 -0
- {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/py.typed +0 -0
- {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/signals.py +0 -0
- {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/templates/security/_macros.html +0 -0
- {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/templates/security/change_password.html +0 -0
- {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/templates/security/login_user.html +0 -0
- {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/templates/security/mf_recovery.html +0 -0
- {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/templates/security/register_user.html +0 -0
- {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/templates/security/two_factor_select.html +0 -0
- {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/templates/security/two_factor_setup.html +0 -0
- {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/templates/security/two_factor_verify_code.html +0 -0
- {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/templates/security/wan_register.html +0 -0
- {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/templates/security/wan_signin.html +0 -0
- {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/templates/security/wan_verify.html +0 -0
- {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/utils.py +0 -0
- {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/webauthn.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: quart-security
|
|
3
|
-
Version: 1.4.
|
|
3
|
+
Version: 1.4.1
|
|
4
4
|
Summary: Native async auth for Quart
|
|
5
5
|
Author: quart-security maintainers
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -136,6 +136,10 @@ Optional for lockout support:
|
|
|
136
136
|
- `failed_login_count`
|
|
137
137
|
- `locked_until`
|
|
138
138
|
|
|
139
|
+
These fields are required when login or MFA lockout is enabled. The built-in
|
|
140
|
+
counter limits account-level guesses. Deploy an application or edge rate limiter
|
|
141
|
+
for source IP and distributed abuse controls.
|
|
142
|
+
|
|
139
143
|
## Key Configuration
|
|
140
144
|
|
|
141
145
|
The extension uses `SECURITY_*` keys for migration-friendly configuration.
|
|
@@ -153,6 +157,7 @@ Core:
|
|
|
153
157
|
- `SECURITY_CHANGEABLE`
|
|
154
158
|
- `SECURITY_TRACKABLE`
|
|
155
159
|
- `SECURITY_CSRF_PROTECT` (default: `True`)
|
|
160
|
+
- `SECURITY_FRESHNESS` (default: `60` minutes)
|
|
156
161
|
|
|
157
162
|
Existing pbkdf2_sha512 and bcrypt hashes continue to verify after the argon2 default change. They are transparently rehashed to argon2id on the user's next successful login.
|
|
158
163
|
|
|
@@ -162,6 +167,10 @@ Existing pbkdf2_sha512 and bcrypt hashes continue to verify after the argon2 def
|
|
|
162
167
|
- `SECURITY_MULTI_FACTOR_RECOVERY_CODES`
|
|
163
168
|
- `SECURITY_MULTI_FACTOR_RECOVERY_CODES_N`
|
|
164
169
|
|
|
170
|
+
Recovery codes are displayed only when generated and are stored as keyed hashes.
|
|
171
|
+
Existing plaintext codes remain valid until their next successful use, when the
|
|
172
|
+
remaining codes are migrated.
|
|
173
|
+
|
|
165
174
|
WebAuthn:
|
|
166
175
|
- `SECURITY_WEBAUTHN`
|
|
167
176
|
- `SECURITY_WAN_ALLOW_AS_FIRST_FACTOR`
|
|
@@ -180,7 +189,7 @@ Routing:
|
|
|
180
189
|
Core:
|
|
181
190
|
- `/login`
|
|
182
191
|
- `/register`
|
|
183
|
-
- `/logout`
|
|
192
|
+
- `/logout` (POST only)
|
|
184
193
|
- `/change`
|
|
185
194
|
|
|
186
195
|
2FA:
|
|
@@ -108,6 +108,10 @@ Optional for lockout support:
|
|
|
108
108
|
- `failed_login_count`
|
|
109
109
|
- `locked_until`
|
|
110
110
|
|
|
111
|
+
These fields are required when login or MFA lockout is enabled. The built-in
|
|
112
|
+
counter limits account-level guesses. Deploy an application or edge rate limiter
|
|
113
|
+
for source IP and distributed abuse controls.
|
|
114
|
+
|
|
111
115
|
## Key Configuration
|
|
112
116
|
|
|
113
117
|
The extension uses `SECURITY_*` keys for migration-friendly configuration.
|
|
@@ -125,6 +129,7 @@ Core:
|
|
|
125
129
|
- `SECURITY_CHANGEABLE`
|
|
126
130
|
- `SECURITY_TRACKABLE`
|
|
127
131
|
- `SECURITY_CSRF_PROTECT` (default: `True`)
|
|
132
|
+
- `SECURITY_FRESHNESS` (default: `60` minutes)
|
|
128
133
|
|
|
129
134
|
Existing pbkdf2_sha512 and bcrypt hashes continue to verify after the argon2 default change. They are transparently rehashed to argon2id on the user's next successful login.
|
|
130
135
|
|
|
@@ -134,6 +139,10 @@ Existing pbkdf2_sha512 and bcrypt hashes continue to verify after the argon2 def
|
|
|
134
139
|
- `SECURITY_MULTI_FACTOR_RECOVERY_CODES`
|
|
135
140
|
- `SECURITY_MULTI_FACTOR_RECOVERY_CODES_N`
|
|
136
141
|
|
|
142
|
+
Recovery codes are displayed only when generated and are stored as keyed hashes.
|
|
143
|
+
Existing plaintext codes remain valid until their next successful use, when the
|
|
144
|
+
remaining codes are migrated.
|
|
145
|
+
|
|
137
146
|
WebAuthn:
|
|
138
147
|
- `SECURITY_WEBAUTHN`
|
|
139
148
|
- `SECURITY_WAN_ALLOW_AS_FIRST_FACTOR`
|
|
@@ -152,7 +161,7 @@ Routing:
|
|
|
152
161
|
Core:
|
|
153
162
|
- `/login`
|
|
154
163
|
- `/register`
|
|
155
|
-
- `/logout`
|
|
164
|
+
- `/logout` (POST only)
|
|
156
165
|
- `/change`
|
|
157
166
|
|
|
158
167
|
2FA:
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import secrets
|
|
6
|
+
import time
|
|
6
7
|
from datetime import timedelta
|
|
7
8
|
|
|
8
9
|
from quart import current_app, g, request, session
|
|
@@ -149,7 +150,11 @@ class Security:
|
|
|
149
150
|
return
|
|
150
151
|
|
|
151
152
|
user = await maybe_await(self.datastore.find_user(fs_uniquifier=user_id))
|
|
152
|
-
|
|
153
|
+
if user is None or not getattr(user, "active", True):
|
|
154
|
+
session.clear()
|
|
155
|
+
g._current_user = AnonymousUser()
|
|
156
|
+
return
|
|
157
|
+
g._current_user = user
|
|
153
158
|
|
|
154
159
|
async def login_user(self, user, fresh=True):
|
|
155
160
|
app = current_app._get_current_object()
|
|
@@ -165,6 +170,7 @@ class Security:
|
|
|
165
170
|
session.clear()
|
|
166
171
|
session["_user_id"] = user_id
|
|
167
172
|
session["_fresh"] = bool(fresh)
|
|
173
|
+
session["_auth_at"] = int(time.time())
|
|
168
174
|
session["_id"] = secrets.token_hex(16)
|
|
169
175
|
session.modified = True
|
|
170
176
|
g._current_user = user
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from contextvars import ContextVar
|
|
5
6
|
from uuid import uuid4
|
|
6
7
|
|
|
7
8
|
from sqlalchemy import select
|
|
@@ -15,10 +16,19 @@ class SQLAlchemyUserDatastore:
|
|
|
15
16
|
self.user_model = user_model
|
|
16
17
|
self.role_model = role_model
|
|
17
18
|
self.webauthn_model = webauthn_model
|
|
19
|
+
self._active_session = ContextVar("quart_security_session", default=None)
|
|
18
20
|
|
|
19
21
|
@property
|
|
20
22
|
def session(self):
|
|
21
|
-
|
|
23
|
+
source = self.session_factory
|
|
24
|
+
configured = getattr(source, "session", None)
|
|
25
|
+
if configured is not None:
|
|
26
|
+
return configured
|
|
27
|
+
active = self._active_session.get()
|
|
28
|
+
if active is None:
|
|
29
|
+
active = source() if callable(source) else source
|
|
30
|
+
self._active_session.set(active)
|
|
31
|
+
return active
|
|
22
32
|
|
|
23
33
|
async def _first(self, model, **kwargs):
|
|
24
34
|
stmt = select(model).filter_by(**kwargs)
|
|
@@ -130,6 +140,18 @@ class SQLAlchemyUserDatastore:
|
|
|
130
140
|
return credential
|
|
131
141
|
return None
|
|
132
142
|
|
|
143
|
+
async def find_user_for_webauthn_credential(self, credential):
|
|
144
|
+
related_user = getattr(credential, "user", None)
|
|
145
|
+
if related_user is not None:
|
|
146
|
+
return related_user
|
|
147
|
+
owner_id = getattr(credential, "user_id", None)
|
|
148
|
+
if owner_id is None:
|
|
149
|
+
return None
|
|
150
|
+
user = await self.find_user(fs_webauthn_user_handle=owner_id)
|
|
151
|
+
if user is None and hasattr(self.user_model, "id"):
|
|
152
|
+
user = await self.find_user(id=owner_id)
|
|
153
|
+
return user
|
|
154
|
+
|
|
133
155
|
async def create_webauthn_credential(self, user, **kwargs):
|
|
134
156
|
if self.webauthn_model is None:
|
|
135
157
|
raise RuntimeError("webauthn_model is required for WebAuthn credentials")
|
|
@@ -168,4 +190,8 @@ class SQLAlchemyUserDatastore:
|
|
|
168
190
|
return True
|
|
169
191
|
|
|
170
192
|
async def commit(self):
|
|
171
|
-
|
|
193
|
+
active = self.session
|
|
194
|
+
try:
|
|
195
|
+
await active.commit()
|
|
196
|
+
finally:
|
|
197
|
+
self._active_session.set(None)
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
"""Authentication and authorization decorators."""
|
|
2
2
|
|
|
3
|
+
import datetime
|
|
4
|
+
import time
|
|
3
5
|
from functools import wraps
|
|
4
6
|
|
|
5
|
-
from quart import abort, current_app, redirect, request
|
|
7
|
+
from quart import abort, current_app, redirect, request, session
|
|
6
8
|
|
|
7
9
|
from .proxies import current_user
|
|
8
10
|
from .utils import url_for_security
|
|
9
11
|
|
|
10
12
|
|
|
11
|
-
def auth_required(*methods):
|
|
13
|
+
def auth_required(*methods, fresh=False):
|
|
12
14
|
"""Require an authenticated user for a route."""
|
|
13
15
|
|
|
14
16
|
allowed_methods = methods or ("session",)
|
|
@@ -22,6 +24,15 @@ def auth_required(*methods):
|
|
|
22
24
|
if request.is_json:
|
|
23
25
|
abort(401)
|
|
24
26
|
return redirect(url_for_security("login", next=request.url))
|
|
27
|
+
if fresh:
|
|
28
|
+
freshness = current_app.config.get(
|
|
29
|
+
"SECURITY_FRESHNESS", datetime.timedelta(minutes=60)
|
|
30
|
+
)
|
|
31
|
+
max_age = freshness.total_seconds()
|
|
32
|
+
authenticated_at = session.get("_auth_at", 0)
|
|
33
|
+
is_stale = time.time() - authenticated_at > max_age
|
|
34
|
+
if not session.get("_fresh") or is_stale:
|
|
35
|
+
abort(401)
|
|
25
36
|
return await current_app.ensure_async(func)(*args, **kwargs)
|
|
26
37
|
|
|
27
38
|
return wrapper
|
|
@@ -8,6 +8,6 @@
|
|
|
8
8
|
{% endfor %}
|
|
9
9
|
</ul>
|
|
10
10
|
<form method="post" action="{{ url_for_security('mf_recovery_codes') }}">
|
|
11
|
-
|
|
11
|
+
{{ mf_recovery_codes_form.hidden_tag() }}
|
|
12
12
|
<button type="submit">Regenerate codes</button>
|
|
13
13
|
</form>
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import hmac
|
|
6
8
|
import io
|
|
7
9
|
import secrets
|
|
8
10
|
|
|
@@ -52,13 +54,55 @@ def generate_recovery_codes(n: int = 3) -> list[str]:
|
|
|
52
54
|
return codes
|
|
53
55
|
|
|
54
56
|
|
|
55
|
-
|
|
57
|
+
_RECOVERY_HASH_PREFIX = "hmac-sha256$"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _normalize_recovery_code(code: str) -> str:
|
|
61
|
+
return code.strip().lower().replace("-", "").replace(" ", "")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _recovery_digest(code: str, secret_key: bytes | str) -> str:
|
|
65
|
+
if isinstance(secret_key, str):
|
|
66
|
+
secret_key = secret_key.encode()
|
|
67
|
+
digest = hmac.new(
|
|
68
|
+
secret_key, _normalize_recovery_code(code).encode(), hashlib.sha256
|
|
69
|
+
).hexdigest()
|
|
70
|
+
return f"{_RECOVERY_HASH_PREFIX}{digest}"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def hash_recovery_codes(codes: list[str], secret_key: bytes | str) -> list[str]:
|
|
74
|
+
return [_recovery_digest(code, secret_key) for code in codes]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def ensure_hashed_recovery_codes(
|
|
78
|
+
codes: list[str], secret_key: bytes | str
|
|
79
|
+
) -> list[str]:
|
|
80
|
+
return [
|
|
81
|
+
code if is_hashed_recovery_code(code) else _recovery_digest(code, secret_key)
|
|
82
|
+
for code in codes
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def is_hashed_recovery_code(code: str) -> bool:
|
|
87
|
+
return code.startswith(_RECOVERY_HASH_PREFIX)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def verify_recovery_code(
|
|
91
|
+
code: str, stored_codes: list[str], *, secret_key: bytes | str = b""
|
|
92
|
+
) -> tuple[bool, list[str]]:
|
|
56
93
|
normalized = code.strip().lower().replace("-", "").replace(" ", "")
|
|
94
|
+
submitted_hash = _recovery_digest(code, secret_key)
|
|
57
95
|
matched_index = -1
|
|
58
96
|
for i, stored in enumerate(stored_codes):
|
|
59
|
-
stored_normalized = stored
|
|
97
|
+
stored_normalized = _normalize_recovery_code(stored)
|
|
60
98
|
# compare_digest on every entry — no early exit
|
|
61
|
-
|
|
99
|
+
matches_plaintext = not is_hashed_recovery_code(
|
|
100
|
+
stored
|
|
101
|
+
) and secrets.compare_digest(normalized, stored_normalized)
|
|
102
|
+
matches_hash = is_hashed_recovery_code(stored) and secrets.compare_digest(
|
|
103
|
+
submitted_hash, stored
|
|
104
|
+
)
|
|
105
|
+
if matches_plaintext or matches_hash:
|
|
62
106
|
matched_index = i
|
|
63
107
|
if matched_index >= 0:
|
|
64
108
|
remaining = [item for i, item in enumerate(stored_codes) if i != matched_index]
|
|
@@ -75,6 +75,29 @@ async def _commit():
|
|
|
75
75
|
await maybe_await(_security.datastore.commit())
|
|
76
76
|
|
|
77
77
|
|
|
78
|
+
def _account_is_locked(user) -> bool:
|
|
79
|
+
locked_until = getattr(user, "locked_until", None)
|
|
80
|
+
return bool(locked_until and naive_utcnow() < locked_until)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def _record_auth_failure(user):
|
|
84
|
+
if not hasattr(user, "failed_login_count"):
|
|
85
|
+
return
|
|
86
|
+
user.failed_login_count = (user.failed_login_count or 0) + 1
|
|
87
|
+
max_attempts = current_app.config.get("SECURITY_LOGIN_MAX_ATTEMPTS", 5)
|
|
88
|
+
if user.failed_login_count >= max_attempts:
|
|
89
|
+
minutes = current_app.config.get("SECURITY_LOCKOUT_MINUTES", 15)
|
|
90
|
+
user.locked_until = naive_utcnow() + datetime.timedelta(minutes=minutes)
|
|
91
|
+
await _commit()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def _reset_auth_failures(user):
|
|
95
|
+
if hasattr(user, "failed_login_count"):
|
|
96
|
+
user.failed_login_count = 0
|
|
97
|
+
user.locked_until = None
|
|
98
|
+
await _commit()
|
|
99
|
+
|
|
100
|
+
|
|
78
101
|
async def _enforce_csrf(submitted_token: str | None = None):
|
|
79
102
|
if not _is_post():
|
|
80
103
|
return
|
|
@@ -182,6 +205,19 @@ async def _find_webauthn_credential(credential_id: bytes, user=None):
|
|
|
182
205
|
return None
|
|
183
206
|
|
|
184
207
|
|
|
208
|
+
async def _find_webauthn_credential_owner(credential):
|
|
209
|
+
finder = getattr(_security.datastore, "find_user_for_webauthn_credential", None)
|
|
210
|
+
if finder:
|
|
211
|
+
return await maybe_await(finder(credential))
|
|
212
|
+
related_user = getattr(credential, "user", None)
|
|
213
|
+
if related_user is not None:
|
|
214
|
+
return related_user
|
|
215
|
+
owner_id = getattr(credential, "user_id", None)
|
|
216
|
+
if owner_id is None:
|
|
217
|
+
return None
|
|
218
|
+
return await _find_user(fs_webauthn_user_handle=owner_id)
|
|
219
|
+
|
|
220
|
+
|
|
185
221
|
async def _create_webauthn_credential(user, **kwargs):
|
|
186
222
|
creator = getattr(_security.datastore, "create_webauthn_credential", None)
|
|
187
223
|
if creator:
|
|
@@ -304,34 +340,22 @@ async def login():
|
|
|
304
340
|
user.password = await hash_password_async(form.password.data)
|
|
305
341
|
await _commit()
|
|
306
342
|
|
|
307
|
-
# Reset failed attempts on success
|
|
308
|
-
if (
|
|
309
|
-
hasattr(user, "failed_login_count")
|
|
310
|
-
and (user.failed_login_count or 0) > 0
|
|
311
|
-
):
|
|
312
|
-
user.failed_login_count = 0
|
|
313
|
-
user.locked_until = None
|
|
314
|
-
await _commit()
|
|
315
|
-
|
|
316
343
|
if current_app.config.get("SECURITY_TWO_FACTOR") and getattr(
|
|
317
344
|
user, "tf_primary_method", None
|
|
318
345
|
):
|
|
319
|
-
session["tf_user_id"] =
|
|
346
|
+
session["tf_user_id"] = {
|
|
347
|
+
"user_id": user.get_id(),
|
|
348
|
+
"issued_at": int(naive_utcnow().timestamp()),
|
|
349
|
+
}
|
|
320
350
|
return redirect(url_for_security("two_factor_token_validation"))
|
|
321
351
|
|
|
352
|
+
await _reset_auth_failures(user)
|
|
322
353
|
await _security.login_user(user)
|
|
323
354
|
return redirect(_resolve_redirect("SECURITY_POST_LOGIN_VIEW", "login"))
|
|
324
355
|
|
|
325
356
|
# Track failed login
|
|
326
|
-
if user
|
|
327
|
-
|
|
328
|
-
max_attempts = current_app.config.get("SECURITY_LOGIN_MAX_ATTEMPTS", 5)
|
|
329
|
-
lockout_minutes = current_app.config.get("SECURITY_LOCKOUT_MINUTES", 15)
|
|
330
|
-
if user.failed_login_count >= max_attempts:
|
|
331
|
-
user.locked_until = naive_utcnow() + datetime.timedelta(
|
|
332
|
-
minutes=lockout_minutes
|
|
333
|
-
)
|
|
334
|
-
await _commit()
|
|
357
|
+
if user:
|
|
358
|
+
await _record_auth_failure(user)
|
|
335
359
|
|
|
336
360
|
await flash("Invalid email or password", "error")
|
|
337
361
|
|
|
@@ -401,15 +425,16 @@ async def register():
|
|
|
401
425
|
return await render_template("security/register_user.html", register_user_form=form)
|
|
402
426
|
|
|
403
427
|
|
|
404
|
-
@security_bp.route("/logout", methods=["
|
|
428
|
+
@security_bp.route("/logout", methods=["POST"])
|
|
405
429
|
async def logout():
|
|
430
|
+
await _enforce_csrf()
|
|
406
431
|
await _security.logout_user()
|
|
407
432
|
await flash("You have been logged out.", "info")
|
|
408
433
|
return redirect(url_for_security("login"))
|
|
409
434
|
|
|
410
435
|
|
|
411
436
|
@security_bp.route("/change", methods=["GET", "POST"])
|
|
412
|
-
@auth_required("session")
|
|
437
|
+
@auth_required("session", fresh=True)
|
|
413
438
|
async def change_password():
|
|
414
439
|
if not current_app.config.get("SECURITY_CHANGEABLE", False):
|
|
415
440
|
abort(404)
|
|
@@ -486,7 +511,7 @@ async def change_password():
|
|
|
486
511
|
|
|
487
512
|
|
|
488
513
|
@security_bp.route("/tf-setup", methods=["GET", "POST"])
|
|
489
|
-
@auth_required("session")
|
|
514
|
+
@auth_required("session", fresh=True)
|
|
490
515
|
async def two_factor_setup():
|
|
491
516
|
if not current_app.config.get("SECURITY_TWO_FACTOR", False):
|
|
492
517
|
abort(404)
|
|
@@ -496,6 +521,7 @@ async def two_factor_setup():
|
|
|
496
521
|
generate_recovery_codes,
|
|
497
522
|
generate_totp_secret,
|
|
498
523
|
get_totp_uri,
|
|
524
|
+
hash_recovery_codes,
|
|
499
525
|
verify_totp,
|
|
500
526
|
)
|
|
501
527
|
|
|
@@ -525,19 +551,29 @@ async def two_factor_setup():
|
|
|
525
551
|
current_user.tf_primary_method = "authenticator"
|
|
526
552
|
session.pop("tf_pending_secret", None)
|
|
527
553
|
|
|
554
|
+
raw_codes = []
|
|
528
555
|
if current_app.config.get("SECURITY_MULTI_FACTOR_RECOVERY_CODES", True):
|
|
529
556
|
if not getattr(current_user, "mf_recovery_codes", None):
|
|
530
557
|
count = current_app.config.get(
|
|
531
558
|
"SECURITY_MULTI_FACTOR_RECOVERY_CODES_N", 3
|
|
532
559
|
)
|
|
533
|
-
|
|
560
|
+
raw_codes = generate_recovery_codes(count)
|
|
561
|
+
current_user.mf_recovery_codes = hash_recovery_codes(
|
|
562
|
+
raw_codes, current_app.secret_key
|
|
563
|
+
)
|
|
534
564
|
|
|
535
565
|
await _commit()
|
|
536
566
|
await tf_profile_changed.send_async(
|
|
537
567
|
current_app._get_current_object(), user=current_user
|
|
538
568
|
)
|
|
539
569
|
await flash("Two-factor authentication enabled.", "success")
|
|
540
|
-
|
|
570
|
+
recovery_form = await QuartForm.from_formdata()
|
|
571
|
+
return await render_template(
|
|
572
|
+
"security/mf_recovery_codes.html",
|
|
573
|
+
recovery_codes=raw_codes,
|
|
574
|
+
has_codes=True,
|
|
575
|
+
mf_recovery_codes_form=recovery_form,
|
|
576
|
+
)
|
|
541
577
|
|
|
542
578
|
await flash("Invalid authentication code.", "error")
|
|
543
579
|
return redirect(
|
|
@@ -576,11 +612,23 @@ async def two_factor_token_validation():
|
|
|
576
612
|
if not current_app.config.get("SECURITY_TWO_FACTOR", False):
|
|
577
613
|
abort(404)
|
|
578
614
|
|
|
579
|
-
from .totp import
|
|
615
|
+
from .totp import (
|
|
616
|
+
ensure_hashed_recovery_codes,
|
|
617
|
+
verify_recovery_code,
|
|
618
|
+
verify_totp,
|
|
619
|
+
)
|
|
580
620
|
|
|
581
621
|
form = await TwoFactorVerifyForm.from_formdata()
|
|
582
622
|
await _enforce_csrf(getattr(form, "_submitted_csrf", None))
|
|
583
|
-
|
|
623
|
+
tf_state = session.get("tf_user_id")
|
|
624
|
+
if isinstance(tf_state, dict):
|
|
625
|
+
if int(naive_utcnow().timestamp()) - tf_state.get("issued_at", 0) > 300:
|
|
626
|
+
session.pop("tf_user_id", None)
|
|
627
|
+
return redirect(url_for_security("login"))
|
|
628
|
+
user_id = tf_state.get("user_id")
|
|
629
|
+
else:
|
|
630
|
+
session.pop("tf_user_id", None)
|
|
631
|
+
return redirect(url_for_security("login"))
|
|
584
632
|
if not user_id:
|
|
585
633
|
return redirect(url_for_security("login"))
|
|
586
634
|
|
|
@@ -588,6 +636,9 @@ async def two_factor_token_validation():
|
|
|
588
636
|
if user is None:
|
|
589
637
|
session.pop("tf_user_id", None)
|
|
590
638
|
return redirect(url_for_security("login"))
|
|
639
|
+
if _account_is_locked(user):
|
|
640
|
+
await flash("Account temporarily locked. Try again later.", "error")
|
|
641
|
+
return redirect(url_for_security("login"))
|
|
591
642
|
|
|
592
643
|
if _is_post():
|
|
593
644
|
form_data = await request.form
|
|
@@ -603,17 +654,23 @@ async def two_factor_token_validation():
|
|
|
603
654
|
and current_app.config.get("SECURITY_MULTI_FACTOR_RECOVERY_CODES", True)
|
|
604
655
|
):
|
|
605
656
|
codes = list(getattr(user, "mf_recovery_codes", None) or [])
|
|
606
|
-
ok, remaining = verify_recovery_code(
|
|
657
|
+
ok, remaining = verify_recovery_code(
|
|
658
|
+
token, codes, secret_key=current_app.secret_key
|
|
659
|
+
)
|
|
607
660
|
if ok:
|
|
608
|
-
user.mf_recovery_codes =
|
|
661
|
+
user.mf_recovery_codes = ensure_hashed_recovery_codes(
|
|
662
|
+
remaining, current_app.secret_key
|
|
663
|
+
)
|
|
609
664
|
await _commit()
|
|
610
665
|
valid = True
|
|
611
666
|
|
|
612
667
|
if valid:
|
|
613
668
|
session.pop("tf_user_id", None)
|
|
669
|
+
await _reset_auth_failures(user)
|
|
614
670
|
await _security.login_user(user)
|
|
615
671
|
return redirect(_resolve_redirect("SECURITY_POST_LOGIN_VIEW", "login"))
|
|
616
672
|
|
|
673
|
+
await _record_auth_failure(user)
|
|
617
674
|
await flash("Invalid code", "error")
|
|
618
675
|
|
|
619
676
|
return await render_template(
|
|
@@ -630,12 +687,12 @@ async def tf_select():
|
|
|
630
687
|
|
|
631
688
|
|
|
632
689
|
@security_bp.route("/mf-recovery-codes", methods=["GET", "POST"])
|
|
633
|
-
@auth_required("session")
|
|
690
|
+
@auth_required("session", fresh=True)
|
|
634
691
|
async def mf_recovery_codes():
|
|
635
692
|
if not current_app.config.get("SECURITY_MULTI_FACTOR_RECOVERY_CODES", True):
|
|
636
693
|
abort(404)
|
|
637
694
|
|
|
638
|
-
from .totp import generate_recovery_codes
|
|
695
|
+
from .totp import generate_recovery_codes, hash_recovery_codes
|
|
639
696
|
|
|
640
697
|
# A dummy form just for hidden_tag() CSRF support
|
|
641
698
|
form = await QuartForm.from_formdata()
|
|
@@ -645,14 +702,14 @@ async def mf_recovery_codes():
|
|
|
645
702
|
await _enforce_csrf()
|
|
646
703
|
count = current_app.config.get("SECURITY_MULTI_FACTOR_RECOVERY_CODES_N", 3)
|
|
647
704
|
codes = generate_recovery_codes(count)
|
|
648
|
-
current_user.mf_recovery_codes =
|
|
705
|
+
current_user.mf_recovery_codes = hash_recovery_codes(
|
|
706
|
+
codes, current_app.secret_key
|
|
707
|
+
)
|
|
649
708
|
await _commit()
|
|
650
709
|
await tf_profile_changed.send_async(
|
|
651
710
|
current_app._get_current_object(), user=current_user
|
|
652
711
|
)
|
|
653
712
|
await flash("Recovery codes regenerated.", "success")
|
|
654
|
-
elif request.args.get("show_codes"):
|
|
655
|
-
codes = list(getattr(current_user, "mf_recovery_codes", None) or [])
|
|
656
713
|
|
|
657
714
|
has_codes = bool(getattr(current_user, "mf_recovery_codes", None))
|
|
658
715
|
|
|
@@ -670,33 +727,49 @@ async def mf_recovery():
|
|
|
670
727
|
await _enforce_csrf(getattr(form, "_submitted_csrf", None))
|
|
671
728
|
|
|
672
729
|
if _is_post() and form.validate():
|
|
673
|
-
|
|
730
|
+
tf_state = session.get("tf_user_id")
|
|
731
|
+
if not isinstance(tf_state, dict):
|
|
732
|
+
session.pop("tf_user_id", None)
|
|
733
|
+
return redirect(url_for_security("login"))
|
|
734
|
+
issued_at = tf_state.get("issued_at", 0)
|
|
735
|
+
if int(naive_utcnow().timestamp()) - issued_at > 300:
|
|
736
|
+
session.pop("tf_user_id", None)
|
|
737
|
+
return redirect(url_for_security("login"))
|
|
738
|
+
user_id = tf_state.get("user_id")
|
|
674
739
|
if not user_id:
|
|
675
740
|
return redirect(url_for_security("login"))
|
|
676
741
|
|
|
677
742
|
user = await _find_user(fs_uniquifier=user_id)
|
|
678
743
|
if user is None:
|
|
679
744
|
return redirect(url_for_security("login"))
|
|
745
|
+
if _account_is_locked(user):
|
|
746
|
+
return redirect(url_for_security("login"))
|
|
680
747
|
|
|
681
|
-
from .totp import verify_recovery_code
|
|
748
|
+
from .totp import ensure_hashed_recovery_codes, verify_recovery_code
|
|
682
749
|
|
|
683
750
|
ok, remaining = verify_recovery_code(
|
|
684
|
-
form.code.data.strip(),
|
|
751
|
+
form.code.data.strip(),
|
|
752
|
+
list(getattr(user, "mf_recovery_codes", None) or []),
|
|
753
|
+
secret_key=current_app.secret_key,
|
|
685
754
|
)
|
|
686
755
|
if ok:
|
|
687
|
-
user.mf_recovery_codes =
|
|
756
|
+
user.mf_recovery_codes = ensure_hashed_recovery_codes(
|
|
757
|
+
remaining, current_app.secret_key
|
|
758
|
+
)
|
|
688
759
|
await _commit()
|
|
689
760
|
session.pop("tf_user_id", None)
|
|
761
|
+
await _reset_auth_failures(user)
|
|
690
762
|
await _security.login_user(user)
|
|
691
763
|
return redirect(_resolve_redirect("SECURITY_POST_LOGIN_VIEW", "login"))
|
|
692
764
|
|
|
765
|
+
await _record_auth_failure(user)
|
|
693
766
|
await flash("Invalid recovery code", "error")
|
|
694
767
|
|
|
695
768
|
return await render_template("security/mf_recovery.html", recovery_form=form)
|
|
696
769
|
|
|
697
770
|
|
|
698
771
|
@security_bp.route("/wan-register", methods=["GET", "POST"])
|
|
699
|
-
@auth_required("session")
|
|
772
|
+
@auth_required("session", fresh=True)
|
|
700
773
|
async def wan_register():
|
|
701
774
|
if not current_app.config.get("SECURITY_WEBAUTHN", False):
|
|
702
775
|
abort(404)
|
|
@@ -857,7 +930,6 @@ async def wan_signin():
|
|
|
857
930
|
_set_wan_state(
|
|
858
931
|
"wan_signin_state",
|
|
859
932
|
challenge=wan.bytes_to_base64url(challenge),
|
|
860
|
-
remember=bool(form.remember.data),
|
|
861
933
|
)
|
|
862
934
|
|
|
863
935
|
return await render_template(
|
|
@@ -892,17 +964,18 @@ async def wan_signin_response():
|
|
|
892
964
|
await flash("Passkey not recognized.", "error")
|
|
893
965
|
return redirect(url_for_security("wan_signin"))
|
|
894
966
|
|
|
895
|
-
credential_user_id = getattr(stored_credential, "user_id", None)
|
|
896
967
|
user_handle = _extract_webauthn_user_handle(credential_payload)
|
|
897
|
-
|
|
898
|
-
if
|
|
968
|
+
user = await _find_webauthn_credential_owner(stored_credential)
|
|
969
|
+
if user is None:
|
|
899
970
|
await flash("Unable to identify user from passkey.", "error")
|
|
900
971
|
return redirect(url_for_security("wan_signin"))
|
|
901
972
|
|
|
902
|
-
user
|
|
903
|
-
if user is None or not getattr(user, "active", True):
|
|
973
|
+
if not getattr(user, "active", True):
|
|
904
974
|
await flash("Unable to find user for passkey sign-in.", "error")
|
|
905
975
|
return redirect(url_for_security("wan_signin"))
|
|
976
|
+
if user_handle and user_handle != user.fs_webauthn_user_handle:
|
|
977
|
+
await flash("Passkey user handle mismatch.", "error")
|
|
978
|
+
return redirect(url_for_security("wan_signin"))
|
|
906
979
|
|
|
907
980
|
if stored_credential is None:
|
|
908
981
|
await flash("Passkey not recognized.", "error")
|
|
@@ -930,7 +1003,6 @@ async def wan_signin_response():
|
|
|
930
1003
|
if hasattr(stored_credential, "lastuse_datetime"):
|
|
931
1004
|
stored_credential.lastuse_datetime = naive_utcnow()
|
|
932
1005
|
|
|
933
|
-
session.permanent = bool(state.get("remember"))
|
|
934
1006
|
await _security.login_user(user)
|
|
935
1007
|
return redirect(_resolve_redirect("SECURITY_POST_LOGIN_VIEW", "login"))
|
|
936
1008
|
|
|
@@ -1047,7 +1119,7 @@ async def wan_verify_response():
|
|
|
1047
1119
|
|
|
1048
1120
|
|
|
1049
1121
|
@security_bp.route("/wan-delete", methods=["POST"])
|
|
1050
|
-
@auth_required("session")
|
|
1122
|
+
@auth_required("session", fresh=True)
|
|
1051
1123
|
async def wan_delete():
|
|
1052
1124
|
if not current_app.config.get("SECURITY_WEBAUTHN", False):
|
|
1053
1125
|
abort(404)
|
|
File without changes
|
|
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.4.0 → quart_security-1.4.1}/quart_security/templates/security/_macros.html
RENAMED
|
File without changes
|
{quart_security-1.4.0 → quart_security-1.4.1}/quart_security/templates/security/change_password.html
RENAMED
|
File without changes
|
{quart_security-1.4.0 → quart_security-1.4.1}/quart_security/templates/security/login_user.html
RENAMED
|
File without changes
|
{quart_security-1.4.0 → quart_security-1.4.1}/quart_security/templates/security/mf_recovery.html
RENAMED
|
File without changes
|
{quart_security-1.4.0 → quart_security-1.4.1}/quart_security/templates/security/register_user.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{quart_security-1.4.0 → quart_security-1.4.1}/quart_security/templates/security/wan_register.html
RENAMED
|
File without changes
|
{quart_security-1.4.0 → quart_security-1.4.1}/quart_security/templates/security/wan_signin.html
RENAMED
|
File without changes
|
{quart_security-1.4.0 → quart_security-1.4.1}/quart_security/templates/security/wan_verify.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|