Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions app/api/routes_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from app.core.audit import AuditEvent, get_audit_logger
from app.core import auth_lockout
from app.core.auth import hash_password, mint_jwt, verify_password
from app.core import password_reset
from app.core import refresh_tokens
from app.core.token_revocation import revoke as revoke_jti
from app.db.tenants_repository import get_tenants_repository
Expand Down Expand Up @@ -102,6 +103,24 @@ class RefreshRequest(BaseModel):
refresh_token: str = Field(min_length=1)


class ForgotPasswordRequest(BaseModel):
email: str

@field_validator("email")
@classmethod
def _email(cls, v: str) -> str:
return _validate_email(v)


class ResetPasswordRequest(BaseModel):
token: str = Field(min_length=1)
password: str = Field(min_length=1)


class MessageResponse(BaseModel):
message: str


class LogoutRequest(BaseModel):
# Optional: when present, the refresh token is revoked too so the session
# cannot be continued after logout.
Expand Down Expand Up @@ -401,6 +420,117 @@ async def refresh(req: RefreshRequest) -> AuthResponse:
)


@router.post("/forgot-password", response_model=MessageResponse)
async def forgot_password(req: ForgotPasswordRequest) -> MessageResponse:
"""Start a password reset for the given email.

Deliberately enumeration-safe: the response is identical whether or not the
email maps to an account, so an attacker can't probe which addresses are
registered. When the email does belong to an active user we mint a
short-lived single-use reset token and email a link; the work happens behind
the same neutral 200.
"""
settings = get_settings()
# Same neutral message in every branch - never reveals account existence.
neutral = MessageResponse(
message="If that email belongs to an account, a reset link is on its way.",
)

users = get_users_repository()
record = users.find_by_email_any_tenant(req.email)
if record is None:
return neutral

try:
ttl_minutes = int(getattr(settings, "password_reset_ttl_minutes", 30))
raw_token = password_reset.issue(
user_id=record["id"],
tenant_id=record["tenant_id"],
email=record["email"],
ttl_seconds=ttl_minutes * 60,
)
from app.core.email import send_password_reset_email

delivery = send_password_reset_email(
to_email=record["email"], raw_token=raw_token, ttl_minutes=ttl_minutes
)
audit_logger.write(AuditEvent(
event_type="auth_password_reset_requested",
tenant_id=record["tenant_id"],
user_id=record["id"],
role=record["role"],
route="/auth/forgot-password",
request={"email": record["email"]},
response={"email_sent": bool(delivery.get("email_sent"))},
metadata={},
))
except Exception: # noqa: BLE001
# Never surface an internal failure here - it would turn into an oracle
# (error vs neutral 200 => email exists). The user can retry.
pass
return neutral


@router.post("/reset-password", response_model=AuthResponse)
async def reset_password(req: ResetPasswordRequest) -> AuthResponse:
"""Complete a password reset: consume the emailed token, set the new
password, and sign the user in (returning a fresh access + refresh pair).

The reset token is single-use and short-lived; an unknown/expired/used token
yields an opaque 400 so nothing is leaked about why it failed.
"""
_validate_password(req.password)

invalid = HTTPException(
status_code=400, detail="this reset link is invalid or has expired"
)
ref = password_reset.consume(req.token)
if ref is None:
raise invalid

users = get_users_repository()
record = users.get(tenant_id=ref.tenant_id, user_id=ref.user_id)
if record is None or record.get("status") != "active":
# The token was already consumed above, so a deactivated/missing account
# simply cannot complete the reset.
raise invalid

try:
record = users.set_password(
tenant_id=ref.tenant_id,
user_id=ref.user_id,
password_hash=hash_password(req.password),
)
except KeyError as exc:
raise invalid from exc

# A successful reset clears any active lockout so the user isn't locked out of
# the account they just regained control of.
try:
auth_lockout.record_success(record["tenant_id"], record["email"])
except Exception: # noqa: BLE001
pass

token = _mint_for(record)
refresh_token = _issue_refresh(record)
audit_logger.write(AuditEvent(
event_type="auth_password_reset_completed",
tenant_id=record["tenant_id"],
user_id=record["id"],
role=record["role"],
route="/auth/reset-password",
request={"email": record["email"]},
response={"user_id": record["id"]},
metadata={},
))
return AuthResponse(
token=token,
refresh_token=refresh_token,
principal=_to_principal_payload(record),
onboarding_required=False,
)


