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.
Files changed (92) hide show
  1. regstack/__init__.py +5 -0
  2. regstack/app.py +150 -0
  3. regstack/auth/__init__.py +21 -0
  4. regstack/auth/clock.py +29 -0
  5. regstack/auth/dependencies.py +102 -0
  6. regstack/auth/jwt.py +145 -0
  7. regstack/auth/lockout.py +59 -0
  8. regstack/auth/mfa.py +29 -0
  9. regstack/auth/password.py +20 -0
  10. regstack/auth/tokens.py +19 -0
  11. regstack/cli/__init__.py +0 -0
  12. regstack/cli/__main__.py +27 -0
  13. regstack/cli/_runtime.py +39 -0
  14. regstack/cli/admin.py +45 -0
  15. regstack/cli/doctor.py +186 -0
  16. regstack/cli/init.py +236 -0
  17. regstack/config/__init__.py +4 -0
  18. regstack/config/loader.py +114 -0
  19. regstack/config/schema.py +148 -0
  20. regstack/config/secrets.py +22 -0
  21. regstack/db/__init__.py +17 -0
  22. regstack/db/client.py +26 -0
  23. regstack/db/indexes.py +70 -0
  24. regstack/db/repositories/__init__.py +0 -0
  25. regstack/db/repositories/blacklist_repo.py +28 -0
  26. regstack/db/repositories/login_attempt_repo.py +27 -0
  27. regstack/db/repositories/mfa_code_repo.py +99 -0
  28. regstack/db/repositories/pending_repo.py +76 -0
  29. regstack/db/repositories/user_repo.py +169 -0
  30. regstack/email/__init__.py +12 -0
  31. regstack/email/base.py +23 -0
  32. regstack/email/composer.py +142 -0
  33. regstack/email/console.py +28 -0
  34. regstack/email/factory.py +23 -0
  35. regstack/email/ses.py +47 -0
  36. regstack/email/smtp.py +46 -0
  37. regstack/email/templates/email_change.html +15 -0
  38. regstack/email/templates/email_change.subject.txt +1 -0
  39. regstack/email/templates/email_change.txt +7 -0
  40. regstack/email/templates/password_reset.html +15 -0
  41. regstack/email/templates/password_reset.subject.txt +1 -0
  42. regstack/email/templates/password_reset.txt +7 -0
  43. regstack/email/templates/sms_login_mfa.txt +1 -0
  44. regstack/email/templates/sms_phone_setup.txt +1 -0
  45. regstack/email/templates/verification.html +15 -0
  46. regstack/email/templates/verification.subject.txt +1 -0
  47. regstack/email/templates/verification.txt +7 -0
  48. regstack/hooks/__init__.py +3 -0
  49. regstack/hooks/events.py +59 -0
  50. regstack/models/__init__.py +15 -0
  51. regstack/models/_objectid.py +30 -0
  52. regstack/models/login_attempt.py +31 -0
  53. regstack/models/mfa_code.py +40 -0
  54. regstack/models/pending_registration.py +38 -0
  55. regstack/models/user.py +104 -0
  56. regstack/routers/__init__.py +37 -0
  57. regstack/routers/_schemas.py +34 -0
  58. regstack/routers/account.py +274 -0
  59. regstack/routers/admin.py +187 -0
  60. regstack/routers/login.py +223 -0
  61. regstack/routers/logout.py +39 -0
  62. regstack/routers/password.py +114 -0
  63. regstack/routers/phone.py +242 -0
  64. regstack/routers/register.py +99 -0
  65. regstack/routers/verify.py +116 -0
  66. regstack/sms/__init__.py +5 -0
  67. regstack/sms/base.py +24 -0
  68. regstack/sms/factory.py +23 -0
  69. regstack/sms/null.py +26 -0
  70. regstack/sms/sns.py +42 -0
  71. regstack/sms/twilio.py +49 -0
  72. regstack/ui/__init__.py +3 -0
  73. regstack/ui/pages.py +148 -0
  74. regstack/ui/static/css/core.css +204 -0
  75. regstack/ui/static/css/theme.css +43 -0
  76. regstack/ui/static/js/regstack.js +411 -0
  77. regstack/ui/templates/auth/email_change_confirm.html +10 -0
  78. regstack/ui/templates/auth/forgot.html +14 -0
  79. regstack/ui/templates/auth/login.html +24 -0
  80. regstack/ui/templates/auth/me.html +110 -0
  81. regstack/ui/templates/auth/mfa_confirm.html +14 -0
  82. regstack/ui/templates/auth/register.html +23 -0
  83. regstack/ui/templates/auth/reset.html +13 -0
  84. regstack/ui/templates/auth/verify.html +10 -0
  85. regstack/ui/templates/base.html +46 -0
  86. regstack/version.py +1 -0
  87. regstack-0.1.0.dist-info/METADATA +209 -0
  88. regstack-0.1.0.dist-info/RECORD +92 -0
  89. regstack-0.1.0.dist-info/WHEEL +4 -0
  90. regstack-0.1.0.dist-info/entry_points.txt +2 -0
  91. regstack-0.1.0.dist-info/licenses/LICENSE +202 -0
  92. regstack-0.1.0.dist-info/licenses/NOTICE +5 -0
