quart-security 1.4.1__tar.gz → 2.0.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 (34) hide show
  1. {quart_security-1.4.1 → quart_security-2.0.1}/PKG-INFO +107 -10
  2. {quart_security-1.4.1 → quart_security-2.0.1}/README.md +100 -5
  3. {quart_security-1.4.1 → quart_security-2.0.1}/pyproject.toml +8 -5
  4. {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/__init__.py +3 -0
  5. {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/core.py +116 -12
  6. {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/datastore.py +89 -10
  7. {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/password.py +22 -23
  8. quart_security-2.0.1/quart_security/state.py +92 -0
  9. quart_security-2.0.1/quart_security/templates/security/two_factor_setup.html +25 -0
  10. {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/totp.py +59 -1
  11. {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/views.py +152 -97
  12. {quart_security-1.4.1 → quart_security-2.0.1}/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.1}/LICENSE +0 -0
  15. {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/breach.py +0 -0
  16. {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/decorators.py +0 -0
  17. {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/email.py +0 -0
  18. {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/forms.py +0 -0
  19. {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/models.py +0 -0
  20. {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/proxies.py +0 -0
  21. {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/py.typed +0 -0
  22. {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/signals.py +0 -0
  23. {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/templates/security/_macros.html +0 -0
  24. {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/templates/security/change_password.html +0 -0
  25. {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/templates/security/login_user.html +0 -0
  26. {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/templates/security/mf_recovery.html +0 -0
  27. {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/templates/security/mf_recovery_codes.html +0 -0
  28. {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/templates/security/register_user.html +0 -0
  29. {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/templates/security/two_factor_select.html +0 -0
  30. {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/templates/security/two_factor_verify_code.html +0 -0
  31. {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/templates/security/wan_register.html +0 -0
  32. {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/templates/security/wan_signin.html +0 -0
  33. {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/templates/security/wan_verify.html +0 -0
  34. {quart_security-1.4.1 → quart_security-2.0.1}/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.1
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,88 @@ 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
+
361
+
362
+ ## TOTP encryption and key rotation
363
+
364
+ Version 2.0.1 encrypts enrolled TOTP seeds and pending setup secrets with authenticated Fernet encryption. Legacy plaintext seeds remain readable and are encrypted on successful login or an authenticated request. Hosts should encrypt dormant records during upgrade using `encrypt_totp_secret(value, app=app)`. This requires no schema change for a 255-character seed column.
365
+
366
+ By default, the key is derived from `SECRET_KEY` with a separate purpose label. Keep that key stable. For independent rotation, configure `SECURITY_TOTP_ENCRYPTION_KEYS` as a nonempty list of Fernet keys. The first key encrypts new data; all keys can decrypt existing data. Retain old keys while rotating existing seeds with `encrypt_totp_secret`, including active pending setup states until their five-minute expiry. Remove old keys only after all data and required backups have been handled. Never rotate `SECRET_KEY` without preserving the derived MFA key if using the default. `derive_totp_encryption_key(old_secret_key)` supplies that key for an explicit keyring. Protect the keyring separately from database backups.
367
+
@@ -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,88 @@ 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.
330
+
331
+
332
+ ## TOTP encryption and key rotation
333
+
334
+ Version 2.0.1 encrypts enrolled TOTP seeds and pending setup secrets with authenticated Fernet encryption. Legacy plaintext seeds remain readable and are encrypted on successful login or an authenticated request. Hosts should encrypt dormant records during upgrade using `encrypt_totp_secret(value, app=app)`. This requires no schema change for a 255-character seed column.
335
+
336
+ By default, the key is derived from `SECRET_KEY` with a separate purpose label. Keep that key stable. For independent rotation, configure `SECURITY_TOTP_ENCRYPTION_KEYS` as a nonempty list of Fernet keys. The first key encrypts new data; all keys can decrypt existing data. Retain old keys while rotating existing seeds with `encrypt_totp_secret`, including active pending setup states until their five-minute expiry. Remove old keys only after all data and required backups have been handled. Never rotate `SECRET_KEY` without preserving the derived MFA key if using the default. `derive_totp_encryption_key(old_secret_key)` supplies that key for an explicit keyring. Protect the keyring separately from database backups.
@@ -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.1"
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,19 @@
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
17
+ from .totp import encrypt_totp_secret, init_totp_encryption
15
18
  from .utils import maybe_await, naive_utcnow, url_for_security
16
19
 
17
20
 
@@ -54,6 +57,7 @@ class Security:
54
57
  "change_password_form", ChangePasswordForm
55
58
  )
56
59
  self.mail_util_cls = kwargs.get("mail_util_cls")
60
+ self.state_store = kwargs.get("state_store")
57
61
 
58
62
  if app is not None:
59
63
  self.init_app(app, datastore=datastore, **kwargs)
@@ -71,9 +75,52 @@ class Security:
71
75
  "change_password_form", self.change_password_form_cls
72
76
  )
73
77
  self.mail_util_cls = kwargs.get("mail_util_cls", self.mail_util_cls)
78
+ self.state_store = kwargs.get("state_store", self.state_store)
79
+ if self.state_store is None:
80
+ if not isinstance(self.datastore, SQLAlchemyUserDatastore):
81
+ raise RuntimeError("Custom datastores require a shared state_store")
82
+ self.state_store = SQLAlchemyStateStore(self.datastore)
74
83
 
75
84
  self._load_defaults(app)
85
+ for name in ("put", "get", "pop", "claim"):
86
+ if not callable(getattr(self.state_store, name, None)):
87
+ raise RuntimeError(f"Security state_store requires {name}")
88
+ required_hooks = ["rotate_uniquifier"]
89
+ if app.config["SECURITY_LOGIN_MAX_ATTEMPTS"] > 0:
90
+ required_hooks.append("record_auth_failure")
91
+ if app.config["SECURITY_MULTI_FACTOR_RECOVERY_CODES"]:
92
+ required_hooks.append("replace_recovery_codes")
93
+ for name in required_hooks:
94
+ if not callable(getattr(self.datastore, name, None)):
95
+ raise RuntimeError(f"Security requires atomic datastore hook: {name}")
96
+ if isinstance(self.datastore, SQLAlchemyUserDatastore):
97
+ required_fields = ["fs_uniquifier", "email", "password", "active"]
98
+ if app.config["SECURITY_LOGIN_MAX_ATTEMPTS"] > 0:
99
+ required_fields += ["failed_login_count", "locked_until"]
100
+ if app.config["SECURITY_TWO_FACTOR"]:
101
+ required_fields += ["tf_primary_method", "tf_totp_secret"]
102
+ if app.config["SECURITY_MULTI_FACTOR_RECOVERY_CODES"]:
103
+ required_fields.append("mf_recovery_codes")
104
+ if app.config["SECURITY_WEBAUTHN"]:
105
+ required_fields.append("fs_webauthn_user_handle")
106
+ missing = [
107
+ name
108
+ for name in required_fields
109
+ if not hasattr(self.datastore.user_model, name)
110
+ ]
111
+ if missing:
112
+ raise RuntimeError(
113
+ f"Security user model lacks fields: {', '.join(missing)}"
114
+ )
115
+ if not app.secret_key:
116
+ raise RuntimeError("Security requires SECRET_KEY")
117
+ app.config["SESSION_COOKIE_SECURE"] = app.config["SECURITY_COOKIE_SECURE"]
118
+ app.config["SESSION_COOKIE_HTTPONLY"] = True
119
+ app.config["SESSION_COOKIE_SAMESITE"] = (
120
+ app.config.get("SESSION_COOKIE_SAMESITE") or "Lax"
121
+ )
76
122
  init_password_context(app)
123
+ init_totp_encryption(app)
77
124
 
78
125
  from .views import security_bp
79
126
 
@@ -86,14 +133,36 @@ class Security:
86
133
 
87
134
  @app.before_request
88
135
  async def _security_load_user():
136
+ begin = getattr(self.datastore, "begin_request", None)
137
+ if begin:
138
+ begin()
89
139
  await self.load_user()
90
140
 
91
141
  @app.before_websocket
92
142
  async def _security_load_user_ws():
143
+ begin = getattr(self.datastore, "begin_request", None)
144
+ if begin:
145
+ begin()
93
146
  await self.load_user()
94
147
 
148
+ @app.teardown_request
149
+ @app.teardown_websocket
150
+ async def _security_close_session(_exception):
151
+ close = getattr(self.datastore, "close", None)
152
+ if close:
153
+ await maybe_await(close())
154
+
95
155
  app.extensions["quart_security_load_user_registered"] = True
96
156
 
157
+ if isinstance(self.state_store, SQLAlchemyStateStore):
158
+
159
+ @app.before_serving
160
+ async def _security_validate_state():
161
+ try:
162
+ await self.state_store.validate()
163
+ finally:
164
+ await self.datastore.close()
165
+
97
166
  app.jinja_env.globals.setdefault("url_for_security", url_for_security)
98
167
 
99
168
  if not app.extensions.get("quart_security_context_registered", False):
@@ -138,6 +207,7 @@ class Security:
138
207
  "SECURITY_POST_REGISTER_VIEW": "/login",
139
208
  "SECURITY_EMAIL_SENDER": "noreply@example.com",
140
209
  "SECURITY_CSRF_PROTECT": True,
210
+ "SECURITY_COOKIE_SECURE": True,
141
211
  }
142
212
 
143
213
  for key, value in defaults.items():
@@ -149,15 +219,28 @@ class Security:
149
219
  g._current_user = AnonymousUser()
150
220
  return
151
221
 
222
+ state = await self.state_store.get(session.get("_id"))
223
+ if not state or state.get("user_id") != user_id:
224
+ session.clear()
225
+ g._current_user = AnonymousUser()
226
+ return
227
+
152
228
  user = await maybe_await(self.datastore.find_user(fs_uniquifier=user_id))
153
229
  if user is None or not getattr(user, "active", True):
154
230
  session.clear()
155
231
  g._current_user = AnonymousUser()
156
232
  return
233
+ if getattr(user, "tf_totp_secret", None) and not user.tf_totp_secret.startswith(
234
+ "fernet$"
235
+ ):
236
+ user.tf_totp_secret = encrypt_totp_secret(user.tf_totp_secret)
237
+ await maybe_await(self.datastore.commit())
157
238
  g._current_user = user
158
239
 
159
240
  async def login_user(self, user, fresh=True):
160
241
  app = current_app._get_current_object()
242
+ if not user.is_active:
243
+ raise ValueError("Cannot authenticate an inactive user")
161
244
 
162
245
  user_id = user.get_id() if hasattr(user, "get_id") else None
163
246
  if not user_id:
@@ -165,16 +248,6 @@ class Security:
165
248
  if not user_id:
166
249
  raise RuntimeError("User must have fs_uniquifier/get_id for session login")
167
250
 
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
251
  if app.config.get("SECURITY_TRACKABLE", True):
179
252
  user.last_login_at = getattr(user, "current_login_at", None)
180
253
  user.last_login_ip = getattr(user, "current_login_ip", None)
@@ -182,14 +255,45 @@ class Security:
182
255
  user.current_login_ip = request.remote_addr
183
256
  user.login_count = (getattr(user, "login_count", None) or 0) + 1
184
257
 
258
+ if getattr(user, "tf_totp_secret", None) and not user.tf_totp_secret.startswith(
259
+ "fernet$"
260
+ ):
261
+ user.tf_totp_secret = encrypt_totp_secret(user.tf_totp_secret)
185
262
  await maybe_await(self.datastore.commit())
263
+ token = await self.state_store.put(
264
+ {"user_id": user_id},
265
+ ttl=int(app.permanent_session_lifetime.total_seconds()),
266
+ )
267
+ # Discard pre-login state only after authentication writes succeed.
268
+ session.clear()
269
+ session["_user_id"] = user_id
270
+ session["_fresh"] = bool(fresh)
271
+ session["_auth_at"] = int(time.time())
272
+ session["_id"] = token
273
+ g._current_user = user
186
274
  await user_authenticated.send_async(app, user=user, authn_via="session")
187
275
 
276
+ async def revoke_user_sessions(self, user):
277
+ changed = await maybe_await(
278
+ self.datastore.rotate_uniquifier(user, user.get_id(), uuid4().hex)
279
+ )
280
+ if not changed:
281
+ session.clear()
282
+ g._current_user = AnonymousUser()
283
+ abort(409)
284
+ await self.state_store.pop(session.get("_id"))
285
+ session["_user_id"] = user.get_id()
286
+ session["_id"] = await self.state_store.put(
287
+ {"user_id": user.get_id()},
288
+ ttl=int(current_app.permanent_session_lifetime.total_seconds()),
289
+ )
290
+
188
291
  async def logout_user(self, user=None):
189
292
  app = current_app._get_current_object()
190
293
  target_user = user or getattr(g, "_current_user", AnonymousUser())
191
294
 
192
295
  if getattr(target_user, "is_authenticated", False):
296
+ await self.state_store.pop(session.get("_id"))
193
297
  await user_logged_out.send_async(app, user=target_user)
194
298
 
195
299
  session.clear()