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