quart-security 1.4.1__tar.gz → 2.0.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 (34) hide show
  1. {quart_security-1.4.1 → quart_security-2.0.0}/PKG-INFO +100 -10
  2. {quart_security-1.4.1 → quart_security-2.0.0}/README.md +93 -5
  3. {quart_security-1.4.1 → quart_security-2.0.0}/pyproject.toml +8 -5
  4. {quart_security-1.4.1 → quart_security-2.0.0}/quart_security/__init__.py +3 -0
  5. {quart_security-1.4.1 → quart_security-2.0.0}/quart_security/core.py +105 -12
  6. {quart_security-1.4.1 → quart_security-2.0.0}/quart_security/datastore.py +89 -10
  7. {quart_security-1.4.1 → quart_security-2.0.0}/quart_security/password.py +22 -23
  8. quart_security-2.0.0/quart_security/state.py +92 -0
  9. quart_security-2.0.0/quart_security/templates/security/two_factor_setup.html +25 -0
  10. {quart_security-1.4.1 → quart_security-2.0.0}/quart_security/totp.py +10 -1
  11. {quart_security-1.4.1 → quart_security-2.0.0}/quart_security/views.py +140 -95
  12. {quart_security-1.4.1 → quart_security-2.0.0}/quart_security/webauthn.py +8 -1
  13. quart_security-1.4.1/quart_security/templates/security/two_factor_setup.html +0 -12
  14. {quart_security-1.4.1 → quart_security-2.0.0}/LICENSE +0 -0
  15. {quart_security-1.4.1 → quart_security-2.0.0}/quart_security/breach.py +0 -0
  16. {quart_security-1.4.1 → quart_security-2.0.0}/quart_security/decorators.py +0 -0
  17. {quart_security-1.4.1 → quart_security-2.0.0}/quart_security/email.py +0 -0
  18. {quart_security-1.4.1 → quart_security-2.0.0}/quart_security/forms.py +0 -0
  19. {quart_security-1.4.1 → quart_security-2.0.0}/quart_security/models.py +0 -0
  20. {quart_security-1.4.1 → quart_security-2.0.0}/quart_security/proxies.py +0 -0
  21. {quart_security-1.4.1 → quart_security-2.0.0}/quart_security/py.typed +0 -0
  22. {quart_security-1.4.1 → quart_security-2.0.0}/quart_security/signals.py +0 -0
  23. {quart_security-1.4.1 → quart_security-2.0.0}/quart_security/templates/security/_macros.html +0 -0
  24. {quart_security-1.4.1 → quart_security-2.0.0}/quart_security/templates/security/change_password.html +0 -0
  25. {quart_security-1.4.1 → quart_security-2.0.0}/quart_security/templates/security/login_user.html +0 -0
  26. {quart_security-1.4.1 → quart_security-2.0.0}/quart_security/templates/security/mf_recovery.html +0 -0
  27. {quart_security-1.4.1 → quart_security-2.0.0}/quart_security/templates/security/mf_recovery_codes.html +0 -0
  28. {quart_security-1.4.1 → quart_security-2.0.0}/quart_security/templates/security/register_user.html +0 -0
  29. {quart_security-1.4.1 → quart_security-2.0.0}/quart_security/templates/security/two_factor_select.html +0 -0
  30. {quart_security-1.4.1 → quart_security-2.0.0}/quart_security/templates/security/two_factor_verify_code.html +0 -0
  31. {quart_security-1.4.1 → quart_security-2.0.0}/quart_security/templates/security/wan_register.html +0 -0
  32. {quart_security-1.4.1 → quart_security-2.0.0}/quart_security/templates/security/wan_signin.html +0 -0
  33. {quart_security-1.4.1 → quart_security-2.0.0}/quart_security/templates/security/wan_verify.html +0 -0
  34. {quart_security-1.4.1 → quart_security-2.0.0}/quart_security/utils.py +0 -0
@@ -1,19 +1,21 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quart-security
3
- Version: 1.4.1
3
+ Version: 2.0.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
8
  License-File: LICENSE
9
9
  Requires-Dist: quart>=0.20.0
