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
parent
b77c2cc9f7
commit
8677117e66
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue