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.
- {quart_security-1.4.0 → quart_security-2.0.0}/PKG-INFO +110 -11
- {quart_security-1.4.0 → quart_security-2.0.0}/README.md +103 -6
- {quart_security-1.4.0 → quart_security-2.0.0}/pyproject.toml +8 -5
- {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/__init__.py +3 -0
- {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/core.py +111 -12
- {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/datastore.py +109 -4
- {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/decorators.py +13 -2
- {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/forms.py +0 -1
- {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/password.py +22 -23
- quart_security-2.0.0/quart_security/state.py +92 -0
- {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/templates/security/mf_recovery_codes.html +1 -1
- quart_security-2.0.0/quart_security/templates/security/two_factor_setup.html +25 -0
- quart_security-2.0.0/quart_security/totp.py +119 -0
- {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/views.py +216 -99
- {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/webauthn.py +8 -1
- quart_security-1.4.0/quart_security/templates/security/two_factor_setup.html +0 -12
- quart_security-1.4.0/quart_security/totp.py +0 -66
- {quart_security-1.4.0 → quart_security-2.0.0}/LICENSE +0 -0
- {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/breach.py +0 -0
- {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/email.py +0 -0
- {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/models.py +0 -0
- {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/proxies.py +0 -0
- {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/py.typed +0 -0
- {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/signals.py +0 -0
- {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/templates/security/_macros.html +0 -0
- {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/templates/security/change_password.html +0 -0
- {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/templates/security/login_user.html +0 -0
- {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/templates/security/mf_recovery.html +0 -0
- {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/templates/security/register_user.html +0 -0
- {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/templates/security/two_factor_select.html +0 -0
- {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/templates/security/two_factor_verify_code.html +0 -0
- {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/templates/security/wan_register.html +0 -0
- {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/templates/security/wan_signin.html +0 -0
- {quart_security-1.4.0 → quart_security-2.0.0}/quart_security/templates/security/wan_verify.html +0 -0
- {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:
|
|
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>=
|
|
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,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
|
-
|
|
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` (
|
|
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
|
-
|
|
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,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
|
-
|
|
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` (
|
|
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 = "
|
|
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>=
|
|
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
|
|
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
|
-
|
|
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()
|