10
- Requires-Dist: sqlalchemy>=2.0
11
- Requires-Dist: libpass>=1.9
10
+ Requires-Dist: sqlalchemy[asyncio]>=2.0
11
+ Requires-Dist: libpass[bcrypt]>=1.9.3
12
12
  Requires-Dist: pyotp>=2.9
13
- Requires-Dist: qrcode>=8.0
13
+ Requires-Dist: qrcode[pil]>=8.0
14
14
  Requires-Dist: webauthn>=2.7
15
+ Requires-Dist: cryptography>=50.0.0
16
+ Requires-Dist: cbor2>=5.9.0
15
17
  Requires-Dist: wtforms>=3.1
16
- Requires-Dist: aiosmtplib>=3.0
18
+ Requires-Dist: aiosmtplib>=5.1.2
17
19
  Requires-Dist: blinker>=1.7
18
20
  Requires-Dist: email-validator>=2.0
19
21
  Requires-Dist: argon2-cffi>=23.1
@@ -89,7 +91,9 @@ Package build backend: `flit`.
89
91
  from quart import Quart
90
92
  from quart_security import Security, SQLAlchemyUserDatastore
91
93
 
92
- # your models should include fields used by auth, roles, and MFA/WebAuthn
94
+ import os
95
+
96
+ # Models must include the fields required by enabled features.
93
97
  from myapp.models import db, User, Role, WebAuthnCredential
94
98
 
95
99
 
@@ -97,8 +101,7 @@ def create_app():
97
101
  app = Quart(__name__)
98
102
 
99
103
  app.config.update(
100
- SECRET_KEY="change-me",
101
- SECURITY_PASSWORD_SALT="change-me-too",
104
+ SECRET_KEY=os.environ["SECRET_KEY"],
102
105
  SECURITY_POST_LOGIN_VIEW="/dashboard",
103
106
  SECURITY_POST_REGISTER_VIEW="/login",
104
107
  )
@@ -132,7 +135,7 @@ For tracking / MFA / WebAuthn features:
132
135
  - `fs_webauthn_user_handle`
133
136
  - relationship/association for stored WebAuthn credentials
134
137
 
135
- Optional for lockout support:
138
+ Required for the default lockout policy:
136
139
  - `failed_login_count`
137
140
  - `locked_until`
138
141
 
@@ -140,13 +143,17 @@ These fields are required when login or MFA lockout is enabled. The built-in
140
143
  counter limits account-level guesses. Deploy an application or edge rate limiter
141
144
  for source IP and distributed abuse controls.
142
145
 
146
+ The database must enforce unique normalized emails, `fs_uniquifier` values,
147
+ WebAuthn user handles, and credential IDs. Use a stable primary key separate from
148
+ `fs_uniquifier`: this value changes when security settings change.
149
+
143
150
  ## Key Configuration
144
151
 
145
152
  The extension uses `SECURITY_*` keys for migration-friendly configuration.
146
153
 
147
154
  Core:
148
155
  - `SECURITY_PASSWORD_HASH` (default: `argon2` / argon2id; set to `pbkdf2_sha512` to keep old behavior)
149
- - `SECURITY_PASSWORD_SALT` (recommended)
156
+ - `SECURITY_PASSWORD_SALT` (legacy salted-hash verification only)
150
157
  - `SECURITY_PASSWORD_LENGTH_MIN` (default: `12`)
151
158
  - `SECURITY_PASSWORD_BREACH_CHECK` (default: `True`) - HIBP k-anonymity check on register/change
152
159
  - `SECURITY_PASSWORD_BREACH_COUNT_MIN` (default: `1`) - minimum breach count to reject
@@ -157,6 +164,7 @@ Core:
157
164
  - `SECURITY_CHANGEABLE`
158
165
  - `SECURITY_TRACKABLE`
159
166
  - `SECURITY_CSRF_PROTECT` (default: `True`)
167
+ - `SECURITY_COOKIE_SECURE` (default: `True`; sets `SESSION_COOKIE_SECURE`)
160
168
  - `SECURITY_FRESHNESS` (default: `60` minutes)
161
169
 
162
170
  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.
@@ -171,6 +179,10 @@ Recovery codes are displayed only when generated and are stored as keyed hashes.
171
179
  Existing plaintext codes remain valid until their next successful use, when the
