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). # locks the flow after 5 wrong attempts (request a fresh code to retry).
CODE_TTL_MINUTES = 10 CODE_TTL_MINUTES = 10
CODE_MAX_ATTEMPTS = 5 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: def _hash_code(code: str) -> str:
@ -264,8 +262,8 @@ def request_password_code(body: RequestCodeIn, request: Request,
db: OrmSession = Depends(db_dep)) -> dict: db: OrmSession = Depends(db_dep)) -> dict:
"""Email a 6-digit password code to the account's address. """Email a 6-digit password code to the account's address.
The response never reveals whether the username exists same message either way, so Deliberately explicit for this small internal team: an unregistered address gets a
the login screen can't be used to enumerate accounts.""" clear 404 instead of an anti-enumeration non-answer."""
from ..config import email_enabled from ..config import email_enabled
from ..services.mailer import MailerError, send_password_code from ..services.mailer import MailerError, send_password_code
if not email_enabled(): 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() user = db.query(models.User).filter(models.User.username == username).first()
if user is None or not user.is_active: if user is None or not user.is_active:
logger.info("password code requested for unknown/inactive account: %s", username) 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). # Light resend throttle: one code per minute (a resend invalidates the previous code).
now = dt.datetime.utcnow() now = dt.datetime.utcnow()
@ -302,7 +301,8 @@ def request_password_code(body: RequestCodeIn, request: Request,
user.reset_code_expires = None user.reset_code_expires = None
db.commit() db.commit()
raise HTTPException(502, f"{e} Ask the administrator to reset your password.") 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: 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") _add_user("coder@utopiabrands.com", "Code Person", "first-password-1")
with TestClient(app) as c: 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"}) 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"}) r = c.post("/api/auth/request-code", json={"username": "coder@utopiabrands.com"})
assert r.status_code == 200 assert r.status_code == 200