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
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Link } from '@inertiajs/react';
|
|
2
|
+
import { Badge } from '@simple-module-py/ui/components/ui/badge';
|
|
3
|
+
import { Button } from '@simple-module-py/ui/components/ui/button';
|
|
4
|
+
import { Card } from '@simple-module-py/ui/components/ui/card';
|
|
5
|
+
import {
|
|
6
|
+
Table,
|
|
7
|
+
TableBody,
|
|
8
|
+
TableCell,
|
|
9
|
+
TableHead,
|
|
10
|
+
TableHeader,
|
|
11
|
+
TableRow,
|
|
12
|
+
} from '@simple-module-py/ui/components/ui/table';
|
|
13
|
+
import { TabsContent } from '@simple-module-py/ui/components/ui/tabs';
|
|
14
|
+
import { Pencil, ShieldCheck } from 'lucide-react';
|
|
15
|
+
|
|
16
|
+
export interface RoleItem {
|
|
17
|
+
id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
description?: string | null;
|
|
20
|
+
user_count: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function RolesTab({ roles }: { roles: RoleItem[] }) {
|
|
24
|
+
return (
|
|
25
|
+
<TabsContent value="roles">
|
|
26
|
+
<Card>
|
|
27
|
+
<Table>
|
|
28
|
+
<TableHeader>
|
|
29
|
+
<TableRow>
|
|
30
|
+
<TableHead>Role</TableHead>
|
|
31
|
+
<TableHead className="hidden md:table-cell">Description</TableHead>
|
|
32
|
+
<TableHead>Users</TableHead>
|
|
33
|
+
<TableHead className="text-right">Actions</TableHead>
|
|
34
|
+
</TableRow>
|
|
35
|
+
</TableHeader>
|
|
36
|
+
<TableBody>
|
|
37
|
+
{roles.map((role) => (
|
|
38
|
+
<TableRow key={role.id}>
|
|
39
|
+
<TableCell className="font-medium">{role.name}</TableCell>
|
|
40
|
+
<TableCell className="hidden md:table-cell text-muted-foreground text-sm">
|
|
41
|
+
{role.description || '—'}
|
|
42
|
+
</TableCell>
|
|
43
|
+
<TableCell>
|
|
44
|
+
<Badge variant="secondary" className="tabular-nums">
|
|
45
|
+
{role.user_count}
|
|
46
|
+
</Badge>
|
|
47
|
+
</TableCell>
|
|
48
|
+
<TableCell className="text-right">
|
|
49
|
+
<Button asChild variant="ghost" size="icon-sm">
|
|
50
|
+
<Link href={`/permissions/roles/${role.id}/edit`} aria-label="Edit role">
|
|
51
|
+
<Pencil />
|
|
52
|
+
</Link>
|
|
53
|
+
</Button>
|
|
54
|
+
</TableCell>
|
|
55
|
+
</TableRow>
|
|
56
|
+
))}
|
|
57
|
+
{roles.length === 0 && (
|
|
58
|
+
<TableRow>
|
|
59
|
+
<TableCell colSpan={4} className="h-32 text-center">
|
|
60
|
+
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
|
61
|
+
<ShieldCheck className="size-8" />
|
|
62
|
+
<p>No roles defined</p>
|
|
63
|
+
</div>
|
|
64
|
+
</TableCell>
|
|
65
|
+
</TableRow>
|
|
66
|
+
)}
|
|
67
|
+
</TableBody>
|
|
68
|
+
</Table>
|
|
69
|
+
</Card>
|
|
70
|
+
</TabsContent>
|
|
71
|
+
);
|
|
72
|
+
}
|
users/constants.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Stable identifiers used by both the seed migration and tests."""
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
ADMIN_ROLE_ID = uuid.UUID("00000000-0000-0000-0000-000000000001")
|
|
6
|
+
USER_ROLE_ID = uuid.UUID("00000000-0000-0000-0000-000000000002")
|
|
7
|
+
|
|
8
|
+
# Role name strings
|
|
9
|
+
ADMIN_ROLE_NAME = "admin"
|
|
10
|
+
USER_ROLE_NAME = "user"
|
|
11
|
+
|
|
12
|
+
# Role descriptions
|
|
13
|
+
ADMIN_ROLE_DESCRIPTION = "Administrator"
|
|
14
|
+
USER_ROLE_DESCRIPTION = "Standard user"
|
|
15
|
+
|
|
16
|
+
# Permission identifiers
|
|
17
|
+
PERM_USERS_MANAGE = "users.manage"
|
|
18
|
+
PERM_USERS_SELF_PROFILE = "users.self.profile"
|
|
19
|
+
|
|
20
|
+
# Session keys
|
|
21
|
+
SESSION_USER_ID_KEY = "user_id"
|
|
22
|
+
|
|
23
|
+
# Admin list-endpoint allowed filter/sort values
|
|
24
|
+
ALLOWED_STATUS = frozenset({"active", "disabled"})
|
|
25
|
+
ALLOWED_VERIFIED = frozenset({"yes", "no"})
|
|
26
|
+
ALLOWED_SORT = frozenset({"email", "last_login_at", "created_at"})
|
|
27
|
+
ALLOWED_ORDER = frozenset({"asc", "desc"})
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def sanitize_list_filters(
|
|
31
|
+
status: str | None,
|
|
32
|
+
verified: str | None,
|
|
33
|
+
sort: str,
|
|
34
|
+
order: str,
|
|
35
|
+
) -> tuple[str | None, str | None, str, str]:
|
|
36
|
+
"""Coerce unknown filter values to None/defaults."""
|
|
37
|
+
return (
|
|
38
|
+
status if status in ALLOWED_STATUS else None,
|
|
39
|
+
verified if verified in ALLOWED_VERIFIED else None,
|
|
40
|
+
sort if sort in ALLOWED_SORT else "email",
|
|
41
|
+
order if order in ALLOWED_ORDER else "asc",
|
|
42
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Public events published by the users module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from simple_module_core.events import Event
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class UserRegistered(Event):
|
|
13
|
+
user_id: uuid.UUID
|
|
14
|
+
email: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class UserInvited(Event):
|
|
19
|
+
user_id: uuid.UUID
|
|
20
|
+
email: str
|
|
21
|
+
invited_by: str | None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class UserDisabled(Event):
|
|
26
|
+
user_id: uuid.UUID
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class RoleAssigned(Event):
|
|
31
|
+
user_id: uuid.UUID
|
|
32
|
+
role_name: str
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Public request/response schemas for the users module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
from fastapi_users.schemas import CreateUpdateDictModel
|
|
9
|
+
from pydantic import ConfigDict, EmailStr
|
|
10
|
+
from sqlmodel import SQLModel
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class UserRead(CreateUpdateDictModel, SQLModel):
|
|
14
|
+
model_config = ConfigDict(from_attributes=True)
|
|
15
|
+
|
|
16
|
+
id: uuid.UUID
|
|
17
|
+
email: EmailStr
|
|
18
|
+
is_active: bool = True
|
|
19
|
+
is_superuser: bool = False
|
|
20
|
+
is_verified: bool = False
|
|
21
|
+
full_name: str | None = None
|
|
22
|
+
tenant_id: str | None = None
|
|
23
|
+
disabled_at: datetime | None = None
|
|
24
|
+
last_login_at: datetime | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class UserCreate(CreateUpdateDictModel, SQLModel):
|
|
28
|
+
email: EmailStr
|
|
29
|
+
password: str
|
|
30
|
+
is_active: bool | None = True
|
|
31
|
+
is_superuser: bool | None = False
|
|
32
|
+
is_verified: bool | None = False
|
|
33
|
+
full_name: str | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class UserUpdate(CreateUpdateDictModel, SQLModel):
|
|
37
|
+
password: str | None = None
|
|
38
|
+
email: EmailStr | None = None
|
|
39
|
+
is_active: bool | None = None
|
|
40
|
+
is_superuser: bool | None = None
|
|
41
|
+
is_verified: bool | None = None
|
|
42
|
+
full_name: str | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# Admin + invite + self profile
|
|
46
|
+
class UserInvite(SQLModel):
|
|
47
|
+
email: EmailStr
|
|
48
|
+
full_name: str | None = None
|
|
49
|
+
role_names: list[str] = []
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class UserListItem(SQLModel):
|
|
53
|
+
id: uuid.UUID
|
|
54
|
+
email: EmailStr
|
|
55
|
+
full_name: str | None = None
|
|
56
|
+
is_active: bool
|
|
57
|
+
is_verified: bool
|
|
58
|
+
disabled_at: datetime | None = None
|
|
59
|
+
last_login_at: datetime | None = None
|
|
60
|
+
created_at: datetime | None = None
|
|
61
|
+
roles: list[str] = []
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class RoleListItem(SQLModel):
|
|
65
|
+
id: uuid.UUID
|
|
66
|
+
name: str
|
|
67
|
+
description: str | None = None
|
|
68
|
+
user_count: int = 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class RoleAssignment(SQLModel):
|
|
72
|
+
role_names: list[str]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class AcceptInviteRequest(SQLModel):
|
|
76
|
+
token: str
|
|
77
|
+
password: str
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class PasswordResetLink(SQLModel):
|
|
81
|
+
link: str
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class SelfProfileUpdate(SQLModel):
|
|
85
|
+
full_name: str | None = None
|
users/db_adapter.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""SQLAlchemyUserDatabase subclass that eager-loads roles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncGenerator
|
|
6
|
+
|
|
7
|
+
from fastapi import Depends
|
|
8
|
+
from fastapi_users_db_sqlalchemy import SQLAlchemyUserDatabase
|
|
9
|
+
from fastapi_users_db_sqlalchemy.access_token import SQLAlchemyAccessTokenDatabase
|
|
10
|
+
from simple_module_db.deps import get_db
|
|
11
|
+
from sqlalchemy import func, select
|
|
12
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
13
|
+
from sqlalchemy.orm import selectinload
|
|
14
|
+
|
|
15
|
+
from users.models import User, UserAccessToken
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class UserDatabaseWithRoles(SQLAlchemyUserDatabase):
|
|
19
|
+
"""Always eager-load User.roles so fastapi-users can read role names
|
|
20
|
+
without triggering implicit async lazy-loads."""
|
|
21
|
+
|
|
22
|
+
async def get(self, id):
|
|
23
|
+
stmt = (
|
|
24
|
+
select(self.user_table)
|
|
25
|
+
.where(self.user_table.id == id)
|
|
26
|
+
.options(selectinload(self.user_table.roles))
|
|
27
|
+
)
|
|
28
|
+
return (await self.session.execute(stmt)).scalar_one_or_none()
|
|
29
|
+
|
|
30
|
+
async def get_by_email(self, email):
|
|
31
|
+
stmt = (
|
|
32
|
+
select(self.user_table)
|
|
33
|
+
.where(func.lower(self.user_table.email) == email.lower())
|
|
34
|
+
.options(selectinload(self.user_table.roles))
|
|
35
|
+
)
|
|
36
|
+
return (await self.session.execute(stmt)).scalar_one_or_none()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def get_user_db(
|
|
40
|
+
session: AsyncSession = Depends(get_db),
|
|
41
|
+
) -> AsyncGenerator[UserDatabaseWithRoles, None]:
|
|
42
|
+
yield UserDatabaseWithRoles(session, User)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def get_access_token_db(
|
|
46
|
+
session: AsyncSession = Depends(get_db),
|
|
47
|
+
) -> AsyncGenerator[SQLAlchemyAccessTokenDatabase[UserAccessToken], None]:
|
|
48
|
+
yield SQLAlchemyAccessTokenDatabase(session, UserAccessToken)
|
users/deps.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Public dependencies and the FastAPIUsers instance.
|
|
2
|
+
|
|
3
|
+
Cookie transport and auth backend are constructed at import time with
|
|
4
|
+
dev-safe defaults (cookie_secure=False, dev cookie name). UsersModule.on_startup
|
|
5
|
+
overrides the cookie params from the real UsersSettings once app.state is
|
|
6
|
+
populated — CookieTransport's cookie fields are mutable on the instance.
|
|
7
|
+
|
|
8
|
+
The singleton is here rather than in register_routes because
|
|
9
|
+
``current_active_user`` and ``current_superuser`` are decorator-factories
|
|
10
|
+
that must be resolvable at import time for route-handler signatures.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import uuid
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
from fastapi import Depends, Request
|
|
19
|
+
from fastapi_users import FastAPIUsers
|
|
20
|
+
from simple_module_core.events import EventBus
|
|
21
|
+
from simple_module_db.deps import get_db
|
|
22
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
23
|
+
|
|
24
|
+
from users.backend import build_auth_backend, build_cookie_transport
|
|
25
|
+
from users.db_adapter import (
|
|
26
|
+
UserDatabaseWithRoles,
|
|
27
|
+
get_access_token_db,
|
|
28
|
+
get_user_db,
|
|
29
|
+
)
|
|
30
|
+
from users.manager import UserManager, get_user_manager
|
|
31
|
+
from users.models import User
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from users.service import UserService
|
|
35
|
+
|
|
36
|
+
# Dev-safe singleton — UsersModule patches cookie params at startup.
|
|
37
|
+
_cookie_transport = build_cookie_transport(
|
|
38
|
+
cookie_name="sm_auth",
|
|
39
|
+
cookie_max_age_seconds=60 * 60 * 24 * 14,
|
|
40
|
+
cookie_secure=False, # host flips True in production via register_routes
|
|
41
|
+
cookie_samesite="lax",
|
|
42
|
+
)
|
|
43
|
+
auth_backend = build_auth_backend(_cookie_transport)
|
|
44
|
+
|
|
45
|
+
fastapi_users = FastAPIUsers[User, uuid.UUID](get_user_manager, [auth_backend])
|
|
46
|
+
|
|
47
|
+
current_active_user = fastapi_users.current_user(active=True)
|
|
48
|
+
current_superuser = fastapi_users.current_user(active=True, superuser=True)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_mailer(request: Request):
|
|
52
|
+
"""Return the mailer from app.state.users (built in UsersModule.on_startup)."""
|
|
53
|
+
return request.app.state.users.mailer
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get_event_bus(request: Request) -> EventBus:
|
|
57
|
+
"""Return the event bus from app.state.sm."""
|
|
58
|
+
return request.app.state.sm.event_bus
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def get_user_service(
|
|
62
|
+
db: AsyncSession = Depends(get_db),
|
|
63
|
+
user_manager: UserManager = Depends(get_user_manager),
|
|
64
|
+
) -> UserService:
|
|
65
|
+
from users.service import UserService
|
|
66
|
+
|
|
67
|
+
return UserService(db, user_manager)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
__all__ = [
|
|
71
|
+
"UserDatabaseWithRoles",
|
|
72
|
+
"UserManager",
|
|
73
|
+
"auth_backend",
|
|
74
|
+
"current_active_user",
|
|
75
|
+
"current_superuser",
|
|
76
|
+
"fastapi_users",
|
|
77
|
+
"get_access_token_db",
|
|
78
|
+
"get_event_bus",
|
|
79
|
+
"get_mailer",
|
|
80
|
+
"get_user_db",
|
|
81
|
+
"get_user_manager",
|
|
82
|
+
"get_user_service",
|
|
83
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""REST and view endpoints for the users module."""
|
users/endpoints/api.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""REST API endpoints for the users module.
|
|
2
|
+
|
|
3
|
+
Structure:
|
|
4
|
+
/api/users/auth/login — wrapper with rate limit
|
|
5
|
+
/api/users/auth/* — fastapi-users routers (register/reset/verify/logout)
|
|
6
|
+
/api/users/auth/accept-invite — custom (verify + set password + login)
|
|
7
|
+
/api/users/me — self profile
|
|
8
|
+
/api/users/admin/* — admin REST (RequiresPermission('users.manage'))
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
|
|
15
|
+
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
|
16
|
+
from fastapi.security import OAuth2PasswordRequestForm
|
|
17
|
+
from fastapi_users import exceptions as fu_exceptions
|
|
18
|
+
|
|
19
|
+
from users.constants import SESSION_USER_ID_KEY
|
|
20
|
+
from users.contracts.schemas import (
|
|
21
|
+
AcceptInviteRequest,
|
|
22
|
+
SelfProfileUpdate,
|
|
23
|
+
UserCreate,
|
|
24
|
+
UserRead,
|
|
25
|
+
UserUpdate,
|
|
26
|
+
)
|
|
27
|
+
from users.deps import (
|
|
28
|
+
auth_backend,
|
|
29
|
+
fastapi_users,
|
|
30
|
+
get_user_manager,
|
|
31
|
+
)
|
|
32
|
+
from users.endpoints.api_admin import admin_router
|
|
33
|
+
from users.manager import UserManager
|
|
34
|
+
from users.rate_limit import LoginRateLimiter, ThroughputLimiter
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
router = APIRouter()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ── Rate limit ───────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_rate_limiter(request: Request) -> LoginRateLimiter:
|
|
44
|
+
"""Return the per-app LoginRateLimiter built in UsersModule.on_startup."""
|
|
45
|
+
return request.app.state.users.rate_limiter
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _client_ip(request: Request) -> str:
|
|
49
|
+
return request.client.host if request.client else "unknown"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def enforce_auth_throughput_limit(request: Request) -> None:
|
|
53
|
+
"""FastAPI dependency that rejects the request with 429 when this IP has
|
|
54
|
+
exhausted its attempts budget on shared auth side-effect endpoints.
|
|
55
|
+
|
|
56
|
+
Applied to forgot-password / register / accept-invite / request-verify-token,
|
|
57
|
+
which otherwise allow unlimited email or account-creation spam.
|
|
58
|
+
"""
|
|
59
|
+
limiter: ThroughputLimiter = request.app.state.users.auth_throughput_limiter
|
|
60
|
+
key = f"{request.url.path}::{_client_ip(request)}"
|
|
61
|
+
if not limiter.check_and_record(key):
|
|
62
|
+
raise HTTPException(
|
|
63
|
+
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
64
|
+
detail="Too many attempts — try again later",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def require_signup_enabled(request: Request) -> None:
|
|
69
|
+
"""Gate the register endpoint at request time on ``allow_signup``.
|
|
70
|
+
|
|
71
|
+
Mounting stays unconditional so settings reloads don't need to rebuild
|
|
72
|
+
the router. When signup is disabled we return 404 so the endpoint
|
|
73
|
+
appears absent (matches the view-side behaviour at ``/users/register``).
|
|
74
|
+
"""
|
|
75
|
+
if not request.app.state.users.settings.allow_signup:
|
|
76
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ── Wrapper login ────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@router.post("/auth/login", status_code=204)
|
|
83
|
+
async def login(
|
|
84
|
+
request: Request,
|
|
85
|
+
response: Response,
|
|
86
|
+
credentials: OAuth2PasswordRequestForm = Depends(),
|
|
87
|
+
user_manager: UserManager = Depends(get_user_manager),
|
|
88
|
+
strategy=Depends(auth_backend.get_strategy),
|
|
89
|
+
limiter: LoginRateLimiter = Depends(get_rate_limiter),
|
|
90
|
+
):
|
|
91
|
+
"""Rate-limited login wrapper. Sets sm_auth cookie + session user_id."""
|
|
92
|
+
key = f"{credentials.username.lower()}::{request.client.host if request.client else 'unknown'}"
|
|
93
|
+
if limiter.is_locked(key):
|
|
94
|
+
raise HTTPException(status_code=429, detail="Too many attempts — try again later")
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
user = await user_manager.authenticate(credentials)
|
|
98
|
+
except fu_exceptions.UserNotExists:
|
|
99
|
+
user = None
|
|
100
|
+
|
|
101
|
+
if user is None or not user.is_active:
|
|
102
|
+
limiter.record_failure(key)
|
|
103
|
+
raise HTTPException(status_code=400, detail="LOGIN_BAD_CREDENTIALS")
|
|
104
|
+
|
|
105
|
+
if not user.is_verified:
|
|
106
|
+
# Match fastapi-users' own behavior when requires_verification=True
|
|
107
|
+
raise HTTPException(status_code=400, detail="LOGIN_USER_NOT_VERIFIED")
|
|
108
|
+
|
|
109
|
+
limiter.reset(key)
|
|
110
|
+
# Fire the login hook (updates last_login_at)
|
|
111
|
+
await user_manager.on_after_login(user, request, response)
|
|
112
|
+
# Set fastapi-users' cookie via auth_backend.login
|
|
113
|
+
login_response = await auth_backend.login(strategy, user)
|
|
114
|
+
# Bridge the session cookie — AuthMiddleware reads this to identify the user
|
|
115
|
+
request.session[SESSION_USER_ID_KEY] = str(user.id)
|
|
116
|
+
return login_response
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ── Mount fastapi-users stock routers ────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
# The stock auth router (login + logout) is mounted at /auth-inner so its
|
|
122
|
+
# logout and other endpoints remain accessible. Our wrapper at /auth/login
|
|
123
|
+
# shadows the stock login endpoint. Logout is exposed via /auth-inner/logout.
|
|
124
|
+
auth_inner = fastapi_users.get_auth_router(auth_backend, requires_verification=True)
|
|
125
|
+
router.include_router(auth_inner, prefix="/auth-inner")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def register_auth_routes(api_router: APIRouter) -> None:
|
|
129
|
+
"""Mount all auth routes.
|
|
130
|
+
|
|
131
|
+
The stock fastapi-users routers (reset/verify/register) ship POST endpoints
|
|
132
|
+
that trigger email side-effects or account creation. We wrap them with the
|
|
133
|
+
throughput limiter so an attacker can't spam password-reset emails or mint
|
|
134
|
+
accounts indefinitely. ``router`` itself is left unwrapped because its
|
|
135
|
+
rate-limited endpoints apply the dep themselves (login via LoginRateLimiter,
|
|
136
|
+
accept-invite via ``enforce_auth_throughput_limit``).
|
|
137
|
+
|
|
138
|
+
The register router is always mounted; ``require_signup_enabled`` gates
|
|
139
|
+
it at request time so ``allow_signup`` is hot-reloadable.
|
|
140
|
+
"""
|
|
141
|
+
api_router.include_router(router)
|
|
142
|
+
api_router.include_router(
|
|
143
|
+
fastapi_users.get_reset_password_router(),
|
|
144
|
+
prefix="/auth",
|
|
145
|
+
tags=["users-auth"],
|
|
146
|
+
dependencies=[Depends(enforce_auth_throughput_limit)],
|
|
147
|
+
)
|
|
148
|
+
api_router.include_router(
|
|
149
|
+
fastapi_users.get_verify_router(UserRead),
|
|
150
|
+
prefix="/auth",
|
|
151
|
+
tags=["users-auth"],
|
|
152
|
+
dependencies=[Depends(enforce_auth_throughput_limit)],
|
|
153
|
+
)
|
|
154
|
+
api_router.include_router(
|
|
155
|
+
fastapi_users.get_register_router(UserRead, UserCreate),
|
|
156
|
+
prefix="/auth",
|
|
157
|
+
tags=["users-auth"],
|
|
158
|
+
dependencies=[
|
|
159
|
+
Depends(require_signup_enabled),
|
|
160
|
+
Depends(enforce_auth_throughput_limit),
|
|
161
|
+
],
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ── Accept-invite (verify + set password + login, one shot) ─────────────────
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@router.post(
|
|
169
|
+
"/auth/accept-invite",
|
|
170
|
+
status_code=204,
|
|
171
|
+
dependencies=[Depends(enforce_auth_throughput_limit)],
|
|
172
|
+
)
|
|
173
|
+
async def accept_invite(
|
|
174
|
+
body: AcceptInviteRequest,
|
|
175
|
+
request: Request,
|
|
176
|
+
response: Response,
|
|
177
|
+
user_manager: UserManager = Depends(get_user_manager),
|
|
178
|
+
strategy=Depends(auth_backend.get_strategy),
|
|
179
|
+
):
|
|
180
|
+
"""Verify an invite token, set the user's password, and log them in."""
|
|
181
|
+
try:
|
|
182
|
+
user = await user_manager.verify(body.token, request=request)
|
|
183
|
+
except (fu_exceptions.InvalidVerifyToken, fu_exceptions.UserAlreadyVerified):
|
|
184
|
+
raise HTTPException(status_code=400, detail="INVITE_BAD_TOKEN") from None
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
await user_manager.update(
|
|
188
|
+
UserUpdate(password=body.password),
|
|
189
|
+
user,
|
|
190
|
+
request=request,
|
|
191
|
+
)
|
|
192
|
+
except fu_exceptions.InvalidPasswordException as e:
|
|
193
|
+
raise HTTPException(status_code=400, detail=f"INVALID_PASSWORD: {e.reason}") from e
|
|
194
|
+
|
|
195
|
+
await user_manager.on_after_login(user, request, response)
|
|
196
|
+
login_response = await auth_backend.login(strategy, user)
|
|
197
|
+
request.session[SESSION_USER_ID_KEY] = str(user.id)
|
|
198
|
+
return login_response
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ── Self profile ─────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@router.get("/me", response_model=UserRead)
|
|
205
|
+
async def read_me(user=Depends(fastapi_users.current_user(active=True))):
|
|
206
|
+
"""Return the currently authenticated user's profile."""
|
|
207
|
+
return user
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@router.patch("/me", response_model=UserRead)
|
|
211
|
+
async def update_me(
|
|
212
|
+
data: SelfProfileUpdate,
|
|
213
|
+
request: Request,
|
|
214
|
+
user=Depends(fastapi_users.current_user(active=True)),
|
|
215
|
+
user_manager: UserManager = Depends(get_user_manager),
|
|
216
|
+
):
|
|
217
|
+
"""Update the currently authenticated user's profile."""
|
|
218
|
+
return await user_manager.update(
|
|
219
|
+
UserUpdate(**data.model_dump(exclude_unset=True)),
|
|
220
|
+
user,
|
|
221
|
+
request=request,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# ── Admin REST — defined in api_admin.py, mounted here ──────────────────────
|
|
226
|
+
|
|
227
|
+
router.include_router(admin_router)
|