172
180
  remaining codes are migrated.
173
181
 
182
+ Accepted TOTP time steps, recovery codes, and WebAuthn challenges cannot be reused.
183
+ Disabling an authenticator requires a current TOTP or unused recovery code.
184
+ Wait for the next TOTP code if the current one was just used to sign in.
185
+
174
186
  WebAuthn:
175
187
  - `SECURITY_WEBAUTHN`
176
188
  - `SECURITY_WAN_ALLOW_AS_FIRST_FACTOR`
@@ -268,3 +280,81 @@ Run this in a HTTPS staging environment with production-like hostnames and real
268
280
  - Set explicit WebAuthn RP values (`SECURITY_WAN_RP_ID`, `SECURITY_WAN_EXPECTED_ORIGIN`) when behind proxies or multiple domains.
269
281
  - Keep CSRF protection enabled unless you have a deliberate replacement.
270
282
 
283
+ ## Version 2.0.0 migration
284
+
285
+ This update requires a shared `quart_security_state` table. It stores expiring
286
+ authentication records and verification state. Cookie contents contain opaque
287
+ references, not pending authenticator secrets. All workers must use the same
288
+ database. Existing login cookies are rejected after this update.
289
+
290
+ Create the table through your application's database migration before deploying
291
+ the new library. For example, in an Alembic migration:
292
+
293
+ ```python
294
+ from alembic import op
295
+ from quart_security import SecurityState
296
+
297
+
298
+ def upgrade():
299
+ SecurityState.__table__.create(op.get_bind())
300
+ ```
301
+
302
+ This includes the expiry index. Startup checks that the table is present. Protect
303
+ database access and backups because pending TOTP secrets are stored there.
304
+ Expired records are removed when new state is written. Maintenance jobs can also
305
+ call `await security.state_store.purge_expired()` in an application context and
306
+ close the datastore after the job.
307
+
308
+ For a session factory, use `async_sessionmaker(engine, expire_on_commit=False)`.
309
+ The library retains the session through commits and closes factory-owned sessions
310
+ at the end of each HTTP request or WebSocket connection. Read-only and failed
311
+ requests also close their sessions. A supplied `db.session` or session instance
312
+ remains owned by the host application; the host must close or roll it back.
313
+ Outside a request, close factory-owned sessions with `await datastore.close()`.
314
+ Do not share one session across concurrent tasks.
315
+
316
+ Password and MFA profile changes rotate the user's `fs_uniquifier`, which rejects
317
+ older authenticated and pending login sessions. Logout revokes the current
318
+ authentication record, including copied cookies for that session. Password
319
+ changes keep the requesting session authenticated. Passkey registration and
320
+ deletion also revoke older sessions. A `secondary` passkey cannot perform
321
+ passwordless sign-in. A primary passkey uses its own user verification and does
322
+ not require the account's TOTP code.
323
+
324
+ Password helpers now use the current application's settings. Outside an app
325
+ context, pass `app=app`, for example `hash_password(password, app=app)`.
326
+
327
+ Cookies default to Secure, HttpOnly, and SameSite=Lax. For local HTTP development
328
+ only, set `SECURITY_COOKIE_SECURE=False`. An explicit SameSite value is preserved.
329
+ Use a strong random secret key and explicit RP/origin settings behind proxies.
330
+ The host still needs source-IP and global rate limits; account lockout does not
331
+ limit registration or anonymous challenge generation. Lockout fields are required
332
+ when `SECURITY_LOGIN_MAX_ATTEMPTS` is greater than zero. Setting it to zero
333
+ explicitly disables account lockout.
334
+
335
+ Custom datastores require a shared `state_store` passed to `Security`. Its async
336
+ methods must implement this contract and propagate storage errors:
337
+
338
+ | Method | Contract |
339
+ | --- | --- |
340
+ | `put(payload, ttl=seconds, token=None)` | Persist a dictionary with an expiry and return an unpredictable reference. |
341
+ | `get(token)` | Return unexpired state or None. |
342
+ | `pop(token)` | Atomically consume unexpired state; exactly one caller receives it. |
343
+ | `claim(token, ttl=seconds)` | Atomically reserve a key; return False while another unexpired claim exists. |
344
+
345
+ A process-local dictionary is suitable only for tests. Custom datastores must also
346
+ provide `record_auth_failure(user, max_attempts=..., lockout_minutes=...)` as an
347
+ atomic increment/lock update and
348
+ `replace_recovery_codes(user, expected, remaining)` as an atomic conditional
349
+ replacement returning a boolean. `rotate_uniquifier(user, expected, replacement)`
350
+ must atomically check the old value and persist both the new value and staged
351
+ profile changes. Return False and roll back staged changes when the old value no
352
+ longer matches. Concurrent profile updates return HTTP 409; the user must sign in
353
+ again. Persist these changes before returning. The
354
+ extension fails at initialization if an enabled control lacks its required hook
355
+ or SQLAlchemy model fields.
356
+
357
+ Dependencies now include the Pillow, bcrypt, and SQLAlchemy asyncio extras and patched
358
+ minimum versions of aiosmtplib, cryptography, and cbor2. The release workflow runs
359
+ lint and tests before building and publishing. CI tests Python 3.11 through 3.14.
360
+
@@ -61,7 +61,9 @@ Package build backend: `flit`.
61
61
  from quart import Quart
