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 <noreply@anthropic.com>
main
Talha Ahmed 2026-08-19 21:49:29 +05:00
parent b77c2cc9f7
commit 8677117e66
4 changed files with 167 additions and 95 deletions

View File

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

View File

@ -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"],

View File

@ -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 }),

View File

@ -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<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [done, setDone] = useState(false);
const sendCode = async () => {
const run = async (fn: () => Promise<void>) => {
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 && (
<div className="rounded-xl border border-bad/30 bg-badbg/60 px-3.5 py-2.5 text-sm text-bad">
{error}
</div>
);
const Back = () => (
<div className="text-center">
<button type="button" className="text-xs font-medium text-subink hover:text-ink"
onClick={onDone}>
Back to sign in
</button>
</div>
);
if (step === "done")
return (
<div>
<h2 className="text-2xl font-semibold text-ink">Password updated</h2>
@ -256,75 +274,95 @@ function ForgotPassword({ initialUsername, onDone }: {
</div>
);
return (
<div>
<h2 className="text-2xl font-semibold text-ink">Reset password</h2>
<p className="mt-1 text-sm text-subink">
We'll email a 6-digit code to your account's address.
</p>
<form onSubmit={reset} className="mt-8 space-y-4">
<div>
<label className="label" htmlFor="fp-user">Username (email)</label>
<div className="flex gap-2">
<div className="relative flex-1">
if (step === "email")
return (
<div>
<h2 className="text-2xl font-semibold text-ink">Reset password</h2>
<p className="mt-1 text-sm text-subink">
Step 1 of 3 we'll email a 6-digit code to your account's address.
</p>
<form className="mt-8 space-y-4" onSubmit={(e) => { e.preventDefault(); sendCode(); }}>
<div>
<label className="label" htmlFor="fp-user">Username (email)</label>
<div className="relative">
<User size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-faint pointer-events-none" />
<input id="fp-user" className="input pl-10 py-2.5" autoComplete="username"
autoFocus={!sent} placeholder="you@utopiabrands.com"
autoFocus placeholder="you@utopiabrands.com"
value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
<button type="button" className="btn-ghost whitespace-nowrap"
disabled={busy || !email.trim()} onClick={sendCode}>
{busy && !sent ? <Spinner /> : null} {sent ? "Resend code" : "Email me a code"}
<p className="mt-1.5 text-xs text-muted">
Your <b>@utopiabrands.com</b> account address codes only go to registered accounts.
</p>
</div>
<Err />
<button className="btn-primary w-full justify-center py-2.5" type="submit"
disabled={busy || !email.trim()}>
{busy ? <Spinner /> : null} Email me a code
</button>
<Back />
</form>
</div>
);
if (step === "code")
return (
<div>
<h2 className="text-2xl font-semibold text-ink">Enter the code</h2>
<p className="mt-1 text-sm text-subink">
Step 2 of 3 sent to <b className="text-ink">{email.trim()}</b>, valid 10 minutes.
</p>
<form className="mt-8 space-y-4" onSubmit={verifyCode}>
<div>
<label className="label" htmlFor="fp-code">6-digit code from the email</label>
<input id="fp-code" className="input py-2.5 num tracking-[0.35em] text-center"
inputMode="numeric" maxLength={6} autoFocus placeholder="••••••"
value={code} onChange={(e) => setCode(e.target.value.replace(/\D/g, ""))} />
</div>
<Err />
<button className="btn-primary w-full justify-center py-2.5" type="submit"
disabled={busy || code.length !== 6}>
{busy ? <Spinner /> : null} Verify code
</button>
<div className="flex items-center justify-between text-xs">
<button type="button" className="font-medium text-subink hover:text-ink"
onClick={() => setStep("email")}>
Different email
</button>
<button type="button" className="font-medium text-primary hover:underline"
disabled={busy} onClick={sendCode}>
Resend code
</button>
</div>
{msg && <p className="mt-1.5 text-xs text-ok">{msg}</p>}
<Back />
</form>
</div>
);
return (
<div>
<h2 className="text-2xl font-semibold text-ink">Choose a new password</h2>
<p className="mt-1 text-sm text-subink">Step 3 of 3 code verified </p>
<form className="mt-8 space-y-4" onSubmit={reset}>
<div>
<label className="label" htmlFor="fp-new">New password (min 8)</label>
<input id="fp-new" className="input py-2.5" type="password" autoFocus
autoComplete="new-password"
value={next} onChange={(e) => setNext(e.target.value)} />
</div>
{sent && (
<>
<div>
<label className="label" htmlFor="fp-code">6-digit code from the email</label>
<input id="fp-code" className="input py-2.5 num tracking-[0.35em] text-center"
inputMode="numeric" maxLength={6} autoFocus placeholder="••••••"
value={code} onChange={(e) => setCode(e.target.value.replace(/\D/g, ""))} />
</div>
<div>
<label className="label" htmlFor="fp-new">New password (min 8)</label>
<input id="fp-new" className="input py-2.5" type="password"
autoComplete="new-password"
value={next} onChange={(e) => setNext(e.target.value)} />
</div>
<div>
<label className="label" htmlFor="fp-rep">Repeat new password</label>
<input id="fp-rep" className="input py-2.5" type="password"
autoComplete="new-password"
value={repeat} onChange={(e) => setRepeat(e.target.value)} />
{repeat.length > 0 && next !== repeat &&
<p className="mt-1.5 text-xs text-bad">Passwords don't match.</p>}
</div>
</>
)}
{error && (
<div className="rounded-xl border border-bad/30 bg-badbg/60 px-3.5 py-2.5 text-sm text-bad">
{error}
</div>
)}
{sent && (
<button className="btn-primary w-full justify-center py-2.5" type="submit"
disabled={busy || code.length !== 6 || next.length < 8 || next !== repeat}>
{busy ? <Spinner /> : null} Set new password
</button>
)}
<div className="text-center">
<button type="button" className="text-xs font-medium text-subink hover:text-ink"
onClick={onDone}>
Back to sign in
</button>
<div>
<label className="label" htmlFor="fp-rep">Repeat new password</label>
<input id="fp-rep" className="input py-2.5" type="password"
autoComplete="new-password"
value={repeat} onChange={(e) => setRepeat(e.target.value)} />
{repeat.length > 0 && next !== repeat &&
<p className="mt-1.5 text-xs text-bad">Passwords don't match.</p>}
</div>
<Err />
<button className="btn-primary w-full justify-center py-2.5" type="submit"
disabled={busy || next.length < 8 || next !== repeat}>
{busy ? <Spinner /> : null} Set new password
</button>
<Back />
</form>
</div>
);