quart-security 1.1.0__tar.gz → 1.2.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 (31) hide show
  1. {quart_security-1.1.0 → quart_security-1.2.1}/PKG-INFO +8 -1
  2. {quart_security-1.1.0 → quart_security-1.2.1}/README.md +7 -0
  3. {quart_security-1.1.0 → quart_security-1.2.1}/pyproject.toml +1 -1
  4. {quart_security-1.1.0 → quart_security-1.2.1}/quart_security/core.py +10 -5
  5. {quart_security-1.1.0 → quart_security-1.2.1}/quart_security/forms.py +8 -2
  6. quart_security-1.2.1/quart_security/utils.py +25 -0
  7. {quart_security-1.1.0 → quart_security-1.2.1}/quart_security/views.py +59 -24
  8. quart_security-1.1.0/quart_security/utils.py +0 -8
  9. {quart_security-1.1.0 → quart_security-1.2.1}/LICENSE +0 -0
  10. {quart_security-1.1.0 → quart_security-1.2.1}/quart_security/__init__.py +0 -0
  11. {quart_security-1.1.0 → quart_security-1.2.1}/quart_security/datastore.py +0 -0
  12. {quart_security-1.1.0 → quart_security-1.2.1}/quart_security/decorators.py +0 -0
  13. {quart_security-1.1.0 → quart_security-1.2.1}/quart_security/email.py +0 -0
  14. {quart_security-1.1.0 → quart_security-1.2.1}/quart_security/models.py +0 -0
  15. {quart_security-1.1.0 → quart_security-1.2.1}/quart_security/password.py +0 -0
  16. {quart_security-1.1.0 → quart_security-1.2.1}/quart_security/proxies.py +0 -0
  17. {quart_security-1.1.0 → quart_security-1.2.1}/quart_security/signals.py +0 -0
  18. {quart_security-1.1.0 → quart_security-1.2.1}/quart_security/templates/security/_macros.html +0 -0
  19. {quart_security-1.1.0 → quart_security-1.2.1}/quart_security/templates/security/change_password.html +0 -0
  20. {quart_security-1.1.0 → quart_security-1.2.1}/quart_security/templates/security/login_user.html +0 -0
  21. {quart_security-1.1.0 → quart_security-1.2.1}/quart_security/templates/security/mf_recovery.html +0 -0
  22. {quart_security-1.1.0 → quart_security-1.2.1}/quart_security/templates/security/mf_recovery_codes.html +0 -0
  23. {quart_security-1.1.0 → quart_security-1.2.1}/quart_security/templates/security/register_user.html +0 -0
  24. {quart_security-1.1.0 → quart_security-1.2.1}/quart_security/templates/security/two_factor_select.html +0 -0
  25. {quart_security-1.1.0 → quart_security-1.2.1}/quart_security/templates/security/two_factor_setup.html +0 -0
  26. {quart_security-1.1.0 → quart_security-1.2.1}/quart_security/templates/security/two_factor_verify_code.html +0 -0
  27. {quart_security-1.1.0 → quart_security-1.2.1}/quart_security/templates/security/wan_register.html +0 -0
  28. {quart_security-1.1.0 → quart_security-1.2.1}/quart_security/templates/security/wan_signin.html +0 -0
  29. {quart_security-1.1.0 → quart_security-1.2.1}/quart_security/templates/security/wan_verify.html +0 -0
  30. {quart_security-1.1.0 → quart_security-1.2.1}/quart_security/totp.py +0 -0
  31. {quart_security-1.1.0 → quart_security-1.2.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.1.0
3
+ Version: 1.2.1
4
4
  Summary: Native async auth for Quart
5
5
  Author: quart-security maintainers
6
6
  Requires-Python: >=3.11
@@ -55,6 +55,7 @@ It is designed as a practical replacement path for Flask-Security style session
55
55
  - Flask-Security-style endpoint naming through `url_for_security()`
56
56
  - Signals for auth lifecycle events
57
57
  - Overridable templates under `templates/security/`
58
+ - Datastore hooks can be implemented as async methods or simple sync methods
58
59
 
59
60
  ## Non-Goals (Current Scope)
60
61
 
@@ -130,6 +131,10 @@ For tracking / MFA / WebAuthn features:
130
131
  - `fs_webauthn_user_handle`
131
132
  - relationship/association for stored WebAuthn credentials
132
133
 
134
+ Optional for lockout support:
135
+ - `failed_login_count`
136
+ - `locked_until`
137
+
133
138
  ## Key Configuration
134
139
 
135
140
  The extension uses `SECURITY_*` keys for migration-friendly configuration.
@@ -138,6 +143,8 @@ Core:
138
143
  - `SECURITY_PASSWORD_HASH` (default: `pbkdf2_sha512`)
139
144
  - `SECURITY_PASSWORD_SALT` (recommended)
140
145
  - `SECURITY_PASSWORD_LENGTH_MIN` (default: `12`)
146
+ - `SECURITY_LOGIN_MAX_ATTEMPTS` (default: `5`)
147
+ - `SECURITY_LOCKOUT_MINUTES` (default: `15`)
141
148
  - `SECURITY_REGISTERABLE`
142
149
  - `SECURITY_CHANGEABLE`
143
150
  - `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.0"
7
+ version = "1.2.1"
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:
@@ -88,6 +87,10 @@ class Security:
88
87
  async def _security_load_user():
89
88
  await self.load_user()
90
89
 
90
+ @app.before_websocket
91
+ async def _security_load_user_ws():
92
+ await self.load_user()
93
+
91
94
  app.extensions["quart_security_load_user_registered"] = True
92
95
 
93
96
  app.jinja_env.globals.setdefault("url_for_security", url_for_security)
@@ -127,6 +130,8 @@ class Security:
127
130
  "SECURITY_WAN_REQUIRE_USER_VERIFICATION": True,
128
131
  "SECURITY_FRESHNESS": timedelta(minutes=60),
129
132
  "SECURITY_FRESHNESS_GRACE_PERIOD": timedelta(minutes=60),
133
+ "SECURITY_LOGIN_MAX_ATTEMPTS": 5,
134
+ "SECURITY_LOCKOUT_MINUTES": 15,
130
135
  "SECURITY_POST_LOGIN_VIEW": "/",
131
136
  "SECURITY_POST_REGISTER_VIEW": "/login",
132
137
  "SECURITY_EMAIL_SENDER": "noreply@example.com",
@@ -144,7 +149,7 @@ class Security:
144
149
  g._current_user = AnonymousUser()
145
150
  return
146
151
 
147
- user = await self.datastore.find_user(fs_uniquifier=user_id)
152
+ user = await maybe_await(self.datastore.find_user(fs_uniquifier=user_id))
148
153
  g._current_user = user or AnonymousUser()
149
154
 
150
155
  async def login_user(self, user, fresh=True):
@@ -164,11 +169,11 @@ class Security:
164
169
  if app.config.get("SECURITY_TRACKABLE", True):
165
170
  user.last_login_at = getattr(user, "current_login_at", None)
166
171
  user.last_login_ip = getattr(user, "current_login_ip", None)
167
- user.current_login_at = datetime.datetime.utcnow()
172
+ user.current_login_at = naive_utcnow()
168
173
  user.current_login_ip = request.remote_addr
169
174
  user.login_count = (getattr(user, "login_count", None) or 0) + 1
170
175
 
171
- await self.datastore.commit()
176
+ await maybe_await(self.datastore.commit())
172
177
  await user_authenticated.send_async(app, user=user, authn_via="session")
173
178
 
174
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
  )
@@ -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:
@@ -253,11 +253,33 @@ async def login():
253
253
 
254
254
  if _is_post() and form.validate():
255
255
  user = await _find_user(email=_normalize_email(form.email.data))
256
+
257
+ # Account lockout check
258
+ if user and hasattr(user, "locked_until") and user.locked_until:
259
+ if datetime.datetime.now() < user.locked_until:
260
+ await flash("Account temporarily locked. Try again later.", "error")
261
+ return await render_template(
262
+ "security/login_user.html", login_user_form=form
263
+ )
264
+ # Lock expired, reset
265
+ user.failed_login_count = 0
266
+ user.locked_until = None
267
+ await _commit()
268
+
256
269
  if (
257
270
  user
258
271
  and getattr(user, "active", True)
259
272
  and verify_password(form.password.data, user.password)
260
273
  ):
274
+ # Reset failed attempts on success
275
+ if (
276
+ hasattr(user, "failed_login_count")
277
+ and (user.failed_login_count or 0) > 0
278
+ ):
279
+ user.failed_login_count = 0
280
+ user.locked_until = None
281
+ await _commit()
282
+
261
283
  if current_app.config.get("SECURITY_TWO_FACTOR") and getattr(
262
284
  user, "tf_primary_method", None
263
285
  ):
@@ -267,6 +289,17 @@ async def login():
267
289
  await _security.login_user(user)
268
290
  return redirect(_resolve_redirect("SECURITY_POST_LOGIN_VIEW", "login"))
269
291
 
292
+ # Track failed login
293
+ if user and hasattr(user, "failed_login_count"):
294
+ user.failed_login_count = (user.failed_login_count or 0) + 1
295
+ max_attempts = current_app.config.get("SECURITY_LOGIN_MAX_ATTEMPTS", 5)
296
+ lockout_minutes = current_app.config.get("SECURITY_LOCKOUT_MINUTES", 15)
297
+ if user.failed_login_count >= max_attempts:
298
+ user.locked_until = datetime.datetime.now() + datetime.timedelta(
299
+ minutes=lockout_minutes
300
+ )
301
+ await _commit()
302
+
270
303
  await flash("Invalid email or password", "error")
271
304
 
272
305
  return await render_template("security/login_user.html", login_user_form=form)
@@ -309,7 +342,7 @@ async def register():
309
342
 
310
343
  user = await _create_user(**user_kwargs)
311
344
  if hasattr(user, "confirmed_at"):
312
- user.confirmed_at = datetime.datetime.utcnow()
345
+ user.confirmed_at = naive_utcnow()
313
346
 
314
347
  await _commit()
315
348
  await user_registered.send_async(current_app._get_current_object(), user=user)
@@ -429,16 +462,12 @@ async def two_factor_setup():
429
462
  current_user.tf_primary_method = "authenticator"
430
463
  session.pop("tf_pending_secret", None)
431
464
 
432
- if current_app.config.get(
433
- "SECURITY_MULTI_FACTOR_RECOVERY_CODES", True
434
- ):
465
+ if current_app.config.get("SECURITY_MULTI_FACTOR_RECOVERY_CODES", True):
435
466
  if not getattr(current_user, "mf_recovery_codes", None):
436
467
  count = current_app.config.get(
437
468
  "SECURITY_MULTI_FACTOR_RECOVERY_CODES_N", 3
438
469
  )
439
- current_user.mf_recovery_codes = generate_recovery_codes(
440
- count
441
- )
470
+ current_user.mf_recovery_codes = generate_recovery_codes(count)
442
471
 
443
472
  await _commit()
444
473
  await tf_profile_changed.send_async(
@@ -505,8 +534,10 @@ async def two_factor_token_validation():
505
534
  if token and getattr(user, "tf_totp_secret", None):
506
535
  valid = verify_totp(user.tf_totp_secret, token)
507
536
 
508
- if not valid and token and current_app.config.get(
509
- "SECURITY_MULTI_FACTOR_RECOVERY_CODES", True
537
+ if (
538
+ not valid
539
+ and token
540
+ and current_app.config.get("SECURITY_MULTI_FACTOR_RECOVERY_CODES", True)
510
541
  ):
511
542
  codes = list(getattr(user, "mf_recovery_codes", None) or [])
512
543
  ok, remaining = verify_recovery_code(token, codes)
@@ -553,7 +584,9 @@ async def mf_recovery_codes():
553
584
  codes = generate_recovery_codes(count)
554
585
  current_user.mf_recovery_codes = codes
555
586
  await _commit()
556
- await tf_profile_changed.send_async(current_app._get_current_object(), user=current_user)
587
+ await tf_profile_changed.send_async(
588
+ current_app._get_current_object(), user=current_user
589
+ )
557
590
  await flash("Recovery codes regenerated.", "success")
558
591
  elif request.args.get("show_codes"):
559
592
  codes = list(getattr(current_user, "mf_recovery_codes", None) or [])
@@ -724,7 +757,7 @@ async def wan_register_response():
724
757
  usage=state.get("usage", "secondary"),
725
758
  backup_state=verification.get("backup_state", False),
726
759
  device_type=verification.get("device_type") or "single_device",
727
- lastuse_datetime=datetime.datetime.utcnow(),
760
+ lastuse_datetime=naive_utcnow(),
728
761
  )
729
762
  await _commit()
730
763
 
@@ -848,7 +881,7 @@ async def wan_signin_response():
848
881
 
849
882
  stored_credential.sign_count = int(new_sign_count)
850
883
  if hasattr(stored_credential, "lastuse_datetime"):
851
- stored_credential.lastuse_datetime = datetime.datetime.utcnow()
884
+ stored_credential.lastuse_datetime = naive_utcnow()
852
885
 
853
886
  session.permanent = bool(state.get("remember"))
854
887
  await _security.login_user(user)
@@ -930,7 +963,9 @@ async def wan_verify_response():
930
963
  await flash("Invalid passkey response payload.", "error")
931
964
  return redirect(url_for_security("wan_verify"))
932
965
 
933
- stored_credential = await _find_webauthn_credential(credential_id, user=current_user)
966
+ stored_credential = await _find_webauthn_credential(
967
+ credential_id, user=current_user
968
+ )
934
969
  if stored_credential is None:
935
970
  await flash("Passkey not recognized.", "error")
936
971
  return redirect(url_for_security("wan_verify"))
@@ -952,7 +987,7 @@ async def wan_verify_response():
952
987
 
953
988
  stored_credential.sign_count = int(new_sign_count)
954
989
  if hasattr(stored_credential, "lastuse_datetime"):
955
- stored_credential.lastuse_datetime = datetime.datetime.utcnow()
990
+ stored_credential.lastuse_datetime = naive_utcnow()
956
991
 
957
992
  session["_fresh"] = True
958
993
  await _commit()
@@ -1,8 +0,0 @@
1
- """Utility helpers."""
2
-
3
- from quart import url_for
4
-
5
-
6
- def url_for_security(endpoint, **kwargs):
7
- """Flask-Security-compatible helper for endpoint URLs."""
8
- return url_for(f"security.{endpoint}", **kwargs)
File without changes