62
62
  from quart_security import Security, SQLAlchemyUserDatastore
63
63
 
64
- # your models should include fields used by auth, roles, and MFA/WebAuthn
64
+ import os
65
+
66
+ # Models must include the fields required by enabled features.
65
67
  from myapp.models import db, User, Role, WebAuthnCredential
66
68
 
67
69
 
@@ -69,8 +71,7 @@ def create_app():
69
71
  app = Quart(__name__)
70
72
 
71
73
  app.config.update(
72
- SECRET_KEY="change-me",
73
- SECURITY_PASSWORD_SALT="change-me-too",
74
+ SECRET_KEY=os.environ["SECRET_KEY"],
74
75
  SECURITY_POST_LOGIN_VIEW="/dashboard",
75
76
  SECURITY_POST_REGISTER_VIEW="/login",
76
77
  )
@@ -104,7 +105,7 @@ For tracking / MFA / WebAuthn features:
104
105
  - `fs_webauthn_user_handle`
105
106
  - relationship/association for stored WebAuthn credentials
106
107
 
107
- Optional for lockout support:
108
+ Required for the default lockout policy:
108
109
  - `failed_login_count`
109
110
  - `locked_until`
110
111
 
@@ -112,13 +113,17 @@ These fields are required when login or MFA lockout is enabled. The built-in
112
113
  counter limits account-level guesses. Deploy an application or edge rate limiter
113
114
  for source IP and distributed abuse controls.
114
115
 
116
+ The database must enforce unique normalized emails, `fs_uniquifier` values,
117
+ WebAuthn user handles, and credential IDs. Use a stable primary key separate from
118
+ `fs_uniquifier`: this value changes when security settings change.
119
+
115
120
  ## Key Configuration
116
121
 
117
122
  The extension uses `SECURITY_*` keys for migration-friendly configuration.
118
123
 
119
124
  Core:
120
125
  - `SECURITY_PASSWORD_HASH` (default: `argon2` / argon2id; set to `pbkdf2_sha512` to keep old behavior)
121
- - `SECURITY_PASSWORD_SALT` (recommended)
126
+ - `SECURITY_PASSWORD_SALT` (legacy salted-hash verification only)
122
127
  - `SECURITY_PASSWORD_LENGTH_MIN` (default: `12`)
123
128
  - `SECURITY_PASSWORD_BREACH_CHECK` (default: `True`) - HIBP k-anonymity check on register/change
124
129
  - `SECURITY_PASSWORD_BREACH_COUNT_MIN` (default: `1`) - minimum breach count to reject
@@ -129,6 +134,7 @@ Core:
129
134
  - `SECURITY_CHANGEABLE`
130
135
  - `SECURITY_TRACKABLE`
131
136
  - `SECURITY_CSRF_PROTECT` (default: `True`)
137
+ - `SECURITY_COOKIE_SECURE` (default: `True`; sets `SESSION_COOKIE_SECURE`)
132
138
  - `SECURITY_FRESHNESS` (default: `60` minutes)
133
139
 
134
140
  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.
