quart-security 1.1.1__tar.gz → 1.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. quart_security-1.3.0/.github/workflows/ci.yml +34 -0
  2. quart_security-1.3.0/.github/workflows/publish.yml +37 -0
  3. quart_security-1.3.0/.gitignore +8 -0
  4. {quart_security-1.1.1 → quart_security-1.3.0}/PKG-INFO +9 -3
  5. {quart_security-1.1.1 → quart_security-1.3.0}/README.md +7 -0
  6. {quart_security-1.1.1 → quart_security-1.3.0}/pyproject.toml +1 -1
  7. {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/core.py +6 -5
  8. {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/forms.py +8 -2
  9. {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/templates/security/login_user.html +3 -0
  10. {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/templates/security/wan_signin.html +3 -6
  11. quart_security-1.3.0/quart_security/utils.py +25 -0
  12. {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/views.py +94 -66
  13. {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/webauthn.py +27 -10
  14. quart_security-1.3.0/tests/conftest.py +241 -0
  15. quart_security-1.3.0/tests/test_auth_flow.py +98 -0
  16. quart_security-1.3.0/tests/test_core_flows.py +129 -0
  17. quart_security-1.3.0/tests/test_password.py +14 -0
  18. quart_security-1.3.0/tests/test_two_factor.py +108 -0
  19. quart_security-1.3.0/tests/test_webauthn.py +243 -0
  20. quart_security-1.1.1/quart_security/utils.py +0 -8
  21. {quart_security-1.1.1 → quart_security-1.3.0}/LICENSE +0 -0
  22. {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/__init__.py +0 -0
  23. {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/datastore.py +0 -0
  24. {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/decorators.py +0 -0
  25. {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/email.py +0 -0
  26. {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/models.py +0 -0
  27. {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/password.py +0 -0
  28. {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/proxies.py +0 -0
  29. {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/signals.py +0 -0
  30. {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/templates/security/_macros.html +0 -0
  31. {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/templates/security/change_password.html +0 -0
  32. {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/templates/security/mf_recovery.html +0 -0
  33. {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/templates/security/mf_recovery_codes.html +0 -0
  34. {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/templates/security/register_user.html +0 -0
  35. {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/templates/security/two_factor_select.html +0 -0
  36. {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/templates/security/two_factor_setup.html +0 -0
  37. {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/templates/security/two_factor_verify_code.html +0 -0
  38. {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/templates/security/wan_register.html +0 -0
  39. {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/templates/security/wan_verify.html +0 -0
  40. {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/totp.py +0 -0
@@ -0,0 +1,34 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ pull_request:
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+
13
+ steps:
14
+ - name: Checkout
15
+ uses: actions/checkout@v4
16
+
17
+ - name: Set up Python
18
+ uses: actions/setup-python@v5
19
+ with:
20
+ python-version: "3.11"
21
+
22
+ - name: Set up uv
23
+ uses: astral-sh/setup-uv@v6
24
+ with:
25
+ enable-cache: true
26
+
27
+ - name: Install dependencies
28
+ run: uv sync --group dev
29
+
30
+ - name: Run Ruff
31
+ run: uv run ruff check .
32
+
33
+ - name: Run tests
34
+ run: uv run pytest -q
@@ -0,0 +1,37 @@
1
+ name: Publish
2
+
3
+ on:
4
+ release:
5
+ types:
6
+ - published
7
+ workflow_dispatch:
8
+
9
+ permissions:
10
+ contents: read
11
+ id-token: write
12
+
13
+ jobs:
14
+ publish:
15
+ if: github.repository == 'level09/quart-security'
16
+ runs-on: ubuntu-latest
17
+ environment:
18
+ name: pypi
19
+ url: https://pypi.org/p/quart-security
20
+
21
+ steps:
22
+ - name: Checkout
23
+ uses: actions/checkout@v4
24
+
25
+ - name: Set up Python
26
+ uses: actions/setup-python@v5
27
+ with:
28
+ python-version: "3.12"
29
+
30
+ - name: Set up uv
31
+ uses: astral-sh/setup-uv@v6
32
+
33
+ - name: Build distributions
34
+ run: uv build
35
+
36
+ - name: Publish to PyPI
37
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .pytest_cache/
4
+ .ruff_cache/
5
+ .uv-cache/
6
+ .venv/
7
+ dist/
8
+ uv.lock
@@ -1,11 +1,10 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.1
2
2
  Name: quart-security
3
- Version: 1.1.1
3
+ Version: 1.3.0
4
4
  Summary: Native async auth for Quart
5
5
  Author: quart-security maintainers
6
6
  Requires-Python: >=3.11
7
7
  Description-Content-Type: text/markdown
8
- License-File: LICENSE
9
8
  Requires-Dist: quart>=0.20.0
10
9
  Requires-Dist: sqlalchemy>=2.0
11
10
  Requires-Dist: libpass>=1.9
@@ -55,6 +54,7 @@ It is designed as a practical replacement path for Flask-Security style session
55
54
  - Flask-Security-style endpoint naming through `url_for_security()`
56
55
  - Signals for auth lifecycle events
57
56
  - Overridable templates under `templates/security/`
57
+ - Datastore hooks can be implemented as async methods or simple sync methods
58
58
 
59
59
  ## Non-Goals (Current Scope)
60
60
 
@@ -130,6 +130,10 @@ For tracking / MFA / WebAuthn features:
130
130
  - `fs_webauthn_user_handle`
131
131
  - relationship/association for stored WebAuthn credentials
132
132
 
133
+ Optional for lockout support:
134
+ - `failed_login_count`
135
+ - `locked_until`
136
+
133
137
  ## Key Configuration
134
138
 
135
139
  The extension uses `SECURITY_*` keys for migration-friendly configuration.
@@ -138,6 +142,8 @@ Core:
138
142
  - `SECURITY_PASSWORD_HASH` (default: `pbkdf2_sha512`)
139
143
  - `SECURITY_PASSWORD_SALT` (recommended)
140
144
  - `SECURITY_PASSWORD_LENGTH_MIN` (default: `12`)
145
+ - `SECURITY_LOGIN_MAX_ATTEMPTS` (default: `5`)
146
+ - `SECURITY_LOCKOUT_MINUTES` (default: `15`)
141
147
  - `SECURITY_REGISTERABLE`
142
148
  - `SECURITY_CHANGEABLE`
143
149
  - `SECURITY_TRACKABLE`
@@ -28,6 +28,7 @@ It is designed as a practical replacement path for Flask-Security style session
28
28
  - Flask-Security-style endpoint naming through `url_for_security()`
29
29
  - Signals for auth lifecycle events
30
30
  - Overridable templates under `templates/security/`
31
+ - Datastore hooks can be implemented as async methods or simple sync methods
31
32
 
32
33
  ## Non-Goals (Current Scope)
33
34
 
@@ -103,6 +104,10 @@ For tracking / MFA / WebAuthn features:
103
104
  - `fs_webauthn_user_handle`
104
105
  - relationship/association for stored WebAuthn credentials
105
106
 
107
+ Optional for lockout support:
108
+ - `failed_login_count`
109
+ - `locked_until`
110
+
106
111
  ## Key Configuration
107
112
 
108
113
  The extension uses `SECURITY_*` keys for migration-friendly configuration.
@@ -111,6 +116,8 @@ Core:
111
116
  - `SECURITY_PASSWORD_HASH` (default: `pbkdf2_sha512`)
112
117
  - `SECURITY_PASSWORD_SALT` (recommended)
113
118
  - `SECURITY_PASSWORD_LENGTH_MIN` (default: `12`)
119
+ - `SECURITY_LOGIN_MAX_ATTEMPTS` (default: `5`)
120
+ - `SECURITY_LOCKOUT_MINUTES` (default: `15`)
114
121
  - `SECURITY_REGISTERABLE`
115
122
  - `SECURITY_CHANGEABLE`
116
123
  - `SECURITY_TRACKABLE`
@@ -4,7 +4,7 @@ build-backend = "flit_core.buildapi"
4
4
 
5
5
  [project]
6
6
  name = "quart-security"
7
- version = "1.1.1"
7
+ version = "1.3.0"
8
8
  description = "Native async auth for Quart"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -2,7 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import datetime
6
5
  import secrets
7
6
  from datetime import timedelta
8
7
 
@@ -12,7 +11,7 @@ from .forms import ChangePasswordForm, LoginForm, RegisterForm
12
11
  from .password import init_password_context
13
12
  from .proxies import AnonymousUser, current_user
14
13
  from .signals import user_authenticated, user_logged_out
15
- from .utils import url_for_security
14
+ from .utils import maybe_await, naive_utcnow, url_for_security
16
15
 
17
16
 
18
17
  class _SecurityConfig:
@@ -131,6 +130,8 @@ class Security:
131
130
  "SECURITY_WAN_REQUIRE_USER_VERIFICATION": True,
132
131
  "SECURITY_FRESHNESS": timedelta(minutes=60),
133
132
  "SECURITY_FRESHNESS_GRACE_PERIOD": timedelta(minutes=60),
133
+ "SECURITY_LOGIN_MAX_ATTEMPTS": 5,
134
+ "SECURITY_LOCKOUT_MINUTES": 15,
134
135
  "SECURITY_POST_LOGIN_VIEW": "/",
135
136
  "SECURITY_POST_REGISTER_VIEW": "/login",
136
137
  "SECURITY_EMAIL_SENDER": "noreply@example.com",
@@ -148,7 +149,7 @@ class Security:
148
149
  g._current_user = AnonymousUser()
149
150
  return
150
151
 
151
- user = await self.datastore.find_user(fs_uniquifier=user_id)
152
+ user = await maybe_await(self.datastore.find_user(fs_uniquifier=user_id))
152
153
  g._current_user = user or AnonymousUser()
153
154
 
154
155
  async def login_user(self, user, fresh=True):
@@ -168,11 +169,11 @@ class Security:
168
169
  if app.config.get("SECURITY_TRACKABLE", True):
169
170
  user.last_login_at = getattr(user, "current_login_at", None)
170
171
  user.last_login_ip = getattr(user, "current_login_ip", None)
171
- user.current_login_at = datetime.datetime.utcnow()
172
+ user.current_login_at = naive_utcnow()
172
173
  user.current_login_ip = request.remote_addr
173
174
  user.login_count = (getattr(user, "login_count", None) or 0) + 1
174
175
 
175
- await self.datastore.commit()
176
+ await maybe_await(self.datastore.commit())
176
177
  await user_authenticated.send_async(app, user=user, authn_via="session")
177
178
 
178
179
  async def logout_user(self, user=None):
@@ -63,7 +63,10 @@ class RegisterForm(QuartForm):
63
63
  password = PasswordField("Password", validators=[DataRequired()])
64
64
  password_confirm = PasswordField(
65
65
  "Confirm Password",
66
- validators=[DataRequired(), EqualTo("password", message="Passwords must match")],
66
+ validators=[
67
+ DataRequired(),
68
+ EqualTo("password", message="Passwords must match"),
69
+ ],
67
70
  )
68
71
 
69
72
 
@@ -95,7 +98,10 @@ class WebAuthnRegisterForm(QuartForm):
95
98
  name = StringField("Credential Name", validators=[DataRequired()])
96
99
  usage = RadioField(
97
100
  "Usage",
98
- choices=[("secondary", "Multi-factor only"), ("primary", "Passwordless sign-in")],
101
+ choices=[
102
+ ("secondary", "Multi-factor only"),
103
+ ("primary", "Passwordless sign-in"),
104
+ ],
99
105
  default="secondary",
100
106
  validators=[DataRequired()],
101
107
  )
@@ -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>
@@ -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-signin-form">
7
+ <form method="post" action="{{ url_for_security('wan_signin') }}" id="wan-discoverable-form">
8
8
  {{ wan_signin_form.hidden_tag() }}
9
- {{ render_input(wan_signin_form.identity) }}
10
- <label style="display: block; margin-bottom: 0.75rem;">
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 %}
@@ -0,0 +1,25 @@
1
+ """Utility helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime
6
+ import inspect
7
+
8
+ from quart import url_for
9
+
10
+
11
+ def url_for_security(endpoint, **kwargs):
12
+ """Flask-Security-compatible helper for endpoint URLs."""
13
+ return url_for(f"security.{endpoint}", **kwargs)
14
+
15
+
16
+ async def maybe_await(value):
17
+ """Await values when needed while supporting sync extension hooks."""
18
+ if inspect.isawaitable(value):
19
+ return await value
20
+ return value
21
+
22
+
23
+ def naive_utcnow() -> datetime.datetime:
24
+ """Return a naive UTC datetime while avoiding utcnow() deprecations."""
25
+ return datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
@@ -32,7 +32,7 @@ from .forms import (
32
32
  from .password import hash_password, validate_password, verify_password
33
33
  from .proxies import _security, current_user
34
34
  from .signals import password_changed, tf_profile_changed, user_registered
35
- from .utils import url_for_security
35
+ from .utils import maybe_await, naive_utcnow, url_for_security
36
36
 
37
37
  security_bp = Blueprint("security", __name__, template_folder="templates")
38
38
 
@@ -58,15 +58,15 @@ def _resolve_redirect(config_key: str, fallback_endpoint: str) -> str:
58
58
 
59
59
 
60
60
  async def _find_user(**kwargs):
61
- return await _security.datastore.find_user(**kwargs)
61
+ return await maybe_await(_security.datastore.find_user(**kwargs))
62
62
 
63
63
 
64
64
  async def _create_user(**kwargs):
65
- return await _security.datastore.create_user(**kwargs)
65
+ return await maybe_await(_security.datastore.create_user(**kwargs))
66
66
 
67
67
 
68
68
  async def _commit():
69
- await _security.datastore.commit()
69
+ await maybe_await(_security.datastore.commit())
70
70
 
71
71
 
72
72
  async def _enforce_csrf(submitted_token: str | None = None):
@@ -125,7 +125,7 @@ def _webauthn_rp_name() -> str:
125
125
  def _set_wan_state(key: str, **payload):
126
126
  session[key] = {
127
127
  "payload": payload,
128
- "issued_at": int(datetime.datetime.utcnow().timestamp()),
128
+ "issued_at": int(naive_utcnow().timestamp()),
129
129
  }
130
130
 
131
131
 
@@ -137,7 +137,7 @@ def _pop_wan_state(key: str, max_age_seconds: int = 300) -> dict | None:
137
137
  payload = state.get("payload")
138
138
  if not isinstance(issued_at, int) or not isinstance(payload, dict):
139
139
  return None
140
- age = int(datetime.datetime.utcnow().timestamp()) - issued_at
140
+ age = int(naive_utcnow().timestamp()) - issued_at
141
141
  if age < 0 or age > max_age_seconds:
142
142
  return None
143
143
  return payload
@@ -146,7 +146,7 @@ def _pop_wan_state(key: str, max_age_seconds: int = 300) -> dict | None:
146
146
  async def _list_webauthn_credentials(user, usage: str | None = None):
147
147
  getter = getattr(_security.datastore, "get_webauthn_credentials", None)
148
148
  if getter:
149
- return await getter(user, usage=usage)
149
+ return await maybe_await(getter(user, usage=usage))
150
150
  credentials = list(getattr(user, "webauthn", None) or [])
151
151
  if usage:
152
152
  credentials = [
@@ -160,7 +160,7 @@ async def _list_webauthn_credentials(user, usage: str | None = None):
160
160
  async def _find_webauthn_credential(credential_id: bytes, user=None):
161
161
  finder = getattr(_security.datastore, "find_webauthn_credential", None)
162
162
  if finder:
163
- return await finder(credential_id, user=user)
163
+ return await maybe_await(finder(credential_id, user=user))
164
164
 
165
165
  candidates = list(getattr(user, "webauthn", None) or []) if user else []
166
166
  for credential in candidates:
@@ -172,14 +172,14 @@ async def _find_webauthn_credential(credential_id: bytes, user=None):
172
172
  async def _create_webauthn_credential(user, **kwargs):
173
173
  creator = getattr(_security.datastore, "create_webauthn_credential", None)
174
174
  if creator:
175
- return await creator(user, **kwargs)
175
+ return await maybe_await(creator(user, **kwargs))
176
176
  raise RuntimeError("Datastore does not implement create_webauthn_credential")
177
177
 
178
178
 
179
179
  async def _delete_webauthn_credential(user, credential):
180
180
  deleter = getattr(_security.datastore, "delete_webauthn_credential", None)
181
181
  if deleter:
182
- return await deleter(user, credential)
182
+ return await maybe_await(deleter(user, credential))
183
183
 
184
184
  user_credentials = getattr(user, "webauthn", None)
185
185
  if user_credentials is not None and credential in user_credentials:
@@ -200,6 +200,21 @@ def _extract_webauthn_credential_id(credential_payload: dict | None) -> bytes |
200
200
  return None
201
201
 
202
202
 
203
+ def _extract_webauthn_user_handle(credential_payload: dict | None) -> str | None:
204
+ if not isinstance(credential_payload, dict):
205
+ return None
206
+ response = credential_payload.get("response")
207
+ if not isinstance(response, dict):
208
+ return None
209
+ user_handle = response.get("userHandle")
210
+ if not isinstance(user_handle, str) or not user_handle:
211
+ return None
212
+ try:
213
+ return wan.base64url_to_bytes(user_handle).decode("utf-8")
214
+ except Exception:
215
+ return None
216
+
217
+
203
218
  async def _extract_webauthn_credential_payload():
204
219
  payload = await request.get_json(silent=True)
205
220
  if isinstance(payload, dict):
@@ -253,11 +268,33 @@ async def login():
253
268
 
254
269
  if _is_post() and form.validate():
255
270
  user = await _find_user(email=_normalize_email(form.email.data))
271
+
272
+ # Account lockout check
273
+ if user and hasattr(user, "locked_until") and user.locked_until:
274
+ if datetime.datetime.now() < user.locked_until:
275
+ await flash("Account temporarily locked. Try again later.", "error")
276
+ return await render_template(
277
+ "security/login_user.html", login_user_form=form
278
+ )
279
+ # Lock expired, reset
280
+ user.failed_login_count = 0
281
+ user.locked_until = None
282
+ await _commit()
283
+
256
284
  if (
257
285
  user
258
286
  and getattr(user, "active", True)
259
287
  and verify_password(form.password.data, user.password)
260
288
  ):
289
+ # Reset failed attempts on success
290
+ if (
291
+ hasattr(user, "failed_login_count")
292
+ and (user.failed_login_count or 0) > 0
293
+ ):
294
+ user.failed_login_count = 0
295
+ user.locked_until = None
296
+ await _commit()
297
+
261
298
  if current_app.config.get("SECURITY_TWO_FACTOR") and getattr(
262
299
  user, "tf_primary_method", None
263
300
  ):
@@ -267,6 +304,17 @@ async def login():
267
304
  await _security.login_user(user)
268
305
  return redirect(_resolve_redirect("SECURITY_POST_LOGIN_VIEW", "login"))
269
306
 
307
+ # Track failed login
308
+ if user and hasattr(user, "failed_login_count"):
309
+ user.failed_login_count = (user.failed_login_count or 0) + 1
310
+ max_attempts = current_app.config.get("SECURITY_LOGIN_MAX_ATTEMPTS", 5)
311
+ lockout_minutes = current_app.config.get("SECURITY_LOCKOUT_MINUTES", 15)
312
+ if user.failed_login_count >= max_attempts:
313
+ user.locked_until = datetime.datetime.now() + datetime.timedelta(
314
+ minutes=lockout_minutes
315
+ )
316
+ await _commit()
317
+
270
318
  await flash("Invalid email or password", "error")
271
319
 
272
320
  return await render_template("security/login_user.html", login_user_form=form)
@@ -309,7 +357,7 @@ async def register():
309
357
 
310
358
  user = await _create_user(**user_kwargs)
311
359
  if hasattr(user, "confirmed_at"):
312
- user.confirmed_at = datetime.datetime.utcnow()
360
+ user.confirmed_at = naive_utcnow()
313
361
 
314
362
  await _commit()
315
363
  await user_registered.send_async(current_app._get_current_object(), user=user)
@@ -429,16 +477,12 @@ async def two_factor_setup():
429
477
  current_user.tf_primary_method = "authenticator"
430
478
  session.pop("tf_pending_secret", None)
431
479
 
432
- if current_app.config.get(
433
- "SECURITY_MULTI_FACTOR_RECOVERY_CODES", True
434
- ):
480
+ if current_app.config.get("SECURITY_MULTI_FACTOR_RECOVERY_CODES", True):
435
481
  if not getattr(current_user, "mf_recovery_codes", None):
436
482
  count = current_app.config.get(
437
483
  "SECURITY_MULTI_FACTOR_RECOVERY_CODES_N", 3
438
484
  )
439
- current_user.mf_recovery_codes = generate_recovery_codes(
440
- count
441
- )
485
+ current_user.mf_recovery_codes = generate_recovery_codes(count)
442
486
 
443
487
  await _commit()
444
488
  await tf_profile_changed.send_async(
@@ -505,8 +549,10 @@ async def two_factor_token_validation():
505
549
  if token and getattr(user, "tf_totp_secret", None):
506
550
  valid = verify_totp(user.tf_totp_secret, token)
507
551
 
508
- if not valid and token and current_app.config.get(
509
- "SECURITY_MULTI_FACTOR_RECOVERY_CODES", True
552
+ if (
553
+ not valid
554
+ and token
555
+ and current_app.config.get("SECURITY_MULTI_FACTOR_RECOVERY_CODES", True)
510
556
  ):
511
557
  codes = list(getattr(user, "mf_recovery_codes", None) or [])
512
558
  ok, remaining = verify_recovery_code(token, codes)
@@ -553,7 +599,9 @@ async def mf_recovery_codes():
553
599
  codes = generate_recovery_codes(count)
554
600
  current_user.mf_recovery_codes = codes
555
601
  await _commit()
556
- await tf_profile_changed.send_async(current_app._get_current_object(), user=current_user)
602
+ await tf_profile_changed.send_async(
603
+ current_app._get_current_object(), user=current_user
604
+ )
557
605
  await flash("Recovery codes regenerated.", "success")
558
606
  elif request.args.get("show_codes"):
559
607
  codes = list(getattr(current_user, "mf_recovery_codes", None) or [])
@@ -656,6 +704,7 @@ async def wan_register():
656
704
  rp_name=_webauthn_rp_name(),
657
705
  challenge=challenge,
658
706
  existing_credentials=registered_credentials,
707
+ discoverable=(requested_usage == "primary"),
659
708
  )
660
709
  credential_options = wan.options_to_json_dict(options)
661
710
 
@@ -724,7 +773,7 @@ async def wan_register_response():
724
773
  usage=state.get("usage", "secondary"),
725
774
  backup_state=verification.get("backup_state", False),
726
775
  device_type=verification.get("device_type") or "single_device",
727
- lastuse_datetime=datetime.datetime.utcnow(),
776
+ lastuse_datetime=naive_utcnow(),
728
777
  )
729
778
  await _commit()
730
779
 
@@ -743,46 +792,13 @@ async def wan_signin():
743
792
  response_form = await QuartForm.from_formdata()
744
793
 
745
794
  credential_options = None
795
+
746
796
  if _is_post():
747
797
  await _enforce_csrf(getattr(form, "_submitted_csrf", None))
748
- identity = _normalize_email(form.identity.data)
749
- if not identity:
750
- await flash("Email is required.", "error")
751
- return await render_template(
752
- "security/wan_signin.html",
753
- wan_signin_form=form,
754
- wan_signin_response_form=response_form,
755
- credential_options=None,
756
- )
757
-
758
- user = await _find_user(email=identity)
759
- if user is None or not getattr(user, "active", True):
760
- await flash("Unable to authenticate with passkey.", "error")
761
- return await render_template(
762
- "security/wan_signin.html",
763
- wan_signin_form=form,
764
- wan_signin_response_form=response_form,
765
- credential_options=None,
766
- )
767
-
768
- credentials = await _list_webauthn_credentials(user)
769
- credentials = [
770
- credential
771
- for credential in credentials
772
- if getattr(credential, "usage", "secondary") in {"primary", "first"}
773
- ]
774
- if not credentials:
775
- await flash("No passkey is configured for passwordless sign-in.", "error")
776
- return await render_template(
777
- "security/wan_signin.html",
778
- wan_signin_form=form,
779
- wan_signin_response_form=response_form,
780
- credential_options=None,
781
- )
782
798
 
783
799
  challenge = secrets.token_bytes(32)
784
800
  options = await wan.begin_authentication(
785
- credentials,
801
+ [],
786
802
  rp_id=_webauthn_rp_id(),
787
803
  challenge=challenge,
788
804
  )
@@ -790,7 +806,6 @@ async def wan_signin():
790
806
  _set_wan_state(
791
807
  "wan_signin_state",
792
808
  challenge=wan.bytes_to_base64url(challenge),
793
- user_id=user.get_id(),
794
809
  remember=bool(form.remember.data),
795
810
  )
796
811
 
@@ -815,18 +830,29 @@ async def wan_signin_response():
815
830
  await flash("Passkey sign-in expired. Please try again.", "error")
816
831
  return redirect(url_for_security("wan_signin"))
817
832
 
818
- user = await _find_user(fs_uniquifier=state.get("user_id"))
819
- if user is None:
820
- await flash("Unable to find user for passkey sign-in.", "error")
821
- return redirect(url_for_security("wan_signin"))
822
-
823
833
  credential_payload = await _extract_webauthn_credential_payload()
824
834
  credential_id = _extract_webauthn_credential_id(credential_payload)
825
835
  if not credential_payload or credential_id is None:
826
836
  await flash("Invalid passkey response payload.", "error")
827
837
  return redirect(url_for_security("wan_signin"))
828
838
 
829
- stored_credential = await _find_webauthn_credential(credential_id, user=user)
839
+ stored_credential = await _find_webauthn_credential(credential_id, user=None)
840
+ if stored_credential is None:
841
+ await flash("Passkey not recognized.", "error")
842
+ return redirect(url_for_security("wan_signin"))
843
+
844
+ credential_user_id = getattr(stored_credential, "user_id", None)
845
+ user_handle = _extract_webauthn_user_handle(credential_payload)
846
+ lookup_handle = credential_user_id or user_handle
847
+ if not lookup_handle:
848
+ await flash("Unable to identify user from passkey.", "error")
849
+ return redirect(url_for_security("wan_signin"))
850
+
851
+ user = await _find_user(fs_webauthn_user_handle=lookup_handle)
852
+ if user is None or not getattr(user, "active", True):
853
+ await flash("Unable to find user for passkey sign-in.", "error")
854
+ return redirect(url_for_security("wan_signin"))
855
+
830
856
  if stored_credential is None:
831
857
  await flash("Passkey not recognized.", "error")
832
858
  return redirect(url_for_security("wan_signin"))
@@ -848,7 +874,7 @@ async def wan_signin_response():
848
874
 
849
875
  stored_credential.sign_count = int(new_sign_count)
850
876
  if hasattr(stored_credential, "lastuse_datetime"):
851
- stored_credential.lastuse_datetime = datetime.datetime.utcnow()
877
+ stored_credential.lastuse_datetime = naive_utcnow()
852
878
 
853
879
  session.permanent = bool(state.get("remember"))
854
880
  await _security.login_user(user)
@@ -930,7 +956,9 @@ async def wan_verify_response():
930
956
  await flash("Invalid passkey response payload.", "error")
931
957
  return redirect(url_for_security("wan_verify"))
932
958
 
933
- stored_credential = await _find_webauthn_credential(credential_id, user=current_user)
959
+ stored_credential = await _find_webauthn_credential(
960
+ credential_id, user=current_user
961
+ )
934
962
  if stored_credential is None:
935
963
  await flash("Passkey not recognized.", "error")
936
964
  return redirect(url_for_security("wan_verify"))
@@ -952,7 +980,7 @@ async def wan_verify_response():
952
980
 
953
981
  stored_credential.sign_count = int(new_sign_count)
954
982
  if hasattr(stored_credential, "lastuse_datetime"):
955
- stored_credential.lastuse_datetime = datetime.datetime.utcnow()
983
+ stored_credential.lastuse_datetime = naive_utcnow()
956
984
 
957
985
  session["_fresh"] = True
958
986
  await _commit()