regstack 0.1.0__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.
- regstack/__init__.py +5 -0
- regstack/app.py +150 -0
- regstack/auth/__init__.py +21 -0
- regstack/auth/clock.py +29 -0
- regstack/auth/dependencies.py +102 -0
- regstack/auth/jwt.py +145 -0
- regstack/auth/lockout.py +59 -0
- regstack/auth/mfa.py +29 -0
- regstack/auth/password.py +20 -0
- regstack/auth/tokens.py +19 -0
- regstack/cli/__init__.py +0 -0
- regstack/cli/__main__.py +27 -0
- regstack/cli/_runtime.py +39 -0
- regstack/cli/admin.py +45 -0
- regstack/cli/doctor.py +186 -0
- regstack/cli/init.py +236 -0
- regstack/config/__init__.py +4 -0
- regstack/config/loader.py +114 -0
- regstack/config/schema.py +148 -0
- regstack/config/secrets.py +22 -0
- regstack/db/__init__.py +17 -0
- regstack/db/client.py +26 -0
- regstack/db/indexes.py +70 -0
- regstack/db/repositories/__init__.py +0 -0
- regstack/db/repositories/blacklist_repo.py +28 -0
- regstack/db/repositories/login_attempt_repo.py +27 -0
- regstack/db/repositories/mfa_code_repo.py +99 -0
- regstack/db/repositories/pending_repo.py +76 -0
- regstack/db/repositories/user_repo.py +169 -0
- regstack/email/__init__.py +12 -0
- regstack/email/base.py +23 -0
- regstack/email/composer.py +142 -0
- regstack/email/console.py +28 -0
- regstack/email/factory.py +23 -0
- regstack/email/ses.py +47 -0
- regstack/email/smtp.py +46 -0
- regstack/email/templates/email_change.html +15 -0
- regstack/email/templates/email_change.subject.txt +1 -0
- regstack/email/templates/email_change.txt +7 -0
- regstack/email/templates/password_reset.html +15 -0
- regstack/email/templates/password_reset.subject.txt +1 -0
- regstack/email/templates/password_reset.txt +7 -0
- regstack/email/templates/sms_login_mfa.txt +1 -0
- regstack/email/templates/sms_phone_setup.txt +1 -0
- regstack/email/templates/verification.html +15 -0
- regstack/email/templates/verification.subject.txt +1 -0
- regstack/email/templates/verification.txt +7 -0
- regstack/hooks/__init__.py +3 -0
- regstack/hooks/events.py +59 -0
- regstack/models/__init__.py +15 -0
- regstack/models/_objectid.py +30 -0
- regstack/models/login_attempt.py +31 -0
- regstack/models/mfa_code.py +40 -0
- regstack/models/pending_registration.py +38 -0
- regstack/models/user.py +104 -0
- regstack/routers/__init__.py +37 -0
- regstack/routers/_schemas.py +34 -0
- regstack/routers/account.py +274 -0
- regstack/routers/admin.py +187 -0
- regstack/routers/login.py +223 -0
- regstack/routers/logout.py +39 -0
- regstack/routers/password.py +114 -0
- regstack/routers/phone.py +242 -0
- regstack/routers/register.py +99 -0
- regstack/routers/verify.py +116 -0
- regstack/sms/__init__.py +5 -0
- regstack/sms/base.py +24 -0
- regstack/sms/factory.py +23 -0
- regstack/sms/null.py +26 -0
- regstack/sms/sns.py +42 -0
- regstack/sms/twilio.py +49 -0
- regstack/ui/__init__.py +3 -0
- regstack/ui/pages.py +148 -0
- regstack/ui/static/css/core.css +204 -0
- regstack/ui/static/css/theme.css +43 -0
- regstack/ui/static/js/regstack.js +411 -0
- regstack/ui/templates/auth/email_change_confirm.html +10 -0
- regstack/ui/templates/auth/forgot.html +14 -0
- regstack/ui/templates/auth/login.html +24 -0
- regstack/ui/templates/auth/me.html +110 -0
- regstack/ui/templates/auth/mfa_confirm.html +14 -0
- regstack/ui/templates/auth/register.html +23 -0
- regstack/ui/templates/auth/reset.html +13 -0
- regstack/ui/templates/auth/verify.html +10 -0
- regstack/ui/templates/base.html +46 -0
- regstack/version.py +1 -0
- regstack-0.1.0.dist-info/METADATA +209 -0
- regstack-0.1.0.dist-info/RECORD +92 -0
- regstack-0.1.0.dist-info/WHEEL +4 -0
- regstack-0.1.0.dist-info/entry_points.txt +2 -0
- regstack-0.1.0.dist-info/licenses/LICENSE +202 -0
- regstack-0.1.0.dist-info/licenses/NOTICE +5 -0
regstack/models/user.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
from typing import Annotated, Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
|
|
7
|
+
|
|
8
|
+
from regstack.models._objectid import ObjectIdStr
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _utcnow() -> datetime:
|
|
12
|
+
return datetime.now(UTC)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
PasswordStr = Annotated[str, Field(min_length=8, max_length=128)]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BaseUser(BaseModel):
|
|
19
|
+
"""Persisted user document.
|
|
20
|
+
|
|
21
|
+
The default field set covers what both winebox and putplace need today.
|
|
22
|
+
Hosts add their own fields by subclassing or by registering an extension
|
|
23
|
+
mixin via ``RegStack.extend_user_model``.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
|
27
|
+
|
|
28
|
+
id: ObjectIdStr | None = Field(default=None, alias="_id")
|
|
29
|
+
email: EmailStr
|
|
30
|
+
hashed_password: str
|
|
31
|
+
is_active: bool = True
|
|
32
|
+
is_verified: bool = False
|
|
33
|
+
is_superuser: bool = False
|
|
34
|
+
full_name: str | None = None
|
|
35
|
+
phone_number: str | None = None
|
|
36
|
+
is_mfa_enabled: bool = False
|
|
37
|
+
created_at: datetime = Field(default_factory=_utcnow)
|
|
38
|
+
updated_at: datetime = Field(default_factory=_utcnow)
|
|
39
|
+
last_login: datetime | None = None
|
|
40
|
+
tokens_invalidated_after: datetime | None = None
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def is_admin(self) -> bool:
|
|
44
|
+
"""Alias kept for parity with putplace's ``is_admin`` field name."""
|
|
45
|
+
return self.is_superuser
|
|
46
|
+
|
|
47
|
+
def to_mongo(self) -> dict[str, Any]:
|
|
48
|
+
data = self.model_dump(by_alias=True, exclude_none=True)
|
|
49
|
+
if data.get("_id") is None:
|
|
50
|
+
data.pop("_id", None)
|
|
51
|
+
return data
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class UserCreate(BaseModel):
|
|
55
|
+
model_config = ConfigDict(extra="forbid")
|
|
56
|
+
|
|
57
|
+
email: EmailStr
|
|
58
|
+
password: PasswordStr
|
|
59
|
+
full_name: str | None = Field(default=None, max_length=200)
|
|
60
|
+
|
|
61
|
+
@field_validator("password")
|
|
62
|
+
@classmethod
|
|
63
|
+
def _strip(cls, v: str) -> str:
|
|
64
|
+
return v
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class UserUpdate(BaseModel):
|
|
68
|
+
model_config = ConfigDict(extra="forbid")
|
|
69
|
+
|
|
70
|
+
full_name: str | None = Field(default=None, max_length=200)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class UserPublic(BaseModel):
|
|
74
|
+
"""Safe-to-serialise projection of a user (no password hash)."""
|
|
75
|
+
|
|
76
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
77
|
+
|
|
78
|
+
id: str = Field(alias="_id")
|
|
79
|
+
email: EmailStr
|
|
80
|
+
is_active: bool
|
|
81
|
+
is_verified: bool
|
|
82
|
+
is_superuser: bool
|
|
83
|
+
full_name: str | None = None
|
|
84
|
+
phone_number: str | None = None
|
|
85
|
+
is_mfa_enabled: bool = False
|
|
86
|
+
created_at: datetime
|
|
87
|
+
last_login: datetime | None = None
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def from_user(cls, user: BaseUser) -> UserPublic:
|
|
91
|
+
if user.id is None:
|
|
92
|
+
raise ValueError("Cannot serialise a user without an id")
|
|
93
|
+
return cls(
|
|
94
|
+
_id=user.id,
|
|
95
|
+
email=user.email,
|
|
96
|
+
is_active=user.is_active,
|
|
97
|
+
is_verified=user.is_verified,
|
|
98
|
+
is_superuser=user.is_superuser,
|
|
99
|
+
full_name=user.full_name,
|
|
100
|
+
phone_number=user.phone_number,
|
|
101
|
+
is_mfa_enabled=user.is_mfa_enabled,
|
|
102
|
+
created_at=user.created_at,
|
|
103
|
+
last_login=user.last_login,
|
|
104
|
+
)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter
|
|
6
|
+
|
|
7
|
+
from regstack.routers.account import build_account_router
|
|
8
|
+
from regstack.routers.admin import build_admin_router
|
|
9
|
+
from regstack.routers.login import build_login_router
|
|
10
|
+
from regstack.routers.logout import build_logout_router
|
|
11
|
+
from regstack.routers.password import build_password_router
|
|
12
|
+
from regstack.routers.phone import build_phone_router
|
|
13
|
+
from regstack.routers.register import build_register_router
|
|
14
|
+
from regstack.routers.verify import build_verify_router
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from regstack.app import RegStack
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_router(rs: RegStack) -> APIRouter:
|
|
21
|
+
"""Compose the JSON router that hosts mount via ``app.include_router``."""
|
|
22
|
+
router = APIRouter(tags=["regstack"])
|
|
23
|
+
router.include_router(build_register_router(rs))
|
|
24
|
+
router.include_router(build_verify_router(rs))
|
|
25
|
+
router.include_router(build_login_router(rs))
|
|
26
|
+
router.include_router(build_logout_router(rs))
|
|
27
|
+
router.include_router(build_account_router(rs))
|
|
28
|
+
if rs.config.enable_password_reset:
|
|
29
|
+
router.include_router(build_password_router(rs))
|
|
30
|
+
if rs.config.enable_sms_2fa:
|
|
31
|
+
router.include_router(build_phone_router(rs))
|
|
32
|
+
if rs.config.enable_admin_router:
|
|
33
|
+
router.include_router(build_admin_router(rs))
|
|
34
|
+
return router
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
__all__ = ["build_admin_router", "build_router"]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
|
6
|
+
|
|
7
|
+
PasswordStr = Annotated[str, Field(min_length=8, max_length=128)]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LoginRequest(BaseModel):
|
|
11
|
+
model_config = ConfigDict(extra="forbid")
|
|
12
|
+
email: EmailStr
|
|
13
|
+
password: PasswordStr
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TokenResponse(BaseModel):
|
|
17
|
+
access_token: str
|
|
18
|
+
token_type: str = "bearer"
|
|
19
|
+
expires_in: int
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MessageResponse(BaseModel):
|
|
23
|
+
message: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PendingResponse(BaseModel):
|
|
27
|
+
"""Returned when registration starts a verification flow rather than
|
|
28
|
+
creating a logged-in user immediately. The host can use this to redirect
|
|
29
|
+
to a "check your email" page.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
status: str = "pending_verification"
|
|
33
|
+
email: EmailStr
|
|
34
|
+
expires_at: str # ISO-8601 UTC, JSON-friendly
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Annotated, Any
|
|
4
|
+
|
|
5
|
+
import jwt as pyjwt
|
|
6
|
+
from fastapi import APIRouter, Body, Depends, HTTPException, status
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
|
8
|
+
|
|
9
|
+
from regstack.auth.jwt import TokenError
|
|
10
|
+
from regstack.config.secrets import derive_secret
|
|
11
|
+
from regstack.db.repositories.user_repo import UserAlreadyExistsError
|
|
12
|
+
from regstack.models.user import BaseUser, UserPublic
|
|
13
|
+
from regstack.routers._schemas import MessageResponse, PasswordStr
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from regstack.app import RegStack
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_EMAIL_CHANGE_PURPOSE = "email_change"
|
|
20
|
+
_NEW_EMAIL_CLAIM = "new_email"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ChangePasswordRequest(BaseModel):
|
|
24
|
+
model_config = ConfigDict(extra="forbid")
|
|
25
|
+
current_password: str
|
|
26
|
+
new_password: PasswordStr
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ChangeEmailRequest(BaseModel):
|
|
30
|
+
model_config = ConfigDict(extra="forbid")
|
|
31
|
+
new_email: EmailStr
|
|
32
|
+
current_password: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ConfirmEmailChangeRequest(BaseModel):
|
|
36
|
+
model_config = ConfigDict(extra="forbid")
|
|
37
|
+
token: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class DeleteAccountRequest(BaseModel):
|
|
41
|
+
model_config = ConfigDict(extra="forbid")
|
|
42
|
+
current_password: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class UpdateProfileRequest(BaseModel):
|
|
46
|
+
model_config = ConfigDict(extra="forbid")
|
|
47
|
+
full_name: str | None = Field(default=None, max_length=200)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_account_router(rs: RegStack) -> APIRouter:
|
|
51
|
+
router = APIRouter()
|
|
52
|
+
|
|
53
|
+
@router.get(
|
|
54
|
+
"/me",
|
|
55
|
+
response_model=UserPublic,
|
|
56
|
+
summary="Return the authenticated user",
|
|
57
|
+
)
|
|
58
|
+
async def me(user: BaseUser = Depends(rs.deps.current_user())) -> UserPublic:
|
|
59
|
+
return UserPublic.from_user(user)
|
|
60
|
+
|
|
61
|
+
@router.patch(
|
|
62
|
+
"/me",
|
|
63
|
+
response_model=UserPublic,
|
|
64
|
+
summary="Update the authenticated user's profile fields",
|
|
65
|
+
)
|
|
66
|
+
async def update_me(
|
|
67
|
+
payload: UpdateProfileRequest,
|
|
68
|
+
user: BaseUser = Depends(rs.deps.current_user()),
|
|
69
|
+
) -> UserPublic:
|
|
70
|
+
assert user.id is not None
|
|
71
|
+
await rs.users.set_full_name(user.id, payload.full_name)
|
|
72
|
+
user.full_name = payload.full_name
|
|
73
|
+
return UserPublic.from_user(user)
|
|
74
|
+
|
|
75
|
+
@router.post(
|
|
76
|
+
"/change-password",
|
|
77
|
+
response_model=MessageResponse,
|
|
78
|
+
summary="Change the authenticated user's password (revokes existing sessions)",
|
|
79
|
+
)
|
|
80
|
+
async def change_password(
|
|
81
|
+
payload: ChangePasswordRequest,
|
|
82
|
+
user: BaseUser = Depends(rs.deps.current_user()),
|
|
83
|
+
) -> MessageResponse:
|
|
84
|
+
if not rs.password_hasher.verify(payload.current_password, user.hashed_password):
|
|
85
|
+
raise HTTPException(
|
|
86
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
87
|
+
detail="Current password is incorrect.",
|
|
88
|
+
)
|
|
89
|
+
if rs.password_hasher.verify(payload.new_password, user.hashed_password):
|
|
90
|
+
raise HTTPException(
|
|
91
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
92
|
+
detail="New password must differ from the current password.",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
new_hash = rs.password_hasher.hash(payload.new_password)
|
|
96
|
+
assert user.id is not None
|
|
97
|
+
await rs.users.update_password(user.id, new_hash)
|
|
98
|
+
await rs.lockout.clear(user.email)
|
|
99
|
+
await rs.hooks.fire("password_changed", user=user)
|
|
100
|
+
return MessageResponse(message="Password changed. Existing sessions have been signed out.")
|
|
101
|
+
|
|
102
|
+
@router.post(
|
|
103
|
+
"/change-email",
|
|
104
|
+
response_model=MessageResponse,
|
|
105
|
+
status_code=status.HTTP_202_ACCEPTED,
|
|
106
|
+
summary="Request an email-address change (sends confirmation to new address)",
|
|
107
|
+
)
|
|
108
|
+
async def change_email(
|
|
109
|
+
payload: ChangeEmailRequest,
|
|
110
|
+
user: BaseUser = Depends(rs.deps.current_user()),
|
|
111
|
+
) -> MessageResponse:
|
|
112
|
+
if payload.new_email.lower() == user.email.lower():
|
|
113
|
+
raise HTTPException(
|
|
114
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
115
|
+
detail="New email is the same as the current email.",
|
|
116
|
+
)
|
|
117
|
+
if not rs.password_hasher.verify(payload.current_password, user.hashed_password):
|
|
118
|
+
raise HTTPException(
|
|
119
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
120
|
+
detail="Current password is incorrect.",
|
|
121
|
+
)
|
|
122
|
+
clash = await rs.users.get_by_email(payload.new_email)
|
|
123
|
+
if clash is not None:
|
|
124
|
+
raise HTTPException(
|
|
125
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
126
|
+
detail="That email address is already in use.",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
assert user.id is not None
|
|
130
|
+
ttl = rs.config.email_change_token_ttl_seconds
|
|
131
|
+
token = _encode_email_change_token(rs, user.id, payload.new_email, ttl)
|
|
132
|
+
url = _email_change_url(rs, token)
|
|
133
|
+
message = rs.mail.email_change(
|
|
134
|
+
to=payload.new_email,
|
|
135
|
+
full_name=user.full_name,
|
|
136
|
+
url=url,
|
|
137
|
+
ttl_minutes=max(ttl // 60, 1),
|
|
138
|
+
)
|
|
139
|
+
await rs.email.send(message)
|
|
140
|
+
await rs.hooks.fire(
|
|
141
|
+
"email_change_requested",
|
|
142
|
+
user=user,
|
|
143
|
+
new_email=payload.new_email,
|
|
144
|
+
url=url,
|
|
145
|
+
)
|
|
146
|
+
return MessageResponse(
|
|
147
|
+
message="A confirmation link has been sent to the new email address."
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
@router.post(
|
|
151
|
+
"/confirm-email-change",
|
|
152
|
+
response_model=UserPublic,
|
|
153
|
+
summary="Consume an email-change token and swap the address",
|
|
154
|
+
)
|
|
155
|
+
async def confirm_email_change(payload: ConfirmEmailChangeRequest) -> UserPublic:
|
|
156
|
+
try:
|
|
157
|
+
user_id, new_email = _decode_email_change_token(rs, payload.token)
|
|
158
|
+
except TokenError as exc:
|
|
159
|
+
raise HTTPException(
|
|
160
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
161
|
+
detail="Token is invalid or has expired.",
|
|
162
|
+
) from exc
|
|
163
|
+
|
|
164
|
+
user = await rs.users.get_by_id(user_id)
|
|
165
|
+
if user is None or not user.is_active or user.id is None:
|
|
166
|
+
raise HTTPException(
|
|
167
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
168
|
+
detail="Token does not match an active account.",
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
await rs.users.update_email(user.id, new_email)
|
|
173
|
+
except UserAlreadyExistsError as exc:
|
|
174
|
+
raise HTTPException(
|
|
175
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
176
|
+
detail="That email address is already in use.",
|
|
177
|
+
) from exc
|
|
178
|
+
|
|
179
|
+
previous_email = user.email
|
|
180
|
+
user.email = new_email
|
|
181
|
+
await rs.lockout.clear(previous_email)
|
|
182
|
+
await rs.hooks.fire("email_changed", user=user, previous_email=previous_email)
|
|
183
|
+
return UserPublic.from_user(user)
|
|
184
|
+
|
|
185
|
+
@router.delete(
|
|
186
|
+
"/account",
|
|
187
|
+
response_model=MessageResponse,
|
|
188
|
+
summary="Permanently delete the authenticated user's account",
|
|
189
|
+
)
|
|
190
|
+
async def delete_account(
|
|
191
|
+
payload: Annotated[DeleteAccountRequest, Body()],
|
|
192
|
+
user: BaseUser = Depends(rs.deps.current_user()),
|
|
193
|
+
) -> MessageResponse:
|
|
194
|
+
if not rs.config.enable_account_deletion:
|
|
195
|
+
raise HTTPException(
|
|
196
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
197
|
+
detail="Account deletion is disabled for this application.",
|
|
198
|
+
)
|
|
199
|
+
if not rs.password_hasher.verify(payload.current_password, user.hashed_password):
|
|
200
|
+
raise HTTPException(
|
|
201
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
202
|
+
detail="Current password is incorrect.",
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
assert user.id is not None
|
|
206
|
+
await rs.users.delete(user.id)
|
|
207
|
+
await rs.pending.delete_by_email(user.email)
|
|
208
|
+
await rs.lockout.clear(user.email)
|
|
209
|
+
await rs.hooks.fire("user_deleted", user=user)
|
|
210
|
+
return MessageResponse(message="Account deleted.")
|
|
211
|
+
|
|
212
|
+
return router
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _encode_email_change_token(rs: RegStack, user_id: str, new_email: str, ttl: int) -> str:
|
|
216
|
+
"""Mint a JWT carrying both ``sub=user_id`` and a ``new_email`` claim.
|
|
217
|
+
|
|
218
|
+
Goes through pyjwt directly (rather than ``rs.jwt.encode``) because we
|
|
219
|
+
need a custom claim. Same per-purpose key derivation though, so this
|
|
220
|
+
token is unforgeable from a session token.
|
|
221
|
+
"""
|
|
222
|
+
import secrets as _secrets
|
|
223
|
+
|
|
224
|
+
now = rs.clock.now()
|
|
225
|
+
from datetime import timedelta
|
|
226
|
+
|
|
227
|
+
payload: dict[str, Any] = {
|
|
228
|
+
"sub": user_id,
|
|
229
|
+
"jti": _secrets.token_urlsafe(16),
|
|
230
|
+
"iat": int(now.timestamp()),
|
|
231
|
+
"exp": int((now + timedelta(seconds=ttl)).timestamp()),
|
|
232
|
+
"purpose": _EMAIL_CHANGE_PURPOSE,
|
|
233
|
+
_NEW_EMAIL_CLAIM: new_email,
|
|
234
|
+
}
|
|
235
|
+
if rs.config.jwt_audience is not None:
|
|
236
|
+
payload["aud"] = rs.config.jwt_audience
|
|
237
|
+
key = derive_secret(rs.config.jwt_secret.get_secret_value(), _EMAIL_CHANGE_PURPOSE)
|
|
238
|
+
return pyjwt.encode(payload, key, algorithm=rs.config.jwt_algorithm)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _decode_email_change_token(rs: RegStack, token: str) -> tuple[str, str]:
|
|
242
|
+
key = derive_secret(rs.config.jwt_secret.get_secret_value(), _EMAIL_CHANGE_PURPOSE)
|
|
243
|
+
try:
|
|
244
|
+
claims = pyjwt.decode(
|
|
245
|
+
token,
|
|
246
|
+
key,
|
|
247
|
+
algorithms=[rs.config.jwt_algorithm],
|
|
248
|
+
audience=rs.config.jwt_audience,
|
|
249
|
+
options={
|
|
250
|
+
"require": ["sub", "exp", "iat", "jti", "purpose", _NEW_EMAIL_CLAIM],
|
|
251
|
+
"verify_exp": False,
|
|
252
|
+
"verify_iat": False,
|
|
253
|
+
"verify_nbf": False,
|
|
254
|
+
},
|
|
255
|
+
)
|
|
256
|
+
except pyjwt.PyJWTError as exc:
|
|
257
|
+
raise TokenError(str(exc)) from exc
|
|
258
|
+
|
|
259
|
+
if claims.get("purpose") != _EMAIL_CHANGE_PURPOSE:
|
|
260
|
+
raise TokenError("token purpose mismatch")
|
|
261
|
+
|
|
262
|
+
from datetime import datetime
|
|
263
|
+
|
|
264
|
+
now = rs.clock.now()
|
|
265
|
+
exp = datetime.fromtimestamp(int(claims["exp"]), tz=now.tzinfo)
|
|
266
|
+
if now >= exp:
|
|
267
|
+
raise TokenError("Token has expired")
|
|
268
|
+
|
|
269
|
+
return str(claims["sub"]), str(claims[_NEW_EMAIL_CLAIM])
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _email_change_url(rs: RegStack, token: str) -> str:
|
|
273
|
+
base = str(rs.config.base_url).rstrip("/")
|
|
274
|
+
return f"{base}/confirm-email-change?token={token}"
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import timedelta
|
|
4
|
+
from typing import TYPE_CHECKING, Annotated
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
|
+
|
|
9
|
+
from regstack.auth.tokens import generate_verification_token
|
|
10
|
+
from regstack.models.pending_registration import PendingRegistration
|
|
11
|
+
from regstack.models.user import BaseUser, UserPublic
|
|
12
|
+
from regstack.routers._schemas import MessageResponse
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from regstack.app import RegStack
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AdminUserUpdate(BaseModel):
|
|
19
|
+
model_config = ConfigDict(extra="forbid")
|
|
20
|
+
is_active: bool | None = None
|
|
21
|
+
is_superuser: bool | None = None
|
|
22
|
+
full_name: str | None = Field(default=None, max_length=200)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AdminStats(BaseModel):
|
|
26
|
+
total_users: int
|
|
27
|
+
active_users: int
|
|
28
|
+
verified_users: int
|
|
29
|
+
superusers: int
|
|
30
|
+
pending_registrations: int
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class UserListResponse(BaseModel):
|
|
34
|
+
items: list[UserPublic]
|
|
35
|
+
total: int
|
|
36
|
+
skip: int
|
|
37
|
+
limit: int
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def build_admin_router(rs: RegStack) -> APIRouter:
|
|
41
|
+
router = APIRouter(prefix="/admin", tags=["regstack-admin"])
|
|
42
|
+
|
|
43
|
+
@router.get(
|
|
44
|
+
"/stats",
|
|
45
|
+
response_model=AdminStats,
|
|
46
|
+
summary="Aggregate counts for the admin dashboard",
|
|
47
|
+
)
|
|
48
|
+
async def stats(_admin: BaseUser = Depends(rs.deps.current_admin())) -> AdminStats:
|
|
49
|
+
total = await rs.users.count()
|
|
50
|
+
active = await rs.users.count(filter_={"is_active": True})
|
|
51
|
+
verified = await rs.users.count(filter_={"is_verified": True})
|
|
52
|
+
supers = await rs.users.count(filter_={"is_superuser": True})
|
|
53
|
+
pending = await rs.pending._collection.count_documents({}) # type: ignore[attr-defined]
|
|
54
|
+
return AdminStats(
|
|
55
|
+
total_users=total,
|
|
56
|
+
active_users=active,
|
|
57
|
+
verified_users=verified,
|
|
58
|
+
superusers=supers,
|
|
59
|
+
pending_registrations=pending,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
@router.get(
|
|
63
|
+
"/users",
|
|
64
|
+
response_model=UserListResponse,
|
|
65
|
+
summary="List users (paginated)",
|
|
66
|
+
)
|
|
67
|
+
async def list_users(
|
|
68
|
+
skip: Annotated[int, Query(ge=0)] = 0,
|
|
69
|
+
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
|
70
|
+
_admin: BaseUser = Depends(rs.deps.current_admin()),
|
|
71
|
+
) -> UserListResponse:
|
|
72
|
+
users = await rs.users.list_paged(skip=skip, limit=limit)
|
|
73
|
+
total = await rs.users.count()
|
|
74
|
+
return UserListResponse(
|
|
75
|
+
items=[UserPublic.from_user(u) for u in users],
|
|
76
|
+
total=total,
|
|
77
|
+
skip=skip,
|
|
78
|
+
limit=limit,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
@router.get(
|
|
82
|
+
"/users/{user_id}",
|
|
83
|
+
response_model=UserPublic,
|
|
84
|
+
summary="Fetch a single user by id",
|
|
85
|
+
)
|
|
86
|
+
async def get_user(
|
|
87
|
+
user_id: str = Path(...),
|
|
88
|
+
_admin: BaseUser = Depends(rs.deps.current_admin()),
|
|
89
|
+
) -> UserPublic:
|
|
90
|
+
user = await _require_user(rs, user_id)
|
|
91
|
+
return UserPublic.from_user(user)
|
|
92
|
+
|
|
93
|
+
@router.patch(
|
|
94
|
+
"/users/{user_id}",
|
|
95
|
+
response_model=UserPublic,
|
|
96
|
+
summary="Update mutable user flags",
|
|
97
|
+
)
|
|
98
|
+
async def update_user(
|
|
99
|
+
payload: AdminUserUpdate,
|
|
100
|
+
user_id: str = Path(...),
|
|
101
|
+
_admin: BaseUser = Depends(rs.deps.current_admin()),
|
|
102
|
+
) -> UserPublic:
|
|
103
|
+
user = await _require_user(rs, user_id)
|
|
104
|
+
assert user.id is not None
|
|
105
|
+
|
|
106
|
+
if payload.is_active is not None:
|
|
107
|
+
await rs.users.set_active(user.id, is_active=payload.is_active)
|
|
108
|
+
user.is_active = payload.is_active
|
|
109
|
+
if payload.is_active is False:
|
|
110
|
+
# A disabled user should not retain a live session.
|
|
111
|
+
await rs.users.set_tokens_invalidated_after(user.id, rs.clock.now())
|
|
112
|
+
if payload.is_superuser is not None:
|
|
113
|
+
await rs.users.set_superuser(user.id, is_superuser=payload.is_superuser)
|
|
114
|
+
user.is_superuser = payload.is_superuser
|
|
115
|
+
if payload.full_name is not None:
|
|
116
|
+
await rs.users.set_full_name(user.id, payload.full_name)
|
|
117
|
+
user.full_name = payload.full_name
|
|
118
|
+
return UserPublic.from_user(user)
|
|
119
|
+
|
|
120
|
+
@router.delete(
|
|
121
|
+
"/users/{user_id}",
|
|
122
|
+
response_model=MessageResponse,
|
|
123
|
+
summary="Permanently delete a user",
|
|
124
|
+
)
|
|
125
|
+
async def delete_user(
|
|
126
|
+
user_id: str = Path(...),
|
|
127
|
+
_admin: BaseUser = Depends(rs.deps.current_admin()),
|
|
128
|
+
) -> MessageResponse:
|
|
129
|
+
user = await _require_user(rs, user_id)
|
|
130
|
+
assert user.id is not None
|
|
131
|
+
await rs.users.delete(user.id)
|
|
132
|
+
await rs.pending.delete_by_email(user.email)
|
|
133
|
+
await rs.lockout.clear(user.email)
|
|
134
|
+
await rs.hooks.fire("user_deleted", user=user)
|
|
135
|
+
return MessageResponse(message=f"User {user.email} deleted.")
|
|
136
|
+
|
|
137
|
+
@router.post(
|
|
138
|
+
"/users/{user_id}/resend-verification",
|
|
139
|
+
response_model=MessageResponse,
|
|
140
|
+
status_code=status.HTTP_202_ACCEPTED,
|
|
141
|
+
summary="Re-send a verification email for an unverified user",
|
|
142
|
+
)
|
|
143
|
+
async def admin_resend_verification(
|
|
144
|
+
user_id: str = Path(...),
|
|
145
|
+
_admin: BaseUser = Depends(rs.deps.current_admin()),
|
|
146
|
+
) -> MessageResponse:
|
|
147
|
+
user = await _require_user(rs, user_id)
|
|
148
|
+
if user.is_verified:
|
|
149
|
+
raise HTTPException(
|
|
150
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
151
|
+
detail="User is already verified.",
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Move the user to a pending registration row so the standard verify
|
|
155
|
+
# endpoint completes the flow. Less special-case code, one path.
|
|
156
|
+
raw, token_hash = generate_verification_token()
|
|
157
|
+
ttl = rs.config.verification_token_ttl_seconds
|
|
158
|
+
pending = PendingRegistration(
|
|
159
|
+
email=user.email,
|
|
160
|
+
hashed_password=user.hashed_password,
|
|
161
|
+
full_name=user.full_name,
|
|
162
|
+
token_hash=token_hash,
|
|
163
|
+
expires_at=rs.clock.now() + timedelta(seconds=ttl),
|
|
164
|
+
)
|
|
165
|
+
await rs.pending.upsert(pending)
|
|
166
|
+
|
|
167
|
+
base = str(rs.config.base_url).rstrip("/")
|
|
168
|
+
url = f"{base}/verify?token={raw}"
|
|
169
|
+
message = rs.mail.verification(to=user.email, full_name=user.full_name, url=url)
|
|
170
|
+
await rs.email.send(message)
|
|
171
|
+
await rs.hooks.fire("verification_requested", email=user.email, url=url)
|
|
172
|
+
return MessageResponse(message=f"Verification email sent to {user.email}.")
|
|
173
|
+
|
|
174
|
+
return router
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
async def _require_user(rs: RegStack, user_id: str) -> BaseUser:
|
|
178
|
+
user = await rs.users.get_by_id(user_id)
|
|
179
|
+
if user is None:
|
|
180
|
+
raise HTTPException(
|
|
181
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
182
|
+
detail="User not found.",
|
|
183
|
+
)
|
|
184
|
+
return user
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
__all__ = ["AdminStats", "AdminUserUpdate", "UserListResponse", "build_admin_router"]
|