@@ -143,6 +149,10 @@ Recovery codes are displayed only when generated and are stored as keyed hashes.
143
149
  Existing plaintext codes remain valid until their next successful use, when the
144
150
  remaining codes are migrated.
145
151
 
152
+ Accepted TOTP time steps, recovery codes, and WebAuthn challenges cannot be reused.
153
+ Disabling an authenticator requires a current TOTP or unused recovery code.
154
+ Wait for the next TOTP code if the current one was just used to sign in.
155
+
146
156
  WebAuthn:
147
157
  - `SECURITY_WEBAUTHN`
148
158
  - `SECURITY_WAN_ALLOW_AS_FIRST_FACTOR`
@@ -239,3 +249,81 @@ Run this in a HTTPS staging environment with production-like hostnames and real
239
249
  - Run behind HTTPS for WebAuthn in non-local environments.
240
250
  - Set explicit WebAuthn RP values (`SECURITY_WAN_RP_ID`, `SECURITY_WAN_EXPECTED_ORIGIN`) when behind proxies or multiple domains.
241
251
  - Keep CSRF protection enabled unless you have a deliberate replacement.
252
+
253
+ ## Version 2.0.0 migration
254
+
255
+ This update requires a shared `quart_security_state` table. It stores expiring
256
+ authentication records and verification state. Cookie contents contain opaque
257
+ references, not pending authenticator secrets. All workers must use the same
258
+ database. Existing login cookies are rejected after this update.
259
+
260
+ Create the table through your application's database migration before deploying
261
+ the new library. For example, in an Alembic migration:
262
+
263
+ ```python
264
+ from alembic import op
265
+ from quart_security import SecurityState
266
+
267
+
268
+ def upgrade():
269
+ SecurityState.__table__.create(op.get_bind())
270
+ ```
271
+
272
+ This includes the expiry index. Startup checks that the table is present. Protect
273
+ database access and backups because pending TOTP secrets are stored there.
274
+ Expired records are removed when new state is written. Maintenance jobs can also
275
+ call `await security.state_store.purge_expired()` in an application context and
276
+ close the datastore after the job.
277
+
278
+ For a session factory, use `async_sessionmaker(engine, expire_on_commit=False)`.
279
+ The library retains the session through commits and closes factory-owned sessions
280
+ at the end of each HTTP request or WebSocket connection. Read-only and failed
281
+ requests also close their sessions. A supplied `db.session` or session instance
282
+ remains owned by the host application; the host must close or roll it back.
283
+ Outside a request, close factory-owned sessions with `await datastore.close()`.
284
+ Do not share one session across concurrent tasks.
285
+
286
+ Password and MFA profile changes rotate the user's `fs_uniquifier`, which rejects
287
+ older authenticated and pending login sessions. Logout revokes the current
288
+ authentication record, including copied cookies for that session. Password
289
+ changes keep the requesting session authenticated. Passkey registration and
290
+ deletion also revoke older sessions. A `secondary` passkey cannot perform
291
+ passwordless sign-in. A primary passkey uses its own user verification and does
292
+ not require the account's TOTP code.
293
+
294
+ Password helpers now use the current application's settings. Outside an app
295
+ context, pass `app=app`, for example `hash_password(password, app=app)`.
296
+
297
+ Cookies default to Secure, HttpOnly, and SameSite=Lax. For local HTTP development
298
+ only, set `SECURITY_COOKIE_SECURE=False`. An explicit SameSite value is preserved.
299
+ Use a strong random secret key and explicit RP/origin settings behind proxies.
300
+ The host still needs source-IP and global rate limits; account lockout does not
301
+ limit registration or anonymous challenge generation. Lockout fields are required
302
+ when `SECURITY_LOGIN_MAX_ATTEMPTS` is greater than zero. Setting it to zero
303
+ explicitly disables account lockout.
304
+
305
+ Custom datastores require a shared `state_store` passed to `Security`. Its async
306
+ methods must implement this contract and propagate storage errors:
307
+
308
+ | Method | Contract |
309
+ | --- | --- |
310
+ | `put(payload, ttl=seconds, token=None)` | Persist a dictionary with an expiry and return an unpredictable reference. |
311
+ | `get(token)` | Return unexpired state or None. |
312
+ | `pop(token)` | Atomically consume unexpired state; exactly one caller receives it. |
313
+ | `claim(token, ttl=seconds)` | Atomically reserve a key; return False while another unexpired claim exists. |
314
+
315
+ A process-local dictionary is suitable only for tests. Custom datastores must also
316
+ provide `record_auth_failure(user, max_attempts=..., lockout_minutes=...)` as an
317
+ atomic increment/lock update and
318
+ `replace_recovery_codes(user, expected, remaining)` as an atomic conditional
319
+ replacement returning a boolean. `rotate_uniquifier(user, expected, replacement)`
320
+ must atomically check the old value and persist both the new value and staged
321
+ profile changes. Return False and roll back staged changes when the old value no
322
+ longer matches. Concurrent profile updates return HTTP 409; the user must sign in
323
+ again. Persist these changes before returning. The
324
+ extension fails at initialization if an enabled control lacks its required hook
325
+ or SQLAlchemy model fields.
326
+
327
+ Dependencies now include the Pillow, bcrypt, and SQLAlchemy asyncio extras and patched
328
+ minimum versions of aiosmtplib, cryptography, and cbor2. The release workflow runs
329
+ lint and tests before building and publishing. CI tests Python 3.11 through 3.14.
@@ -4,7 +4,7 @@ build-backend = "flit_core.buildapi"
4
4
 
