simple-module-users 0.0.15__tar.gz → 0.0.17__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.
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/PKG-INFO +6 -6
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/pyproject.toml +6 -6
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/_middleware_support.py +18 -13
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_api_admin.py +8 -9
- simple_module_users-0.0.17/tests/test_token_api.py +191 -0
- simple_module_users-0.0.17/tests/test_users_middleware_resolvers.py +190 -0
- simple_module_users-0.0.17/tests/test_users_provider.py +46 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_views.py +34 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/auth_local/api.py +12 -2
- simple_module_users-0.0.17/users/auth_local/token_api.py +159 -0
- simple_module_users-0.0.17/users/middleware.py +10 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/models/__init__.py +2 -0
- simple_module_users-0.0.17/users/models/refresh_token.py +22 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/module.py +20 -19
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/pages/AcceptInvite.tsx +2 -1
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/pages/ForgotPassword.tsx +2 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/pages/Login.tsx +2 -2
- simple_module_users-0.0.17/users/pages/Profile.tsx +154 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/pages/Register.tsx +2 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/pages/ResetPassword.tsx +2 -1
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/pages/VerifyEmail.tsx +2 -1
- simple_module_users-0.0.17/users/provider.py +130 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/settings.py +4 -0
- simple_module_users-0.0.15/users/middleware.py +0 -143
- simple_module_users-0.0.15/users/pages/Profile.tsx +0 -148
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/.gitignore +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/LICENSE +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/README.md +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/package.json +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/.gitkeep +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/conftest.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_access_token_model.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_api_admin_filters.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_api_auth.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_backend.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_bootstrap.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_bootstrap_resolution.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_cli.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_constants.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_db_adapter.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_invite_flow.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_invite_reuse.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_mailer.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_negative_authz.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_oauth.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_rate_limit.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_role_model.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_service_admin.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_settings.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_user_manager.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_user_model.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_user_role_model.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_user_service.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_users_deps.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_users_middleware.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_users_middleware_public_paths.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_views_admin.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tsconfig.json +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/__init__.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/admin/__init__.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/admin/api.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/admin/components/IndexFilters.tsx +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/admin/components/RolesTab.tsx +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/admin/components/UserRow.tsx +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/admin/service.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/admin/views.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/auth_local/__init__.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/auth_local/rate_limit.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/auth_local/views.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/backend.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/bootstrap.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/cli.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/constants.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/contracts/__init__.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/contracts/events.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/contracts/schemas.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/db_adapter.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/deps.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/exceptions.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/mailer/__init__.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/mailer/console.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/mailer/smtp.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/mailer/templates/.gitkeep +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/mailer/templates/invite.txt +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/mailer/templates/reset_password.txt +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/mailer/templates/verify_email.txt +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/manager.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/models/_base.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/models/access_token.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/models/oauth_account.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/models/role.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/models/user.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/models/user_role.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/oauth/__init__.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/oauth/api.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/oauth/providers.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/pages/.gitkeep +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/pages/Users/Edit.tsx +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/pages/Users/Index.tsx +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/pages/Users/Invite.tsx +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/pages/Users/components/AccountStatusCard.tsx +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/py.typed +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/roles_cache.py +0 -0
- {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/state.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: simple_module_users
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.17
|
|
4
4
|
Summary: Email + password user management, admin invites, RBAC-ready — replaces Keycloak for simple_module apps
|
|
5
5
|
Project-URL: Homepage, https://github.com/antosubash/simple_module_python
|
|
6
6
|
Project-URL: Repository, https://github.com/antosubash/simple_module_python
|
|
@@ -24,11 +24,11 @@ Requires-Python: >=3.12
|
|
|
24
24
|
Requires-Dist: aiosmtplib>=3.0
|
|
25
25
|
Requires-Dist: cachetools>=5.3
|
|
26
26
|
Requires-Dist: fastapi-users[oauth,sqlalchemy]<16,>=15
|
|
27
|
-
Requires-Dist: simple-module-auth==0.0.
|
|
28
|
-
Requires-Dist: simple-module-core==0.0.
|
|
29
|
-
Requires-Dist: simple-module-db==0.0.
|
|
30
|
-
Requires-Dist: simple-module-hosting==0.0.
|
|
31
|
-
Requires-Dist: simple-module-settings==0.0.
|
|
27
|
+
Requires-Dist: simple-module-auth==0.0.17
|
|
28
|
+
Requires-Dist: simple-module-core==0.0.17
|
|
29
|
+
Requires-Dist: simple-module-db==0.0.17
|
|
30
|
+
Requires-Dist: simple-module-hosting==0.0.17
|
|
31
|
+
Requires-Dist: simple-module-settings==0.0.17
|
|
32
32
|
Requires-Dist: typer>=0.12
|
|
33
33
|
Description-Content-Type: text/markdown
|
|
34
34
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "simple_module_users"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.17"
|
|
4
4
|
description = "Email + password user management, admin invites, RBAC-ready — replaces Keycloak for simple_module apps"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -21,11 +21,11 @@ classifiers = [
|
|
|
21
21
|
"Typing :: Typed",
|
|
22
22
|
]
|
|
23
23
|
dependencies = [
|
|
24
|
-
"simple_module_core==0.0.
|
|
25
|
-
"simple_module_db==0.0.
|
|
26
|
-
"simple_module_hosting==0.0.
|
|
27
|
-
"simple_module_settings==0.0.
|
|
28
|
-
"simple_module_auth==0.0.
|
|
24
|
+
"simple_module_core==0.0.17",
|
|
25
|
+
"simple_module_db==0.0.17",
|
|
26
|
+
"simple_module_hosting==0.0.17",
|
|
27
|
+
"simple_module_settings==0.0.17",
|
|
28
|
+
"simple_module_auth==0.0.17",
|
|
29
29
|
# Pinned to a narrow range: `deps.py` relies on mutating CookieTransport
|
|
30
30
|
# fields after construction (see reconfigure_cookie_transport in backend.py).
|
|
31
31
|
# Bumping the major version requires re-checking those field names.
|
|
@@ -9,35 +9,34 @@ parameters.
|
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
|
-
import json
|
|
13
12
|
import uuid
|
|
14
|
-
from base64 import b64encode
|
|
15
13
|
from types import SimpleNamespace
|
|
16
14
|
from typing import Any
|
|
17
15
|
|
|
18
16
|
import pytest
|
|
17
|
+
from auth.middleware import AuthMiddleware
|
|
19
18
|
from fastapi import FastAPI, Request
|
|
20
|
-
from
|
|
19
|
+
from simple_module_test import forge_session_cookie
|
|
21
20
|
from starlette.middleware.sessions import SessionMiddleware
|
|
22
21
|
from starlette.responses import JSONResponse
|
|
23
22
|
from users.constants import ADMIN_ROLE_ID, USER_ROLE_ID
|
|
24
|
-
from users.middleware import AuthMiddleware
|
|
25
23
|
|
|
26
24
|
SECRET_KEY = "test-secret-key-for-session-middleware"
|
|
27
25
|
|
|
28
26
|
|
|
29
|
-
def _sign_session(data: dict[str, Any], secret: str = SECRET_KEY) -> str:
|
|
30
|
-
"""Encode and sign a session dict exactly as Starlette's SessionMiddleware does."""
|
|
31
|
-
raw = b64encode(json.dumps(data).encode()).decode()
|
|
32
|
-
return TimestampSigner(secret).sign(raw).decode("utf-8")
|
|
33
|
-
|
|
34
|
-
|
|
35
27
|
def _session_cookie(data: dict[str, Any]) -> dict[str, str]:
|
|
36
|
-
return {"session":
|
|
28
|
+
return {"session": forge_session_cookie(SECRET_KEY, data)}
|
|
29
|
+
|
|
37
30
|
|
|
31
|
+
async def _build_app(db_state, inner_handler=None, *, principal_resolvers=None):
|
|
32
|
+
"""Build a minimal ASGI app with AuthMiddleware + SessionMiddleware.
|
|
38
33
|
|
|
39
|
-
|
|
40
|
-
|
|
34
|
+
``principal_resolvers`` (optional) is a list of resolvers seeded onto
|
|
35
|
+
``app.state.auth.principal_resolvers`` before the middleware runs.
|
|
36
|
+
Defaults to an empty registry — matches a production app where no
|
|
37
|
+
downstream module has registered anything.
|
|
38
|
+
"""
|
|
39
|
+
from auth.state import AuthState
|
|
41
40
|
|
|
42
41
|
async def _default_handler(request: Request):
|
|
43
42
|
user = getattr(request.state, "user", None)
|
|
@@ -62,6 +61,12 @@ async def _build_app(db_state, inner_handler=None):
|
|
|
62
61
|
|
|
63
62
|
app = FastAPI()
|
|
64
63
|
app.state.sm = SimpleNamespace(db=db_state)
|
|
64
|
+
from users.provider import UsersAuthProvider
|
|
65
|
+
|
|
66
|
+
app.state.auth = AuthState(
|
|
67
|
+
auth_provider=UsersAuthProvider(),
|
|
68
|
+
principal_resolvers=list(principal_resolvers or []),
|
|
69
|
+
)
|
|
65
70
|
|
|
66
71
|
@app.get("/{path:path}")
|
|
67
72
|
async def _catch_all(request: Request, path: str = ""):
|
|
@@ -46,11 +46,10 @@ class TestAdminList:
|
|
|
46
46
|
@pytest.mark.anyio
|
|
47
47
|
async def test_list_without_auth_is_rejected(self, anon_client):
|
|
48
48
|
resp = await anon_client.get("/api/users/admin", follow_redirects=False)
|
|
49
|
-
# AuthMiddleware
|
|
50
|
-
#
|
|
51
|
-
|
|
52
|
-
assert resp.
|
|
53
|
-
assert resp.headers["location"].endswith("/users/login")
|
|
49
|
+
# AuthMiddleware returns 401 JSON for unauthenticated /api/* paths
|
|
50
|
+
# (view routes still get a 302 redirect to /users/login).
|
|
51
|
+
assert resp.status_code == 401
|
|
52
|
+
assert resp.json() == {"detail": "Not authenticated"}
|
|
54
53
|
|
|
55
54
|
@pytest.mark.anyio
|
|
56
55
|
async def test_list_as_admin_returns_200(self, admin_client, users_db):
|
|
@@ -128,8 +127,8 @@ class TestAdminInvite:
|
|
|
128
127
|
json={"email": "hacker@example.com"},
|
|
129
128
|
follow_redirects=False,
|
|
130
129
|
)
|
|
131
|
-
assert resp.status_code ==
|
|
132
|
-
assert resp.
|
|
130
|
+
assert resp.status_code == 401
|
|
131
|
+
assert resp.json() == {"detail": "Not authenticated"}
|
|
133
132
|
|
|
134
133
|
|
|
135
134
|
# ---------------------------------------------------------------------------
|
|
@@ -205,8 +204,8 @@ class TestAdminSetRoles:
|
|
|
205
204
|
json={"role_names": ["admin"]},
|
|
206
205
|
follow_redirects=False,
|
|
207
206
|
)
|
|
208
|
-
assert resp.status_code ==
|
|
209
|
-
assert resp.
|
|
207
|
+
assert resp.status_code == 401
|
|
208
|
+
assert resp.json() == {"detail": "Not authenticated"}
|
|
210
209
|
|
|
211
210
|
@pytest.mark.anyio
|
|
212
211
|
async def test_set_roles_nonexistent_returns_404(self, admin_client):
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Tests for bearer token endpoints: /api/users/auth/token*.
|
|
2
|
+
|
|
3
|
+
Covers: login via email+password, refresh rotation, revoke, and error paths.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import uuid
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
from fastapi_users.password import PasswordHelper
|
|
12
|
+
from users.models import User
|
|
13
|
+
|
|
14
|
+
_pw = PasswordHelper()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _hash(plain: str) -> str:
|
|
18
|
+
return _pw.hash(plain)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def _seed_user(session, email="api@example.com", password="SecurePass1!"):
|
|
22
|
+
"""Create a verified, active user for token tests."""
|
|
23
|
+
user = User(
|
|
24
|
+
id=uuid.uuid4(),
|
|
25
|
+
email=email,
|
|
26
|
+
hashed_password=_hash(password),
|
|
27
|
+
is_active=True,
|
|
28
|
+
is_superuser=False,
|
|
29
|
+
is_verified=True,
|
|
30
|
+
)
|
|
31
|
+
session.add(user)
|
|
32
|
+
await session.commit()
|
|
33
|
+
await session.refresh(user)
|
|
34
|
+
return user
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
# POST /api/users/auth/token — login
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TestTokenLogin:
|
|
43
|
+
@pytest.mark.anyio
|
|
44
|
+
async def test_invalid_email_returns_401(self, anon_client):
|
|
45
|
+
resp = await anon_client.post(
|
|
46
|
+
"/api/users/auth/token",
|
|
47
|
+
json={"email": "nobody@example.com", "password": "whatever"},
|
|
48
|
+
)
|
|
49
|
+
assert resp.status_code == 401
|
|
50
|
+
assert resp.json()["detail"] == "Invalid credentials"
|
|
51
|
+
|
|
52
|
+
@pytest.mark.anyio
|
|
53
|
+
async def test_wrong_password_returns_401(self, anon_client, users_db):
|
|
54
|
+
await _seed_user(users_db)
|
|
55
|
+
resp = await anon_client.post(
|
|
56
|
+
"/api/users/auth/token",
|
|
57
|
+
json={"email": "api@example.com", "password": "WRONG"},
|
|
58
|
+
)
|
|
59
|
+
assert resp.status_code == 401
|
|
60
|
+
assert resp.json()["detail"] == "Invalid credentials"
|
|
61
|
+
|
|
62
|
+
@pytest.mark.anyio
|
|
63
|
+
async def test_inactive_user_returns_401(self, anon_client, users_db):
|
|
64
|
+
user = await _seed_user(users_db, email="inactive@example.com")
|
|
65
|
+
user.is_active = False
|
|
66
|
+
await users_db.commit()
|
|
67
|
+
|
|
68
|
+
resp = await anon_client.post(
|
|
69
|
+
"/api/users/auth/token",
|
|
70
|
+
json={"email": "inactive@example.com", "password": "SecurePass1!"},
|
|
71
|
+
)
|
|
72
|
+
assert resp.status_code == 401
|
|
73
|
+
|
|
74
|
+
@pytest.mark.anyio
|
|
75
|
+
async def test_valid_credentials_returns_token_pair(self, anon_client, users_db):
|
|
76
|
+
await _seed_user(users_db)
|
|
77
|
+
resp = await anon_client.post(
|
|
78
|
+
"/api/users/auth/token",
|
|
79
|
+
json={"email": "api@example.com", "password": "SecurePass1!"},
|
|
80
|
+
)
|
|
81
|
+
assert resp.status_code == 200
|
|
82
|
+
body = resp.json()
|
|
83
|
+
assert body["token_type"] == "bearer"
|
|
84
|
+
assert body["access_token"]
|
|
85
|
+
assert body["refresh_token"]
|
|
86
|
+
assert body["expires_in"] > 0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
# POST /api/users/auth/token/refresh
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class TestTokenRefresh:
|
|
95
|
+
@pytest.mark.anyio
|
|
96
|
+
async def test_invalid_uuid_returns_401(self, anon_client):
|
|
97
|
+
resp = await anon_client.post(
|
|
98
|
+
"/api/users/auth/token/refresh",
|
|
99
|
+
json={"refresh_token": "not-a-uuid"},
|
|
100
|
+
)
|
|
101
|
+
assert resp.status_code == 401
|
|
102
|
+
assert resp.json()["detail"] == "Invalid refresh token"
|
|
103
|
+
|
|
104
|
+
@pytest.mark.anyio
|
|
105
|
+
async def test_nonexistent_token_returns_401(self, anon_client):
|
|
106
|
+
resp = await anon_client.post(
|
|
107
|
+
"/api/users/auth/token/refresh",
|
|
108
|
+
json={"refresh_token": str(uuid.uuid4())},
|
|
109
|
+
)
|
|
110
|
+
assert resp.status_code == 401
|
|
111
|
+
assert resp.json()["detail"] == "Invalid or expired refresh token"
|
|
112
|
+
|
|
113
|
+
@pytest.mark.anyio
|
|
114
|
+
async def test_valid_refresh_rotates_tokens(self, anon_client, users_db):
|
|
115
|
+
"""Login, then refresh — old refresh revoked, new pair returned."""
|
|
116
|
+
await _seed_user(users_db)
|
|
117
|
+
login = await anon_client.post(
|
|
118
|
+
"/api/users/auth/token",
|
|
119
|
+
json={"email": "api@example.com", "password": "SecurePass1!"},
|
|
120
|
+
)
|
|
121
|
+
assert login.status_code == 200
|
|
122
|
+
old_refresh = login.json()["refresh_token"]
|
|
123
|
+
|
|
124
|
+
refresh_resp = await anon_client.post(
|
|
125
|
+
"/api/users/auth/token/refresh",
|
|
126
|
+
json={"refresh_token": old_refresh},
|
|
127
|
+
)
|
|
128
|
+
assert refresh_resp.status_code == 200
|
|
129
|
+
new_body = refresh_resp.json()
|
|
130
|
+
assert new_body["access_token"]
|
|
131
|
+
assert new_body["refresh_token"] != old_refresh
|
|
132
|
+
|
|
133
|
+
# Old refresh token should now be revoked
|
|
134
|
+
reuse = await anon_client.post(
|
|
135
|
+
"/api/users/auth/token/refresh",
|
|
136
|
+
json={"refresh_token": old_refresh},
|
|
137
|
+
)
|
|
138
|
+
assert reuse.status_code == 401
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
# DELETE /api/users/auth/token — revoke
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class TestTokenRevoke:
|
|
147
|
+
@pytest.mark.anyio
|
|
148
|
+
async def test_invalid_format_returns_400(self, anon_client):
|
|
149
|
+
resp = await anon_client.request(
|
|
150
|
+
"DELETE",
|
|
151
|
+
"/api/users/auth/token",
|
|
152
|
+
json={"refresh_token": "garbage"},
|
|
153
|
+
)
|
|
154
|
+
assert resp.status_code == 400
|
|
155
|
+
assert resp.json()["detail"] == "Invalid token format"
|
|
156
|
+
|
|
157
|
+
@pytest.mark.anyio
|
|
158
|
+
async def test_nonexistent_token_returns_ok(self, anon_client):
|
|
159
|
+
"""Revoking a non-existent token is idempotent — still returns ok."""
|
|
160
|
+
resp = await anon_client.request(
|
|
161
|
+
"DELETE",
|
|
162
|
+
"/api/users/auth/token",
|
|
163
|
+
json={"refresh_token": str(uuid.uuid4())},
|
|
164
|
+
)
|
|
165
|
+
assert resp.status_code == 200
|
|
166
|
+
assert resp.json()["status"] == "ok"
|
|
167
|
+
|
|
168
|
+
@pytest.mark.anyio
|
|
169
|
+
async def test_revoke_makes_refresh_unusable(self, anon_client, users_db):
|
|
170
|
+
"""After revoking, the refresh token can no longer be used."""
|
|
171
|
+
await _seed_user(users_db)
|
|
172
|
+
login = await anon_client.post(
|
|
173
|
+
"/api/users/auth/token",
|
|
174
|
+
json={"email": "api@example.com", "password": "SecurePass1!"},
|
|
175
|
+
)
|
|
176
|
+
rt = login.json()["refresh_token"]
|
|
177
|
+
|
|
178
|
+
# Revoke
|
|
179
|
+
revoke_resp = await anon_client.request(
|
|
180
|
+
"DELETE",
|
|
181
|
+
"/api/users/auth/token",
|
|
182
|
+
json={"refresh_token": rt},
|
|
183
|
+
)
|
|
184
|
+
assert revoke_resp.status_code == 200
|
|
185
|
+
|
|
186
|
+
# Attempt refresh — should fail
|
|
187
|
+
refresh_resp = await anon_client.post(
|
|
188
|
+
"/api/users/auth/token/refresh",
|
|
189
|
+
json={"refresh_token": rt},
|
|
190
|
+
)
|
|
191
|
+
assert refresh_resp.status_code == 401
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""AuthMiddleware tests for the principal-resolver chain.
|
|
2
|
+
|
|
3
|
+
Covers ``app.state.auth.principal_resolvers`` consultation order, error
|
|
4
|
+
isolation, the session-short-circuit precedence, and the /api/* vs view-path
|
|
5
|
+
unauthenticated split (401 JSON vs 302 redirect).
|
|
6
|
+
|
|
7
|
+
Helpers + fixtures live in ``_middleware_support`` alongside the broader
|
|
8
|
+
middleware tests in ``test_users_middleware`` and the PUBLIC_PATHS suite in
|
|
9
|
+
``test_users_middleware_public_paths``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
import pytest
|
|
16
|
+
from _middleware_support import _build_app, _session_cookie
|
|
17
|
+
from fastapi import Request
|
|
18
|
+
from starlette.responses import JSONResponse
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _ctx(uid: str = "11111111-1111-1111-1111-111111111111", **overrides):
|
|
22
|
+
"""Build a UserContext for resolver tests."""
|
|
23
|
+
from auth.contracts.schemas import UserContext
|
|
24
|
+
|
|
25
|
+
fields = {
|
|
26
|
+
"id": uid,
|
|
27
|
+
"email": "pat@example.com",
|
|
28
|
+
"name": "PAT User",
|
|
29
|
+
"roles": ["user"],
|
|
30
|
+
"tenant_id": None,
|
|
31
|
+
}
|
|
32
|
+
fields.update(overrides)
|
|
33
|
+
return UserContext(**fields)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.mark.anyio
|
|
37
|
+
async def test_resolver_returning_context_authenticates_request(db_state):
|
|
38
|
+
"""A registered resolver that returns a UserContext authenticates the request."""
|
|
39
|
+
|
|
40
|
+
async def stub_resolver(request):
|
|
41
|
+
return _ctx()
|
|
42
|
+
|
|
43
|
+
app = await _build_app(db_state, principal_resolvers=[stub_resolver])
|
|
44
|
+
transport = httpx.ASGITransport(app=app)
|
|
45
|
+
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
|
|
46
|
+
resp = await client.get("/dashboard")
|
|
47
|
+
|
|
48
|
+
assert resp.status_code == 200
|
|
49
|
+
data = resp.json()
|
|
50
|
+
assert data["user"]["email"] == "pat@example.com"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.mark.anyio
|
|
54
|
+
async def test_resolver_first_non_none_wins(db_state):
|
|
55
|
+
"""The first resolver returning a context wins; later resolvers are not consulted."""
|
|
56
|
+
second_called = False
|
|
57
|
+
|
|
58
|
+
async def first_none(request):
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
async def second_returns(request):
|
|
62
|
+
nonlocal second_called
|
|
63
|
+
second_called = True
|
|
64
|
+
return _ctx(email="second@example.com")
|
|
65
|
+
|
|
66
|
+
async def third_should_not_run(request):
|
|
67
|
+
raise AssertionError("third resolver should not run after a match")
|
|
68
|
+
|
|
69
|
+
app = await _build_app(
|
|
70
|
+
db_state,
|
|
71
|
+
principal_resolvers=[first_none, second_returns, third_should_not_run],
|
|
72
|
+
)
|
|
73
|
+
transport = httpx.ASGITransport(app=app)
|
|
74
|
+
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
|
|
75
|
+
resp = await client.get("/dashboard")
|
|
76
|
+
|
|
77
|
+
assert resp.status_code == 200
|
|
78
|
+
assert resp.json()["user"]["email"] == "second@example.com"
|
|
79
|
+
assert second_called
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@pytest.mark.anyio
|
|
83
|
+
async def test_all_resolvers_return_none_falls_through_to_redirect(db_state):
|
|
84
|
+
"""When every resolver returns None for a view route → 302 to /users/login."""
|
|
85
|
+
|
|
86
|
+
async def none_resolver(request):
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
app = await _build_app(db_state, principal_resolvers=[none_resolver])
|
|
90
|
+
transport = httpx.ASGITransport(app=app)
|
|
91
|
+
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
|
|
92
|
+
resp = await client.get("/dashboard", follow_redirects=False)
|
|
93
|
+
|
|
94
|
+
assert resp.status_code == 302
|
|
95
|
+
assert resp.headers["location"] == "/users/login"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@pytest.mark.anyio
|
|
99
|
+
async def test_resolver_raising_does_not_crash_middleware(db_state, caplog):
|
|
100
|
+
"""A resolver that raises is logged and the chain continues to the next."""
|
|
101
|
+
import logging
|
|
102
|
+
|
|
103
|
+
async def boom(request):
|
|
104
|
+
raise RuntimeError("resolver kaboom")
|
|
105
|
+
|
|
106
|
+
async def fallback(request):
|
|
107
|
+
return _ctx(email="fallback@example.com")
|
|
108
|
+
|
|
109
|
+
app = await _build_app(db_state, principal_resolvers=[boom, fallback])
|
|
110
|
+
transport = httpx.ASGITransport(app=app)
|
|
111
|
+
with caplog.at_level(logging.ERROR, logger="users.middleware"):
|
|
112
|
+
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
|
|
113
|
+
resp = await client.get("/dashboard")
|
|
114
|
+
|
|
115
|
+
assert resp.status_code == 200
|
|
116
|
+
assert resp.json()["user"]["email"] == "fallback@example.com"
|
|
117
|
+
assert any("resolver" in rec.message.lower() for rec in caplog.records)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@pytest.mark.anyio
|
|
121
|
+
async def test_api_path_unauthenticated_returns_401_json(db_state):
|
|
122
|
+
"""Unauthenticated /api/private should return 401 JSON, not a 302 redirect."""
|
|
123
|
+
app = await _build_app(db_state)
|
|
124
|
+
transport = httpx.ASGITransport(app=app)
|
|
125
|
+
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
|
|
126
|
+
resp = await client.get("/api/private-thing", follow_redirects=False)
|
|
127
|
+
|
|
128
|
+
assert resp.status_code == 401
|
|
129
|
+
assert resp.headers["content-type"].startswith("application/json")
|
|
130
|
+
assert resp.json() == {"detail": "Not authenticated"}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@pytest.mark.anyio
|
|
134
|
+
async def test_view_path_unauthenticated_still_redirects(db_state):
|
|
135
|
+
"""View routes (non-/api/*) keep the existing 302-to-login behavior."""
|
|
136
|
+
app = await _build_app(db_state)
|
|
137
|
+
transport = httpx.ASGITransport(app=app)
|
|
138
|
+
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
|
|
139
|
+
resp = await client.get("/dashboard", follow_redirects=False)
|
|
140
|
+
|
|
141
|
+
assert resp.status_code == 302
|
|
142
|
+
assert resp.headers["location"] == "/users/login"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@pytest.mark.anyio
|
|
146
|
+
async def test_session_wins_over_resolver(db_state, mw_active_user):
|
|
147
|
+
"""A valid session cookie short-circuits — the resolver chain is not consulted."""
|
|
148
|
+
resolver_called = False
|
|
149
|
+
|
|
150
|
+
async def should_not_run(request):
|
|
151
|
+
nonlocal resolver_called
|
|
152
|
+
resolver_called = True
|
|
153
|
+
return _ctx(email="should-not-win@example.com")
|
|
154
|
+
|
|
155
|
+
app = await _build_app(db_state, principal_resolvers=[should_not_run])
|
|
156
|
+
transport = httpx.ASGITransport(app=app)
|
|
157
|
+
cookies = _session_cookie({"user_id": str(mw_active_user.id)})
|
|
158
|
+
async with httpx.AsyncClient(
|
|
159
|
+
transport=transport, base_url="http://testserver", cookies=cookies
|
|
160
|
+
) as client:
|
|
161
|
+
resp = await client.get("/dashboard")
|
|
162
|
+
|
|
163
|
+
assert resp.status_code == 200
|
|
164
|
+
assert resp.json()["user"]["email"] == "middleware-test@example.com"
|
|
165
|
+
assert resolver_called is False
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@pytest.mark.anyio
|
|
169
|
+
async def test_resolver_does_not_write_session(db_state):
|
|
170
|
+
"""Resolver-authenticated requests must not persist anything to the session."""
|
|
171
|
+
captured = {}
|
|
172
|
+
|
|
173
|
+
async def capture(request: Request):
|
|
174
|
+
captured["session"] = dict(request.session)
|
|
175
|
+
user = getattr(request.state, "user", None)
|
|
176
|
+
return JSONResponse({"authenticated": user is not None})
|
|
177
|
+
|
|
178
|
+
async def stub_resolver(request):
|
|
179
|
+
return _ctx()
|
|
180
|
+
|
|
181
|
+
app = await _build_app(db_state, capture, principal_resolvers=[stub_resolver])
|
|
182
|
+
transport = httpx.ASGITransport(app=app)
|
|
183
|
+
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
|
|
184
|
+
resp = await client.get("/dashboard")
|
|
185
|
+
|
|
186
|
+
assert resp.status_code == 200
|
|
187
|
+
assert resp.json() == {"authenticated": True}
|
|
188
|
+
# Session must not contain a user_id, user_ctx, or anything resolver-added.
|
|
189
|
+
assert "user_id" not in captured["session"]
|
|
190
|
+
assert "user_ctx" not in captured["session"]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Tests for UsersAuthProvider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from auth.contracts.provider import AuthProvider
|
|
6
|
+
from users.provider import UsersAuthProvider
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_users_provider_satisfies_protocol():
|
|
10
|
+
provider = UsersAuthProvider()
|
|
11
|
+
assert isinstance(provider, AuthProvider)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_login_url():
|
|
15
|
+
provider = UsersAuthProvider()
|
|
16
|
+
assert provider.get_login_url(None) == "/users/login"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_logout_url():
|
|
20
|
+
provider = UsersAuthProvider()
|
|
21
|
+
assert provider.get_logout_url(None) == "/users/logout"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_public_paths():
|
|
25
|
+
provider = UsersAuthProvider()
|
|
26
|
+
prefixes, _exact = provider.get_public_paths()
|
|
27
|
+
assert "/users/login" in prefixes
|
|
28
|
+
assert "/api/users/auth/" in prefixes
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_is_bearer_request_true():
|
|
32
|
+
from unittest.mock import MagicMock
|
|
33
|
+
|
|
34
|
+
request = MagicMock()
|
|
35
|
+
request.headers = {"authorization": "Bearer abc123"}
|
|
36
|
+
provider = UsersAuthProvider()
|
|
37
|
+
assert provider.is_bearer_request(request) is True
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_is_bearer_request_false():
|
|
41
|
+
from unittest.mock import MagicMock
|
|
42
|
+
|
|
43
|
+
request = MagicMock()
|
|
44
|
+
request.headers = {}
|
|
45
|
+
provider = UsersAuthProvider()
|
|
46
|
+
assert provider.is_bearer_request(request) is False
|
|
@@ -112,6 +112,15 @@ class TestLoginPage:
|
|
|
112
112
|
{"label": "User", "email": "user@example.com", "password": "UserPass1!"},
|
|
113
113
|
]
|
|
114
114
|
|
|
115
|
+
@pytest.mark.anyio
|
|
116
|
+
async def test_login_redirect_url_is_dashboard_when_installed(self, anon_client):
|
|
117
|
+
"""Regression for #173: with Dashboard installed, prop stays /dashboard/."""
|
|
118
|
+
resp = await anon_client.get(
|
|
119
|
+
"/users/login",
|
|
120
|
+
headers={"X-Inertia": "true", "X-Inertia-Version": "1.0"},
|
|
121
|
+
)
|
|
122
|
+
assert resp.json()["props"]["login_redirect_url"] == "/dashboard/"
|
|
123
|
+
|
|
115
124
|
|
|
116
125
|
class TestRegisterPage:
|
|
117
126
|
@pytest.mark.anyio
|
|
@@ -241,3 +250,28 @@ async def test_roles_payload_returns_id_name_dicts(users_app):
|
|
|
241
250
|
names = [item["name"] for item in payload]
|
|
242
251
|
assert "admin" in names
|
|
243
252
|
assert "user" in names
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@pytest.mark.anyio
|
|
256
|
+
async def test_login_redirect_fallback_without_dashboard(users_app):
|
|
257
|
+
"""Regression for #173: without Dashboard, redirect falls back to
|
|
258
|
+
the first sibling module view_prefix, not ``/`` (which may 404)."""
|
|
259
|
+
sm = users_app.state.sm
|
|
260
|
+
original = sm.modules
|
|
261
|
+
no_dash = tuple(m for m in original if m.meta.name != "Dashboard")
|
|
262
|
+
assert len(no_dash) < len(original), "test setup: Dashboard should exist"
|
|
263
|
+
|
|
264
|
+
settings = users_app.state.users.settings
|
|
265
|
+
settings.login_redirect_url = "/dashboard/"
|
|
266
|
+
object.__setattr__(sm, "modules", no_dash)
|
|
267
|
+
try:
|
|
268
|
+
from users.module import UsersModule
|
|
269
|
+
|
|
270
|
+
mod = UsersModule()
|
|
271
|
+
await mod.on_startup(users_app)
|
|
272
|
+
url = settings.login_redirect_url
|
|
273
|
+
assert url != "/", "must not fall back to / (may 404)"
|
|
274
|
+
assert url.startswith("/"), "must be an absolute path"
|
|
275
|
+
assert url.endswith("/"), "must have trailing slash"
|
|
276
|
+
finally:
|
|
277
|
+
object.__setattr__(sm, "modules", original)
|
|
@@ -117,12 +117,22 @@ async def login(
|
|
|
117
117
|
# ── Mount fastapi-users stock routers ────────────────────────────────────────
|
|
118
118
|
|
|
119
119
|
# The stock auth router (login + logout) is mounted at /auth-inner so its
|
|
120
|
-
#
|
|
121
|
-
#
|
|
120
|
+
# endpoints remain accessible. Our wrappers at /auth/login and /auth/logout
|
|
121
|
+
# shadow the stock endpoints to also manage the session cookie.
|
|
122
122
|
auth_inner = fastapi_users.get_auth_router(auth_backend, requires_verification=True)
|
|
123
123
|
router.include_router(auth_inner, prefix="/auth-inner")
|
|
124
124
|
|
|
125
125
|
|
|
126
|
+
@router.post("/auth/logout", status_code=204)
|
|
127
|
+
async def api_logout(request: Request):
|
|
128
|
+
"""API logout — clears both the access-token cookie and the session."""
|
|
129
|
+
request.session.clear()
|
|
130
|
+
cookie_name = request.app.state.users.settings.cookie_name
|
|
131
|
+
response = Response(status_code=204)
|
|
132
|
+
response.delete_cookie(cookie_name, path="/")
|
|
133
|
+
return response
|
|
134
|
+
|
|
135
|
+
|
|
126
136
|
# ── Accept-invite (verify + set password + login, one shot) ─────────────────
|
|
127
137
|
|
|
128
138
|
|