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.
Files changed (39) hide show
  1. quart_security-1.3.0/.github/workflows/ci.yml +34 -0
  2. quart_security-1.3.0/.github/workflows/publish.yml +37 -0
  3. quart_security-1.3.0/.gitignore +8 -0
  4. {quart_security-1.2.1 → quart_security-1.3.0}/PKG-INFO +2 -3
  5. {quart_security-1.2.1 → quart_security-1.3.0}/pyproject.toml +1 -1
  6. {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/templates/security/login_user.html +3 -0
  7. {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/templates/security/wan_signin.html +3 -6
  8. {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/views.py +35 -42
  9. {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/webauthn.py +27 -10
  10. quart_security-1.3.0/tests/conftest.py +241 -0
  11. quart_security-1.3.0/tests/test_auth_flow.py +98 -0
  12. quart_security-1.3.0/tests/test_core_flows.py +129 -0
  13. quart_security-1.3.0/tests/test_password.py +14 -0
  14. quart_security-1.3.0/tests/test_two_factor.py +108 -0
  15. quart_security-1.3.0/tests/test_webauthn.py +243 -0
  16. {quart_security-1.2.1 → quart_security-1.3.0}/LICENSE +0 -0
  17. {quart_security-1.2.1 → quart_security-1.3.0}/README.md +0 -0
  18. {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/__init__.py +0 -0
  19. {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/core.py +0 -0
  20. {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/datastore.py +0 -0
  21. {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/decorators.py +0 -0
  22. {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/email.py +0 -0
  23. {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/forms.py +0 -0
  24. {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/models.py +0 -0
  25. {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/password.py +0 -0
  26. {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/proxies.py +0 -0
  27. {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/signals.py +0 -0
  28. {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/templates/security/_macros.html +0 -0
  29. {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/templates/security/change_password.html +0 -0
  30. {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/templates/security/mf_recovery.html +0 -0
  31. {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/templates/security/mf_recovery_codes.html +0 -0
  32. {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/templates/security/register_user.html +0 -0
  33. {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/templates/security/two_factor_select.html +0 -0
  34. {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/templates/security/two_factor_setup.html +0 -0
  35. {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/templates/security/two_factor_verify_code.html +0 -0
  36. {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/templates/security/wan_register.html +0 -0
  37. {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/templates/security/wan_verify.html +0 -0
  38. {quart_security-1.2.1 → quart_security-1.3.0}/quart_security/totp.py +0 -0
  39. {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
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .pytest_cache/
4
+ .ruff_cache/
5
+ .uv-cache/
6
+ .venv/
7
+ dist/
8
+ uv.lock
@@ -1,11 +1,10 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.1
2
2
  Name: quart-security
3
- Version: 1.2.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
@@ -4,7 +4,7 @@ build-backend = "flit_core.buildapi"
4
4
 
5
5
  [project]
6
6
  name = "quart-security"
7
- version = "1.2.1"
7
+ version = "1.3.0"
8
8
  description = "Native async auth for Quart"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -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>
@@ -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-signin-form">
7
+ <form method="post" action="{{ url_for_security('wan_signin') }}" id="wan-discoverable-form">
8
8
  {{ wan_signin_form.hidden_tag() }}
9
- {{ render_input(wan_signin_form.identity) }}
10
- <label style="display: block; margin-bottom: 0.75rem;">
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
- credentials,
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=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 PublicKeyCredentialDescriptor
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
- return api["generate_registration_options"](
76
- rp_id=rp_id,
77
- rp_name=rp_name,
78
- challenge=challenge,
79
- user_id=user_id,
80
- user_name=getattr(user, "email", ""),
81
- user_display_name=getattr(user, "name", None) or getattr(user, "email", ""),
82
- exclude_credentials=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