5
5
  [project]
6
6
  name = "quart-security"
7
- version = "1.4.1"
7
+ version = "2.0.0"
8
8
  description = "Native async auth for Quart"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -14,13 +14,15 @@ authors = [
14
14
  ]
15
15
  dependencies = [
16
16
  "quart>=0.20.0",
17
- "sqlalchemy>=2.0",
18
- "libpass>=1.9",
17
+ "sqlalchemy[asyncio]>=2.0",
18
+ "libpass[bcrypt]>=1.9.3",
19
19
  "pyotp>=2.9",
20
- "qrcode>=8.0",
20
+ "qrcode[pil]>=8.0",
21
21
  "webauthn>=2.7",
22
+ "cryptography>=50.0.0",
23
+ "cbor2>=5.9.0",
22
24
  "wtforms>=3.1",
23
- "aiosmtplib>=3.0",
25
+ "aiosmtplib>=5.1.2",
24
26
  "blinker>=1.7",
25
27
  "email-validator>=2.0",
26
28
  "argon2-cffi>=23.1",
@@ -44,6 +46,7 @@ name = "quart_security"
44
46
 
45
47
  [dependency-groups]
46
48
  dev = [
49
+ "aiosqlite>=0.20",
47
50
  "pytest>=8.0",
48
51
  "pytest-asyncio>=0.23",
49
52
  "respx>=0.23.1",
@@ -18,11 +18,14 @@ from .signals import (
18
18
  user_logged_out,
19
19
  user_registered,
20
20
  )
21
+ from .state import SecurityState, SQLAlchemyStateStore
21
22
  from .utils import url_for_security
22
23
 
23
24
  __all__ = [
24
25
  "Security",
25
26
  "SQLAlchemyUserDatastore",
27
+ "SecurityState",
28
+ "SQLAlchemyStateStore",
26
29
  "auth_required",
27
30
  "roles_required",
28
31
  "UserMixin",
@@ -2,16 +2,18 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import secrets
6
5
  import time
7
6
  from datetime import timedelta
7
+ from uuid import uuid4
8
8
 
9
- from quart import current_app, g, request, session
9
+ from quart import abort, current_app, g, request, session
10
10
 
11
+ from .datastore import SQLAlchemyUserDatastore
11
12
  from .forms import ChangePasswordForm, LoginForm, RegisterForm
12
13
  from .password import init_password_context
13
14
  from .proxies import AnonymousUser, current_user
14
15
  from .signals import user_authenticated, user_logged_out
16
+ from .state import SQLAlchemyStateStore
15
17
  from .utils import maybe_await, naive_utcnow, url_for_security
16
18
 
17
19
 
@@ -54,6 +56,7 @@ class Security:
54
56
  "change_password_form", ChangePasswordForm
55
57
  )
56
58
  self.mail_util_cls = kwargs.get("mail_util_cls")
59
+ self.state_store = kwargs.get("state_store")
57
60
 
58
61
  if app is not None:
59
62
  self.init_app(app, datastore=datastore, **kwargs)
@@ -71,8 +74,50 @@ class Security:
71
74
  "change_password_form", self.change_password_form_cls
72
75
  )
73
76
  self.mail_util_cls = kwargs.get("mail_util_cls", self.mail_util_cls)
77
+ self.state_store = kwargs.get("state_store", self.state_store)
78
+ if self.state_store is None:
79
+ if not isinstance(self.datastore, SQLAlchemyUserDatastore):
80
+ raise RuntimeError("Custom datastores require a shared state_store")
81
+ self.state_store = SQLAlchemyStateStore(self.datastore)
74
82
 
75
83
  self._load_defaults(app)
84
+ for name in ("put", "get", "pop", "claim"):
85
+ if not callable(getattr(self.state_store, name, None)):
86
+ raise RuntimeError(f"Security state_store requires {name}")
87
+ required_hooks = ["rotate_uniquifier"]
88
+ if app.config["SECURITY_LOGIN_MAX_ATTEMPTS"] > 0:
89
+ required_hooks.append("record_auth_failure")
90
+ if app.config["SECURITY_MULTI_FACTOR_RECOVERY_CODES"]:
91
+ required_hooks.append("replace_recovery_codes")
92
+ for name in required_hooks:
93
+ if not callable(getattr(self.datastore, name, None)):
94
+ raise RuntimeError(f"Security requires atomic datastore hook: {name}")
95
+ if isinstance(self.datastore, SQLAlchemyUserDatastore):
96
+ required_fields = ["fs_uniquifier", "email", "password", "active"]
97
+ if app.config["SECURITY_LOGIN_MAX_ATTEMPTS"] > 0:
98
+ required_fields += ["failed_login_count", "locked_until"]
99
+ if app.config["SECURITY_TWO_FACTOR"]:
100
+ required_fields += ["tf_primary_method", "tf_totp_secret"]
101
+ if app.config["SECURITY_MULTI_FACTOR_RECOVERY_CODES"]:
102
+ required_fields.append("mf_recovery_codes")
103
+ if app.config["SECURITY_WEBAUTHN"]:
104
+ required_fields.append("fs_webauthn_user_handle")
105
+ missing = [
106
+ name
107
+ for name in required_fields
108
+ if not hasattr(self.datastore.user_model, name)
109
+ ]
110
+ if missing:
111
+ raise RuntimeError(
112
+ f"Security user model lacks fields: {', '.join(missing)}"
113
+ )
114
+ if not app.secret_key:
115
+ raise RuntimeError("Security requires SECRET_KEY")
116
+ app.config["SESSION_COOKIE_SECURE"] = app.config["SECURITY_COOKIE_SECURE"]
117
+ app.config["SESSION_COOKIE_HTTPONLY"] = True
118
+ app.config["SESSION_COOKIE_SAMESITE"] = (
119
+ app.config.get("SESSION_COOKIE_SAMESITE") or "Lax"
120
+ )
76
121
  init_password_context(app)
77
122
 
78
123
  from .views import security_bp
@@ -86,14 +131,36 @@ class Security:
86
131
 
87
132
  @app.before_request
88
133
  async def _security_load_user():
134
+ begin = getattr(self.datastore, "begin_request", None)
135
+ if begin:
136
+ begin()
89
137
  await self.load_user()
90
138
 
91
139
  @app.before_websocket
92
140
  async def _security_load_user_ws():
141
+ begin = getattr(self.datastore, "begin_request", None)
142
+ if begin:
143
+ begin()
93
144
  await self.load_user()
94
145
 
146
+ @app.teardown_request
147
+ @app.teardown_websocket
148
+ async def _security_close_session(_exception):
149
+ close = getattr(self.datastore, "close", None)
150
+ if close:
151
+ await maybe_await(close())
152
+
95
153
  app.extensions["quart_security_load_user_registered"] = True
96
154
 
155
+ if isinstance(self.state_store, SQLAlchemyStateStore):
156
+
157
+ @app.before_serving
158
+ async def _security_validate_state():
159
+ try:
160
+ await self.state_store.validate()
161
+ finally:
162
+ await self.datastore.close()
163
+
97
164
  app.jinja_env.globals.setdefault("url_for_security", url_for_security)
98
165
 
99
166
  if not app.extensions.get("quart_security_context_registered", False):
@@ -138,6 +205,7 @@ class Security:
138
205
  "SECURITY_POST_REGISTER_VIEW": "/login",
139
206
  "SECURITY_EMAIL_SENDER": "noreply@example.com",
140
207
  "SECURITY_CSRF_PROTECT": True,
208
+ "SECURITY_COOKIE_SECURE": True,
141
209
  }