@router.post("/logout", status_code=204)
async def logout(
authorization: str = Header(default=""),
Expand Down
3 changes: 3 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,9 @@ class Settings(BaseSettings):
# lockout. We have lockout (below) but not yet HIBP; raising to 12 reduces
# the brute-forceable space while we wire that up.
password_min_length: int = 12
# "Forgot password" reset links are short-lived and single-use. Kept tight
# because the link is a bearer credential delivered over email.
password_reset_ttl_minutes: int = 30
# Per-account brute force lockout. After this many consecutive failed
# /auth/signin attempts within auth_lockout_window_minutes, further
# attempts for the same email are rejected for auth_lockout_minutes.
Expand Down
79 changes: 79 additions & 0 deletions app/core/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ def _invite_url(raw_token: str) -> str:
return f"{base}/invitations/{raw_token}"


def _reset_url(raw_token: str) -> str:
base = get_settings().app_public_base_url.rstrip("/")
return f"{base}/reset-password?token={raw_token}"


def _render_invite_html(
*, company_name: str, role_label: str, invite_url: str, expires: str, message: str | None
) -> str:
Expand Down Expand Up @@ -78,6 +83,80 @@ def _render_invite_html(
</div>"""


def _render_reset_html(*, reset_url: str, ttl_minutes: int) -> str:
expiry = (
f"about {ttl_minutes} minutes"
if ttl_minutes < 60
else f"about {ttl_minutes // 60} hour(s)"
)
return f"""\
<div style="background:#fafafa;padding:32px 0;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;">
<div style="max-width:480px;margin:0 auto;background:#ffffff;border:1px solid #e4e4e7;border-radius:16px;padding:32px;">
<p style="margin:0 0 8px;color:#ea580c;font-size:12px;font-weight:600;letter-spacing:0.08em;text-transform:uppercase;">PetroBrain password reset</p>
<h1 style="margin:0 0 16px;color:#18181b;font-size:22px;">Reset your password</h1>
<p style="margin:0 0 16px;color:#3f3f46;font-size:14px;line-height:22px;">
We received a request to reset the password on your PetroBrain account. Click the button below to choose a new one. If you did not request this, you can safely ignore this email - your password will not change.
</p>
<a href="{reset_url}" style="display:inline-block;background:#ea580c;color:#ffffff;text-decoration:none;font-size:14px;font-weight:600;padding:12px 24px;border-radius:12px;">Reset password</a>
<p style="margin:20px 0 0;color:#a1a1aa;font-size:12px;line-height:18px;word-break:break-all;">
Or paste this link into your browser:<br/>{reset_url}
</p>
<p style="margin:16px 0 0;color:#a1a1aa;font-size:12px;">This link expires in {expiry} and can be used once.</p>
</div>
</div>"""


def send_password_reset_email(
*, to_email: str, raw_token: str, ttl_minutes: int
) -> dict[str, Any]:
"""Send a password-reset email through Resend.

Returns a ``delivery`` dict; never raises. When no Resend key is configured
the reset token is still valid and the caller can surface the link directly
(e.g. in dev) without crashing the request path.
"""
settings = get_settings()
api_key = settings.resend_api_key.strip()
reset_url = _reset_url(raw_token)
if not api_key:
return {
"email_sent": False,
"message": "Password reset prepared, but email delivery is not enabled.",
"reset_url": reset_url,
}

payload = {
"from": settings.invite_email_from,
"to": [to_email],
"subject": "Reset your PetroBrain password",
"html": _render_reset_html(reset_url=reset_url, ttl_minutes=ttl_minutes),
}
try:
response = httpx.post(
RESEND_ENDPOINT,
headers={"Authorization": f"Bearer {api_key}"},
json=payload,
timeout=_TIMEOUT_SECONDS,
)
response.raise_for_status()
except httpx.HTTPStatusError as exc:
detail = exc.response.text[:200] if exc.response is not None else str(exc)
logger.warning("resend password reset email failed: %s", detail)
return {
"email_sent": False,
"message": "Could not deliver the reset email.",
"reset_url": reset_url,
}
except httpx.HTTPError as exc:
logger.warning("resend password reset email transport error: %s", exc)
return {
"email_sent": False,
"message": "Email service was unreachable.",
"reset_url": reset_url,
}
return {"email_sent": True, "message": f"Password reset email sent to {to_email}."}


def send_invitation_email(
*,
to_email: str,
Expand Down
8 changes: 7 additions & 1 deletion app/core/http_hardening.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,13 @@ def add_security_headers(response: Response) -> Response:
def rate_limit_key(request: Request, settings: Settings) -> tuple[str, int] | None:
path = request.url.path
method = request.method.upper()
if path in {"/auth/signup", "/auth/signin", "/auth/refresh"} and method == "POST":
if path in {
"/auth/signup",
"/auth/signin",
"/auth/refresh",
"/auth/forgot-password",
"/auth/reset-password",
} and method == "POST":
# Auth routes are always IP-keyed: a credential-stuffing attacker isn't
# carrying a valid JWT, so principal-based keying would do nothing.
return (
Expand Down
Loading
Loading