Forgot-password: unregistered emails get an explicit 404

Deliberate for the small internal team - a clear 'is not a registered
account' beats the anti-enumeration non-answer that read as success.
Successful sends now name the address and expiry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
main
Talha Ahmed 2026-08-19 22:05:34 +05:00
parent 8677117e66
commit 4ec322da71
2 changed files with 9 additions and 8 deletions

View File

@ -247,8 +247,6 @@ def me(request: Request) -> dict:
# locks the flow after 5 wrong attempts (request a fresh code to retry).
CODE_TTL_MINUTES = 10
CODE_MAX_ATTEMPTS = 5
_GENERIC_CODE_MSG = ("If that account exists, a code has been emailed to it. "
"It expires in 10 minutes.")
def _hash_code(code: str) -> str:
@ -264,8 +262,8 @@ def request_password_code(body: RequestCodeIn, request: Request,
db: OrmSession = Depends(db_dep)) -> dict:
"""Email a 6-digit password code to the account's address.
The response never reveals whether the username exists same message either way, so
the login screen can't be used to enumerate accounts."""
Deliberately explicit for this small internal team: an unregistered address gets a
clear 404 instead of an anti-enumeration non-answer."""
from ..config import email_enabled
from ..services.mailer import MailerError, send_password_code
if not email_enabled():
@ -279,7 +277,8 @@ def request_password_code(body: RequestCodeIn, request: Request,
user = db.query(models.User).filter(models.User.username == username).first()
if user is None or not user.is_active:
logger.info("password code requested for unknown/inactive account: %s", username)
return {"sent": True, "detail": _GENERIC_CODE_MSG}
raise HTTPException(404, f"{username} isn't a registered account — check the "
f"address, or ask the administrator to create it.")
# Light resend throttle: one code per minute (a resend invalidates the previous code).
now = dt.datetime.utcnow()
@ -302,7 +301,8 @@ def request_password_code(body: RequestCodeIn, request: Request,
user.reset_code_expires = None
db.commit()
raise HTTPException(502, f"{e} Ask the administrator to reset your password.")
return {"sent": True, "detail": _GENERIC_CODE_MSG}
return {"sent": True,
"detail": f"Code sent to {username} — it expires in {CODE_TTL_MINUTES} minutes."}
def _user_with_valid_code(db: OrmSession, username: str, code: str) -> models.User:

View File

@ -158,9 +158,10 @@ def test_email_code_reset_flow(clean_users, monkeypatch):
_add_user("coder@utopiabrands.com", "Code Person", "first-password-1")
with TestClient(app) as c:
# Unknown account: generic answer, no email, no enumeration.
# Unknown account: explicit 404 (deliberate for this small internal team), no email.
r = c.post("/api/auth/request-code", json={"username": "ghost@utopiabrands.com"})
assert r.status_code == 200 and "code" not in sent
assert r.status_code == 404 and "code" not in sent
assert "registered" in r.json()["detail"]
r = c.post("/api/auth/request-code", json={"username": "coder@utopiabrands.com"})
assert r.status_code == 200