142
210
 
143
211
  for key, value in defaults.items():
@@ -149,6 +217,12 @@ class Security:
149
217
  g._current_user = AnonymousUser()
150
218
  return
151
219
 
220
+ state = await self.state_store.get(session.get("_id"))
221
+ if not state or state.get("user_id") != user_id:
222
+ session.clear()
223
+ g._current_user = AnonymousUser()
224
+ return
225
+
152
226
  user = await maybe_await(self.datastore.find_user(fs_uniquifier=user_id))
153
227
  if user is None or not getattr(user, "active", True):
154
228
  session.clear()
@@ -158,6 +232,8 @@ class Security:
158
232
 
159
233
  async def login_user(self, user, fresh=True):
160
234
  app = current_app._get_current_object()
235
+ if not user.is_active:
236
+ raise ValueError("Cannot authenticate an inactive user")
161
237
 
162
238
  user_id = user.get_id() if hasattr(user, "get_id") else None
163
239
  if not user_id:
@@ -165,16 +241,6 @@ class Security:
165
241
  if not user_id:
166
242
  raise RuntimeError("User must have fs_uniquifier/get_id for session login")
167
243
 
168
- # Regenerate session to prevent fixation: discard pre-login state,
169
- # then write only the login keys into a clean session.
170
- session.clear()
171
- session["_user_id"] = user_id
172
- session["_fresh"] = bool(fresh)
173
- session["_auth_at"] = int(time.time())
174
- session["_id"] = secrets.token_hex(16)
175
- session.modified = True
176
- g._current_user = user
177
-
178
244
  if app.config.get("SECURITY_TRACKABLE", True):
