simple-module-users 0.0.1__py3-none-any.whl
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.1.dist-info/METADATA +88 -0
- simple_module_users-0.0.1.dist-info/RECORD +56 -0
- simple_module_users-0.0.1.dist-info/WHEEL +4 -0
- simple_module_users-0.0.1.dist-info/entry_points.txt +5 -0
- simple_module_users-0.0.1.dist-info/licenses/LICENSE +21 -0
- users/__init__.py +0 -0
- users/backend.py +85 -0
- users/bootstrap.py +246 -0
- users/cli.py +75 -0
- users/components/IndexFilters.tsx +72 -0
- users/components/RolesTab.tsx +72 -0
- users/constants.py +42 -0
- users/contracts/__init__.py +0 -0
- users/contracts/events.py +32 -0
- users/contracts/schemas.py +85 -0
- users/db_adapter.py +48 -0
- users/deps.py +83 -0
- users/endpoints/__init__.py +1 -0
- users/endpoints/api.py +227 -0
- users/endpoints/api_admin.py +167 -0
- users/endpoints/views.py +220 -0
- users/exceptions.py +18 -0
- users/mailer/__init__.py +33 -0
- users/mailer/console.py +27 -0
- users/mailer/smtp.py +77 -0
- users/mailer/templates/.gitkeep +0 -0
- users/mailer/templates/invite.txt +1 -0
- users/mailer/templates/reset_password.txt +1 -0
- users/mailer/templates/verify_email.txt +1 -0
- users/manager.py +146 -0
- users/middleware.py +143 -0
- users/models/__init__.py +24 -0
- users/models/_base.py +9 -0
- users/models/access_token.py +33 -0
- users/models/role.py +34 -0
- users/models/user.py +67 -0
- users/models/user_role.py +39 -0
- users/module.py +155 -0
- users/package.json +16 -0
- users/pages/.gitkeep +0 -0
- users/pages/AcceptInvite.tsx +106 -0
- users/pages/ForgotPassword.tsx +90 -0
- users/pages/Login.tsx +181 -0
- users/pages/Profile.tsx +112 -0
- users/pages/Register.tsx +152 -0
- users/pages/ResetPassword.tsx +112 -0
- users/pages/Users/Edit.tsx +293 -0
- users/pages/Users/Index.tsx +296 -0
- users/pages/Users/Invite.tsx +135 -0
- users/pages/VerifyEmail.tsx +110 -0
- users/py.typed +0 -0
- users/rate_limit.py +59 -0
- users/roles_cache.py +58 -0
- users/service.py +257 -0
- users/settings.py +99 -0
- users/state.py +33 -0
users/service.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""UserService — admin operations delegating to the DB and UserManager."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import secrets
|
|
6
|
+
import uuid
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
|
|
9
|
+
from sqlalchemy import delete, func, or_, select
|
|
10
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
11
|
+
from sqlalchemy.orm import selectinload
|
|
12
|
+
|
|
13
|
+
from users.contracts.schemas import RoleListItem, UserCreate, UserListItem
|
|
14
|
+
from users.exceptions import UserNotFoundError
|
|
15
|
+
from users.manager import UserManager
|
|
16
|
+
from users.models import Role, User, UserRole
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class UserService:
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
db: AsyncSession,
|
|
23
|
+
user_manager: UserManager,
|
|
24
|
+
) -> None:
|
|
25
|
+
self._db = db
|
|
26
|
+
self._manager = user_manager
|
|
27
|
+
|
|
28
|
+
# ── Helpers ─────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
async def _resolve_roles(self, role_names: list[str]) -> list[Role]:
|
|
31
|
+
"""Return Role ORM objects matching the given names."""
|
|
32
|
+
if not role_names:
|
|
33
|
+
return []
|
|
34
|
+
result = await self._db.execute(select(Role).where(Role.name.in_(role_names)))
|
|
35
|
+
return list(result.scalars().all())
|
|
36
|
+
|
|
37
|
+
def to_list_item(self, user: User) -> UserListItem:
|
|
38
|
+
"""Build the DTO from a User with roles already eager-loaded."""
|
|
39
|
+
return UserListItem(
|
|
40
|
+
id=user.id,
|
|
41
|
+
email=user.email,
|
|
42
|
+
full_name=user.full_name,
|
|
43
|
+
is_active=user.is_active,
|
|
44
|
+
is_verified=user.is_verified,
|
|
45
|
+
disabled_at=user.disabled_at,
|
|
46
|
+
last_login_at=user.last_login_at,
|
|
47
|
+
created_at=user.created_at,
|
|
48
|
+
roles=[r.name for r in user.roles],
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
async def list_roles(self) -> list[RoleListItem]:
|
|
52
|
+
stmt = (
|
|
53
|
+
select(Role, func.count(UserRole.user_id))
|
|
54
|
+
.outerjoin(UserRole, UserRole.role_id == Role.id)
|
|
55
|
+
.group_by(Role.id)
|
|
56
|
+
.order_by(Role.name)
|
|
57
|
+
)
|
|
58
|
+
result = await self._db.execute(stmt)
|
|
59
|
+
return [
|
|
60
|
+
RoleListItem(
|
|
61
|
+
id=role.id,
|
|
62
|
+
name=role.name,
|
|
63
|
+
description=role.description,
|
|
64
|
+
user_count=user_count,
|
|
65
|
+
)
|
|
66
|
+
for role, user_count in result.all()
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
async def _get_user_with_roles(self, user_id: uuid.UUID) -> User | None:
|
|
70
|
+
result = await self._db.execute(
|
|
71
|
+
select(User).where(User.id == user_id).options(selectinload(User.roles))
|
|
72
|
+
)
|
|
73
|
+
return result.scalar_one_or_none()
|
|
74
|
+
|
|
75
|
+
async def _require_user(self, user_id: uuid.UUID) -> User:
|
|
76
|
+
"""Fetch a user with roles eager-loaded, or raise UserNotFoundError."""
|
|
77
|
+
user = await self._get_user_with_roles(user_id)
|
|
78
|
+
if user is None:
|
|
79
|
+
raise UserNotFoundError(user_id)
|
|
80
|
+
return user
|
|
81
|
+
|
|
82
|
+
# ── Public API ───────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
async def list_users(
|
|
85
|
+
self,
|
|
86
|
+
*,
|
|
87
|
+
page: int = 1,
|
|
88
|
+
per_page: int = 20,
|
|
89
|
+
search: str | None = None,
|
|
90
|
+
status: str | None = None,
|
|
91
|
+
role_name: str | None = None,
|
|
92
|
+
verified: str | None = None,
|
|
93
|
+
sort: str = "email",
|
|
94
|
+
order: str = "asc",
|
|
95
|
+
) -> tuple[list[UserListItem], int]:
|
|
96
|
+
"""Returns (items, total_count). last_login_at sort always uses NULLS LAST."""
|
|
97
|
+
stmt = select(User).options(selectinload(User.roles))
|
|
98
|
+
count_stmt = select(func.count()).select_from(User)
|
|
99
|
+
|
|
100
|
+
conditions = []
|
|
101
|
+
|
|
102
|
+
if search:
|
|
103
|
+
pattern = f"%{search}%"
|
|
104
|
+
conditions.append(
|
|
105
|
+
or_(
|
|
106
|
+
User.email.ilike(pattern),
|
|
107
|
+
User.full_name.ilike(pattern),
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if status == "active":
|
|
112
|
+
conditions.append(User.is_active.is_(True))
|
|
113
|
+
elif status == "disabled":
|
|
114
|
+
conditions.append(User.is_active.is_(False))
|
|
115
|
+
|
|
116
|
+
if verified == "yes":
|
|
117
|
+
conditions.append(User.is_verified.is_(True))
|
|
118
|
+
elif verified == "no":
|
|
119
|
+
conditions.append(User.is_verified.is_(False))
|
|
120
|
+
|
|
121
|
+
if role_name is not None:
|
|
122
|
+
subq = (
|
|
123
|
+
select(UserRole.user_id)
|
|
124
|
+
.join(Role, Role.id == UserRole.role_id)
|
|
125
|
+
.where(Role.name == role_name)
|
|
126
|
+
)
|
|
127
|
+
conditions.append(User.id.in_(subq))
|
|
128
|
+
|
|
129
|
+
for cond in conditions:
|
|
130
|
+
stmt = stmt.where(cond)
|
|
131
|
+
count_stmt = count_stmt.where(cond)
|
|
132
|
+
|
|
133
|
+
total = (await self._db.execute(count_stmt)).scalar_one()
|
|
134
|
+
|
|
135
|
+
sort_col_map = {
|
|
136
|
+
"email": User.email,
|
|
137
|
+
"last_login_at": User.last_login_at,
|
|
138
|
+
"created_at": User.created_at,
|
|
139
|
+
}
|
|
140
|
+
sort_col = sort_col_map.get(sort, User.email)
|
|
141
|
+
|
|
142
|
+
if sort == "last_login_at":
|
|
143
|
+
order_clause = (
|
|
144
|
+
sort_col.desc().nulls_last() # type: ignore[union-attr]
|
|
145
|
+
if order == "desc"
|
|
146
|
+
else sort_col.asc().nulls_last() # type: ignore[union-attr]
|
|
147
|
+
)
|
|
148
|
+
else:
|
|
149
|
+
order_clause = (
|
|
150
|
+
sort_col.desc() if order == "desc" else sort_col.asc() # type: ignore[union-attr]
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
stmt = stmt.order_by(order_clause).offset((page - 1) * per_page).limit(per_page)
|
|
154
|
+
rows = (await self._db.execute(stmt)).scalars().all()
|
|
155
|
+
|
|
156
|
+
items = [self.to_list_item(u) for u in rows]
|
|
157
|
+
return items, total
|
|
158
|
+
|
|
159
|
+
async def invite(
|
|
160
|
+
self,
|
|
161
|
+
email: str,
|
|
162
|
+
full_name: str | None,
|
|
163
|
+
role_names: list[str],
|
|
164
|
+
*,
|
|
165
|
+
invited_by: User | None = None,
|
|
166
|
+
) -> tuple[User, str]:
|
|
167
|
+
"""Creates unverified user + random unusable password, assigns roles,
|
|
168
|
+
mints a verification token. Returns (user, token)."""
|
|
169
|
+
password = secrets.token_urlsafe(32)
|
|
170
|
+
user_create = UserCreate(
|
|
171
|
+
email=email,
|
|
172
|
+
password=password,
|
|
173
|
+
full_name=full_name,
|
|
174
|
+
is_active=True,
|
|
175
|
+
is_verified=False,
|
|
176
|
+
)
|
|
177
|
+
user = await self._manager.create(user_create, safe=False)
|
|
178
|
+
|
|
179
|
+
# Assign roles
|
|
180
|
+
roles = await self._resolve_roles(role_names)
|
|
181
|
+
invited_by_str = str(invited_by.id) if invited_by else None
|
|
182
|
+
for role in roles:
|
|
183
|
+
self._db.add(
|
|
184
|
+
UserRole(
|
|
185
|
+
user_id=user.id,
|
|
186
|
+
role_id=role.id,
|
|
187
|
+
assigned_by=invited_by_str,
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
if roles:
|
|
191
|
+
await self._db.flush()
|
|
192
|
+
await self._db.refresh(user, attribute_names=["roles"])
|
|
193
|
+
|
|
194
|
+
token = await self._manager.generate_verification_token(user)
|
|
195
|
+
return user, token
|
|
196
|
+
|
|
197
|
+
async def disable(self, user_id: uuid.UUID) -> User:
|
|
198
|
+
user = await self._require_user(user_id)
|
|
199
|
+
user.disabled_at = datetime.now(UTC)
|
|
200
|
+
user.is_active = False
|
|
201
|
+
await self._db.flush()
|
|
202
|
+
return user
|
|
203
|
+
|
|
204
|
+
async def enable(self, user_id: uuid.UUID) -> User:
|
|
205
|
+
user = await self._require_user(user_id)
|
|
206
|
+
user.disabled_at = None
|
|
207
|
+
user.is_active = True
|
|
208
|
+
await self._db.flush()
|
|
209
|
+
return user
|
|
210
|
+
|
|
211
|
+
async def mark_verified(self, user_id: uuid.UUID) -> User:
|
|
212
|
+
user = await self._require_user(user_id)
|
|
213
|
+
if not user.is_verified:
|
|
214
|
+
user.is_verified = True
|
|
215
|
+
await self._db.flush()
|
|
216
|
+
return user
|
|
217
|
+
|
|
218
|
+
async def set_roles(
|
|
219
|
+
self,
|
|
220
|
+
user_id: uuid.UUID,
|
|
221
|
+
role_names: list[str],
|
|
222
|
+
*,
|
|
223
|
+
assigned_by: str | None = None,
|
|
224
|
+
) -> User:
|
|
225
|
+
user = await self._require_user(user_id)
|
|
226
|
+
|
|
227
|
+
# Delete all existing role assignments for this user
|
|
228
|
+
await self._db.execute(delete(UserRole).where(UserRole.user_id == user_id))
|
|
229
|
+
|
|
230
|
+
# Insert new role assignments
|
|
231
|
+
roles = await self._resolve_roles(role_names)
|
|
232
|
+
for role in roles:
|
|
233
|
+
self._db.add(
|
|
234
|
+
UserRole(
|
|
235
|
+
user_id=user_id,
|
|
236
|
+
role_id=role.id,
|
|
237
|
+
assigned_by=assigned_by,
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
await self._db.flush()
|
|
242
|
+
await self._db.refresh(user, attribute_names=["roles"])
|
|
243
|
+
return user
|
|
244
|
+
|
|
245
|
+
async def generate_reset_link(self, user_id: uuid.UUID, base_url: str) -> str:
|
|
246
|
+
"""Build an admin-copyable password-reset URL. No email side-effect."""
|
|
247
|
+
user = await self._require_user(user_id)
|
|
248
|
+
|
|
249
|
+
token = await self._manager.generate_reset_password_token(user)
|
|
250
|
+
return f"{base_url.rstrip('/')}/users/reset-password?token={token}"
|
|
251
|
+
|
|
252
|
+
async def get_with_roles(self, user_id: uuid.UUID) -> User | None:
|
|
253
|
+
return await self._get_user_with_roles(user_id)
|
|
254
|
+
|
|
255
|
+
async def get_list_item(self, user_id: uuid.UUID) -> UserListItem:
|
|
256
|
+
user = await self._require_user(user_id)
|
|
257
|
+
return self.to_list_item(user)
|
users/settings.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Users module settings — DB-backed via ``register_module_settings``.
|
|
2
|
+
|
|
3
|
+
Construction no longer reads ``SM_USERS_*`` environment variables. Values
|
|
4
|
+
come from pydantic defaults at boot, then get hydrated from the DB by the
|
|
5
|
+
hosting lifespan before module ``on_startup`` runs. Runtime changes go
|
|
6
|
+
through ``settings.reload.apply_changes_and_reload``.
|
|
7
|
+
|
|
8
|
+
The one remaining env read is ``SM_ENVIRONMENT``, consulted by the
|
|
9
|
+
``@model_validator`` to refuse placeholder token secrets in production —
|
|
10
|
+
that's a host-level setting, not a users-module field.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
|
|
17
|
+
from pydantic import Field, model_validator
|
|
18
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
19
|
+
from simple_module_core.environments import NON_PROD_ENVIRONMENTS
|
|
20
|
+
|
|
21
|
+
_PLACEHOLDER_RESET_SECRET = "dev-reset-token-secret-change-me"
|
|
22
|
+
_PLACEHOLDER_VERIFY_SECRET = "dev-verify-token-secret-change-me"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class UsersSettings(BaseSettings):
|
|
26
|
+
"""Local user management configuration."""
|
|
27
|
+
|
|
28
|
+
model_config = SettingsConfigDict(extra="ignore")
|
|
29
|
+
|
|
30
|
+
# Self-service signup
|
|
31
|
+
allow_signup: bool = False
|
|
32
|
+
require_verification: bool = True
|
|
33
|
+
|
|
34
|
+
# Token secrets — MUST be set in production. Dev default is a deterministic
|
|
35
|
+
# placeholder that's obvious in logs so it can't be mistaken for a real key.
|
|
36
|
+
reset_password_token_secret: str = "dev-reset-token-secret-change-me"
|
|
37
|
+
verification_token_secret: str = "dev-verify-token-secret-change-me"
|
|
38
|
+
reset_password_token_lifetime_seconds: int = 60 * 60 # 1 hour
|
|
39
|
+
verification_token_lifetime_seconds: int = 60 * 60 * 24 * 7 # 7 days
|
|
40
|
+
|
|
41
|
+
# Cookie (fastapi-users AuthenticationBackend)
|
|
42
|
+
cookie_name: str = "sm_auth"
|
|
43
|
+
cookie_max_age_seconds: int = 60 * 60 * 24 * 14 # 14 days
|
|
44
|
+
cookie_secure: bool = True # flipped False in dev by the module at startup
|
|
45
|
+
cookie_samesite: str = "lax"
|
|
46
|
+
|
|
47
|
+
# Mailer
|
|
48
|
+
mailer: str = Field(default="console", pattern="^(console|smtp)$")
|
|
49
|
+
base_url: str = "http://localhost:8000"
|
|
50
|
+
smtp_host: str = ""
|
|
51
|
+
smtp_port: int = 587
|
|
52
|
+
smtp_username: str = ""
|
|
53
|
+
smtp_password: str = ""
|
|
54
|
+
smtp_from: str = "no-reply@localhost"
|
|
55
|
+
smtp_tls: bool = True
|
|
56
|
+
|
|
57
|
+
# Rate limit (login — failure-based lockout)
|
|
58
|
+
login_rate_limit_failures: int = 5
|
|
59
|
+
login_rate_limit_window_seconds: int = 300
|
|
60
|
+
login_rate_limit_cooldown_seconds: int = 900
|
|
61
|
+
|
|
62
|
+
# Rate limit (auth side-effects: forgot-password, register, accept-invite,
|
|
63
|
+
# request-verify-token). Counts every attempt per IP per window.
|
|
64
|
+
auth_rate_limit_attempts: int = 10
|
|
65
|
+
auth_rate_limit_window_seconds: int = 300
|
|
66
|
+
|
|
67
|
+
# Bootstrap (env-var auto-create users on first boot)
|
|
68
|
+
bootstrap_email: str = ""
|
|
69
|
+
bootstrap_password: str = ""
|
|
70
|
+
# Optional second seed user with the "user" role — handy in dev for
|
|
71
|
+
# testing non-admin flows without logging out/in repeatedly.
|
|
72
|
+
bootstrap_user_email: str = ""
|
|
73
|
+
bootstrap_user_password: str = ""
|
|
74
|
+
|
|
75
|
+
@model_validator(mode="after")
|
|
76
|
+
def _forbid_placeholder_token_secrets_in_production(self) -> UsersSettings:
|
|
77
|
+
"""Fail boot if the reset/verify token secrets are still placeholders.
|
|
78
|
+
|
|
79
|
+
Both are HMAC keys for fastapi-users JWTs. A well-known default lets
|
|
80
|
+
an attacker mint password-reset or email-verification tokens for any
|
|
81
|
+
user. The environment is read from ``SM_ENVIRONMENT`` (host setting)
|
|
82
|
+
so this check has no runtime coupling to the hosting package.
|
|
83
|
+
"""
|
|
84
|
+
env = os.environ.get("SM_ENVIRONMENT", "development")
|
|
85
|
+
if env in NON_PROD_ENVIRONMENTS:
|
|
86
|
+
return self
|
|
87
|
+
bad = []
|
|
88
|
+
if self.reset_password_token_secret == _PLACEHOLDER_RESET_SECRET:
|
|
89
|
+
bad.append("RESET_PASSWORD_TOKEN_SECRET")
|
|
90
|
+
if self.verification_token_secret == _PLACEHOLDER_VERIFY_SECRET:
|
|
91
|
+
bad.append("VERIFICATION_TOKEN_SECRET")
|
|
92
|
+
if bad:
|
|
93
|
+
names = ", ".join(bad)
|
|
94
|
+
raise ValueError(
|
|
95
|
+
f"users.{names} must be set to non-default value(s) when "
|
|
96
|
+
f"SM_ENVIRONMENT={env!r}. Generate with "
|
|
97
|
+
"`python -c 'import secrets; print(secrets.token_urlsafe(48))'`."
|
|
98
|
+
)
|
|
99
|
+
return self
|
users/state.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Module-scoped state container for the users module.
|
|
2
|
+
|
|
3
|
+
Stored as ``app.state.users`` by :meth:`UsersModule.register_settings` (for
|
|
4
|
+
fields available at that phase) and populated the rest of the way during
|
|
5
|
+
:meth:`UsersModule.on_startup` (for fields that depend on the DB or other
|
|
6
|
+
framework services).
|
|
7
|
+
|
|
8
|
+
Not frozen — ``on_startup`` needs to set fields that aren't available at
|
|
9
|
+
``register_settings`` time. Convention: set once during boot, treat as
|
|
10
|
+
read-only after.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from users.mailer import Mailer
|
|
20
|
+
from users.rate_limit import LoginRateLimiter, ThroughputLimiter
|
|
21
|
+
from users.roles_cache import RoleSummary
|
|
22
|
+
from users.settings import UsersSettings
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class UsersState:
|
|
27
|
+
"""Users-module singletons. Single slot at ``app.state.users``."""
|
|
28
|
+
|
|
29
|
+
settings: UsersSettings
|
|
30
|
+
mailer: Mailer | None = None
|
|
31
|
+
rate_limiter: LoginRateLimiter | None = None
|
|
32
|
+
auth_throughput_limiter: ThroughputLimiter | None = None
|
|
33
|
+
roles_cache: list[RoleSummary] = field(default_factory=list)
|