diff --git a/app/api/routes_auth.py b/app/api/routes_auth.py index 17a9bf4..b009d16 100644 --- a/app/api/routes_auth.py +++ b/app/api/routes_auth.py @@ -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 @@ -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. @@ -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=""), diff --git a/app/config.py b/app/config.py index 47e2319..5bd3a98 100644 --- a/app/config.py +++ b/app/config.py @@ -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. diff --git a/app/core/email.py b/app/core/email.py index 61e3aad..dc5e5d2 100644 --- a/app/core/email.py +++ b/app/core/email.py @@ -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: @@ -78,6 +83,80 @@ def _render_invite_html( """ +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"""\ +
+
+

PetroBrain password reset

+

Reset your password

+

+ 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. +

+ Reset password +

+ Or paste this link into your browser:
{reset_url} +

+

This link expires in {expiry} and can be used once.

+
+
""" + + +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, diff --git a/app/core/http_hardening.py b/app/core/http_hardening.py index 4d790c7..3b3d69c 100644 --- a/app/core/http_hardening.py +++ b/app/core/http_hardening.py @@ -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 ( diff --git a/app/core/password_reset.py b/app/core/password_reset.py new file mode 100644 index 0000000..40e0400 --- /dev/null +++ b/app/core/password_reset.py @@ -0,0 +1,190 @@ +"""Server-side password-reset tokens (opaque, single-use, expiring). + +The "forgot password" flow hands the user an opaque high-entropy token by email; +presenting it back at ``/auth/reset-password`` lets them set a new password +without their old one. The design mirrors :mod:`app.core.refresh_tokens`: + +* The token handed to the user is an opaque secret. We never store it; we store + only its SHA-256 hash mapped to the owning user. A store compromise therefore + does not leak usable reset tokens. +* It is **single-use**: ``consume`` atomically reads and deletes the record, so a + given reset link works exactly once. A replayed/already-used link fails. +* It is short-lived (``password_reset_ttl_minutes``), so a leaked link stops + working quickly even if it is never used. + +Backends mirror ``refresh_tokens``: per-process memory for dev/tests, Redis for +prod (shared across replicas, auto-expiring). Both fail safe - a store error on +consume means the reset is rejected (the user requests a new link), never +silently accepted. +""" +from __future__ import annotations + +import hashlib +import json +import logging +import secrets +import time +from dataclasses import dataclass +from threading import Lock +from typing import Protocol + +logger = logging.getLogger(__name__) + +_KEY_PREFIX = "pb:pwreset:" +# Opaque token entropy. 32 bytes -> 43-char urlsafe string, ~256 bits. +_TOKEN_BYTES = 32 + + +@dataclass(frozen=True) +class ResetRecord: + user_id: str + tenant_id: str + email: str + + +def _hash(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +class _Backend(Protocol): + def store(self, token_hash: str, payload: str, ttl_seconds: int) -> None: ... + def consume(self, token_hash: str) -> str | None: ... + def discard(self, token_hash: str) -> None: ... + + +class _MemoryBackend: + def __init__(self) -> None: + self._entries: dict[str, tuple[str, float]] = {} + self._lock = Lock() + + def store(self, token_hash: str, payload: str, ttl_seconds: int) -> None: + with self._lock: + self._entries[token_hash] = (payload, time.time() + max(0, ttl_seconds)) + self._sweep_locked() + + def consume(self, token_hash: str) -> str | None: + with self._lock: + self._sweep_locked() + entry = self._entries.pop(token_hash, None) + return entry[0] if entry else None + + def discard(self, token_hash: str) -> None: + with self._lock: + self._entries.pop(token_hash, None) + + def _sweep_locked(self) -> None: + now = time.time() + for k in [k for k, (_, exp) in self._entries.items() if exp <= now]: + del self._entries[k] + + +class _RedisBackend: + def __init__(self, client) -> None: # type: ignore[no-untyped-def] + self._client = client + + def store(self, token_hash: str, payload: str, ttl_seconds: int) -> None: + if ttl_seconds <= 0: + return + self._client.set(_KEY_PREFIX + token_hash, payload, ex=ttl_seconds) + + def consume(self, token_hash: str) -> str | None: + # GETDEL is atomic (Redis 6.2+/ElastiCache): the token is read and + # invalidated in one round trip so it cannot be consumed twice even under + # concurrent resets. + try: + return self._client.getdel(_KEY_PREFIX + token_hash) + except AttributeError: # very old client without getdel + pipe = self._client.pipeline() + pipe.get(_KEY_PREFIX + token_hash) + pipe.delete(_KEY_PREFIX + token_hash) + value, _ = pipe.execute() + return value + + def discard(self, token_hash: str) -> None: + try: + self._client.delete(_KEY_PREFIX + token_hash) + except Exception as exc: # noqa: BLE001 + logger.warning("pwreset_discard_redis_unreachable", extra={"error": str(exc)}) + + +_backend: _Backend | None = None + + +def _get_backend() -> _Backend: + global _backend + if _backend is not None: + return _backend + from app.config import get_settings + + settings = get_settings() + choice = (settings.refresh_token_backend or "").strip().lower() + if not choice: + choice = "redis" if settings.environment.lower() in {"prod", "production"} else "memory" + if choice == "redis": + _backend = _build_redis_backend(settings) + else: + _backend = _MemoryBackend() + return _backend + + +def _build_redis_backend(settings) -> _Backend: + try: + import redis # type: ignore + + from app.core.redis_security import redis_ssl_options + + client = redis.Redis.from_url( + settings.redis_url, decode_responses=True, + **redis_ssl_options(settings.redis_url, settings), + ) + client.ping() + return _RedisBackend(client) + except Exception as exc: # noqa: BLE001 + logger.error( + "password_reset_redis_unavailable_falling_back_to_memory", + extra={"error": str(exc)}, + ) + return _MemoryBackend() + + +def issue(*, user_id: str, tenant_id: str, email: str, ttl_seconds: int) -> str: + """Mint a new opaque reset token bound to the user, store its hash, and + return the raw token to email to the user (never stored or logged).""" + token = secrets.token_urlsafe(_TOKEN_BYTES) + payload = json.dumps({"user_id": user_id, "tenant_id": tenant_id, "email": email}) + try: + _get_backend().store(_hash(token), payload, ttl_seconds) + except Exception as exc: # noqa: BLE001 + # Fail closed: if we cannot persist the token we must not hand one out, + # otherwise the user would hold a reset link the server can't honour. + logger.error("pwreset_issue_failed", extra={"error": str(exc)}) + raise + return token + + +def consume(token: str) -> ResetRecord | None: + """Atomically validate + invalidate a reset token (single use). Returns the + owning record, or None if the token is unknown/expired/already used.""" + if not token: + return None + try: + raw = _get_backend().consume(_hash(token)) + except Exception as exc: # noqa: BLE001 + logger.warning("pwreset_consume_failed", extra={"error": str(exc)}) + return None + if not raw: + return None + try: + data = json.loads(raw) + return ResetRecord( + user_id=data["user_id"], + tenant_id=data["tenant_id"], + email=data["email"], + ) + except (ValueError, KeyError, TypeError): + return None + + +def reset_for_tests() -> None: + global _backend + _backend = None diff --git a/frontend/apps/web/app/forgot-password/page.tsx b/frontend/apps/web/app/forgot-password/page.tsx new file mode 100644 index 0000000..6bc118c --- /dev/null +++ b/frontend/apps/web/app/forgot-password/page.tsx @@ -0,0 +1,17 @@ +import type { Metadata } from 'next'; +import { Suspense } from 'react'; + +import { ForgotPasswordForm } from '@/lib/auth/ForgotPasswordForm'; + +export const metadata: Metadata = { + title: 'PetroBrain - Reset password', + description: 'Request a link to reset your PetroBrain password.', +}; + +export default function ForgotPasswordPage() { + return ( + + + + ); +} diff --git a/frontend/apps/web/app/reset-password/page.tsx b/frontend/apps/web/app/reset-password/page.tsx new file mode 100644 index 0000000..d12b0e2 --- /dev/null +++ b/frontend/apps/web/app/reset-password/page.tsx @@ -0,0 +1,17 @@ +import type { Metadata } from 'next'; +import { Suspense } from 'react'; + +import { ResetPasswordForm } from '@/lib/auth/ResetPasswordForm'; + +export const metadata: Metadata = { + title: 'PetroBrain - New password', + description: 'Choose a new password for your PetroBrain account.', +}; + +export default function ResetPasswordPage() { + return ( + + + + ); +} diff --git a/frontend/apps/web/lib/auth/AuthForm.tsx b/frontend/apps/web/lib/auth/AuthForm.tsx index c82db47..699023a 100644 --- a/frontend/apps/web/lib/auth/AuthForm.tsx +++ b/frontend/apps/web/lib/auth/AuthForm.tsx @@ -247,12 +247,22 @@ export function AuthForm({ mode }: AuthFormProps) {
- +
+ + {mode === 'signin' ? ( + + Forgot password? + + ) : null} +
s.apiBaseUrl); + + const [email, setEmail] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [sent, setSent] = useState(false); + const [confirmation, setConfirmation] = useState(''); + + const abortRef = useRef(null); + useEffect(() => () => abortRef.current?.abort(), []); + + async function submit(e: FormEvent) { + e.preventDefault(); + if (busy) return; + const cleaned = email.trim(); + if (!EMAIL_RE.test(cleaned)) { + setError('Please enter a valid email address.'); + return; + } + setError(null); + setBusy(true); + const controller = new AbortController(); + abortRef.current = controller; + const abortTimer = setTimeout(() => controller.abort(), 60_000); + try { + const message = await requestPasswordReset(apiBaseUrl, cleaned, controller.signal); + setConfirmation(message); + setSent(true); + } catch (err) { + if (err instanceof AuthError) { + setError(err.message); + } else if ((err as { name?: string }).name === 'AbortError') { + setError('That took too long. Please wait a moment and try again.'); + } else { + setError('Could not send the reset link. Check your connection and try again.'); + } + } finally { + clearTimeout(abortTimer); + setBusy(false); + } + } + + return ( +
+
+
+ +
+
+ +

+ PetroBrain +

+

+ {sent ? 'Check your email' : 'Reset your password'} +

+

+ {sent + ? confirmation + : 'Enter your account email and we will send you a link to set a new password.'} +

+
+ + {sent ? ( +
+

+ The link expires soon and can be used once. If it does not arrive, check your + spam folder or try again. +

+ + Back to sign in + +
+ ) : ( +
+
+ + setEmail(e.target.value)} + placeholder="you@example.com" + className="h-11 w-full rounded-xl border border-neutral-200 bg-white px-3.5 text-sm shadow-[0_1px_2px_rgba(15,23,42,0.04)] transition-all hover:border-primary-300 focus:border-primary-400 focus:outline-none focus:ring-2 focus:ring-primary-200 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100 dark:placeholder-neutral-500 dark:hover:border-primary-600 dark:focus:border-primary-500 dark:focus:ring-primary-800" + /> +
+ + {error ? ( +

+ {error} +

+ ) : null} + + + +

+ Remembered it?{' '} + + Sign in + +

+
+ )} +
+
+ ); +} diff --git a/frontend/apps/web/lib/auth/ResetPasswordForm.tsx b/frontend/apps/web/lib/auth/ResetPasswordForm.tsx new file mode 100644 index 0000000..fe9f896 --- /dev/null +++ b/frontend/apps/web/lib/auth/ResetPasswordForm.tsx @@ -0,0 +1,186 @@ +'use client'; + +import Link from 'next/link'; +import type { Route } from 'next'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useEffect, useRef, useState, type FormEvent } from 'react'; + +import { Logo } from '@petrobrain/ui'; + +import { useChatStore } from '@/lib/chat/store'; + +import { AuthError, resetPassword } from './api'; + +const MIN_PASSWORD_LENGTH = 8; + +/** + * Completes a password reset. The token comes from the emailed link + * (/reset-password?token=...). On success the backend signs the user straight + * in, so we store the session and drop them into the app just like signin. + */ +export function ResetPasswordForm() { + const router = useRouter(); + const searchParams = useSearchParams(); + const apiBaseUrl = useChatStore((s) => s.apiBaseUrl); + const setSession = useChatStore((s) => s.setSession); + const clearSessionExpired = useChatStore((s) => s.clearSessionExpired); + + const token = searchParams.get('token') ?? ''; + + const [password, setPassword] = useState(''); + const [confirm, setConfirm] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const abortRef = useRef(null); + useEffect(() => () => abortRef.current?.abort(), []); + + async function submit(e: FormEvent) { + e.preventDefault(); + if (busy) return; + if (password.length < MIN_PASSWORD_LENGTH) { + setError(`Password must be at least ${MIN_PASSWORD_LENGTH} characters.`); + return; + } + if (password !== confirm) { + setError('Passwords do not match.'); + return; + } + setError(null); + setBusy(true); + const controller = new AbortController(); + abortRef.current = controller; + const abortTimer = setTimeout(() => controller.abort(), 60_000); + try { + const res = await resetPassword(apiBaseUrl, { token, password }, controller.signal); + setSession(res.token, res.refresh_token, res.principal); + clearSessionExpired(); + router.push('/chat' as Route); + } catch (err) { + if (err instanceof AuthError) { + setError(err.message); + } else if ((err as { name?: string }).name === 'AbortError') { + setError('That took too long. Please wait a moment and try again.'); + } else { + setError('Could not reset your password. Check your connection and try again.'); + } + setBusy(false); + } finally { + clearTimeout(abortTimer); + } + } + + const missingToken = !token; + + return ( +
+
+
+ +
+
+ +

+ PetroBrain +

+

+ Choose a new password +

+

+ {missingToken + ? 'This reset link is missing its token. Request a new one to continue.' + : 'Enter a new password for your account. You will be signed in once it is saved.'} +

+
+ + {missingToken ? ( +
+ + Request a new link + +
+ ) : ( +
+
+ + setPassword(e.target.value)} + placeholder={`At least ${MIN_PASSWORD_LENGTH} characters`} + className="h-11 w-full rounded-xl border border-neutral-200 bg-white px-3.5 text-sm shadow-[0_1px_2px_rgba(15,23,42,0.04)] transition-all hover:border-primary-300 focus:border-primary-400 focus:outline-none focus:ring-2 focus:ring-primary-200 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100 dark:placeholder-neutral-500 dark:hover:border-primary-600 dark:focus:border-primary-500 dark:focus:ring-primary-800" + /> +
+ +
+ + setConfirm(e.target.value)} + placeholder="Re-enter your password" + className="h-11 w-full rounded-xl border border-neutral-200 bg-white px-3.5 text-sm shadow-[0_1px_2px_rgba(15,23,42,0.04)] transition-all hover:border-primary-300 focus:border-primary-400 focus:outline-none focus:ring-2 focus:ring-primary-200 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100 dark:placeholder-neutral-500 dark:hover:border-primary-600 dark:focus:border-primary-500 dark:focus:ring-primary-800" + /> +
+ + {error ? ( +

+ {error} +

+ ) : null} + + + +

+ + Back to sign in + +

+
+ )} +
+
+ ); +} diff --git a/frontend/apps/web/lib/auth/api.ts b/frontend/apps/web/lib/auth/api.ts index d3dadae..4c4b7af 100644 --- a/frontend/apps/web/lib/auth/api.ts +++ b/frontend/apps/web/lib/auth/api.ts @@ -80,6 +80,72 @@ export function signin( return postAuth(baseUrl, '/auth/signin', body, signal); } +/** + * Kick off a password reset for an email. The backend is enumeration-safe and + * always answers 200 with a neutral message, so this resolves with that message + * regardless of whether the email maps to an account. Only network/5xx faults + * throw, so the UI can show one neutral confirmation in the success path. + */ +export async function requestPasswordReset( + baseUrl: string, + email: string, + signal?: AbortSignal, +): Promise { + const res = await fetch(`${baseUrl}/auth/forgot-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + ...(signal ? { signal } : {}), + }); + if (res.ok) { + try { + const data = (await res.json()) as { message?: string }; + if (data && typeof data.message === 'string') return data.message; + } catch { + // Non-JSON 200; fall through to the generic confirmation. + } + return 'If that email belongs to an account, a reset link is on its way.'; + } + let detail = `${res.status} ${res.statusText}`; + try { + const data = (await res.json()) as AuthErrorBody; + if (data && typeof data.detail === 'string') detail = data.detail; + } catch { + // non-JSON body; keep the status-line fallback + } + throw new AuthError(detail, res.status); +} + +/** + * Complete a password reset with the emailed token and a new password. On + * success the backend signs the user straight in, returning the same + * `{token, refresh_token, principal}` shape as signin. Throws AuthError on an + * invalid/expired token (400) or a too-weak password (422). + */ +export async function resetPassword( + baseUrl: string, + body: { token: string; password: string }, + signal?: AbortSignal, +): Promise { + const res = await fetch(`${baseUrl}/auth/reset-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + ...(signal ? { signal } : {}), + }); + if (res.ok) { + return (await res.json()) as AuthResponse; + } + let detail = `${res.status} ${res.statusText}`; + try { + const data = (await res.json()) as AuthErrorBody; + if (data && typeof data.detail === 'string') detail = data.detail; + } catch { + // non-JSON body; keep the status-line fallback + } + throw new AuthError(detail, res.status); +} + /** * Exchange a refresh token for a fresh access + refresh pair. Throws AuthError * on a 401 (token used/expired/revoked) so the caller can drop the session and diff --git a/tests/test_auth_password_reset.py b/tests/test_auth_password_reset.py new file mode 100644 index 0000000..2b946e1 --- /dev/null +++ b/tests/test_auth_password_reset.py @@ -0,0 +1,166 @@ +"""Forgot-password / reset-password flow tests for /auth.""" +import os +import sys +from types import SimpleNamespace + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import pytest +from fastapi.testclient import TestClient + +from app.api import deps, routes_auth +from app.core import password_reset +from app.db.tenants_repository import LocalJsonTenantsRepository +from app.db.users_repository import LocalJsonUsersRepository +from app.main import app +from tests.auth_helpers import JWT_AUDIENCE, JWT_ISSUER, JWT_SECRET + + +client = TestClient(app) + + +def _auth_settings(**overrides): + base = { + "jwt_secret": JWT_SECRET, + "jwt_public_key": "", + "jwt_issuer": JWT_ISSUER, + "jwt_audience": JWT_AUDIENCE, + "jwt_ttl_hours": 1, + "refresh_token_ttl_days": 14, + "refresh_token_backend": "memory", + "enable_self_signup": True, + "default_signup_tenant_id": "demo", + "default_signup_tenant_name": "Demo", + "default_signup_role": "engineer", + "password_min_length": 8, + "password_reset_ttl_minutes": 30, + "environment": "dev", + "resend_api_key": "", + } + base.update(overrides) + return SimpleNamespace(**base) + + +@pytest.fixture +def tenants_repo(tmp_path): + return LocalJsonTenantsRepository(tmp_path / "tenants.jsonl") + + +@pytest.fixture +def users_repo(tmp_path): + return LocalJsonUsersRepository(tmp_path / "users.jsonl") + + +@pytest.fixture +def captured_emails(monkeypatch): + """Capture password-reset emails instead of sending them, exposing the raw + token so a test can drive the second leg of the flow.""" + sent: list[dict] = [] + + def _fake_send(*, to_email, raw_token, ttl_minutes): + sent.append({"to_email": to_email, "raw_token": raw_token}) + return {"email_sent": True, "message": "sent"} + + monkeypatch.setattr("app.core.email.send_password_reset_email", _fake_send) + return sent + + +@pytest.fixture(autouse=True) +def wire(monkeypatch, tenants_repo, users_repo): + settings = _auth_settings() + monkeypatch.setattr(deps, "get_settings", lambda: settings) + monkeypatch.setattr(routes_auth, "get_settings", lambda: settings) + monkeypatch.setattr(routes_auth, "get_users_repository", lambda: users_repo) + monkeypatch.setattr(routes_auth, "get_tenants_repository", lambda: tenants_repo) + from app.core import auth_lockout + auth_lockout.reset_for_tests() + password_reset.reset_for_tests() + from app.core.http_hardening import clear_rate_limits + clear_rate_limits() + yield + password_reset.reset_for_tests() + + +def _signup(email: str, password: str = "hunter2hunter2") -> dict: + r = client.post("/auth/signup", json={"email": email, "password": password}) + assert r.status_code == 201, r.text + return r.json() + + +def test_forgot_password_unknown_email_is_neutral_200(captured_emails): + r = client.post("/auth/forgot-password", json={"email": "nobody@example.com"}) + assert r.status_code == 200, r.text + assert "reset link" in r.json()["message"].lower() + # No email issued for an unknown address. + assert captured_emails == [] + + +def test_forgot_password_known_email_issues_reset_token(captured_emails): + _signup("known@example.com") + r = client.post("/auth/forgot-password", json={"email": "known@example.com"}) + assert r.status_code == 200, r.text + assert len(captured_emails) == 1 + assert captured_emails[0]["to_email"] == "known@example.com" + assert captured_emails[0]["raw_token"] + + +def test_forgot_password_response_identical_for_known_and_unknown(captured_emails): + _signup("real@example.com") + known = client.post("/auth/forgot-password", json={"email": "real@example.com"}) + unknown = client.post("/auth/forgot-password", json={"email": "ghost@example.com"}) + # Enumeration-safety: same status and same body either way. + assert known.status_code == unknown.status_code == 200 + assert known.json() == unknown.json() + + +def test_reset_password_completes_and_signs_in(captured_emails): + _signup("reset@example.com", password="oldpassword1") + client.post("/auth/forgot-password", json={"email": "reset@example.com"}) + token = captured_emails[0]["raw_token"] + + r = client.post( + "/auth/reset-password", json={"token": token, "password": "brandnewpass1"} + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["token"] and body["refresh_token"] + assert body["principal"]["email"] == "reset@example.com" + + # New password works; old one no longer does. + assert client.post( + "/auth/signin", json={"email": "reset@example.com", "password": "brandnewpass1"} + ).status_code == 200 + assert client.post( + "/auth/signin", json={"email": "reset@example.com", "password": "oldpassword1"} + ).status_code == 401 + + +def test_reset_token_is_single_use(captured_emails): + _signup("once@example.com") + client.post("/auth/forgot-password", json={"email": "once@example.com"}) + token = captured_emails[0]["raw_token"] + + first = client.post( + "/auth/reset-password", json={"token": token, "password": "firstchange1"} + ) + assert first.status_code == 200 + second = client.post( + "/auth/reset-password", json={"token": token, "password": "secondchange1"} + ) + assert second.status_code == 400 + + +def test_reset_password_rejects_unknown_token(): + r = client.post( + "/auth/reset-password", json={"token": "not-a-real-token", "password": "whatever12"} + ) + assert r.status_code == 400 + assert "invalid" in r.json()["detail"].lower() or "expired" in r.json()["detail"].lower() + + +def test_reset_password_rejects_short_password(captured_emails): + _signup("shorty@example.com") + client.post("/auth/forgot-password", json={"email": "shorty@example.com"}) + token = captured_emails[0]["raw_token"] + r = client.post("/auth/reset-password", json={"token": token, "password": "abc"}) + assert r.status_code == 422