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.
Files changed (32) hide show
  1. {quart_security-1.4.0 → quart_security-1.4.1}/PKG-INFO +11 -2
  2. {quart_security-1.4.0 → quart_security-1.4.1}/README.md +10 -1
  3. {quart_security-1.4.0 → quart_security-1.4.1}/pyproject.toml +1 -1
  4. {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/core.py +7 -1
  5. {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/datastore.py +28 -2
  6. {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/decorators.py +13 -2
  7. {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/forms.py +0 -1
  8. {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/templates/security/mf_recovery_codes.html +1 -1
  9. {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/totp.py +47 -3
  10. {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/views.py +118 -46
  11. {quart_security-1.4.0 → quart_security-1.4.1}/LICENSE +0 -0
  12. {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/__init__.py +0 -0
  13. {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/breach.py +0 -0
  14. {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/email.py +0 -0
  15. {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/models.py +0 -0
  16. {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/password.py +0 -0
  17. {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/proxies.py +0 -0
  18. {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/py.typed +0 -0
  19. {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/signals.py +0 -0
  20. {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/templates/security/_macros.html +0 -0
  21. {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/templates/security/change_password.html +0 -0
  22. {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/templates/security/login_user.html +0 -0
  23. {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/templates/security/mf_recovery.html +0 -0
  24. {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/templates/security/register_user.html +0 -0
  25. {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/templates/security/two_factor_select.html +0 -0
  26. {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/templates/security/two_factor_setup.html +0 -0
  27. {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/templates/security/two_factor_verify_code.html +0 -0
  28. {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/templates/security/wan_register.html +0 -0
  29. {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/templates/security/wan_signin.html +0 -0
  30. {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/templates/security/wan_verify.html +0 -0
  31. {quart_security-1.4.0 → quart_security-1.4.1}/quart_security/utils.py +0 -0
  32. {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.0
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:
@@ -4,7 +4,7 @@ build-backend = "flit_core.buildapi"
4
4
 
5
5
  [project]
6
6
  name = "quart-security"
7
- version = "1.4.0"
7
+ version = "1.4.1"
8
8
  description = "Native async auth for Quart"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -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
- g._current_user = user or AnonymousUser()
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
- return self.session_factory()
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
- await self.session.commit()
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
@@ -109,5 +109,4 @@ class WebAuthnRegisterForm(QuartForm):
109
109
 
110
110
  class WebAuthnVerifyForm(QuartForm):
111
111
  identity = StringField("Email", validators=[Optional()])
112
- remember = BooleanField("Remember me")
113
112
  credential = StringField("Credential", validators=[Optional()])
@@ -8,6 +8,6 @@
8
8
  {% endfor %}
9
9
  </ul>
10
10
  <form method="post" action="{{ url_for_security('mf_recovery_codes') }}">
11
- <input id="csrf_token" name="csrf_token" type="hidden" value="{{ csrf_token }}">
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
- def verify_recovery_code(code: str, stored_codes: list[str]) -> tuple[bool, list[str]]:
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.strip().lower().replace("-", "").replace(" ", "")
97
+ stored_normalized = _normalize_recovery_code(stored)
60
98
  # compare_digest on every entry — no early exit
61
- if secrets.compare_digest(normalized, stored_normalized):
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"] = user.get_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 and hasattr(user, "failed_login_count"):
327
- user.failed_login_count = (user.failed_login_count or 0) + 1
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=["GET", "POST"])
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
- current_user.mf_recovery_codes = generate_recovery_codes(count)
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
- return redirect(url_for_security("mf_recovery_codes"))
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 verify_recovery_code, verify_totp
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
- user_id = session.get("tf_user_id")
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(token, codes)
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 = remaining
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 = 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
- user_id = session.get("tf_user_id")
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(), list(getattr(user, "mf_recovery_codes", None) or [])
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 = remaining
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
- lookup_handle = credential_user_id or user_handle
898
- if not lookup_handle:
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 = await _find_user(fs_webauthn_user_handle=lookup_handle)
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