quart-security 1.4.0__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 (35) hide show
  1. {quart_security-1.4.0 → quart_security-2.0.0}/PKG-INFO +110 -11
  2. {quart_security-1.4.0 → quart_security-2.0.0}/README.md +103 -6
  3. {quart_security-1.4.0 → quart_security-2.0.0}/pyproject.toml +8 -5
  4. {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/__init__.py +3 -0
  5. {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/core.py +111 -12
  6. {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/datastore.py +109 -4
  7. {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/decorators.py +13 -2
  8. {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/forms.py +0 -1
  9. {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/password.py +22 -23
  10. quart_security-2.0.0/quart_security/state.py +92 -0
  11. {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/templates/security/mf_recovery_codes.html +1 -1
  12. quart_security-2.0.0/quart_security/templates/security/two_factor_setup.html +25 -0
  13. quart_security-2.0.0/quart_security/totp.py +119 -0
  14. {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/views.py +216 -99
  15. {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/webauthn.py +8 -1
  16. quart_security-1.4.0/quart_security/templates/security/two_factor_setup.html +0 -12
  17. quart_security-1.4.0/quart_security/totp.py +0 -66
  18. {quart_security-1.4.0 → quart_security-2.0.0}/LICENSE +0 -0
  19. {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/breach.py +0 -0
  20. {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/email.py +0 -0
  21. {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/models.py +0 -0
  22. {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/proxies.py +0 -0
  23. {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/py.typed +0 -0
  24. {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/signals.py +0 -0
  25. {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/templates/security/_macros.html +0 -0
  26. {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/templates/security/change_password.html +0 -0
  27. {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/templates/security/login_user.html +0 -0
  28. {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/templates/security/mf_recovery.html +0 -0
  29. {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/templates/security/register_user.html +0 -0
  30. {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/templates/security/two_factor_select.html +0 -0
  31. {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/templates/security/two_factor_verify_code.html +0 -0
  32. {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/templates/security/wan_register.html +0 -0
  33. {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/templates/security/wan_signin.html +0 -0
  34. {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/templates/security/wan_verify.html +0 -0
  35. {quart_security-1.4.0 → 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.0
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,17 +135,25 @@ 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
 
142
+ These fields are required when login or MFA lockout is enabled. The built-in
143
+ counter limits account-level guesses. Deploy an application or edge rate limiter
144
+ for source IP and distributed abuse controls.
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
+
139
150
  ## Key Configuration
140
151
 
141
152
  The extension uses `SECURITY_*` keys for migration-friendly configuration.
142
153
 
143
154
  Core:
144
155
  - `SECURITY_PASSWORD_HASH` (default: `argon2` / argon2id; set to `pbkdf2_sha512` to keep old behavior)
145
- - `SECURITY_PASSWORD_SALT` (recommended)
156
+ - `SECURITY_PASSWORD_SALT` (legacy salted-hash verification only)
146
157
  - `SECURITY_PASSWORD_LENGTH_MIN` (default: `12`)
147
158
  - `SECURITY_PASSWORD_BREACH_CHECK` (default: `True`) - HIBP k-anonymity check on register/change
148
159
  - `SECURITY_PASSWORD_BREACH_COUNT_MIN` (default: `1`) - minimum breach count to reject
@@ -153,6 +164,8 @@ Core:
153
164
  - `SECURITY_CHANGEABLE`
154
165
  - `SECURITY_TRACKABLE`
155
166
  - `SECURITY_CSRF_PROTECT` (default: `True`)
167
+ - `SECURITY_COOKIE_SECURE` (default: `True`; sets `SESSION_COOKIE_SECURE`)
168
+ - `SECURITY_FRESHNESS` (default: `60` minutes)
156
169
 
157
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.
158
171
 
@@ -162,6 +175,14 @@ Existing pbkdf2_sha512 and bcrypt hashes continue to verify after the argon2 def
162
175
  - `SECURITY_MULTI_FACTOR_RECOVERY_CODES`
163
176
  - `SECURITY_MULTI_FACTOR_RECOVERY_CODES_N`
164
177
 
178
+ Recovery codes are displayed only when generated and are stored as keyed hashes.
179
+ Existing plaintext codes remain valid until their next successful use, when the
180
+ remaining codes are migrated.
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
+
165
186
  WebAuthn:
166
187
  - `SECURITY_WEBAUTHN`
167
188
  - `SECURITY_WAN_ALLOW_AS_FIRST_FACTOR`
@@ -180,7 +201,7 @@ Routing:
180
201
  Core:
181
202
  - `/login`
182
203
  - `/register`
183
- - `/logout`
204
+ - `/logout` (POST only)
184
205
  - `/change`
185
206
 
186
207
  2FA:
@@ -259,3 +280,81 @@ Run this in a HTTPS staging environment with production-like hostnames and real
259
280
  - Set explicit WebAuthn RP values (`SECURITY_WAN_RP_ID`, `SECURITY_WAN_EXPECTED_ORIGIN`) when behind proxies or multiple domains.
260
281
  - Keep CSRF protection enabled unless you have a deliberate replacement.
261
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,17 +105,25 @@ 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
+ These fields are required when login or MFA lockout is enabled. The built-in
113
+ counter limits account-level guesses. Deploy an application or edge rate limiter
114
+ for source IP and distributed abuse controls.
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
+
111
120
  ## Key Configuration
112
121
 
113
122
  The extension uses `SECURITY_*` keys for migration-friendly configuration.
114
123
 
115
124
  Core:
116
125
  - `SECURITY_PASSWORD_HASH` (default: `argon2` / argon2id; set to `pbkdf2_sha512` to keep old behavior)
117
- - `SECURITY_PASSWORD_SALT` (recommended)
126
+ - `SECURITY_PASSWORD_SALT` (legacy salted-hash verification only)
118
127
  - `SECURITY_PASSWORD_LENGTH_MIN` (default: `12`)
119
128
  - `SECURITY_PASSWORD_BREACH_CHECK` (default: `True`) - HIBP k-anonymity check on register/change
120
129
  - `SECURITY_PASSWORD_BREACH_COUNT_MIN` (default: `1`) - minimum breach count to reject
@@ -125,6 +134,8 @@ Core:
125
134
  - `SECURITY_CHANGEABLE`
126
135
  - `SECURITY_TRACKABLE`
127
136
  - `SECURITY_CSRF_PROTECT` (default: `True`)
137
+ - `SECURITY_COOKIE_SECURE` (default: `True`; sets `SESSION_COOKIE_SECURE`)
138
+ - `SECURITY_FRESHNESS` (default: `60` minutes)
128
139
 
129
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.
130
141
 
@@ -134,6 +145,14 @@ Existing pbkdf2_sha512 and bcrypt hashes continue to verify after the argon2 def
134
145
  - `SECURITY_MULTI_FACTOR_RECOVERY_CODES`
135
146
  - `SECURITY_MULTI_FACTOR_RECOVERY_CODES_N`
136
147
 
148
+ Recovery codes are displayed only when generated and are stored as keyed hashes.
149
+ Existing plaintext codes remain valid until their next successful use, when the
150
+ remaining codes are migrated.
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
+
137
156
  WebAuthn:
138
157
  - `SECURITY_WEBAUTHN`
139
158
  - `SECURITY_WAN_ALLOW_AS_FIRST_FACTOR`
@@ -152,7 +171,7 @@ Routing:
152
171
  Core:
153
172
  - `/login`
154
173
  - `/register`
155
- - `/logout`
174
+ - `/logout` (POST only)
156
175
  - `/change`
157
176
 
158
177
  2FA:
@@ -230,3 +249,81 @@ Run this in a HTTPS staging environment with production-like hostnames and real
230
249
  - Run behind HTTPS for WebAuthn in non-local environments.
231
250
  - Set explicit WebAuthn RP values (`SECURITY_WAN_RP_ID`, `SECURITY_WAN_EXPECTED_ORIGIN`) when behind proxies or multiple domains.
232
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.0"
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,15 +2,18 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import secrets
5
+ import time
6
6
  from datetime import timedelta
7
+ from uuid import uuid4
7
8
 
8
- from quart import current_app, g, request, session
9
+ from quart import abort, current_app, g, request, session
9
10
 
11
+ from .datastore import SQLAlchemyUserDatastore
10
12
  from .forms import ChangePasswordForm, LoginForm, RegisterForm
11
13
  from .password import init_password_context
12
14
  from .proxies import AnonymousUser, current_user
13
15
  from .signals import user_authenticated, user_logged_out
16
+ from .state import SQLAlchemyStateStore
14
17
  from .utils import maybe_await, naive_utcnow, url_for_security
15
18
 
16
19
 
@@ -53,6 +56,7 @@ class Security:
53
56
  "change_password_form", ChangePasswordForm
54
57
  )
55
58
  self.mail_util_cls = kwargs.get("mail_util_cls")
59
+ self.state_store = kwargs.get("state_store")
56
60
 
57
61
  if app is not None:
58
62
  self.init_app(app, datastore=datastore, **kwargs)
@@ -70,8 +74,50 @@ class Security:
70
74
  "change_password_form", self.change_password_form_cls
71
75
  )
72
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)
73
82
 
74
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
+ )
75
121
  init_password_context(app)
76
122
 
77
123
  from .views import security_bp
@@ -85,14 +131,36 @@ class Security:
85
131
 
86
132
  @app.before_request
87
133
  async def _security_load_user():
134
+ begin = getattr(self.datastore, "begin_request", None)
135
+ if begin:
136
+ begin()
88
137
  await self.load_user()
89
138
 
90
139
  @app.before_websocket
91
140
  async def _security_load_user_ws():
141
+ begin = getattr(self.datastore, "begin_request", None)
142
+ if begin:
143
+ begin()
92
144
  await self.load_user()
93
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
+
94
153
  app.extensions["quart_security_load_user_registered"] = True
95
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
+
96
164
  app.jinja_env.globals.setdefault("url_for_security", url_for_security)
97
165
 
98
166
  if not app.extensions.get("quart_security_context_registered", False):
@@ -137,6 +205,7 @@ class Security:
137
205
  "SECURITY_POST_REGISTER_VIEW": "/login",
138
206
  "SECURITY_EMAIL_SENDER": "noreply@example.com",
139
207
  "SECURITY_CSRF_PROTECT": True,
208
+ "SECURITY_COOKIE_SECURE": True,
140
209
  }
141
210
 
142
211
  for key, value in defaults.items():
@@ -148,11 +217,23 @@ class Security:
148
217
  g._current_user = AnonymousUser()
149
218
  return
150
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
+
151
226
  user = await maybe_await(self.datastore.find_user(fs_uniquifier=user_id))
152
- g._current_user = user or AnonymousUser()
227
+ if user is None or not getattr(user, "active", True):
228
+ session.clear()
229
+ g._current_user = AnonymousUser()
230
+ return
231
+ g._current_user = user
153
232
 
154
233
  async def login_user(self, user, fresh=True):
155
234
  app = current_app._get_current_object()
235
+ if not user.is_active:
236
+ raise ValueError("Cannot authenticate an inactive user")
156
237
 
157
238
  user_id = user.get_id() if hasattr(user, "get_id") else None
158
239
  if not user_id:
@@ -160,15 +241,6 @@ class Security:
160
241
  if not user_id:
161
242
  raise RuntimeError("User must have fs_uniquifier/get_id for session login")
162
243
 
163
- # Regenerate session to prevent fixation: discard pre-login state,
164
- # then write only the login keys into a clean session.
165
- session.clear()
166
- session["_user_id"] = user_id
167
- session["_fresh"] = bool(fresh)
168
- session["_id"] = secrets.token_hex(16)
169
- session.modified = True
170
- g._current_user = user
171
-
172
244
  if app.config.get("SECURITY_TRACKABLE", True):
173
245
  user.last_login_at = getattr(user, "current_login_at", None)
174
246
  user.last_login_ip = getattr(user, "current_login_ip", None)
@@ -177,13 +249,40 @@ class Security:
177
249
  user.login_count = (getattr(user, "login_count", None) or 0) + 1
178
250
 
179
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
180
263
  await user_authenticated.send_async(app, user=user, authn_via="session")
181
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
+
182
280
  async def logout_user(self, user=None):
183
281
  app = current_app._get_current_object()
184
282
  target_user = user or getattr(g, "_current_user", AnonymousUser())
185
283
 
186
284
  if getattr(target_user, "is_authenticated", False):
285
+ await self.state_store.pop(session.get("_id"))
187
286
  await user_logged_out.send_async(app, user=target_user)
188
287
 
189
288
  session.clear()