quart-security 1.2.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.2.1 → quart_security-1.3.0}/PKG-INFO +2 -3
- {quart_security-1.2.1 → quart_security-1.3.0}/pyproject.toml +1 -1
- {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/templates/security/login_user.html +3 -0
- {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/templates/security/wan_signin.html +3 -6
- {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/views.py +35 -42
- {quart_security-1.2.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.2.1 → quart_security-1.3.0}/LICENSE +0 -0
- {quart_security-1.2.1 → quart_security-1.3.0}/README.md +0 -0
- {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/__init__.py +0 -0
- {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/core.py +0 -0
- {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/datastore.py +0 -0
- {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/decorators.py +0 -0
- {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/email.py +0 -0
- {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/forms.py +0 -0
- {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/models.py +0 -0
- {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/password.py +0 -0
- {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/proxies.py +0 -0
- {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/signals.py +0 -0
- {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/templates/security/_macros.html +0 -0
- {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/templates/security/change_password.html +0 -0
- {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/templates/security/mf_recovery.html +0 -0
- {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/templates/security/mf_recovery_codes.html +0 -0
- {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/templates/security/register_user.html +0 -0
- {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/templates/security/two_factor_select.html +0 -0
- {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/templates/security/two_factor_setup.html +0 -0
- {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/templates/security/two_factor_verify_code.html +0 -0
- {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/templates/security/wan_register.html +0 -0
- {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/templates/security/wan_verify.html +0 -0
- {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/totp.py +0 -0
- {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/utils.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
|
{quart_security-1.2.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.2.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 %}
|
|
@@ -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):
|
|
@@ -689,6 +704,7 @@ async def wan_register():
|
|
|
689
704
|
rp_name=_webauthn_rp_name(),
|
|
690
705
|
challenge=challenge,
|
|
691
706
|
existing_credentials=registered_credentials,
|
|
707
|
+
discoverable=(requested_usage == "primary"),
|
|
692
708
|
)
|
|
693
709
|
credential_options = wan.options_to_json_dict(options)
|
|
694
710
|
|
|
@@ -776,46 +792,13 @@ async def wan_signin():
|
|
|
776
792
|
response_form = await QuartForm.from_formdata()
|
|
777
793
|
|
|
778
794
|
credential_options = None
|
|
795
|
+
|
|
779
796
|
if _is_post():
|
|
780
797
|
await _enforce_csrf(getattr(form, "_submitted_csrf", None))
|
|
781
|
-
identity = _normalize_email(form.identity.data)
|
|
782
|
-
if not identity:
|
|
783
|
-
await flash("Email is required.", "error")
|
|
784
|
-
return await render_template(
|
|
785
|
-
"security/wan_signin.html",
|
|
786
|
-
wan_signin_form=form,
|
|
787
|
-
wan_signin_response_form=response_form,
|
|
788
|
-
credential_options=None,
|
|
789
|
-
)
|
|
790
|
-
|
|
791
|
-
user = await _find_user(email=identity)
|
|
792
|
-
if user is None or not getattr(user, "active", True):
|
|
793
|
-
await flash("Unable to authenticate with passkey.", "error")
|
|
794
|
-
return await render_template(
|
|
795
|
-
"security/wan_signin.html",
|
|
796
|
-
wan_signin_form=form,
|
|
797
|
-
wan_signin_response_form=response_form,
|
|
798
|
-
credential_options=None,
|
|
799
|
-
)
|
|
800
|
-
|
|
801
|
-
credentials = await _list_webauthn_credentials(user)
|
|
802
|
-
credentials = [
|
|
803
|
-
credential
|
|
804
|
-
for credential in credentials
|
|
805
|
-
if getattr(credential, "usage", "secondary") in {"primary", "first"}
|
|
806
|
-
]
|
|
807
|
-
if not credentials:
|
|
808
|
-
await flash("No passkey is configured for passwordless sign-in.", "error")
|
|
809
|
-
return await render_template(
|
|
810
|
-
"security/wan_signin.html",
|
|
811
|
-
wan_signin_form=form,
|
|
812
|
-
wan_signin_response_form=response_form,
|
|
813
|
-
credential_options=None,
|
|
814
|
-
)
|
|
815
798
|
|
|
816
799
|
challenge = secrets.token_bytes(32)
|
|
817
800
|
options = await wan.begin_authentication(
|
|
818
|
-
|
|
801
|
+
[],
|
|
819
802
|
rp_id=_webauthn_rp_id(),
|
|
820
803
|
challenge=challenge,
|
|
821
804
|
)
|
|
@@ -823,7 +806,6 @@ async def wan_signin():
|
|
|
823
806
|
_set_wan_state(
|
|
824
807
|
"wan_signin_state",
|
|
825
808
|
challenge=wan.bytes_to_base64url(challenge),
|
|
826
|
-
user_id=user.get_id(),
|
|
827
809
|
remember=bool(form.remember.data),
|
|
828
810
|
)
|
|
829
811
|
|
|
@@ -848,18 +830,29 @@ async def wan_signin_response():
|
|
|
848
830
|
await flash("Passkey sign-in expired. Please try again.", "error")
|
|
849
831
|
return redirect(url_for_security("wan_signin"))
|
|
850
832
|
|
|
851
|
-
user = await _find_user(fs_uniquifier=state.get("user_id"))
|
|
852
|
-
if user is None:
|
|
853
|
-
await flash("Unable to find user for passkey sign-in.", "error")
|
|
854
|
-
return redirect(url_for_security("wan_signin"))
|
|
855
|
-
|
|
856
833
|
credential_payload = await _extract_webauthn_credential_payload()
|
|
857
834
|
credential_id = _extract_webauthn_credential_id(credential_payload)
|
|
858
835
|
if not credential_payload or credential_id is None:
|
|
859
836
|
await flash("Invalid passkey response payload.", "error")
|
|
860
837
|
return redirect(url_for_security("wan_signin"))
|
|
861
838
|
|
|
862
|
-
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
|
+
|
|
863
856
|
if stored_credential is None:
|
|
864
857
|
await flash("Passkey not recognized.", "error")
|
|
865
858
|
return redirect(url_for_security("wan_signin"))
|
|
@@ -26,7 +26,12 @@ def _require_webauthn():
|
|
|
26
26
|
verify_authentication_response,
|
|
27
27
|
verify_registration_response,
|
|
28
28
|
)
|
|
29
|
-
from webauthn.helpers.structs import
|
|
29
|
+
from webauthn.helpers.structs import (
|
|
30
|
+
AuthenticatorSelectionCriteria,
|
|
31
|
+
PublicKeyCredentialDescriptor,
|
|
32
|
+
ResidentKeyRequirement,
|
|
33
|
+
UserVerificationRequirement,
|
|
34
|
+
)
|
|
30
35
|
except ImportError as exc:
|
|
31
36
|
raise RuntimeError("webauthn package is required for WebAuthn support") from exc
|
|
32
37
|
|
|
@@ -37,6 +42,9 @@ def _require_webauthn():
|
|
|
37
42
|
"verify_authentication_response": verify_authentication_response,
|
|
38
43
|
"verify_registration_response": verify_registration_response,
|
|
39
44
|
"PublicKeyCredentialDescriptor": PublicKeyCredentialDescriptor,
|
|
45
|
+
"AuthenticatorSelectionCriteria": AuthenticatorSelectionCriteria,
|
|
46
|
+
"ResidentKeyRequirement": ResidentKeyRequirement,
|
|
47
|
+
"UserVerificationRequirement": UserVerificationRequirement,
|
|
40
48
|
}
|
|
41
49
|
|
|
42
50
|
|
|
@@ -59,6 +67,7 @@ async def begin_registration(
|
|
|
59
67
|
rp_name,
|
|
60
68
|
challenge: bytes,
|
|
61
69
|
existing_credentials=None,
|
|
70
|
+
discoverable: bool = False,
|
|
62
71
|
):
|
|
63
72
|
api = _require_webauthn()
|
|
64
73
|
|
|
@@ -72,15 +81,23 @@ async def begin_registration(
|
|
|
72
81
|
api["PublicKeyCredentialDescriptor"](id=cred.credential_id)
|
|
73
82
|
)
|
|
74
83
|
|
|
75
|
-
|
|
76
|
-
rp_id
|
|
77
|
-
rp_name
|
|
78
|
-
challenge
|
|
79
|
-
user_id
|
|
80
|
-
user_name
|
|
81
|
-
user_display_name
|
|
82
|
-
exclude_credentials
|
|
83
|
-
|
|
84
|
+
kwargs = {
|
|
85
|
+
"rp_id": rp_id,
|
|
86
|
+
"rp_name": rp_name,
|
|
87
|
+
"challenge": challenge,
|
|
88
|
+
"user_id": user_id,
|
|
89
|
+
"user_name": getattr(user, "email", ""),
|
|
90
|
+
"user_display_name": getattr(user, "name", None) or getattr(user, "email", ""),
|
|
91
|
+
"exclude_credentials": exclude_credentials,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if discoverable:
|
|
95
|
+
kwargs["authenticator_selection"] = api["AuthenticatorSelectionCriteria"](
|
|
96
|
+
resident_key=api["ResidentKeyRequirement"].REQUIRED,
|
|
97
|
+
user_verification=api["UserVerificationRequirement"].REQUIRED,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
return api["generate_registration_options"](**kwargs)
|
|
84
101
|
|
|
85
102
|
|
|
86
103
|
async def complete_registration(
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from itertools import count
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from quart import Quart
|
|
7
|
+
|
|
8
|
+
from quart_security import Security, auth_required, hash_password, roles_required
|
|
9
|
+
from quart_security.models import RoleMixin, UserMixin
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Role(RoleMixin):
|
|
14
|
+
name: str
|
|
15
|
+
description: str | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class WebAuthnCredential:
|
|
20
|
+
credential_id: bytes
|
|
21
|
+
public_key: bytes
|
|
22
|
+
sign_count: int
|
|
23
|
+
name: str
|
|
24
|
+
usage: str = "secondary"
|
|
25
|
+
backup_state: bool = False
|
|
26
|
+
device_type: str = "single_device"
|
|
27
|
+
lastuse_datetime: datetime.datetime | None = None
|
|
28
|
+
user_id: str | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class User(UserMixin):
|
|
33
|
+
fs_uniquifier: str
|
|
34
|
+
email: str
|
|
35
|
+
password: str
|
|
36
|
+
active: bool = True
|
|
37
|
+
name: str | None = None
|
|
38
|
+
roles: list[Role] = field(default_factory=list)
|
|
39
|
+
password_set: bool = True
|
|
40
|
+
login_count: int | None = None
|
|
41
|
+
last_login_at: object = None
|
|
42
|
+
current_login_at: object = None
|
|
43
|
+
last_login_ip: str | None = None
|
|
44
|
+
current_login_ip: str | None = None
|
|
45
|
+
tf_primary_method: str | None = None
|
|
46
|
+
tf_totp_secret: str | None = None
|
|
47
|
+
mf_recovery_codes: list[str] | None = None
|
|
48
|
+
fs_webauthn_user_handle: str | None = None
|
|
49
|
+
webauthn: list[WebAuthnCredential] = field(default_factory=list)
|
|
50
|
+
failed_login_count: int | None = None
|
|
51
|
+
locked_until: datetime.datetime | None = None
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def has_usable_password(self):
|
|
55
|
+
return self.password_set
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class InMemoryDatastore:
|
|
59
|
+
def __init__(self):
|
|
60
|
+
self.users: list[User] = []
|
|
61
|
+
self.roles: list[Role] = []
|
|
62
|
+
self._user_ids = count(1)
|
|
63
|
+
self._role_ids = count(1)
|
|
64
|
+
|
|
65
|
+
def find_user(self, **kwargs):
|
|
66
|
+
for user in self.users:
|
|
67
|
+
if all(getattr(user, key, None) == value for key, value in kwargs.items()):
|
|
68
|
+
return user
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
def find_role(self, name):
|
|
72
|
+
for role in self.roles:
|
|
73
|
+
if role.name == name:
|
|
74
|
+
return role
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
def create_user(self, **kwargs):
|
|
78
|
+
kwargs.setdefault("fs_uniquifier", f"user-{next(self._user_ids)}")
|
|
79
|
+
user = User(**kwargs)
|
|
80
|
+
self.users.append(user)
|
|
81
|
+
return user
|
|
82
|
+
|
|
83
|
+
def create_role(self, **kwargs):
|
|
84
|
+
kwargs.setdefault("name", f"role-{next(self._role_ids)}")
|
|
85
|
+
role = Role(**kwargs)
|
|
86
|
+
self.roles.append(role)
|
|
87
|
+
return role
|
|
88
|
+
|
|
89
|
+
def add_role_to_user(self, user, role_name):
|
|
90
|
+
role = self.find_role(role_name)
|
|
91
|
+
if role is None:
|
|
92
|
+
role = self.create_role(name=role_name)
|
|
93
|
+
if role in user.roles:
|
|
94
|
+
return False
|
|
95
|
+
user.roles.append(role)
|
|
96
|
+
return True
|
|
97
|
+
|
|
98
|
+
def remove_role_from_user(self, user, role_name):
|
|
99
|
+
for role in list(user.roles):
|
|
100
|
+
if role.name == role_name:
|
|
101
|
+
user.roles.remove(role)
|
|
102
|
+
return True
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
def toggle_active(self, user):
|
|
106
|
+
user.active = not user.active
|
|
107
|
+
return user.active
|
|
108
|
+
|
|
109
|
+
def set_uniquifier(self, user, uniquifier=None):
|
|
110
|
+
user.fs_uniquifier = uniquifier or user.fs_uniquifier
|
|
111
|
+
return user.fs_uniquifier
|
|
112
|
+
|
|
113
|
+
def commit(self):
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
def get_webauthn_credentials(self, user, usage=None):
|
|
117
|
+
credentials = list(user.webauthn or [])
|
|
118
|
+
if usage:
|
|
119
|
+
credentials = [
|
|
120
|
+
credential
|
|
121
|
+
for credential in credentials
|
|
122
|
+
if getattr(credential, "usage", None) == usage
|
|
123
|
+
]
|
|
124
|
+
return credentials
|
|
125
|
+
|
|
126
|
+
def find_webauthn_credential(self, credential_id, user=None):
|
|
127
|
+
if user is not None:
|
|
128
|
+
candidates = self.get_webauthn_credentials(user)
|
|
129
|
+
else:
|
|
130
|
+
# Global lookup across all users (discoverable credential flow)
|
|
131
|
+
candidates = [
|
|
132
|
+
cred for u in self.users for cred in (u.webauthn or [])
|
|
133
|
+
]
|
|
134
|
+
for credential in candidates:
|
|
135
|
+
if credential.credential_id == credential_id:
|
|
136
|
+
return credential
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
def create_webauthn_credential(self, user, **kwargs):
|
|
140
|
+
credential = WebAuthnCredential(**kwargs)
|
|
141
|
+
credential.user_id = user.fs_webauthn_user_handle
|
|
142
|
+
user.webauthn.append(credential)
|
|
143
|
+
return credential
|
|
144
|
+
|
|
145
|
+
def delete_webauthn_credential(self, user, credential):
|
|
146
|
+
if credential in user.webauthn:
|
|
147
|
+
user.webauthn.remove(credential)
|
|
148
|
+
return True
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@pytest.fixture
|
|
153
|
+
def datastore():
|
|
154
|
+
return InMemoryDatastore()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _build_app(
|
|
158
|
+
datastore: InMemoryDatastore, *, two_factor: bool, webauthn: bool
|
|
159
|
+
) -> Quart:
|
|
160
|
+
app = Quart(__name__)
|
|
161
|
+
app.config.update(
|
|
162
|
+
SECRET_KEY="test-secret",
|
|
163
|
+
TESTING=True,
|
|
164
|
+
SECURITY_PASSWORD_SALT="test-salt",
|
|
165
|
+
SECURITY_PASSWORD_LENGTH_MIN=8,
|
|
166
|
+
SECURITY_POST_REGISTER_VIEW="/login",
|
|
167
|
+
SECURITY_POST_LOGIN_VIEW="/protected",
|
|
168
|
+
SECURITY_CSRF_PROTECT=False,
|
|
169
|
+
SECURITY_REGISTERABLE=True,
|
|
170
|
+
SECURITY_CHANGEABLE=True,
|
|
171
|
+
SECURITY_TWO_FACTOR=two_factor,
|
|
172
|
+
SECURITY_WEBAUTHN=webauthn,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
Security(app, datastore)
|
|
176
|
+
|
|
177
|
+
basic_user = datastore.create_user(
|
|
178
|
+
fs_uniquifier="user-1",
|
|
179
|
+
email="user@example.com",
|
|
180
|
+
password=hash_password("correct-password"),
|
|
181
|
+
active=True,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
admin_user = datastore.create_user(
|
|
185
|
+
fs_uniquifier="admin-1",
|
|
186
|
+
email="admin@example.com",
|
|
187
|
+
password=hash_password("correct-password"),
|
|
188
|
+
active=True,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
datastore.add_role_to_user(admin_user, "admin")
|
|
192
|
+
|
|
193
|
+
@app.get("/protected")
|
|
194
|
+
@auth_required("session")
|
|
195
|
+
async def protected():
|
|
196
|
+
return "ok"
|
|
197
|
+
|
|
198
|
+
@app.get("/admin")
|
|
199
|
+
@auth_required("session")
|
|
200
|
+
@roles_required("admin")
|
|
201
|
+
async def admin_only():
|
|
202
|
+
return "admin"
|
|
203
|
+
|
|
204
|
+
@app.get("/")
|
|
205
|
+
async def index():
|
|
206
|
+
return "index"
|
|
207
|
+
|
|
208
|
+
app.extensions["test_basic_user"] = basic_user
|
|
209
|
+
app.extensions["test_admin_user"] = admin_user
|
|
210
|
+
app.extensions["test_datastore"] = datastore
|
|
211
|
+
return app
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@pytest.fixture
|
|
215
|
+
def client(app):
|
|
216
|
+
return app.test_client()
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@pytest.fixture
|
|
220
|
+
def app(datastore):
|
|
221
|
+
return _build_app(datastore, two_factor=False, webauthn=False)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@pytest.fixture
|
|
225
|
+
def app_two_factor(datastore):
|
|
226
|
+
return _build_app(datastore, two_factor=True, webauthn=False)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@pytest.fixture
|
|
230
|
+
def client_two_factor(app_two_factor):
|
|
231
|
+
return app_two_factor.test_client()
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@pytest.fixture
|
|
235
|
+
def app_webauthn(datastore):
|
|
236
|
+
return _build_app(datastore, two_factor=False, webauthn=True)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@pytest.fixture
|
|
240
|
+
def client_webauthn(app_webauthn):
|
|
241
|
+
return app_webauthn.test_client()
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@pytest.mark.asyncio
|
|
7
|
+
async def test_protected_redirects_to_login(client):
|
|
8
|
+
response = await client.get("/protected")
|
|
9
|
+
assert response.status_code == 302
|
|
10
|
+
assert "/login" in response.headers["Location"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.mark.asyncio
|
|
14
|
+
async def test_login_then_access_protected(client):
|
|
15
|
+
response = await client.post(
|
|
16
|
+
"/login",
|
|
17
|
+
form={"email": "user@example.com", "password": "correct-password"},
|
|
18
|
+
)
|
|
19
|
+
assert response.status_code == 302
|
|
20
|
+
assert response.headers["Location"].endswith("/protected")
|
|
21
|
+
|
|
22
|
+
protected = await client.get("/protected")
|
|
23
|
+
assert protected.status_code == 200
|
|
24
|
+
assert (await protected.get_data(as_text=True)) == "ok"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.mark.asyncio
|
|
28
|
+
async def test_admin_route_forbidden_without_role(client):
|
|
29
|
+
await client.post(
|
|
30
|
+
"/login",
|
|
31
|
+
form={"email": "user@example.com", "password": "correct-password"},
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
response = await client.get("/admin")
|
|
35
|
+
assert response.status_code == 403
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.mark.asyncio
|
|
39
|
+
async def test_admin_route_allowed_with_role(client):
|
|
40
|
+
await client.post(
|
|
41
|
+
"/login",
|
|
42
|
+
form={"email": "admin@example.com", "password": "correct-password"},
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
response = await client.get("/admin")
|
|
46
|
+
assert response.status_code == 200
|
|
47
|
+
assert (await response.get_data(as_text=True)) == "admin"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@pytest.mark.asyncio
|
|
51
|
+
async def test_login_lockout_blocks_after_repeated_failures(client, app):
|
|
52
|
+
user = app.extensions["test_basic_user"]
|
|
53
|
+
app.config["SECURITY_LOGIN_MAX_ATTEMPTS"] = 2
|
|
54
|
+
app.config["SECURITY_LOCKOUT_MINUTES"] = 15
|
|
55
|
+
|
|
56
|
+
first = await client.post(
|
|
57
|
+
"/login",
|
|
58
|
+
form={"email": "user@example.com", "password": "wrong-password"},
|
|
59
|
+
)
|
|
60
|
+
assert first.status_code == 200
|
|
61
|
+
assert user.failed_login_count == 1
|
|
62
|
+
assert user.locked_until is None
|
|
63
|
+
|
|
64
|
+
second = await client.post(
|
|
65
|
+
"/login",
|
|
66
|
+
form={"email": "user@example.com", "password": "wrong-password"},
|
|
67
|
+
)
|
|
68
|
+
assert second.status_code == 200
|
|
69
|
+
assert user.failed_login_count == 2
|
|
70
|
+
assert user.locked_until is not None
|
|
71
|
+
|
|
72
|
+
locked = await client.post(
|
|
73
|
+
"/login",
|
|
74
|
+
form={"email": "user@example.com", "password": "correct-password"},
|
|
75
|
+
)
|
|
76
|
+
assert locked.status_code == 200
|
|
77
|
+
assert user.failed_login_count == 2
|
|
78
|
+
assert user.locked_until is not None
|
|
79
|
+
|
|
80
|
+
protected = await client.get("/protected")
|
|
81
|
+
assert protected.status_code == 302
|
|
82
|
+
assert "/login" in protected.headers["Location"]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@pytest.mark.asyncio
|
|
86
|
+
async def test_login_lockout_resets_after_expiry(client, app):
|
|
87
|
+
user = app.extensions["test_basic_user"]
|
|
88
|
+
user.failed_login_count = 3
|
|
89
|
+
user.locked_until = datetime.datetime.now() - datetime.timedelta(minutes=1)
|
|
90
|
+
|
|
91
|
+
response = await client.post(
|
|
92
|
+
"/login",
|
|
93
|
+
form={"email": "user@example.com", "password": "correct-password"},
|
|
94
|
+
)
|
|
95
|
+
assert response.status_code == 302
|
|
96
|
+
assert response.headers["Location"].endswith("/protected")
|
|
97
|
+
assert user.failed_login_count == 0
|
|
98
|
+
assert user.locked_until is None
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from quart_security import verify_password
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@pytest.mark.asyncio
|
|
7
|
+
async def test_register_creates_user(client, app):
|
|
8
|
+
datastore = app.extensions["test_datastore"]
|
|
9
|
+
|
|
10
|
+
response = await client.post(
|
|
11
|
+
"/register",
|
|
12
|
+
form={
|
|
13
|
+
"name": "New User",
|
|
14
|
+
"email": "new@example.com",
|
|
15
|
+
"password": "new-password-123",
|
|
16
|
+
"password_confirm": "new-password-123",
|
|
17
|
+
},
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
assert response.status_code == 302
|
|
21
|
+
assert response.headers["Location"].endswith("/login")
|
|
22
|
+
|
|
23
|
+
user = datastore.find_user(email="new@example.com")
|
|
24
|
+
assert user is not None
|
|
25
|
+
assert user.password != "new-password-123"
|
|
26
|
+
assert verify_password("new-password-123", user.password)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.mark.asyncio
|
|
30
|
+
async def test_register_duplicate_email_returns_form(client, app):
|
|
31
|
+
datastore = app.extensions["test_datastore"]
|
|
32
|
+
existing = len(datastore.users)
|
|
33
|
+
|
|
34
|
+
response = await client.post(
|
|
35
|
+
"/register",
|
|
36
|
+
form={
|
|
37
|
+
"name": "Duplicate",
|
|
38
|
+
"email": "user@example.com",
|
|
39
|
+
"password": "new-password-123",
|
|
40
|
+
"password_confirm": "new-password-123",
|
|
41
|
+
},
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
assert response.status_code == 200
|
|
45
|
+
assert len(datastore.users) == existing
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.mark.asyncio
|
|
49
|
+
async def test_logout_clears_session(client):
|
|
50
|
+
await client.post(
|
|
51
|
+
"/login",
|
|
52
|
+
form={"email": "user@example.com", "password": "correct-password"},
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
protected_before = await client.get("/protected")
|
|
56
|
+
assert protected_before.status_code == 200
|
|
57
|
+
|
|
58
|
+
logout_response = await client.get("/logout")
|
|
59
|
+
assert logout_response.status_code == 302
|
|
60
|
+
assert "/login" in logout_response.headers["Location"]
|
|
61
|
+
|
|
62
|
+
protected_after = await client.get("/protected")
|
|
63
|
+
assert protected_after.status_code == 302
|
|
64
|
+
assert "/login" in protected_after.headers["Location"]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@pytest.mark.asyncio
|
|
68
|
+
async def test_change_password_requires_current_for_standard_user(client, app):
|
|
69
|
+
user = app.extensions["test_basic_user"]
|
|
70
|
+
old_hash = user.password
|
|
71
|
+
|
|
72
|
+
await client.post(
|
|
73
|
+
"/login",
|
|
74
|
+
form={"email": "user@example.com", "password": "correct-password"},
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
response = await client.post(
|
|
78
|
+
"/change",
|
|
79
|
+
form={
|
|
80
|
+
"password": "correct-password",
|
|
81
|
+
"new_password": "updated-password-123",
|
|
82
|
+
"new_password_confirm": "updated-password-123",
|
|
83
|
+
},
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
assert response.status_code == 302
|
|
87
|
+
assert response.headers["Location"].endswith("/change")
|
|
88
|
+
assert user.password != old_hash
|
|
89
|
+
assert verify_password("updated-password-123", user.password)
|
|
90
|
+
|
|
91
|
+
await client.get("/logout")
|
|
92
|
+
|
|
93
|
+
invalid_login = await client.post(
|
|
94
|
+
"/login",
|
|
95
|
+
form={"email": "user@example.com", "password": "correct-password"},
|
|
96
|
+
)
|
|
97
|
+
assert invalid_login.status_code == 200
|
|
98
|
+
|
|
99
|
+
valid_login = await client.post(
|
|
100
|
+
"/login",
|
|
101
|
+
form={"email": "user@example.com", "password": "updated-password-123"},
|
|
102
|
+
)
|
|
103
|
+
assert valid_login.status_code == 302
|
|
104
|
+
assert valid_login.headers["Location"].endswith("/protected")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@pytest.mark.asyncio
|
|
108
|
+
async def test_change_password_skips_current_for_oauth_style_user(client, app):
|
|
109
|
+
user = app.extensions["test_basic_user"]
|
|
110
|
+
user.password_set = False
|
|
111
|
+
|
|
112
|
+
await client.post(
|
|
113
|
+
"/login",
|
|
114
|
+
form={"email": "user@example.com", "password": "correct-password"},
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
response = await client.post(
|
|
118
|
+
"/change",
|
|
119
|
+
form={
|
|
120
|
+
"password": "",
|
|
121
|
+
"new_password": "oauth-updated-123",
|
|
122
|
+
"new_password_confirm": "oauth-updated-123",
|
|
123
|
+
},
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
assert response.status_code == 302
|
|
127
|
+
assert response.headers["Location"].endswith("/change")
|
|
128
|
+
assert user.password_set is True
|
|
129
|
+
assert verify_password("oauth-updated-123", user.password)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from quart_security.password import hash_password, validate_password, verify_password
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_hash_and_verify(app):
|
|
5
|
+
hashed = hash_password("abcdefgh")
|
|
6
|
+
assert hashed != "abcdefgh"
|
|
7
|
+
assert verify_password("abcdefgh", hashed)
|
|
8
|
+
assert not verify_password("abcdefghx", hashed)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_validate_password_length():
|
|
12
|
+
errors = validate_password("short", min_length=8)
|
|
13
|
+
assert errors
|
|
14
|
+
assert "at least 8" in errors[0]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from quart_security import totp
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@pytest.mark.asyncio
|
|
7
|
+
async def test_two_factor_setup_enables_totp_and_recovery_codes(
|
|
8
|
+
client_two_factor, app_two_factor, monkeypatch
|
|
9
|
+
):
|
|
10
|
+
user = app_two_factor.extensions["test_basic_user"]
|
|
11
|
+
|
|
12
|
+
monkeypatch.setattr(totp, "generate_totp_secret", lambda: "test-secret")
|
|
13
|
+
monkeypatch.setattr(
|
|
14
|
+
totp,
|
|
15
|
+
"get_totp_uri",
|
|
16
|
+
lambda secret, email, issuer: "otpauth://totp/test",
|
|
17
|
+
)
|
|
18
|
+
monkeypatch.setattr(totp, "generate_qr_code", lambda uri: "qr-data")
|
|
19
|
+
monkeypatch.setattr(
|
|
20
|
+
totp,
|
|
21
|
+
"verify_totp",
|
|
22
|
+
lambda secret, token: secret == "test-secret" and token == "123456",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
await client_two_factor.post(
|
|
26
|
+
"/login",
|
|
27
|
+
form={"email": "user@example.com", "password": "correct-password"},
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
get_response = await client_two_factor.get("/tf-setup?setup=authenticator")
|
|
31
|
+
assert get_response.status_code == 200
|
|
32
|
+
|
|
33
|
+
post_response = await client_two_factor.post(
|
|
34
|
+
"/tf-setup",
|
|
35
|
+
form={"action": "verify", "token": "123456"},
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
assert post_response.status_code == 302
|
|
39
|
+
assert post_response.headers["Location"].endswith("/mf-recovery-codes")
|
|
40
|
+
assert user.tf_primary_method == "authenticator"
|
|
41
|
+
assert user.tf_totp_secret == "test-secret"
|
|
42
|
+
assert (
|
|
43
|
+
len(user.mf_recovery_codes or [])
|
|
44
|
+
== app_two_factor.config["SECURITY_MULTI_FACTOR_RECOVERY_CODES_N"]
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.mark.asyncio
|
|
49
|
+
async def test_login_requires_second_factor_then_allows_access(
|
|
50
|
+
client_two_factor, app_two_factor, monkeypatch
|
|
51
|
+
):
|
|
52
|
+
user = app_two_factor.extensions["test_basic_user"]
|
|
53
|
+
user.tf_primary_method = "authenticator"
|
|
54
|
+
user.tf_totp_secret = "existing-secret"
|
|
55
|
+
|
|
56
|
+
monkeypatch.setattr(
|
|
57
|
+
totp,
|
|
58
|
+
"verify_totp",
|
|
59
|
+
lambda secret, token: secret == "existing-secret" and token == "123456",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
login = await client_two_factor.post(
|
|
63
|
+
"/login",
|
|
64
|
+
form={"email": "user@example.com", "password": "correct-password"},
|
|
65
|
+
)
|
|
66
|
+
assert login.status_code == 302
|
|
67
|
+
assert login.headers["Location"].endswith("/tf-validate")
|
|
68
|
+
|
|
69
|
+
protected = await client_two_factor.get("/protected")
|
|
70
|
+
assert protected.status_code == 302
|
|
71
|
+
assert "/login" in protected.headers["Location"]
|
|
72
|
+
|
|
73
|
+
invalid_code = await client_two_factor.post(
|
|
74
|
+
"/tf-validate", form={"token": "000000"}
|
|
75
|
+
)
|
|
76
|
+
assert invalid_code.status_code == 200
|
|
77
|
+
|
|
78
|
+
valid_code = await client_two_factor.post("/tf-validate", form={"token": "123456"})
|
|
79
|
+
assert valid_code.status_code == 302
|
|
80
|
+
assert valid_code.headers["Location"].endswith("/protected")
|
|
81
|
+
|
|
82
|
+
protected_after = await client_two_factor.get("/protected")
|
|
83
|
+
assert protected_after.status_code == 200
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@pytest.mark.asyncio
|
|
87
|
+
async def test_two_factor_recovery_code_login_consumes_code(
|
|
88
|
+
client_two_factor, app_two_factor
|
|
89
|
+
):
|
|
90
|
+
user = app_two_factor.extensions["test_basic_user"]
|
|
91
|
+
user.tf_primary_method = "authenticator"
|
|
92
|
+
user.tf_totp_secret = None
|
|
93
|
+
user.mf_recovery_codes = ["recovery-1"]
|
|
94
|
+
|
|
95
|
+
login = await client_two_factor.post(
|
|
96
|
+
"/login",
|
|
97
|
+
form={"email": "user@example.com", "password": "correct-password"},
|
|
98
|
+
)
|
|
99
|
+
assert login.status_code == 302
|
|
100
|
+
assert login.headers["Location"].endswith("/tf-validate")
|
|
101
|
+
|
|
102
|
+
recovery = await client_two_factor.post(
|
|
103
|
+
"/tf-validate",
|
|
104
|
+
form={"token": "recovery-1"},
|
|
105
|
+
)
|
|
106
|
+
assert recovery.status_code == 302
|
|
107
|
+
assert recovery.headers["Location"].endswith("/protected")
|
|
108
|
+
assert user.mf_recovery_codes == []
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from quart_security import webauthn as wan
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _registration_options(challenge: bytes, user_id: bytes = b"user-handle"):
|
|
9
|
+
return {
|
|
10
|
+
"challenge": wan.bytes_to_base64url(challenge),
|
|
11
|
+
"user": {"id": wan.bytes_to_base64url(user_id), "name": "user@example.com"},
|
|
12
|
+
"excludeCredentials": [],
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.mark.asyncio
|
|
17
|
+
async def test_wan_register_creates_credential(
|
|
18
|
+
client_webauthn, app_webauthn, monkeypatch
|
|
19
|
+
):
|
|
20
|
+
user = app_webauthn.extensions["test_basic_user"]
|
|
21
|
+
|
|
22
|
+
async def fake_begin_registration(
|
|
23
|
+
user, rp_id, rp_name, challenge, existing_credentials=None, discoverable=False
|
|
24
|
+
):
|
|
25
|
+
return {"challenge": challenge}
|
|
26
|
+
|
|
27
|
+
def fake_options_to_json_dict(options):
|
|
28
|
+
return _registration_options(options["challenge"])
|
|
29
|
+
|
|
30
|
+
async def fake_complete_registration(
|
|
31
|
+
credential,
|
|
32
|
+
challenge,
|
|
33
|
+
rp_id,
|
|
34
|
+
expected_origin,
|
|
35
|
+
require_user_verification=True,
|
|
36
|
+
):
|
|
37
|
+
return {
|
|
38
|
+
"credential_id": b"cred-1",
|
|
39
|
+
"public_key": b"public-key",
|
|
40
|
+
"sign_count": 0,
|
|
41
|
+
"backup_state": False,
|
|
42
|
+
"device_type": "single_device",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
monkeypatch.setattr(wan, "begin_registration", fake_begin_registration)
|
|
46
|
+
monkeypatch.setattr(wan, "options_to_json_dict", fake_options_to_json_dict)
|
|
47
|
+
monkeypatch.setattr(wan, "complete_registration", fake_complete_registration)
|
|
48
|
+
|
|
49
|
+
await client_webauthn.post(
|
|
50
|
+
"/login",
|
|
51
|
+
form={"email": "user@example.com", "password": "correct-password"},
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
register_start = await client_webauthn.post(
|
|
55
|
+
"/wan-register",
|
|
56
|
+
form={"name": "MacBook Passkey", "usage": "primary"},
|
|
57
|
+
)
|
|
58
|
+
assert register_start.status_code == 200
|
|
59
|
+
|
|
60
|
+
credential_payload = {
|
|
61
|
+
"id": wan.bytes_to_base64url(b"cred-1"),
|
|
62
|
+
"rawId": wan.bytes_to_base64url(b"cred-1"),
|
|
63
|
+
"type": "public-key",
|
|
64
|
+
"response": {
|
|
65
|
+
"clientDataJSON": "client",
|
|
66
|
+
"attestationObject": "attestation",
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
register_finish = await client_webauthn.post(
|
|
71
|
+
"/wan-register-response",
|
|
72
|
+
form={"credential": json.dumps(credential_payload)},
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
assert register_finish.status_code == 302
|
|
76
|
+
assert register_finish.headers["Location"].endswith("/wan-register")
|
|
77
|
+
assert len(user.webauthn) == 1
|
|
78
|
+
assert user.webauthn[0].name == "MacBook Passkey"
|
|
79
|
+
assert user.webauthn[0].usage == "primary"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@pytest.mark.asyncio
|
|
83
|
+
async def test_wan_signin_logs_in_with_passkey(
|
|
84
|
+
client_webauthn, app_webauthn, monkeypatch
|
|
85
|
+
):
|
|
86
|
+
user = app_webauthn.extensions["test_basic_user"]
|
|
87
|
+
user.fs_webauthn_user_handle = "handle-1"
|
|
88
|
+
app_webauthn.extensions["test_datastore"].create_webauthn_credential(
|
|
89
|
+
user,
|
|
90
|
+
credential_id=b"cred-primary",
|
|
91
|
+
public_key=b"public-key",
|
|
92
|
+
sign_count=1,
|
|
93
|
+
name="Phone",
|
|
94
|
+
usage="primary",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
async def fake_begin_authentication(credentials, rp_id, challenge):
|
|
98
|
+
return {"challenge": challenge, "allow_credentials": []}
|
|
99
|
+
|
|
100
|
+
def fake_options_to_json_dict(options):
|
|
101
|
+
return {
|
|
102
|
+
"challenge": wan.bytes_to_base64url(options["challenge"]),
|
|
103
|
+
"allowCredentials": [],
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async def fake_complete_authentication(
|
|
107
|
+
credential,
|
|
108
|
+
challenge,
|
|
109
|
+
rp_id,
|
|
110
|
+
expected_origin,
|
|
111
|
+
stored_credential,
|
|
112
|
+
require_user_verification=True,
|
|
113
|
+
):
|
|
114
|
+
return stored_credential.sign_count + 1
|
|
115
|
+
|
|
116
|
+
monkeypatch.setattr(wan, "begin_authentication", fake_begin_authentication)
|
|
117
|
+
monkeypatch.setattr(wan, "options_to_json_dict", fake_options_to_json_dict)
|
|
118
|
+
monkeypatch.setattr(wan, "complete_authentication", fake_complete_authentication)
|
|
119
|
+
|
|
120
|
+
signin_start = await client_webauthn.post(
|
|
121
|
+
"/wan-signin",
|
|
122
|
+
form={"identity": "", "remember": "y"},
|
|
123
|
+
)
|
|
124
|
+
assert signin_start.status_code == 200
|
|
125
|
+
|
|
126
|
+
credential_payload = {
|
|
127
|
+
"id": wan.bytes_to_base64url(b"cred-primary"),
|
|
128
|
+
"rawId": wan.bytes_to_base64url(b"cred-primary"),
|
|
129
|
+
"type": "public-key",
|
|
130
|
+
"response": {
|
|
131
|
+
"clientDataJSON": "client",
|
|
132
|
+
"authenticatorData": "auth-data",
|
|
133
|
+
"signature": "sig",
|
|
134
|
+
"userHandle": wan.bytes_to_base64url(b"handle-1"),
|
|
135
|
+
},
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
signin_finish = await client_webauthn.post(
|
|
139
|
+
"/wan-signin-response",
|
|
140
|
+
form={"credential": json.dumps(credential_payload)},
|
|
141
|
+
)
|
|
142
|
+
assert signin_finish.status_code == 302
|
|
143
|
+
assert signin_finish.headers["Location"].endswith("/protected")
|
|
144
|
+
|
|
145
|
+
protected = await client_webauthn.get("/protected")
|
|
146
|
+
assert protected.status_code == 200
|
|
147
|
+
assert user.webauthn[0].sign_count == 2
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@pytest.mark.asyncio
|
|
151
|
+
async def test_wan_verify_updates_sign_count(
|
|
152
|
+
client_webauthn, app_webauthn, monkeypatch
|
|
153
|
+
):
|
|
154
|
+
user = app_webauthn.extensions["test_basic_user"]
|
|
155
|
+
user.fs_webauthn_user_handle = "handle-2"
|
|
156
|
+
app_webauthn.extensions["test_datastore"].create_webauthn_credential(
|
|
157
|
+
user,
|
|
158
|
+
credential_id=b"cred-secondary",
|
|
159
|
+
public_key=b"public-key",
|
|
160
|
+
sign_count=10,
|
|
161
|
+
name="Security Key",
|
|
162
|
+
usage="secondary",
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
async def fake_begin_authentication(credentials, rp_id, challenge):
|
|
166
|
+
return {"challenge": challenge, "credential_id": credentials[0].credential_id}
|
|
167
|
+
|
|
168
|
+
def fake_options_to_json_dict(options):
|
|
169
|
+
return {
|
|
170
|
+
"challenge": wan.bytes_to_base64url(options["challenge"]),
|
|
171
|
+
"allowCredentials": [
|
|
172
|
+
{"id": wan.bytes_to_base64url(options["credential_id"])}
|
|
173
|
+
],
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async def fake_complete_authentication(
|
|
177
|
+
credential,
|
|
178
|
+
challenge,
|
|
179
|
+
rp_id,
|
|
180
|
+
expected_origin,
|
|
181
|
+
stored_credential,
|
|
182
|
+
require_user_verification=True,
|
|
183
|
+
):
|
|
184
|
+
return stored_credential.sign_count + 5
|
|
185
|
+
|
|
186
|
+
monkeypatch.setattr(wan, "begin_authentication", fake_begin_authentication)
|
|
187
|
+
monkeypatch.setattr(wan, "options_to_json_dict", fake_options_to_json_dict)
|
|
188
|
+
monkeypatch.setattr(wan, "complete_authentication", fake_complete_authentication)
|
|
189
|
+
|
|
190
|
+
await client_webauthn.post(
|
|
191
|
+
"/login",
|
|
192
|
+
form={"email": "user@example.com", "password": "correct-password"},
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
verify_start = await client_webauthn.post("/wan-verify")
|
|
196
|
+
assert verify_start.status_code == 200
|
|
197
|
+
|
|
198
|
+
credential_payload = {
|
|
199
|
+
"id": wan.bytes_to_base64url(b"cred-secondary"),
|
|
200
|
+
"rawId": wan.bytes_to_base64url(b"cred-secondary"),
|
|
201
|
+
"type": "public-key",
|
|
202
|
+
"response": {
|
|
203
|
+
"clientDataJSON": "client",
|
|
204
|
+
"authenticatorData": "auth-data",
|
|
205
|
+
"signature": "sig",
|
|
206
|
+
"userHandle": None,
|
|
207
|
+
},
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
verify_finish = await client_webauthn.post(
|
|
211
|
+
"/wan-verify-response",
|
|
212
|
+
form={"credential": json.dumps(credential_payload)},
|
|
213
|
+
)
|
|
214
|
+
assert verify_finish.status_code == 302
|
|
215
|
+
assert user.webauthn[0].sign_count == 15
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@pytest.mark.asyncio
|
|
219
|
+
async def test_wan_delete_removes_credential(client_webauthn, app_webauthn):
|
|
220
|
+
user = app_webauthn.extensions["test_basic_user"]
|
|
221
|
+
user.fs_webauthn_user_handle = "handle-3"
|
|
222
|
+
app_webauthn.extensions["test_datastore"].create_webauthn_credential(
|
|
223
|
+
user,
|
|
224
|
+
credential_id=b"cred-delete",
|
|
225
|
+
public_key=b"public-key",
|
|
226
|
+
sign_count=0,
|
|
227
|
+
name="To Delete",
|
|
228
|
+
usage="secondary",
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
await client_webauthn.post(
|
|
232
|
+
"/login",
|
|
233
|
+
form={"email": "user@example.com", "password": "correct-password"},
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
response = await client_webauthn.post(
|
|
237
|
+
"/wan-delete",
|
|
238
|
+
form={"credential_id": wan.bytes_to_base64url(b"cred-delete")},
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
assert response.status_code == 302
|
|
242
|
+
assert response.headers["Location"].endswith("/wan-register")
|
|
243
|
+
assert user.webauthn == []
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{quart_security-1.2.1 → quart_security-1.3.0}/quart_security/templates/security/_macros.html
RENAMED
|
File without changes
|
{quart_security-1.2.1 → quart_security-1.3.0}/quart_security/templates/security/change_password.html
RENAMED
|
File without changes
|
{quart_security-1.2.1 → quart_security-1.3.0}/quart_security/templates/security/mf_recovery.html
RENAMED
|
File without changes
|
|
File without changes
|
{quart_security-1.2.1 → quart_security-1.3.0}/quart_security/templates/security/register_user.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{quart_security-1.2.1 → quart_security-1.3.0}/quart_security/templates/security/wan_register.html
RENAMED
|
File without changes
|
{quart_security-1.2.1 → quart_security-1.3.0}/quart_security/templates/security/wan_verify.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|