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.
- {quart_security-1.4.1 → quart_security-2.0.1}/PKG-INFO +107 -10
- {quart_security-1.4.1 → quart_security-2.0.1}/README.md +100 -5
- {quart_security-1.4.1 → quart_security-2.0.1}/pyproject.toml +8 -5
- {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/__init__.py +3 -0
- {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/core.py +116 -12
- {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/datastore.py +89 -10
- {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/password.py +22 -23
- quart_security-2.0.1/quart_security/state.py +92 -0
- quart_security-2.0.1/quart_security/templates/security/two_factor_setup.html +25 -0
- {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/totp.py +59 -1
- {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/views.py +152 -97
- {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/webauthn.py +8 -1
- quart_security-1.4.1/quart_security/templates/security/two_factor_setup.html +0 -12
- {quart_security-1.4.1 → quart_security-2.0.1}/LICENSE +0 -0
- {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/breach.py +0 -0
- {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/decorators.py +0 -0
- {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/email.py +0 -0
- {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/forms.py +0 -0
- {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/models.py +0 -0
- {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/proxies.py +0 -0
- {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/py.typed +0 -0
- {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/signals.py +0 -0
- {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/templates/security/_macros.html +0 -0
- {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/templates/security/change_password.html +0 -0
- {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/templates/security/login_user.html +0 -0
- {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/templates/security/mf_recovery.html +0 -0
- {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/templates/security/mf_recovery_codes.html +0 -0
- {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/templates/security/register_user.html +0 -0
- {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/templates/security/two_factor_select.html +0 -0
- {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/templates/security/two_factor_verify_code.html +0 -0
- {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/templates/security/wan_register.html +0 -0
- {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/templates/security/wan_signin.html +0 -0
- {quart_security-1.4.1 → quart_security-2.0.1}/quart_security/templates/security/wan_verify.html +0 -0
- {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:
|
|
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>=
|
|
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
|
-
|
|
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="
|
|
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
|
-
|
|
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` (
|
|
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
|
-
|
|
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="
|
|
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
|
-
|
|
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` (
|
|
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 = "
|
|
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>=
|
|
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()
|