179
245
  user.last_login_at = getattr(user, "current_login_at", None)
180
246
  user.last_login_ip = getattr(user, "current_login_ip", None)
@@ -183,13 +249,40 @@ class Security:
183
249
  user.login_count = (getattr(user, "login_count", None) or 0) + 1
184
250
 
185
251
  await maybe_await(self.datastore.commit())
252
+ token = await self.state_store.put(
253
+ {"user_id": user_id},
254
+ ttl=int(app.permanent_session_lifetime.total_seconds()),
255
+ )
256
+ # Discard pre-login state only after authentication writes succeed.
257
+ session.clear()
258
+ session["_user_id"] = user_id
259
+ session["_fresh"] = bool(fresh)
260
+ session["_auth_at"] = int(time.time())
261
+ session["_id"] = token
262
+ g._current_user = user
186
263
  await user_authenticated.send_async(app, user=user, authn_via="session")
187
264
 
265
+ async def revoke_user_sessions(self, user):
266
+ changed = await maybe_await(
267
+ self.datastore.rotate_uniquifier(user, user.get_id(), uuid4().hex)
268
+ )
269
+ if not changed:
270
+ session.clear()
271
+ g._current_user = AnonymousUser()
272
+ abort(409)
273
+ await self.state_store.pop(session.get("_id"))
274
+ session["_user_id"] = user.get_id()
275
+ session["_id"] = await self.state_store.put(
276
+ {"user_id": user.get_id()},
277
+ ttl=int(current_app.permanent_session_lifetime.total_seconds()),
278
+ )
279
+
188
280
  async def logout_user(self, user=None):
189
281
  app = current_app._get_current_object()
190
282
  target_user = user or getattr(g, "_current_user", AnonymousUser())
191
283
 
192
284
  if getattr(target_user, "is_authenticated", False):
285
+ await self.state_store.pop(session.get("_id"))
193
286
  await user_logged_out.send_async(app, user=target_user)
194
287
 
195
288
  session.clear()