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
|
@@ -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
|
+
]
|