From 8677117e66e52e20cd27eabca8014a8b718f7e9f Mon Sep 17 00:00:00 2001 From: Talha Ahmed Date: Wed, 19 Aug 2026 21:49:29 +0500 Subject: [PATCH] Forgot-password is now a 3-step wizard: email -> verify code -> new password New POST /api/auth/verify-code checks the code without consuming it (wrong guesses still count toward the 5-attempt lockout); the password fields only appear after the code verifies. Email step hints that codes go only to registered @utopiabrands.com accounts. Co-Authored-By: Claude Fable 5 --- ar-aging-app/backend/app/api/auth.py | 55 ++++-- ar-aging-app/backend/tests/test_auth.py | 7 + ar-aging-app/frontend/src/api/client.ts | 4 + ar-aging-app/frontend/src/pages/Login.tsx | 196 +++++++++++++--------- 4 files changed, 167 insertions(+), 95 deletions(-) diff --git a/ar-aging-app/backend/app/api/auth.py b/ar-aging-app/backend/app/api/auth.py index 17eed93..8c561a6 100644 --- a/ar-aging-app/backend/app/api/auth.py +++ b/ar-aging-app/backend/app/api/auth.py @@ -45,7 +45,8 @@ router = APIRouter(prefix="/api/auth", tags=["auth"]) # Paths reachable without a token: health probes, login itself, the "is auth on?" check, # and the forgot-password code flow (which by definition happens while locked out). OPEN_PATHS = {"/api/health", "/api/auth/login", "/api/auth/status", - "/api/auth/request-code", "/api/auth/reset-password"} + "/api/auth/request-code", "/api/auth/verify-code", + "/api/auth/reset-password"} if SECRET_KEY: _SECRET = SECRET_KEY.encode() @@ -304,6 +305,42 @@ def request_password_code(body: RequestCodeIn, request: Request, return {"sent": True, "detail": _GENERIC_CODE_MSG} +def _user_with_valid_code(db: OrmSession, username: str, code: str) -> models.User: + """The account IF the code is currently valid — one generic error otherwise (never + confirms which part was wrong). A wrong code counts toward the attempt lockout.""" + generic = HTTPException(400, "That code is wrong, expired, or already used — " + "request a fresh one.") + if not username or not code.strip(): + raise generic + user = db.query(models.User).filter(models.User.username == username).first() + now = dt.datetime.utcnow() + if (user is None or not user.is_active or not user.reset_code_hash + or not user.reset_code_expires or user.reset_code_expires < now + or user.reset_code_attempts >= CODE_MAX_ATTEMPTS): + raise generic + if not hmac.compare_digest(_hash_code(code.strip()), user.reset_code_hash): + user.reset_code_attempts += 1 + db.commit() + raise generic + return user + + +class VerifyCodeIn(BaseModel): + username: str = "" # optional when signed in + code: str + + +@router.post("/verify-code") +def verify_password_code(body: VerifyCodeIn, request: Request, + db: OrmSession = Depends(db_dep)) -> dict: + """Step check for the reset UI: is this code valid? Does NOT consume the code — the + reset itself re-validates and burns it. Wrong guesses still count toward lockout.""" + me_user = current_user(request) + username = (me_user.username if me_user else body.username).strip().lower() + _user_with_valid_code(db, username, body.code) + return {"valid": True} + + class ResetPasswordIn(BaseModel): username: str = "" # optional when signed in code: str @@ -317,23 +354,9 @@ def reset_password_with_code(body: ResetPasswordIn, request: Request, screen). One generic failure message — never confirms which part was wrong.""" me_user = current_user(request) username = (me_user.username if me_user else body.username).strip().lower() - generic = HTTPException(400, "That code is wrong, expired, or already used — " - "request a fresh one.") - if not username or not body.code.strip(): - raise generic if len(body.new_password) < 8: raise HTTPException(400, "The new password must be at least 8 characters.") - - user = db.query(models.User).filter(models.User.username == username).first() - now = dt.datetime.utcnow() - if (user is None or not user.is_active or not user.reset_code_hash - or not user.reset_code_expires or user.reset_code_expires < now - or user.reset_code_attempts >= CODE_MAX_ATTEMPTS): - raise generic - if not hmac.compare_digest(_hash_code(body.code.strip()), user.reset_code_hash): - user.reset_code_attempts += 1 - db.commit() - raise generic + user = _user_with_valid_code(db, username, body.code) user.password_hash = hash_password(body.new_password) user.reset_code_hash = "" # single use diff --git a/ar-aging-app/backend/tests/test_auth.py b/ar-aging-app/backend/tests/test_auth.py index d538426..d34ea23 100644 --- a/ar-aging-app/backend/tests/test_auth.py +++ b/ar-aging-app/backend/tests/test_auth.py @@ -176,6 +176,13 @@ def test_email_code_reset_flow(clean_users, monkeypatch): "username": "coder@utopiabrands.com", "code": bad, "new_password": "second-password-2"}).status_code == 400 + # Step-2 verify: wrong code 400, right code valid — and NOT consumed by verifying. + assert c.post("/api/auth/verify-code", json={ + "username": "coder@utopiabrands.com", "code": bad}).status_code == 400 + r = c.post("/api/auth/verify-code", json={ + "username": "coder@utopiabrands.com", "code": sent["code"]}) + assert r.status_code == 200 and r.json()["valid"] is True + # Right code sets the new password and is single-use. r = c.post("/api/auth/reset-password", json={ "username": "coder@utopiabrands.com", "code": sent["code"], diff --git a/ar-aging-app/frontend/src/api/client.ts b/ar-aging-app/frontend/src/api/client.ts index 296e22a..b7ae314 100644 --- a/ar-aging-app/frontend/src/api/client.ts +++ b/ar-aging-app/frontend/src/api/client.ts @@ -503,6 +503,10 @@ export const api = { req<{ sent: boolean; detail: string }>("/auth/request-code", { method: "POST", body: JSON.stringify({ username }), }), + verifyPasswordCode: (code: string, username = "") => + req<{ valid: boolean }>("/auth/verify-code", { + method: "POST", body: JSON.stringify({ username, code }), + }), resetPassword: (code: string, new_password: string, username = "") => req<{ changed: boolean }>("/auth/reset-password", { method: "POST", body: JSON.stringify({ username, code, new_password }), diff --git a/ar-aging-app/frontend/src/pages/Login.tsx b/ar-aging-app/frontend/src/pages/Login.tsx index 84e5550..4735ffe 100644 --- a/ar-aging-app/frontend/src/pages/Login.tsx +++ b/ar-aging-app/frontend/src/pages/Login.tsx @@ -205,26 +205,23 @@ export default function Login() { ); } -/** Forgot-password: email a 6-digit code, then set a new password with it. */ +/** Forgot-password, one step at a time: + * 1. email → send the code 2. enter + verify the code 3. set the new password. */ function ForgotPassword({ initialUsername, onDone }: { initialUsername: string; onDone: () => void; }) { + const [step, setStep] = useState<"email" | "code" | "password" | "done">("email"); const [email, setEmail] = useState(initialUsername); - const [sent, setSent] = useState(false); const [code, setCode] = useState(""); const [next, setNext] = useState(""); const [repeat, setRepeat] = useState(""); - const [msg, setMsg] = useState(null); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); - const [done, setDone] = useState(false); - const sendCode = async () => { + const run = async (fn: () => Promise) => { setError(null); setBusy(true); try { - const r = await api.requestPasswordCode(email.trim()); - setMsg(r.detail); - setSent(true); + await fn(); } catch (e) { setError((e as Error).message); } finally { @@ -232,20 +229,41 @@ function ForgotPassword({ initialUsername, onDone }: { } }; - const reset = async (e: FormEvent) => { + const sendCode = () => run(async () => { + await api.requestPasswordCode(email.trim()); + setCode(""); + setStep("code"); + }); + const verifyCode = (e: FormEvent) => { e.preventDefault(); - setError(null); setBusy(true); - try { + run(async () => { + await api.verifyPasswordCode(code.trim(), email.trim()); + setStep("password"); + }); + }; + const reset = (e: FormEvent) => { + e.preventDefault(); + run(async () => { await api.resetPassword(code.trim(), next, email.trim()); - setDone(true); - } catch (err) { - setError((err as Error).message); - } finally { - setBusy(false); - } + setStep("done"); + }); }; - if (done) + const Err = () => error && ( +
+ {error} +
+ ); + const Back = () => ( +
+ +
+ ); + + if (step === "done") return (

Password updated

@@ -256,75 +274,95 @@ function ForgotPassword({ initialUsername, onDone }: {
); - return ( -
-

Reset password

-

- We'll email a 6-digit code to your account's address. -

- -
-
- -
-
+ if (step === "email") + return ( +
+

Reset password

+

+ Step 1 of 3 — we'll email a 6-digit code to your account's address. +

+ { e.preventDefault(); sendCode(); }}> +
+ +
setEmail(e.target.value)} />
-
+ + + + +
+ ); + + if (step === "code") + return ( +
+

Enter the code

+

+ Step 2 of 3 — sent to {email.trim()}, valid 10 minutes. +

+
+
+ + setCode(e.target.value.replace(/\D/g, ""))} /> +
+ + +
+ +
- {msg &&

{msg}

} + + +
+ ); + + return ( +
+

Choose a new password

+

Step 3 of 3 — code verified ✓

+
+
+ + setNext(e.target.value)} />
- - {sent && ( - <> -
- - setCode(e.target.value.replace(/\D/g, ""))} /> -
-
- - setNext(e.target.value)} /> -
-
- - setRepeat(e.target.value)} /> - {repeat.length > 0 && next !== repeat && -

Passwords don't match.

} -
- - )} - - {error && ( -
- {error} -
- )} - - {sent && ( - - )} - -
- +
+ + setRepeat(e.target.value)} /> + {repeat.length > 0 && next !== repeat && +

Passwords don't match.

}
+ + +
);