quart-security 1.1.1__tar.gz → 1.3.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.3.0/.github/workflows/ci.yml +34 -0
- quart_security-1.3.0/.github/workflows/publish.yml +37 -0
- quart_security-1.3.0/.gitignore +8 -0
- {quart_security-1.1.1 → quart_security-1.3.0}/PKG-INFO +9 -3
- {quart_security-1.1.1 → quart_security-1.3.0}/README.md +7 -0
- {quart_security-1.1.1 → quart_security-1.3.0}/pyproject.toml +1 -1
- {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/core.py +6 -5
- {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/forms.py +8 -2
- {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/templates/security/login_user.html +3 -0
- {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/templates/security/wan_signin.html +3 -6
- quart_security-1.3.0/quart_security/utils.py +25 -0
- {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/views.py +94 -66
- {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/webauthn.py +27 -10
- quart_security-1.3.0/tests/conftest.py +241 -0
- quart_security-1.3.0/tests/test_auth_flow.py +98 -0
- quart_security-1.3.0/tests/test_core_flows.py +129 -0
- quart_security-1.3.0/tests/test_password.py +14 -0
- quart_security-1.3.0/tests/test_two_factor.py +108 -0
- quart_security-1.3.0/tests/test_webauthn.py +243 -0
- quart_security-1.1.1/quart_security/utils.py +0 -8
- {quart_security-1.1.1 → quart_security-1.3.0}/LICENSE +0 -0
- {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/__init__.py +0 -0
- {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/datastore.py +0 -0
- {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/decorators.py +0 -0
- {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/email.py +0 -0
- {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/models.py +0 -0
- {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/password.py +0 -0
- {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/proxies.py +0 -0
- {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/signals.py +0 -0
- {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/templates/security/_macros.html +0 -0
- {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/templates/security/change_password.html +0 -0
- {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/templates/security/mf_recovery.html +0 -0
- {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/templates/security/mf_recovery_codes.html +0 -0
- {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/templates/security/register_user.html +0 -0
- {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/templates/security/two_factor_select.html +0 -0
- {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/templates/security/two_factor_setup.html +0 -0
- {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/templates/security/two_factor_verify_code.html +0 -0
- {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/templates/security/wan_register.html +0 -0
- {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/templates/security/wan_verify.html +0 -0
- {quart_security-1.1.1 → quart_security-1.3.0}/quart_security/totp.py +0 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- main
|
|
7
|
+
pull_request:
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
|
|
13
|
+
steps:
|
|
14
|
+
- name: Checkout
|
|
15
|
+
uses: actions/checkout@v4
|
|
16
|
+
|
|
17
|
+
- name: Set up Python
|
|
18
|
+
uses: actions/setup-python@v5
|
|
19
|
+
with:
|
|
20
|
+
python-version: "3.11"
|
|
21
|
+
|
|
22
|
+
- name: Set up uv
|
|
23
|
+
uses: astral-sh/setup-uv@v6
|
|
24
|
+
with:
|
|
25
|
+
enable-cache: true
|
|
26
|
+
|
|
27
|
+
- name: Install dependencies
|
|
28
|
+
run: uv sync --group dev
|
|
29
|
+
|
|
30
|
+
- name: Run Ruff
|
|
31
|
+
run: uv run ruff check .
|
|
32
|
+
|
|
33
|
+
- name: Run tests
|
|
34
|
+
run: uv run pytest -q
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types:
|
|
6
|
+
- published
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
|
|
9
|
+
permissions:
|
|
10
|
+
contents: read
|
|
11
|
+
id-token: write
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
publish:
|
|
15
|
+
if: github.repository == 'level09/quart-security'
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
environment:
|
|
18
|
+
name: pypi
|
|
19
|
+
url: https://pypi.org/p/quart-security
|
|
20
|
+
|
|
21
|
+
steps:
|
|
22
|
+
- name: Checkout
|
|
23
|
+
uses: actions/checkout@v4
|
|
24
|
+
|
|
25
|
+
- name: Set up Python
|
|
26
|
+
uses: actions/setup-python@v5
|
|
27
|
+
with:
|
|
28
|
+
python-version: "3.12"
|
|
29
|
+
|
|
30
|
+
- name: Set up uv
|
|
31
|
+
uses: astral-sh/setup-uv@v6
|
|
32
|
+
|
|
33
|
+
- name: Build distributions
|
|
34
|
+
run: uv build
|
|
35
|
+
|
|
36
|
+
- name: Publish to PyPI
|
|
37
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
2
|
Name: quart-security
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.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
|
-
License-File: LICENSE
|
|
9
8
|
Requires-Dist: quart>=0.20.0
|
|
10
9
|
Requires-Dist: sqlalchemy>=2.0
|
|
11
10
|
Requires-Dist: libpass>=1.9
|
|
@@ -55,6 +54,7 @@ It is designed as a practical replacement path for Flask-Security style session
|
|
|
55
54
|
- Flask-Security-style endpoint naming through `url_for_security()`
|
|
56
55
|
- Signals for auth lifecycle events
|
|
57
56
|
- Overridable templates under `templates/security/`
|
|
57
|
+
- Datastore hooks can be implemented as async methods or simple sync methods
|
|
58
58
|
|
|
59
59
|
## Non-Goals (Current Scope)
|
|
60
60
|
|
|
@@ -130,6 +130,10 @@ For tracking / MFA / WebAuthn features:
|
|
|
130
130
|
- `fs_webauthn_user_handle`
|
|
131
131
|
- relationship/association for stored WebAuthn credentials
|
|
132
132
|
|
|
133
|
+
Optional for lockout support:
|
|
134
|
+
- `failed_login_count`
|
|
135
|
+
- `locked_until`
|
|
136
|
+
|
|
133
137
|
## Key Configuration
|
|
134
138
|
|
|
135
139
|
The extension uses `SECURITY_*` keys for migration-friendly configuration.
|
|
@@ -138,6 +142,8 @@ Core:
|
|
|
138
142
|
- `SECURITY_PASSWORD_HASH` (default: `pbkdf2_sha512`)
|
|
139
143
|
- `SECURITY_PASSWORD_SALT` (recommended)
|
|
140
144
|
- `SECURITY_PASSWORD_LENGTH_MIN` (default: `12`)
|
|
145
|
+
- `SECURITY_LOGIN_MAX_ATTEMPTS` (default: `5`)
|
|
146
|
+
- `SECURITY_LOCKOUT_MINUTES` (default: `15`)
|
|
141
147
|
- `SECURITY_REGISTERABLE`
|
|
142
148
|
- `SECURITY_CHANGEABLE`
|
|
143
149
|
- `SECURITY_TRACKABLE`
|
|
@@ -28,6 +28,7 @@ It is designed as a practical replacement path for Flask-Security style session
|
|
|
28
28
|
- Flask-Security-style endpoint naming through `url_for_security()`
|
|
29
29
|
- Signals for auth lifecycle events
|
|
30
30
|
- Overridable templates under `templates/security/`
|
|
31
|
+
- Datastore hooks can be implemented as async methods or simple sync methods
|
|
31
32
|
|
|
32
33
|
## Non-Goals (Current Scope)
|
|
33
34
|
|
|
@@ -103,6 +104,10 @@ For tracking / MFA / WebAuthn features:
|
|
|
103
104
|
- `fs_webauthn_user_handle`
|
|
104
105
|
- relationship/association for stored WebAuthn credentials
|
|
105
106
|
|
|
107
|
+
Optional for lockout support:
|
|
108
|
+
- `failed_login_count`
|
|
109
|
+
- `locked_until`
|
|
110
|
+
|
|
106
111
|
## Key Configuration
|
|
107
112
|
|
|
108
113
|
The extension uses `SECURITY_*` keys for migration-friendly configuration.
|
|
@@ -111,6 +116,8 @@ Core:
|
|
|
111
116
|
- `SECURITY_PASSWORD_HASH` (default: `pbkdf2_sha512`)
|
|
112
117
|
- `SECURITY_PASSWORD_SALT` (recommended)
|
|
113
118
|
- `SECURITY_PASSWORD_LENGTH_MIN` (default: `12`)
|
|
119
|
+
- `SECURITY_LOGIN_MAX_ATTEMPTS` (default: `5`)
|
|
120
|
+
- `SECURITY_LOCKOUT_MINUTES` (default: `15`)
|
|
114
121
|
- `SECURITY_REGISTERABLE`
|
|
115
122
|
- `SECURITY_CHANGEABLE`
|
|
116
123
|
- `SECURITY_TRACKABLE`
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import datetime
|
|
6
5
|
import secrets
|
|
7
6
|
from datetime import timedelta
|
|
8
7
|
|
|
@@ -12,7 +11,7 @@ from .forms import ChangePasswordForm, LoginForm, RegisterForm
|
|
|
12
11
|
from .password import init_password_context
|
|
13
12
|
from .proxies import AnonymousUser, current_user
|
|
14
13
|
from .signals import user_authenticated, user_logged_out
|
|
15
|
-
from .utils import url_for_security
|
|
14
|
+
from .utils import maybe_await, naive_utcnow, url_for_security
|
|
16
15
|
|
|
17
16
|
|
|
18
17
|
class _SecurityConfig:
|
|
@@ -131,6 +130,8 @@ class Security:
|
|
|
131
130
|
"SECURITY_WAN_REQUIRE_USER_VERIFICATION": True,
|
|
132
131
|
"SECURITY_FRESHNESS": timedelta(minutes=60),
|
|
133
132
|
"SECURITY_FRESHNESS_GRACE_PERIOD": timedelta(minutes=60),
|
|
133
|
+
"SECURITY_LOGIN_MAX_ATTEMPTS": 5,
|
|
134
|
+
"SECURITY_LOCKOUT_MINUTES": 15,
|
|
134
135
|
"SECURITY_POST_LOGIN_VIEW": "/",
|
|
135
136
|
"SECURITY_POST_REGISTER_VIEW": "/login",
|
|
136
137
|
"SECURITY_EMAIL_SENDER": "noreply@example.com",
|
|
@@ -148,7 +149,7 @@ class Security:
|
|
|
148
149
|
g._current_user = AnonymousUser()
|
|
149
150
|
return
|
|
150
151
|
|
|
151
|
-
user = await self.datastore.find_user(fs_uniquifier=user_id)
|
|
152
|
+
user = await maybe_await(self.datastore.find_user(fs_uniquifier=user_id))
|
|
152
153
|
g._current_user = user or AnonymousUser()
|
|
153
154
|
|
|
154
155
|
async def login_user(self, user, fresh=True):
|
|
@@ -168,11 +169,11 @@ class Security:
|
|
|
168
169
|
if app.config.get("SECURITY_TRACKABLE", True):
|
|
169
170
|
user.last_login_at = getattr(user, "current_login_at", None)
|
|
170
171
|
user.last_login_ip = getattr(user, "current_login_ip", None)
|
|
171
|
-
user.current_login_at =
|
|
172
|
+
user.current_login_at = naive_utcnow()
|
|
172
173
|
user.current_login_ip = request.remote_addr
|
|
173
174
|
user.login_count = (getattr(user, "login_count", None) or 0) + 1
|
|
174
175
|
|
|
175
|
-
await self.datastore.commit()
|
|
176
|
+
await maybe_await(self.datastore.commit())
|
|
176
177
|
await user_authenticated.send_async(app, user=user, authn_via="session")
|
|
177
178
|
|
|
178
179
|
async def logout_user(self, user=None):
|
|
@@ -63,7 +63,10 @@ class RegisterForm(QuartForm):
|
|
|
63
63
|
password = PasswordField("Password", validators=[DataRequired()])
|
|
64
64
|
password_confirm = PasswordField(
|
|
65
65
|
"Confirm Password",
|
|
66
|
-
validators=[
|
|
66
|
+
validators=[
|
|
67
|
+
DataRequired(),
|
|
68
|
+
EqualTo("password", message="Passwords must match"),
|
|
69
|
+
],
|
|
67
70
|
)
|
|
68
71
|
|
|
69
72
|
|
|
@@ -95,7 +98,10 @@ class WebAuthnRegisterForm(QuartForm):
|
|
|
95
98
|
name = StringField("Credential Name", validators=[DataRequired()])
|
|
96
99
|
usage = RadioField(
|
|
97
100
|
"Usage",
|
|
98
|
-
choices=[
|
|
101
|
+
choices=[
|
|
102
|
+
("secondary", "Multi-factor only"),
|
|
103
|
+
("primary", "Passwordless sign-in"),
|
|
104
|
+
],
|
|
99
105
|
default="secondary",
|
|
100
106
|
validators=[DataRequired()],
|
|
101
107
|
)
|
{quart_security-1.1.1 → quart_security-1.3.0}/quart_security/templates/security/login_user.html
RENAMED
|
@@ -8,4 +8,7 @@
|
|
|
8
8
|
{{ render_input(login_user_form.password, 'password') }}
|
|
9
9
|
<button type="submit">Sign in</button>
|
|
10
10
|
</form>
|
|
11
|
+
{% if security.webauthn and security.wan_allow_as_first_factor %}
|
|
12
|
+
<p><a href="{{ url_for_security('wan_signin') }}">Sign in with passkey</a></p>
|
|
13
|
+
{% endif %}
|
|
11
14
|
<p><a href="{{ url_for_security('register') }}">Create account</a></p>
|
{quart_security-1.1.1 → quart_security-1.3.0}/quart_security/templates/security/wan_signin.html
RENAMED
|
@@ -4,13 +4,10 @@
|
|
|
4
4
|
<h1>Sign In with Passkey</h1>
|
|
5
5
|
|
|
6
6
|
{% if not credential_options %}
|
|
7
|
-
<form method="post" action="{{ url_for_security('wan_signin') }}" id="wan-
|
|
7
|
+
<form method="post" action="{{ url_for_security('wan_signin') }}" id="wan-discoverable-form">
|
|
8
8
|
{{ wan_signin_form.hidden_tag() }}
|
|
9
|
-
|
|
10
|
-
<
|
|
11
|
-
{{ wan_signin_form.remember }} {{ wan_signin_form.remember.label.text }}
|
|
12
|
-
</label>
|
|
13
|
-
<button type="submit">Continue</button>
|
|
9
|
+
<input type="hidden" name="identity" value="">
|
|
10
|
+
<button type="submit">Sign in with passkey</button>
|
|
14
11
|
</form>
|
|
15
12
|
<p><a href="{{ url_for_security('login') }}">Back to password login</a></p>
|
|
16
13
|
{% else %}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Utility helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
import inspect
|
|
7
|
+
|
|
8
|
+
from quart import url_for
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def url_for_security(endpoint, **kwargs):
|
|
12
|
+
"""Flask-Security-compatible helper for endpoint URLs."""
|
|
13
|
+
return url_for(f"security.{endpoint}", **kwargs)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def maybe_await(value):
|
|
17
|
+
"""Await values when needed while supporting sync extension hooks."""
|
|
18
|
+
if inspect.isawaitable(value):
|
|
19
|
+
return await value
|
|
20
|
+
return value
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def naive_utcnow() -> datetime.datetime:
|
|
24
|
+
"""Return a naive UTC datetime while avoiding utcnow() deprecations."""
|
|
25
|
+
return datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
|
|
@@ -32,7 +32,7 @@ from .forms import (
|
|
|
32
32
|
from .password import hash_password, validate_password, verify_password
|
|
33
33
|
from .proxies import _security, current_user
|
|
34
34
|
from .signals import password_changed, tf_profile_changed, user_registered
|
|
35
|
-
from .utils import url_for_security
|
|
35
|
+
from .utils import maybe_await, naive_utcnow, url_for_security
|
|
36
36
|
|
|
37
37
|
security_bp = Blueprint("security", __name__, template_folder="templates")
|
|
38
38
|
|
|
@@ -58,15 +58,15 @@ def _resolve_redirect(config_key: str, fallback_endpoint: str) -> str:
|
|
|
58
58
|
|
|
59
59
|
|
|
60
60
|
async def _find_user(**kwargs):
|
|
61
|
-
return await _security.datastore.find_user(**kwargs)
|
|
61
|
+
return await maybe_await(_security.datastore.find_user(**kwargs))
|
|
62
62
|
|
|
63
63
|
|
|
64
64
|
async def _create_user(**kwargs):
|
|
65
|
-
return await _security.datastore.create_user(**kwargs)
|
|
65
|
+
return await maybe_await(_security.datastore.create_user(**kwargs))
|
|
66
66
|
|
|
67
67
|
|
|
68
68
|
async def _commit():
|
|
69
|
-
await _security.datastore.commit()
|
|
69
|
+
await maybe_await(_security.datastore.commit())
|
|
70
70
|
|
|
71
71
|
|
|
72
72
|
async def _enforce_csrf(submitted_token: str | None = None):
|
|
@@ -125,7 +125,7 @@ def _webauthn_rp_name() -> str:
|
|
|
125
125
|
def _set_wan_state(key: str, **payload):
|
|
126
126
|
session[key] = {
|
|
127
127
|
"payload": payload,
|
|
128
|
-
"issued_at": int(
|
|
128
|
+
"issued_at": int(naive_utcnow().timestamp()),
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
|
|
@@ -137,7 +137,7 @@ def _pop_wan_state(key: str, max_age_seconds: int = 300) -> dict | None:
|
|
|
137
137
|
payload = state.get("payload")
|
|
138
138
|
if not isinstance(issued_at, int) or not isinstance(payload, dict):
|
|
139
139
|
return None
|
|
140
|
-
age = int(
|
|
140
|
+
age = int(naive_utcnow().timestamp()) - issued_at
|
|
141
141
|
if age < 0 or age > max_age_seconds:
|
|
142
142
|
return None
|
|
143
143
|
return payload
|
|
@@ -146,7 +146,7 @@ def _pop_wan_state(key: str, max_age_seconds: int = 300) -> dict | None:
|
|
|
146
146
|
async def _list_webauthn_credentials(user, usage: str | None = None):
|
|
147
147
|
getter = getattr(_security.datastore, "get_webauthn_credentials", None)
|
|
148
148
|
if getter:
|
|
149
|
-
return await getter(user, usage=usage)
|
|
149
|
+
return await maybe_await(getter(user, usage=usage))
|
|
150
150
|
credentials = list(getattr(user, "webauthn", None) or [])
|
|
151
151
|
if usage:
|
|
152
152
|
credentials = [
|
|
@@ -160,7 +160,7 @@ async def _list_webauthn_credentials(user, usage: str | None = None):
|
|
|
160
160
|
async def _find_webauthn_credential(credential_id: bytes, user=None):
|
|
161
161
|
finder = getattr(_security.datastore, "find_webauthn_credential", None)
|
|
162
162
|
if finder:
|
|
163
|
-
return await finder(credential_id, user=user)
|
|
163
|
+
return await maybe_await(finder(credential_id, user=user))
|
|
164
164
|
|
|
165
165
|
candidates = list(getattr(user, "webauthn", None) or []) if user else []
|
|
166
166
|
for credential in candidates:
|
|
@@ -172,14 +172,14 @@ async def _find_webauthn_credential(credential_id: bytes, user=None):
|
|
|
172
172
|
async def _create_webauthn_credential(user, **kwargs):
|
|
173
173
|
creator = getattr(_security.datastore, "create_webauthn_credential", None)
|
|
174
174
|
if creator:
|
|
175
|
-
return await creator(user, **kwargs)
|
|
175
|
+
return await maybe_await(creator(user, **kwargs))
|
|
176
176
|
raise RuntimeError("Datastore does not implement create_webauthn_credential")
|
|
177
177
|
|
|
178
178
|
|
|
179
179
|
async def _delete_webauthn_credential(user, credential):
|
|
180
180
|
deleter = getattr(_security.datastore, "delete_webauthn_credential", None)
|
|
181
181
|
if deleter:
|
|
182
|
-
return await deleter(user, credential)
|
|
182
|
+
return await maybe_await(deleter(user, credential))
|
|
183
183
|
|
|
184
184
|
user_credentials = getattr(user, "webauthn", None)
|
|
185
185
|
if user_credentials is not None and credential in user_credentials:
|
|
@@ -200,6 +200,21 @@ def _extract_webauthn_credential_id(credential_payload: dict | None) -> bytes |
|
|
|
200
200
|
return None
|
|
201
201
|
|
|
202
202
|
|
|
203
|
+
def _extract_webauthn_user_handle(credential_payload: dict | None) -> str | None:
|
|
204
|
+
if not isinstance(credential_payload, dict):
|
|
205
|
+
return None
|
|
206
|
+
response = credential_payload.get("response")
|
|
207
|
+
if not isinstance(response, dict):
|
|
208
|
+
return None
|
|
209
|
+
user_handle = response.get("userHandle")
|
|
210
|
+
if not isinstance(user_handle, str) or not user_handle:
|
|
211
|
+
return None
|
|
212
|
+
try:
|
|
213
|
+
return wan.base64url_to_bytes(user_handle).decode("utf-8")
|
|
214
|
+
except Exception:
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
|
|
203
218
|
async def _extract_webauthn_credential_payload():
|
|
204
219
|
payload = await request.get_json(silent=True)
|
|
205
220
|
if isinstance(payload, dict):
|
|
@@ -253,11 +268,33 @@ async def login():
|
|
|
253
268
|
|
|
254
269
|
if _is_post() and form.validate():
|
|
255
270
|
user = await _find_user(email=_normalize_email(form.email.data))
|
|
271
|
+
|
|
272
|
+
# Account lockout check
|
|
273
|
+
if user and hasattr(user, "locked_until") and user.locked_until:
|
|
274
|
+
if datetime.datetime.now() < user.locked_until:
|
|
275
|
+
await flash("Account temporarily locked. Try again later.", "error")
|
|
276
|
+
return await render_template(
|
|
277
|
+
"security/login_user.html", login_user_form=form
|
|
278
|
+
)
|
|
279
|
+
# Lock expired, reset
|
|
280
|
+
user.failed_login_count = 0
|
|
281
|
+
user.locked_until = None
|
|
282
|
+
await _commit()
|
|
283
|
+
|
|
256
284
|
if (
|
|
257
285
|
user
|
|
258
286
|
and getattr(user, "active", True)
|
|
259
287
|
and verify_password(form.password.data, user.password)
|
|
260
288
|
):
|
|
289
|
+
# Reset failed attempts on success
|
|
290
|
+
if (
|
|
291
|
+
hasattr(user, "failed_login_count")
|
|
292
|
+
and (user.failed_login_count or 0) > 0
|
|
293
|
+
):
|
|
294
|
+
user.failed_login_count = 0
|
|
295
|
+
user.locked_until = None
|
|
296
|
+
await _commit()
|
|
297
|
+
|
|
261
298
|
if current_app.config.get("SECURITY_TWO_FACTOR") and getattr(
|
|
262
299
|
user, "tf_primary_method", None
|
|
263
300
|
):
|
|
@@ -267,6 +304,17 @@ async def login():
|
|
|
267
304
|
await _security.login_user(user)
|
|
268
305
|
return redirect(_resolve_redirect("SECURITY_POST_LOGIN_VIEW", "login"))
|
|
269
306
|
|
|
307
|
+
# Track failed login
|
|
308
|
+
if user and hasattr(user, "failed_login_count"):
|
|
309
|
+
user.failed_login_count = (user.failed_login_count or 0) + 1
|
|
310
|
+
max_attempts = current_app.config.get("SECURITY_LOGIN_MAX_ATTEMPTS", 5)
|
|
311
|
+
lockout_minutes = current_app.config.get("SECURITY_LOCKOUT_MINUTES", 15)
|
|
312
|
+
if user.failed_login_count >= max_attempts:
|
|
313
|
+
user.locked_until = datetime.datetime.now() + datetime.timedelta(
|
|
314
|
+
minutes=lockout_minutes
|
|
315
|
+
)
|
|
316
|
+
await _commit()
|
|
317
|
+
|
|
270
318
|
await flash("Invalid email or password", "error")
|
|
271
319
|
|
|
272
320
|
return await render_template("security/login_user.html", login_user_form=form)
|
|
@@ -309,7 +357,7 @@ async def register():
|
|
|
309
357
|
|
|
310
358
|
user = await _create_user(**user_kwargs)
|
|
311
359
|
if hasattr(user, "confirmed_at"):
|
|
312
|
-
user.confirmed_at =
|
|
360
|
+
user.confirmed_at = naive_utcnow()
|
|
313
361
|
|
|
314
362
|
await _commit()
|
|
315
363
|
await user_registered.send_async(current_app._get_current_object(), user=user)
|
|
@@ -429,16 +477,12 @@ async def two_factor_setup():
|
|
|
429
477
|
current_user.tf_primary_method = "authenticator"
|
|
430
478
|
session.pop("tf_pending_secret", None)
|
|
431
479
|
|
|
432
|
-
if current_app.config.get(
|
|
433
|
-
"SECURITY_MULTI_FACTOR_RECOVERY_CODES", True
|
|
434
|
-
):
|
|
480
|
+
if current_app.config.get("SECURITY_MULTI_FACTOR_RECOVERY_CODES", True):
|
|
435
481
|
if not getattr(current_user, "mf_recovery_codes", None):
|
|
436
482
|
count = current_app.config.get(
|
|
437
483
|
"SECURITY_MULTI_FACTOR_RECOVERY_CODES_N", 3
|
|
438
484
|
)
|
|
439
|
-
current_user.mf_recovery_codes = generate_recovery_codes(
|
|
440
|
-
count
|
|
441
|
-
)
|
|
485
|
+
current_user.mf_recovery_codes = generate_recovery_codes(count)
|
|
442
486
|
|
|
443
487
|
await _commit()
|
|
444
488
|
await tf_profile_changed.send_async(
|
|
@@ -505,8 +549,10 @@ async def two_factor_token_validation():
|
|
|
505
549
|
if token and getattr(user, "tf_totp_secret", None):
|
|
506
550
|
valid = verify_totp(user.tf_totp_secret, token)
|
|
507
551
|
|
|
508
|
-
if
|
|
509
|
-
|
|
552
|
+
if (
|
|
553
|
+
not valid
|
|
554
|
+
and token
|
|
555
|
+
and current_app.config.get("SECURITY_MULTI_FACTOR_RECOVERY_CODES", True)
|
|
510
556
|
):
|
|
511
557
|
codes = list(getattr(user, "mf_recovery_codes", None) or [])
|
|
512
558
|
ok, remaining = verify_recovery_code(token, codes)
|
|
@@ -553,7 +599,9 @@ async def mf_recovery_codes():
|
|
|
553
599
|
codes = generate_recovery_codes(count)
|
|
554
600
|
current_user.mf_recovery_codes = codes
|
|
555
601
|
await _commit()
|
|
556
|
-
await tf_profile_changed.send_async(
|
|
602
|
+
await tf_profile_changed.send_async(
|
|
603
|
+
current_app._get_current_object(), user=current_user
|
|
604
|
+
)
|
|
557
605
|
await flash("Recovery codes regenerated.", "success")
|
|
558
606
|
elif request.args.get("show_codes"):
|
|
559
607
|
codes = list(getattr(current_user, "mf_recovery_codes", None) or [])
|
|
@@ -656,6 +704,7 @@ async def wan_register():
|
|
|
656
704
|
rp_name=_webauthn_rp_name(),
|
|
657
705
|
challenge=challenge,
|
|
658
706
|
existing_credentials=registered_credentials,
|
|
707
|
+
discoverable=(requested_usage == "primary"),
|
|
659
708
|
)
|
|
660
709
|
credential_options = wan.options_to_json_dict(options)
|
|
661
710
|
|
|
@@ -724,7 +773,7 @@ async def wan_register_response():
|
|
|
724
773
|
usage=state.get("usage", "secondary"),
|
|
725
774
|
backup_state=verification.get("backup_state", False),
|
|
726
775
|
device_type=verification.get("device_type") or "single_device",
|
|
727
|
-
lastuse_datetime=
|
|
776
|
+
lastuse_datetime=naive_utcnow(),
|
|
728
777
|
)
|
|
729
778
|
await _commit()
|
|
730
779
|
|
|
@@ -743,46 +792,13 @@ async def wan_signin():
|
|
|
743
792
|
response_form = await QuartForm.from_formdata()
|
|
744
793
|
|
|
745
794
|
credential_options = None
|
|
795
|
+
|
|
746
796
|
if _is_post():
|
|
747
797
|
await _enforce_csrf(getattr(form, "_submitted_csrf", None))
|
|
748
|
-
identity = _normalize_email(form.identity.data)
|
|
749
|
-
if not identity:
|
|
750
|
-
await flash("Email is required.", "error")
|
|
751
|
-
return await render_template(
|
|
752
|
-
"security/wan_signin.html",
|
|
753
|
-
wan_signin_form=form,
|
|
754
|
-
wan_signin_response_form=response_form,
|
|
755
|
-
credential_options=None,
|
|
756
|
-
)
|
|
757
|
-
|
|
758
|
-
user = await _find_user(email=identity)
|
|
759
|
-
if user is None or not getattr(user, "active", True):
|
|
760
|
-
await flash("Unable to authenticate with passkey.", "error")
|
|
761
|
-
return await render_template(
|
|
762
|
-
"security/wan_signin.html",
|
|
763
|
-
wan_signin_form=form,
|
|
764
|
-
wan_signin_response_form=response_form,
|
|
765
|
-
credential_options=None,
|
|
766
|
-
)
|
|
767
|
-
|
|
768
|
-
credentials = await _list_webauthn_credentials(user)
|
|
769
|
-
credentials = [
|
|
770
|
-
credential
|
|
771
|
-
for credential in credentials
|
|
772
|
-
if getattr(credential, "usage", "secondary") in {"primary", "first"}
|
|
773
|
-
]
|
|
774
|
-
if not credentials:
|
|
775
|
-
await flash("No passkey is configured for passwordless sign-in.", "error")
|
|
776
|
-
return await render_template(
|
|
777
|
-
"security/wan_signin.html",
|
|
778
|
-
wan_signin_form=form,
|
|
779
|
-
wan_signin_response_form=response_form,
|
|
780
|
-
credential_options=None,
|
|
781
|
-
)
|
|
782
798
|
|
|
783
799
|
challenge = secrets.token_bytes(32)
|
|
784
800
|
options = await wan.begin_authentication(
|
|
785
|
-
|
|
801
|
+
[],
|
|
786
802
|
rp_id=_webauthn_rp_id(),
|
|
787
803
|
challenge=challenge,
|
|
788
804
|
)
|
|
@@ -790,7 +806,6 @@ async def wan_signin():
|
|
|
790
806
|
_set_wan_state(
|
|
791
807
|
"wan_signin_state",
|
|
792
808
|
challenge=wan.bytes_to_base64url(challenge),
|
|
793
|
-
user_id=user.get_id(),
|
|
794
809
|
remember=bool(form.remember.data),
|
|
795
810
|
)
|
|
796
811
|
|
|
@@ -815,18 +830,29 @@ async def wan_signin_response():
|
|
|
815
830
|
await flash("Passkey sign-in expired. Please try again.", "error")
|
|
816
831
|
return redirect(url_for_security("wan_signin"))
|
|
817
832
|
|
|
818
|
-
user = await _find_user(fs_uniquifier=state.get("user_id"))
|
|
819
|
-
if user is None:
|
|
820
|
-
await flash("Unable to find user for passkey sign-in.", "error")
|
|
821
|
-
return redirect(url_for_security("wan_signin"))
|
|
822
|
-
|
|
823
833
|
credential_payload = await _extract_webauthn_credential_payload()
|
|
824
834
|
credential_id = _extract_webauthn_credential_id(credential_payload)
|
|
825
835
|
if not credential_payload or credential_id is None:
|
|
826
836
|
await flash("Invalid passkey response payload.", "error")
|
|
827
837
|
return redirect(url_for_security("wan_signin"))
|
|
828
838
|
|
|
829
|
-
stored_credential = await _find_webauthn_credential(credential_id, user=
|
|
839
|
+
stored_credential = await _find_webauthn_credential(credential_id, user=None)
|
|
840
|
+
if stored_credential is None:
|
|
841
|
+
await flash("Passkey not recognized.", "error")
|
|
842
|
+
return redirect(url_for_security("wan_signin"))
|
|
843
|
+
|
|
844
|
+
credential_user_id = getattr(stored_credential, "user_id", None)
|
|
845
|
+
user_handle = _extract_webauthn_user_handle(credential_payload)
|
|
846
|
+
lookup_handle = credential_user_id or user_handle
|
|
847
|
+
if not lookup_handle:
|
|
848
|
+
await flash("Unable to identify user from passkey.", "error")
|
|
849
|
+
return redirect(url_for_security("wan_signin"))
|
|
850
|
+
|
|
851
|
+
user = await _find_user(fs_webauthn_user_handle=lookup_handle)
|
|
852
|
+
if user is None or not getattr(user, "active", True):
|
|
853
|
+
await flash("Unable to find user for passkey sign-in.", "error")
|
|
854
|
+
return redirect(url_for_security("wan_signin"))
|
|
855
|
+
|
|
830
856
|
if stored_credential is None:
|
|
831
857
|
await flash("Passkey not recognized.", "error")
|
|
832
858
|
return redirect(url_for_security("wan_signin"))
|
|
@@ -848,7 +874,7 @@ async def wan_signin_response():
|
|
|
848
874
|
|
|
849
875
|
stored_credential.sign_count = int(new_sign_count)
|
|
850
876
|
if hasattr(stored_credential, "lastuse_datetime"):
|
|
851
|
-
stored_credential.lastuse_datetime =
|
|
877
|
+
stored_credential.lastuse_datetime = naive_utcnow()
|
|
852
878
|
|
|
853
879
|
session.permanent = bool(state.get("remember"))
|
|
854
880
|
await _security.login_user(user)
|
|
@@ -930,7 +956,9 @@ async def wan_verify_response():
|
|
|
930
956
|
await flash("Invalid passkey response payload.", "error")
|
|
931
957
|
return redirect(url_for_security("wan_verify"))
|
|
932
958
|
|
|
933
|
-
stored_credential = await _find_webauthn_credential(
|
|
959
|
+
stored_credential = await _find_webauthn_credential(
|
|
960
|
+
credential_id, user=current_user
|
|
961
|
+
)
|
|
934
962
|
if stored_credential is None:
|
|
935
963
|
await flash("Passkey not recognized.", "error")
|
|
936
964
|
return redirect(url_for_security("wan_verify"))
|
|
@@ -952,7 +980,7 @@ async def wan_verify_response():
|
|
|
952
980
|
|
|
953
981
|
stored_credential.sign_count = int(new_sign_count)
|
|
954
982
|
if hasattr(stored_credential, "lastuse_datetime"):
|
|
955
|
-
stored_credential.lastuse_datetime =
|
|
983
|
+
stored_credential.lastuse_datetime = naive_utcnow()
|
|
956
984
|
|
|
957
985
|
session["_fresh"] = True
|
|
958
986
|
await _commit()
|