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
parent
8677117e66
commit
4ec322da71
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue