simple-module-users 0.0.16__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.16 → simple_module_users-0.0.17}/PKG-INFO +6 -6
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/pyproject.toml +6 -6
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/_middleware_support.py +4 -1
- simple_module_users-0.0.17/tests/test_token_api.py +191 -0
- simple_module_users-0.0.17/tests/test_users_provider.py +46 -0
- {simple_module_users-0.0.16 → 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.16 → 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.16 → simple_module_users-0.0.17}/users/module.py +5 -15
- simple_module_users-0.0.17/users/provider.py +130 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/settings.py +4 -0
- simple_module_users-0.0.16/users/middleware.py +0 -168
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/.gitignore +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/LICENSE +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/README.md +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/package.json +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/.gitkeep +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/conftest.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_access_token_model.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_api_admin.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_api_admin_filters.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_api_auth.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_backend.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_bootstrap.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_bootstrap_resolution.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_cli.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_constants.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_db_adapter.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_invite_flow.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_invite_reuse.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_mailer.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_negative_authz.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_oauth.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_rate_limit.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_role_model.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_service_admin.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_settings.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_user_manager.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_user_model.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_user_role_model.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_user_service.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_users_deps.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_users_middleware.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_users_middleware_public_paths.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_users_middleware_resolvers.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_views.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tests/test_views_admin.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/tsconfig.json +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/__init__.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/admin/__init__.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/admin/api.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/admin/components/IndexFilters.tsx +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/admin/components/RolesTab.tsx +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/admin/components/UserRow.tsx +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/admin/service.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/admin/views.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/auth_local/__init__.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/auth_local/rate_limit.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/auth_local/views.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/backend.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/bootstrap.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/cli.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/constants.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/contracts/__init__.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/contracts/events.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/contracts/schemas.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/db_adapter.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/deps.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/exceptions.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/mailer/__init__.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/mailer/console.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/mailer/smtp.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/mailer/templates/.gitkeep +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/mailer/templates/invite.txt +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/mailer/templates/reset_password.txt +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/mailer/templates/verify_email.txt +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/manager.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/models/_base.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/models/access_token.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/models/oauth_account.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/models/role.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/models/user.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/models/user_role.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/oauth/__init__.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/oauth/api.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/oauth/providers.py +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/pages/.gitkeep +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/pages/AcceptInvite.tsx +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/pages/ForgotPassword.tsx +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/pages/Login.tsx +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/pages/Profile.tsx +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/pages/Register.tsx +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/pages/ResetPassword.tsx +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/pages/Users/Edit.tsx +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/pages/Users/Index.tsx +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/pages/Users/Invite.tsx +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/pages/Users/components/AccountStatusCard.tsx +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/pages/VerifyEmail.tsx +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/py.typed +0 -0
- {simple_module_users-0.0.16 → simple_module_users-0.0.17}/users/roles_cache.py +0 -0
- {simple_module_users-0.0.16 → 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.
|
|
@@ -14,12 +14,12 @@ from types import SimpleNamespace
|
|
|
14
14
|
from typing import Any
|
|
15
15
|
|
|
16
16
|
import pytest
|
|
17
|
+
from auth.middleware import AuthMiddleware
|
|
17
18
|
from fastapi import FastAPI, Request
|
|
18
19
|
from simple_module_test import forge_session_cookie
|
|
19
20
|
from starlette.middleware.sessions import SessionMiddleware
|
|
20
21
|
from starlette.responses import JSONResponse
|
|
21
22
|
from users.constants import ADMIN_ROLE_ID, USER_ROLE_ID
|
|
22
|
-
from users.middleware import AuthMiddleware
|
|
23
23
|
|
|
24
24
|
SECRET_KEY = "test-secret-key-for-session-middleware"
|
|
25
25
|
|
|
@@ -61,7 +61,10 @@ async def _build_app(db_state, inner_handler=None, *, principal_resolvers=None):
|
|
|
61
61
|
|
|
62
62
|
app = FastAPI()
|
|
63
63
|
app.state.sm = SimpleNamespace(db=db_state)
|
|
64
|
+
from users.provider import UsersAuthProvider
|
|
65
|
+
|
|
64
66
|
app.state.auth = AuthState(
|
|
67
|
+
auth_provider=UsersAuthProvider(),
|
|
65
68
|
principal_resolvers=list(principal_resolvers or []),
|
|
66
69
|
)
|
|
67
70
|
|
|
@@ -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,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
|
|
@@ -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
|
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Bearer token endpoints for mobile/API clients.
|
|
2
|
+
|
|
3
|
+
Provides email+password → access_token + refresh_token, refresh, and revoke
|
|
4
|
+
flows for clients that cannot use browser cookies (mobile apps, CLI tools,
|
|
5
|
+
third-party API consumers).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import uuid as uuid_mod
|
|
11
|
+
from datetime import UTC, datetime, timedelta
|
|
12
|
+
|
|
13
|
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
14
|
+
from simple_module_db.deps import get_db
|
|
15
|
+
from sqlalchemy import select
|
|
16
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
17
|
+
from sqlmodel import SQLModel
|
|
18
|
+
|
|
19
|
+
from users.models import User
|
|
20
|
+
from users.models.refresh_token import RefreshToken
|
|
21
|
+
|
|
22
|
+
# Pre-computed bcrypt hash used when a login attempt targets a non-existent
|
|
23
|
+
# user. Running verify against this takes the same time as a real check,
|
|
24
|
+
# preventing timing-based email enumeration.
|
|
25
|
+
_DUMMY_HASH = "$2b$12$LJ3m4ys3Lg/PFgWCZxEzR.ZVxFMz3yeqHEhSYmiJ9gJOPG7W3Cq2G"
|
|
26
|
+
|
|
27
|
+
router = APIRouter(prefix="/auth", tags=["users-token"])
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TokenRequest(SQLModel):
|
|
31
|
+
"""Email + password login for bearer-token auth."""
|
|
32
|
+
|
|
33
|
+
email: str
|
|
34
|
+
password: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TokenResponse(SQLModel):
|
|
38
|
+
"""Access + refresh token pair returned on successful auth."""
|
|
39
|
+
|
|
40
|
+
access_token: str
|
|
41
|
+
refresh_token: str
|
|
42
|
+
token_type: str = "bearer"
|
|
43
|
+
expires_in: int
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RefreshRequest(SQLModel):
|
|
47
|
+
"""Body for refresh and revoke endpoints."""
|
|
48
|
+
|
|
49
|
+
refresh_token: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@router.post("/token", response_model=TokenResponse)
|
|
53
|
+
async def token_login(
|
|
54
|
+
body: TokenRequest,
|
|
55
|
+
request: Request,
|
|
56
|
+
db: AsyncSession = Depends(get_db),
|
|
57
|
+
):
|
|
58
|
+
"""Exchange email + password for an access/refresh token pair."""
|
|
59
|
+
from fastapi_users.password import PasswordHelper
|
|
60
|
+
|
|
61
|
+
helper = PasswordHelper()
|
|
62
|
+
|
|
63
|
+
stmt = select(User).where(User.email == body.email)
|
|
64
|
+
user = (await db.execute(stmt)).scalar_one_or_none()
|
|
65
|
+
if user is None or not user.is_active or user.disabled_at is not None:
|
|
66
|
+
# Constant-time: run bcrypt on a dummy hash to prevent timing-based
|
|
67
|
+
# email enumeration (existing user + wrong password takes ~50ms for
|
|
68
|
+
# bcrypt; missing user would be instant without this).
|
|
69
|
+
helper.verify_and_update(body.password, _DUMMY_HASH)
|
|
70
|
+
raise HTTPException(status_code=401, detail="Invalid credentials")
|
|
71
|
+
|
|
72
|
+
verified, _ = helper.verify_and_update(body.password, user.hashed_password)
|
|
73
|
+
if not verified:
|
|
74
|
+
raise HTTPException(status_code=401, detail="Invalid credentials")
|
|
75
|
+
|
|
76
|
+
settings = request.app.state.users.settings
|
|
77
|
+
return await _create_token_pair(db, user.id, settings)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@router.post("/token/refresh", response_model=TokenResponse)
|
|
81
|
+
async def token_refresh(
|
|
82
|
+
body: RefreshRequest,
|
|
83
|
+
request: Request,
|
|
84
|
+
db: AsyncSession = Depends(get_db),
|
|
85
|
+
):
|
|
86
|
+
"""Rotate a refresh token into a new access/refresh pair."""
|
|
87
|
+
try:
|
|
88
|
+
token_uuid = uuid_mod.UUID(body.refresh_token)
|
|
89
|
+
except (ValueError, TypeError):
|
|
90
|
+
raise HTTPException(status_code=401, detail="Invalid refresh token") from None
|
|
91
|
+
|
|
92
|
+
now = datetime.now(UTC)
|
|
93
|
+
stmt = select(RefreshToken).where(
|
|
94
|
+
RefreshToken.token == token_uuid,
|
|
95
|
+
RefreshToken.revoked_at.is_(None), # type: ignore[union-attr]
|
|
96
|
+
RefreshToken.expires_at > now,
|
|
97
|
+
)
|
|
98
|
+
rt = (await db.execute(stmt)).scalar_one_or_none()
|
|
99
|
+
if rt is None:
|
|
100
|
+
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
|
|
101
|
+
|
|
102
|
+
rt.revoked_at = now
|
|
103
|
+
await db.flush()
|
|
104
|
+
|
|
105
|
+
settings = request.app.state.users.settings
|
|
106
|
+
return await _create_token_pair(db, rt.user_id, settings)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@router.delete("/token")
|
|
110
|
+
async def token_revoke(
|
|
111
|
+
body: RefreshRequest,
|
|
112
|
+
db: AsyncSession = Depends(get_db),
|
|
113
|
+
):
|
|
114
|
+
"""Revoke a refresh token (idempotent)."""
|
|
115
|
+
try:
|
|
116
|
+
token_uuid = uuid_mod.UUID(body.refresh_token)
|
|
117
|
+
except (ValueError, TypeError):
|
|
118
|
+
raise HTTPException(status_code=400, detail="Invalid token format") from None
|
|
119
|
+
|
|
120
|
+
stmt = select(RefreshToken).where(RefreshToken.token == token_uuid)
|
|
121
|
+
rt = (await db.execute(stmt)).scalar_one_or_none()
|
|
122
|
+
if rt and rt.revoked_at is None:
|
|
123
|
+
rt.revoked_at = datetime.now(UTC)
|
|
124
|
+
await db.flush()
|
|
125
|
+
return {"status": "ok"}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
async def _create_token_pair(
|
|
129
|
+
db: AsyncSession,
|
|
130
|
+
user_id: uuid_mod.UUID,
|
|
131
|
+
settings,
|
|
132
|
+
) -> TokenResponse:
|
|
133
|
+
"""Mint a new access token + refresh token pair and persist both."""
|
|
134
|
+
from users.models import UserAccessToken
|
|
135
|
+
|
|
136
|
+
now = datetime.now(UTC)
|
|
137
|
+
|
|
138
|
+
access_token = UserAccessToken(
|
|
139
|
+
token=str(uuid_mod.uuid4()),
|
|
140
|
+
user_id=user_id,
|
|
141
|
+
created_at=now,
|
|
142
|
+
)
|
|
143
|
+
db.add(access_token)
|
|
144
|
+
|
|
145
|
+
refresh = RefreshToken(
|
|
146
|
+
token=uuid_mod.uuid4(),
|
|
147
|
+
user_id=user_id,
|
|
148
|
+
created_at=now,
|
|
149
|
+
expires_at=now + timedelta(seconds=settings.refresh_token_lifetime_seconds),
|
|
150
|
+
)
|
|
151
|
+
db.add(refresh)
|
|
152
|
+
await db.flush()
|
|
153
|
+
|
|
154
|
+
return TokenResponse(
|
|
155
|
+
access_token=access_token.token,
|
|
156
|
+
refresh_token=str(refresh.token),
|
|
157
|
+
token_type="bearer",
|
|
158
|
+
expires_in=settings.bearer_token_lifetime_seconds,
|
|
159
|
+
)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Backwards-compatibility re-export.
|
|
2
|
+
|
|
3
|
+
The canonical AuthMiddleware now lives in ``auth.middleware``. This shim
|
|
4
|
+
exists only to avoid breaking imports in downstream apps that referenced
|
|
5
|
+
``users.middleware.AuthMiddleware`` directly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from auth.middleware import AuthMiddleware
|
|
9
|
+
|
|
10
|
+
__all__ = ["AuthMiddleware"]
|
|
@@ -10,6 +10,7 @@ from fastapi_users_db_sqlalchemy.access_token import SQLAlchemyAccessTokenDataba
|
|
|
10
10
|
from users.models._base import Base
|
|
11
11
|
from users.models.access_token import UserAccessToken
|
|
12
12
|
from users.models.oauth_account import OAuthAccount
|
|
13
|
+
from users.models.refresh_token import RefreshToken
|
|
13
14
|
from users.models.role import Role
|
|
14
15
|
from users.models.user import User
|
|
15
16
|
from users.models.user_role import UserRole
|
|
@@ -17,6 +18,7 @@ from users.models.user_role import UserRole
|
|
|
17
18
|
__all__ = [
|
|
18
19
|
"Base",
|
|
19
20
|
"OAuthAccount",
|
|
21
|
+
"RefreshToken",
|
|
20
22
|
"Role",
|
|
21
23
|
"SQLAlchemyAccessTokenDatabase",
|
|
22
24
|
"SQLAlchemyUserDatabase",
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Refresh token for mobile/API bearer auth."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid as uuid_mod
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
|
|
8
|
+
from sqlmodel import Field
|
|
9
|
+
|
|
10
|
+
from users.models._base import Base
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RefreshToken(Base, table=True): # ty: ignore[unsupported-base]
|
|
14
|
+
"""Opaque refresh token exchanged for a new access + refresh pair."""
|
|
15
|
+
|
|
16
|
+
__tablename__ = "users_refresh_token"
|
|
17
|
+
|
|
18
|
+
token: uuid_mod.UUID = Field(default_factory=uuid_mod.uuid4, primary_key=True)
|
|
19
|
+
user_id: uuid_mod.UUID = Field(foreign_key="users_user.id", index=True)
|
|
20
|
+
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
21
|
+
expires_at: datetime
|
|
22
|
+
revoked_at: datetime | None = None
|
|
@@ -39,12 +39,11 @@ class UsersModule(ModuleBase):
|
|
|
39
39
|
view_prefix="/users",
|
|
40
40
|
depends_on=[_MODULE_DEPENDENCY_AUTH],
|
|
41
41
|
)
|
|
42
|
+
_is_auth_provider = True
|
|
42
43
|
|
|
43
44
|
def register_settings(self, app: FastAPI) -> None:
|
|
44
45
|
import importlib
|
|
45
46
|
|
|
46
|
-
from auth.contracts.schemas import UserContext
|
|
47
|
-
|
|
48
47
|
from users.settings import UsersSettings
|
|
49
48
|
from users.state import UsersState
|
|
50
49
|
|
|
@@ -58,15 +57,9 @@ class UsersModule(ModuleBase):
|
|
|
58
57
|
|
|
59
58
|
register_module_settings(app, "users", UsersSettings, lambda s: UsersState(settings=s))
|
|
60
59
|
|
|
61
|
-
|
|
62
|
-
return {
|
|
63
|
-
"id": user.id,
|
|
64
|
-
"name": user.name,
|
|
65
|
-
"email": user.email,
|
|
66
|
-
"roles": user.roles,
|
|
67
|
-
}
|
|
60
|
+
from users.provider import UsersAuthProvider
|
|
68
61
|
|
|
69
|
-
app.state.
|
|
62
|
+
app.state.auth.auth_provider = UsersAuthProvider()
|
|
70
63
|
|
|
71
64
|
def register_permissions(self, registry: PermissionRegistry) -> None:
|
|
72
65
|
registry.add_group(
|
|
@@ -113,6 +106,7 @@ class UsersModule(ModuleBase):
|
|
|
113
106
|
from users.admin.api import admin_router
|
|
114
107
|
from users.admin.views import router as admin_views
|
|
115
108
|
from users.auth_local import api as auth_local_api
|
|
109
|
+
from users.auth_local.token_api import router as token_router
|
|
116
110
|
from users.auth_local.views import router as auth_views
|
|
117
111
|
from users.contracts.schemas import UserCreate, UserRead
|
|
118
112
|
from users.deps import fastapi_users
|
|
@@ -127,6 +121,7 @@ class UsersModule(ModuleBase):
|
|
|
127
121
|
settings = UsersSettings()
|
|
128
122
|
|
|
129
123
|
api_router.include_router(auth_local_api.router)
|
|
124
|
+
api_router.include_router(token_router)
|
|
130
125
|
api_router.include_router(admin_router)
|
|
131
126
|
# Throughput-wrap the stock fastapi-users routers; ``require_signup_enabled``
|
|
132
127
|
# gates /register at request time so ``allow_signup`` is hot-reloadable.
|
|
@@ -156,11 +151,6 @@ class UsersModule(ModuleBase):
|
|
|
156
151
|
view_router.include_router(auth_views)
|
|
157
152
|
view_router.include_router(admin_views)
|
|
158
153
|
|
|
159
|
-
def register_middleware(self, app: FastAPI) -> None:
|
|
160
|
-
from users.middleware import AuthMiddleware
|
|
161
|
-
|
|
162
|
-
app.add_middleware(AuthMiddleware)
|
|
163
|
-
|
|
164
154
|
async def on_startup(self, app: FastAPI) -> None:
|
|
165
155
|
"""Build the mailer, rate limiter, and apply production cookie params."""
|
|
166
156
|
import asyncio
|