@@ -0,0 +1,223 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import timedelta
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ from fastapi import APIRouter, HTTPException, status
7
+ from fastapi.responses import JSONResponse
8
+ from pydantic import BaseModel, ConfigDict, Field
9
+
10
+ from regstack.auth.jwt import TokenError
11
+ from regstack.auth.mfa import generate_mfa_code
12
+ from regstack.db.repositories.mfa_code_repo import MfaVerifyOutcome
13
+ from regstack.models.mfa_code import MfaCode
14
+ from regstack.routers._schemas import LoginRequest, TokenResponse
15
+ from regstack.sms.base import SmsMessage
16
+
17
+ if TYPE_CHECKING:
18
+ from regstack.app import RegStack
19
+
20
+
21
+ _INVALID = HTTPException(
22
+ status_code=status.HTTP_401_UNAUTHORIZED,
23
+ detail="Invalid email or password.",
24
+ )
25
+ _LOGIN_MFA_PURPOSE = "login_mfa"
26
+
27
+
28
+ class MfaPendingResponse(BaseModel):
29
+ status: str = "mfa_required"
30
+ mfa_pending_token: str
31
+ expires_in: int
32
+ delivery: str = "sms"
33
+
34
+
35
+ class MfaConfirmRequest(BaseModel):
36
+ model_config = ConfigDict(extra="forbid")
37
+ mfa_pending_token: str
38
+ code: str = Field(min_length=4, max_length=10)
39
+
40
+
41
+ def build_login_router(rs: RegStack) -> APIRouter:
42
+ router = APIRouter()
43
+
44
+ @router.post(
45
+ "/login",
46
+ response_model=TokenResponse | MfaPendingResponse,
47
+ responses={
48
+ 401: {"description": "Invalid credentials"},
49
+ 403: {"description": "Account disabled or unverified"},
50
+ 429: {"description": "Too many failed attempts; account temporarily locked"},
51
+ },
52
+ summary="Exchange credentials for a JWT — or start the MFA second step",
53
+ )
54
+ async def login(payload: LoginRequest):
55
+ decision = await rs.lockout.check(payload.email)
56
+ if decision.locked:
57
+ return JSONResponse(
58
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
59
+ content={
60
+ "detail": (
61
+ "Too many failed attempts. "
62
+ f"Try again in {decision.retry_after_seconds} seconds."
63
+ )
64
+ },
65
+ headers={"Retry-After": str(decision.retry_after_seconds)},
66
+ )
67
+
68
+ user = await rs.users.get_by_email(payload.email)
69
+ if user is None or user.id is None:
70
+ await rs.lockout.record_failure(payload.email)
71
+ raise _INVALID
72
+ if not user.is_active:
73
+ raise HTTPException(
74
+ status_code=status.HTTP_403_FORBIDDEN,
75
+ detail="Account is disabled.",
76
+ )
77
+ if not rs.password_hasher.verify(payload.password, user.hashed_password):
78
+ await rs.lockout.record_failure(payload.email)
79
+ raise _INVALID
80
+ if rs.config.require_verification and not user.is_verified:
81
+ raise HTTPException(
82
+ status_code=status.HTTP_403_FORBIDDEN,
83
+ detail="Email address has not been verified.",
84
+ )
85
+
86
+ if user.is_mfa_enabled and user.phone_number:
87
+ return await _start_mfa_step(rs, user)
88
+
89
+ token, payload_obj = rs.jwt.encode(user.id)
90
+ await rs.users.set_last_login(user.id, payload_obj.iat)
91
+ await rs.lockout.clear(user.email)
92
+ await rs.hooks.fire("user_logged_in", user=user)
93
+ return TokenResponse(access_token=token, expires_in=rs.config.jwt_ttl_seconds)
94
+
95
+ @router.post(
96
+ "/login/mfa-confirm",
97
+ response_model=TokenResponse,
98
+ summary="Complete an MFA-required login by submitting the SMS code",
99
+ )
100
+ async def mfa_confirm(payload: MfaConfirmRequest) -> TokenResponse:
101
+ try:
102
+ user_id = _decode_mfa_token(rs, payload.mfa_pending_token)
103
+ except TokenError as exc:
104
+ raise HTTPException(
105
+ status_code=status.HTTP_400_BAD_REQUEST,
106
+ detail="MFA token is invalid or has expired.",
107
+ ) from exc
108
+
109
+ result = await rs.mfa_codes.verify(user_id=user_id, kind="login_mfa", raw_code=payload.code)
110
+ if result.outcome is not MfaVerifyOutcome.OK:
111
+ raise _mfa_outcome(result)
112
+
113
+ user = await rs.users.get_by_id(user_id)
114
+ if user is None or user.id is None or not user.is_active:
115
+ raise HTTPException(
116
+ status_code=status.HTTP_400_BAD_REQUEST,
117
+ detail="Token does not match an active account.",
118
+ )
119
+
120
+ token, payload_obj = rs.jwt.encode(user.id)
121
+ await rs.users.set_last_login(user.id, payload_obj.iat)
122
+ await rs.lockout.clear(user.email)
123
+ await rs.hooks.fire("user_logged_in", user=user)
124
+ return TokenResponse(access_token=token, expires_in=rs.config.jwt_ttl_seconds)
125
+
126
+ return router
127
+
128
+
129
+ async def _start_mfa_step(rs: RegStack, user) -> MfaPendingResponse:
130
+ raw_code, code_hash = generate_mfa_code(rs.config)
131
+ ttl = rs.config.sms_code_ttl_seconds
132
+ assert user.id is not None
133
+ await rs.mfa_codes.put(
134
+ MfaCode(
135
+ user_id=user.id,
136
+ kind="login_mfa",
137
+ code_hash=code_hash,
138
+ expires_at=rs.clock.now() + timedelta(seconds=ttl),
139
+ max_attempts=rs.config.sms_code_max_attempts,
140
+ )
141
+ )
142
+ body = rs.mail.sms_body(
143
+ kind="login_mfa",
144
+ code=raw_code,
145
+ ttl_minutes=max(ttl // 60, 1),
146
+ )
147
+ await rs.sms.send(
148
+ SmsMessage(to=user.phone_number, body=body, from_number=rs.config.sms.from_number)
149
+ )
150
+ pending_ttl = rs.config.mfa_pending_token_ttl_seconds
151
+ pending_token = _encode_mfa_token(rs, user.id, pending_ttl)
152
+ await rs.hooks.fire("mfa_login_started", user=user, code=raw_code)
153
+ return MfaPendingResponse(mfa_pending_token=pending_token, expires_in=pending_ttl)
154
+
155
+
156
+ def _encode_mfa_token(rs: RegStack, user_id: str, ttl: int) -> str:
157
+ import secrets as _secrets
158
+
159
+ import jwt as pyjwt
160
+
161
+ from regstack.config.secrets import derive_secret
162
+
163
+ now = rs.clock.now()
164
+ claims: dict[str, Any] = {
165
+ "sub": user_id,
166
+ "jti": _secrets.token_urlsafe(16),
167
+ "iat": now.timestamp(),
168
+ "exp": int((now + timedelta(seconds=ttl)).timestamp()),
169
+ "purpose": _LOGIN_MFA_PURPOSE,
170
+ }
171
+ if rs.config.jwt_audience is not None:
172
+ claims["aud"] = rs.config.jwt_audience
173
+ key = derive_secret(rs.config.jwt_secret.get_secret_value(), _LOGIN_MFA_PURPOSE)
174
+ return pyjwt.encode(claims, key, algorithm=rs.config.jwt_algorithm)
175
+
176
+
177
+ def _decode_mfa_token(rs: RegStack, token: str) -> str:
178
+ from datetime import datetime
179
+
180
+ import jwt as pyjwt
181
+
182
+ from regstack.config.secrets import derive_secret
183
+
184
+ key = derive_secret(rs.config.jwt_secret.get_secret_value(), _LOGIN_MFA_PURPOSE)
185
+ try:
186
+ claims = pyjwt.decode(
187
+ token,
188
+ key,
189
+ algorithms=[rs.config.jwt_algorithm],
190
+ audience=rs.config.jwt_audience,
191
+ options={
192
+ "require": ["sub", "exp", "iat", "jti", "purpose"],
193
+ "verify_exp": False,
194
+ "verify_iat": False,
195
+ "verify_nbf": False,
196
+ },
197
+ )
198
+ except pyjwt.PyJWTError as exc:
199
+ raise TokenError(str(exc)) from exc
200
+ if claims.get("purpose") != _LOGIN_MFA_PURPOSE:
201
+ raise TokenError("token purpose mismatch")
202
+ now = rs.clock.now()
203
+ exp = datetime.fromtimestamp(int(claims["exp"]), tz=now.tzinfo)
204
+ if now >= exp:
205
+ raise TokenError("Token has expired")
206
+ return str(claims["sub"])
207
+
208
+
209
+ def _mfa_outcome(result):
210
+ code = result.outcome
211
+ if code is MfaVerifyOutcome.MISSING:
212
+ detail = "No pending sign-in code — start the login flow again."
213
+ elif code is MfaVerifyOutcome.EXPIRED:
214
+ detail = "Sign-in code has expired. Try logging in again."
215
+ elif code is MfaVerifyOutcome.LOCKED:
216
+ detail = "Too many wrong attempts — start the login flow again."
217
+ else:
218
+ detail = (
219
+ f"Wrong code; {result.attempts_remaining} attempts remaining."
220
+ if result.attempts_remaining
221
+ else "Wrong code."
222
+ )
223
+ return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from fastapi import APIRouter, Depends, Request, status
6
+
7
+ from regstack.auth.jwt import TokenError
8
+ from regstack.routers._schemas import MessageResponse
9
+
10
+ if TYPE_CHECKING:
11
+ from regstack.app import RegStack
12
+
13
+
14
+ def build_logout_router(rs: RegStack) -> APIRouter:
15
+ router = APIRouter()
16
+
17
+ @router.post(
18
+ "/logout",
19
+ response_model=MessageResponse,
20
+ status_code=status.HTTP_200_OK,
21
+ summary="Revoke the current bearer token",
22
+ )
23
+ async def logout(
24
+ request: Request,
25
+ _user=Depends(rs.deps.current_user()),
26
+ ) -> MessageResponse:
27
+ # Re-decode the token (the dep already validated it) to grab jti+exp
28
+ # so we can record the revocation. The auth header was already proven
29
+ # well-formed; this decode cannot raise.
30
+ auth = request.headers.get("authorization", "")
31
+ token = auth.split(" ", 1)[1] if " " in auth else ""
32
+ try:
33
+ payload = rs.jwt.decode(token)
34
+ except TokenError:
35
+ return MessageResponse(message="Logged out.")
36
+ await rs.blacklist.revoke(payload.jti, payload.exp)
37
+ return MessageResponse(message="Logged out.")
38
+
39
+ return router
@@ -0,0 +1,114 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from fastapi import APIRouter, HTTPException, status
6
+ from pydantic import BaseModel, ConfigDict, EmailStr, Field
7
+
8
+ from regstack.auth.jwt import TokenError
9
+ from regstack.routers._schemas import MessageResponse, PasswordStr
10
+
11
+ if TYPE_CHECKING:
12
+ from regstack.app import RegStack
13
+
14
+
15
+ _PASSWORD_RESET_PURPOSE = "password_reset"
16
+
17
+
18
+ class ForgotPasswordRequest(BaseModel):
19
+ model_config = ConfigDict(extra="forbid")
20
+ email: EmailStr
21
+
22
+
23
+ class ResetPasswordRequest(BaseModel):
24
+ model_config = ConfigDict(extra="forbid")
25
+ token: str
26
+ new_password: PasswordStr = Field(alias="new_password")
27
+
28
+
29
+ def build_password_router(rs: RegStack) -> APIRouter:
30
+ router = APIRouter()
31
+
32
+ @router.post(
33
+ "/forgot-password",
34
+ response_model=MessageResponse,
35
+ status_code=status.HTTP_202_ACCEPTED,
36
+ summary="Request a password-reset link (always succeeds)",
37
+ )
38
+ async def forgot(payload: ForgotPasswordRequest) -> MessageResponse:
39
+ if not rs.config.enable_password_reset:
40
+ raise HTTPException(
41
+ status_code=status.HTTP_404_NOT_FOUND,
42
+ detail="Password reset is disabled for this application.",
43
+ )
44
+
45
+ # Anti-enumeration: same response regardless of whether the user exists.
46
+ ack = MessageResponse(
47
+ message="If an account exists for that email, a reset link has been sent."
48
+ )
49
+ user = await rs.users.get_by_email(payload.email)
50
+ if user is None or user.id is None or not user.is_active:
51
+ return ack
52
+
53
+ ttl = rs.config.password_reset_token_ttl_seconds
54
+ token, _ = rs.jwt.encode(
55
+ user.id,
56
+ purpose=_PASSWORD_RESET_PURPOSE,
57
+ ttl_seconds=ttl,
58
+ )
59
+ url = _reset_url(rs, token)
60
+ message = rs.mail.password_reset(
61
+ to=user.email,
62
+ full_name=user.full_name,
63
+ url=url,
64
+ ttl_minutes=max(ttl // 60, 1),
65
+ )
66
+ await rs.email.send(message)
67
+ await rs.hooks.fire("password_reset_requested", user=user, url=url)
68
+ return ack
69
+
70
+ @router.post(
71
+ "/reset-password",
72
+ response_model=MessageResponse,
73
+ summary="Consume a password-reset link and set a new password",
74
+ )
75
+ async def reset(payload: ResetPasswordRequest) -> MessageResponse:
76
+ if not rs.config.enable_password_reset:
77
+ raise HTTPException(
78
+ status_code=status.HTTP_404_NOT_FOUND,
79
+ detail="Password reset is disabled for this application.",
80
+ )
81
+
82
+ try:
83
+ token_payload = rs.jwt.decode(payload.token, purpose=_PASSWORD_RESET_PURPOSE)
84
+ except TokenError as exc:
85
+ raise HTTPException(
86
+ status_code=status.HTTP_400_BAD_REQUEST,
87
+ detail="Reset token is invalid or has expired.",
88
+ ) from exc
89
+
90
+ user = await rs.users.get_by_id(token_payload.sub)
91
+ if user is None or user.id is None or not user.is_active:
92
+ raise HTTPException(
93
+ status_code=status.HTTP_400_BAD_REQUEST,
94
+ detail="Reset token does not match an active account.",
95
+ )
96
+
97
+ new_hash = rs.password_hasher.hash(payload.new_password)
98
+ # update_password also bumps tokens_invalidated_after, which bulk-revokes
99
+ # every outstanding session — this is essential after a reset because a
100
+ # stolen session token would otherwise outlive the password change.
101
+ await rs.users.update_password(user.id, new_hash)
102
+ await rs.lockout.clear(user.email)
103
+ await rs.hooks.fire("password_reset_completed", user=user)
104
+ return MessageResponse(message="Password has been reset. Please sign in.")
105
+
106
+ return router
107
+
108
+
109
+ def _reset_url(rs: RegStack, token: str) -> str:
110
+ base = str(rs.config.base_url).rstrip("/")
111
+ return f"{base}/reset-password?token={token}"
112
+
113
+
114
+ __all__ = ["ForgotPasswordRequest", "ResetPasswordRequest", "build_password_router"]
@@ -0,0 +1,242 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import timedelta
4
+ from typing import TYPE_CHECKING
5
+
6
+ from fastapi import APIRouter, Depends, HTTPException, status
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+
9
+ from regstack.auth.jwt import TokenError
10
+ from regstack.auth.mfa import generate_mfa_code
11
+ from regstack.db.repositories.mfa_code_repo import MfaVerifyOutcome
12
+ from regstack.models.mfa_code import MfaCode
13
+ from regstack.models.user import BaseUser, UserPublic
14
+ from regstack.routers._schemas import MessageResponse
15
+ from regstack.sms.base import SmsMessage, is_valid_e164
16
+
17
+ if TYPE_CHECKING:
18
+ from regstack.app import RegStack
19
+
20
+
21
+ _PHONE_SETUP_PURPOSE = "phone_setup"
22
+
23
+
24
+ class PhoneStartRequest(BaseModel):
25
+ model_config = ConfigDict(extra="forbid")
26
+ phone_number: str = Field(min_length=4, max_length=20)
27
+ current_password: str
28
+
29
+
30
+ class PhoneConfirmRequest(BaseModel):
31
+ model_config = ConfigDict(extra="forbid")
32
+ pending_token: str
33
+ code: str = Field(min_length=4, max_length=10)
34
+
35
+
36
+ class PhoneStartResponse(BaseModel):
37
+ status: str = "code_sent"
38
+ pending_token: str
39
+ expires_in: int
40
+
41
+
42
+ class PhoneDisableRequest(BaseModel):
43
+ model_config = ConfigDict(extra="forbid")
44
+ current_password: str
45
+
46
+
47
+ def build_phone_router(rs: RegStack) -> APIRouter:
48
+ router = APIRouter(prefix="/phone", tags=["regstack-phone"])
49
+
50
+ @router.post(
51
+ "/start",
52
+ response_model=PhoneStartResponse,
53
+ status_code=status.HTTP_202_ACCEPTED,
54
+ summary="Send a verification code to a new phone number",
55
+ )
56
+ async def start(
57
+ payload: PhoneStartRequest,
58
+ user: BaseUser = Depends(rs.deps.current_user()),
59
+ ) -> PhoneStartResponse:
60
+ if not is_valid_e164(payload.phone_number):
61
+ raise HTTPException(
62
+ status_code=status.HTTP_400_BAD_REQUEST,
63
+ detail="Phone number must be in E.164 format (e.g. +14155552671).",
64
+ )
65
+ if not rs.password_hasher.verify(payload.current_password, user.hashed_password):
66
+ raise HTTPException(
67
+ status_code=status.HTTP_400_BAD_REQUEST,
68
+ detail="Current password is incorrect.",
69
+ )
70
+
71
+ assert user.id is not None
72
+ raw_code, code_hash = generate_mfa_code(rs.config)
73
+ ttl = rs.config.sms_code_ttl_seconds
74
+ await rs.mfa_codes.put(
75
+ MfaCode(
76
+ user_id=user.id,
77
+ kind="phone_setup",
78
+ code_hash=code_hash,
79
+ expires_at=rs.clock.now() + timedelta(seconds=ttl),
80
+ max_attempts=rs.config.sms_code_max_attempts,
81
+ )
82
+ )
83
+
84
+ body = rs.mail.sms_body(
85
+ kind="phone_setup",
86
+ code=raw_code,
87
+ ttl_minutes=max(ttl // 60, 1),
88
+ )
89
+ await rs.sms.send(
90
+ SmsMessage(
91
+ to=payload.phone_number,
92
+ body=body,
93
+ from_number=rs.config.sms.from_number,
94
+ )
95
+ )
96
+
97
+ pending_ttl = rs.config.mfa_pending_token_ttl_seconds
98
+ token = _encode_phone_setup_token(rs, user.id, payload.phone_number, pending_ttl)
99
+ await rs.hooks.fire(
100
+ "phone_setup_started",
101
+ user=user,
102
+ phone=payload.phone_number,
103
+ code=raw_code,
104
+ )
105
+ return PhoneStartResponse(pending_token=token, expires_in=pending_ttl)
106
+
107
+ @router.post(
108
+ "/confirm",
109
+ response_model=UserPublic,
110
+ summary="Confirm a phone-setup code and enable SMS 2FA",
111
+ )
112
+ async def confirm(payload: PhoneConfirmRequest) -> UserPublic:
113
+ try:
114
+ user_id, phone = _decode_phone_setup_token(rs, payload.pending_token)
115
+ except TokenError as exc:
116
+ raise HTTPException(
117
+ status_code=status.HTTP_400_BAD_REQUEST,
118
+ detail="Pending token is invalid or has expired.",
119
+ ) from exc
120
+
121
+ result = await rs.mfa_codes.verify(
122
+ user_id=user_id, kind="phone_setup", raw_code=payload.code
123
+ )
124
+ if result.outcome is not MfaVerifyOutcome.OK:
125
+ raise _outcome_to_http(result)
126
+
127
+ user = await rs.users.get_by_id(user_id)
128
+ if user is None or user.id is None or not user.is_active:
129
+ raise HTTPException(
130
+ status_code=status.HTTP_400_BAD_REQUEST,
131
+ detail="Token does not match an active account.",
132
+ )
133
+ await rs.users.set_phone(user.id, phone)
134
+ await rs.users.set_mfa_enabled(user.id, is_mfa_enabled=True)
135
+ user.phone_number = phone
136
+ user.is_mfa_enabled = True
137
+ await rs.hooks.fire("mfa_enabled", user=user)
138
+ return UserPublic.from_user(user)
139
+
140
+ @router.delete(
141
+ "",
142
+ response_model=MessageResponse,
143
+ summary="Disable SMS 2FA and clear the phone number",
144
+ )
145
+ async def disable(
146
+ payload: PhoneDisableRequest,
147
+ user: BaseUser = Depends(rs.deps.current_user()),
148
+ ) -> MessageResponse:
149
+ if not rs.password_hasher.verify(payload.current_password, user.hashed_password):
150
+ raise HTTPException(
151
+ status_code=status.HTTP_400_BAD_REQUEST,
152
+ detail="Current password is incorrect.",
153
+ )
154
+ assert user.id is not None
155
+ await rs.users.set_phone(user.id, None)
156
+ await rs.users.set_mfa_enabled(user.id, is_mfa_enabled=False)
157
+ await rs.mfa_codes.delete(user_id=user.id)
158
+ await rs.hooks.fire("mfa_disabled", user=user)
159
+ return MessageResponse(message="SMS 2FA disabled.")
160
+
161
+ return router
162
+
163
+
164
+ def _encode_phone_setup_token(rs: RegStack, user_id: str, phone: str, ttl: int) -> str:
165
+ import secrets as _secrets
166
+ from datetime import timedelta
167
+
168
+ import jwt as pyjwt
169
+
170
+ from regstack.config.secrets import derive_secret
171
+
172
+ now = rs.clock.now()
173
+ claims = {
174
+ "sub": user_id,
175
+ "jti": _secrets.token_urlsafe(16),
176
+ "iat": now.timestamp(),
177
+ "exp": int((now + timedelta(seconds=ttl)).timestamp()),
178
+ "purpose": _PHONE_SETUP_PURPOSE,
179
+ "phone": phone,
180
+ }
181
+ if rs.config.jwt_audience is not None:
182
+ claims["aud"] = rs.config.jwt_audience
183
+ key = derive_secret(rs.config.jwt_secret.get_secret_value(), _PHONE_SETUP_PURPOSE)
184
+ return pyjwt.encode(claims, key, algorithm=rs.config.jwt_algorithm)
185
+
186
+
187
+ def _decode_phone_setup_token(rs: RegStack, token: str) -> tuple[str, str]:
188
+ from datetime import datetime
189
+
190
+ import jwt as pyjwt
191
+
192
+ from regstack.config.secrets import derive_secret
193
+
194
+ key = derive_secret(rs.config.jwt_secret.get_secret_value(), _PHONE_SETUP_PURPOSE)
195
+ try:
196
+ claims = pyjwt.decode(
197
+ token,
198
+ key,
199
+ algorithms=[rs.config.jwt_algorithm],
200
+ audience=rs.config.jwt_audience,
201
+ options={
202
+ "require": ["sub", "exp", "iat", "jti", "purpose", "phone"],
203
+ "verify_exp": False,
204
+ "verify_iat": False,
205
+ "verify_nbf": False,
206
+ },
207
+ )
208
+ except pyjwt.PyJWTError as exc:
209
+ raise TokenError(str(exc)) from exc
210
+ if claims.get("purpose") != _PHONE_SETUP_PURPOSE:
211
+ raise TokenError("token purpose mismatch")
212
+ now = rs.clock.now()
213
+ exp = datetime.fromtimestamp(int(claims["exp"]), tz=now.tzinfo)
214
+ if now >= exp:
215
+ raise TokenError("Token has expired")
216
+ return str(claims["sub"]), str(claims["phone"])
217
+
218
+
219
+ def _outcome_to_http(result):
220
+ code = result.outcome
221
+ if code is MfaVerifyOutcome.MISSING:
222
+ detail = "No pending verification code — request a new one."
223
+ elif code is MfaVerifyOutcome.EXPIRED:
224
+ detail = "Verification code has expired. Request a new one."
225
+ elif code is MfaVerifyOutcome.LOCKED:
226
+ detail = "Too many wrong attempts — request a new code."
227
+ else:
228
+ detail = (
229
+ f"Wrong code; {result.attempts_remaining} attempts remaining."
230
+ if result.attempts_remaining
231
+ else "Wrong code."
232
+ )
233
+ return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
234
+
235
+
236
+ __all__ = [
237
+ "PhoneConfirmRequest",
238
+ "PhoneDisableRequest",
239
+ "PhoneStartRequest",
240
+ "PhoneStartResponse",
241
+ "build_phone_router",
242
+ ]