fix(security): guard admin credential deletion against lockout

Admin credential deletion removed password/WebAuthn credentials with no
last-credential check, so an admin could delete a user's only credential and
lock them out. Use the atomic delete_*_if_not_last repo methods; on refusal
re-render the credentials section unchanged with an explanatory alert.

Refs: porchlight-lg7

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Johan Lundberg 2026-06-05 13:27:00 +02:00
parent 1bb76899a5
commit e54764cda9
No known key found for this signature in database
GPG key ID: A6C152738D03C7D1
3 changed files with 40 additions and 5 deletions

View file

@ -261,10 +261,12 @@ async def delete_user_password(request: Request, userid: str) -> Response:
return HTMLResponse("Forbidden", status_code=403)
cred_repo = request.app.state.credential_repo
await cred_repo.delete_password(userid)
# Refuse atomically if this is the user's last credential (avoids lockout).
deleted = await cred_repo.delete_password_if_not_last(userid)
# Re-render credentials section
webauthn_credentials = await cred_repo.get_webauthn_by_user(userid)
password = await cred_repo.get_password_by_user(userid)
templates = request.app.state.templates
return templates.TemplateResponse(
request,
@ -272,7 +274,8 @@ async def delete_user_password(request: Request, userid: str) -> Response:
{
"target_user": await request.app.state.user_repo.get_by_userid(userid),
"webauthn_credentials": webauthn_credentials,
"has_password": False,
"has_password": password is not None,
"error": None if deleted else "Cannot remove the user's last credential",
},
)
@ -289,7 +292,8 @@ async def delete_user_webauthn(request: Request, userid: str, credential_id_b64:
padded = credential_id_b64 + "=" * (-len(credential_id_b64) % 4)
credential_id = urlsafe_b64decode(padded)
cred_repo = request.app.state.credential_repo
await cred_repo.delete_webauthn(userid, credential_id)
# Refuse atomically if this is the user's last credential (avoids lockout).
deleted = await cred_repo.delete_webauthn_if_not_last(userid, credential_id)
# Re-render credentials section
webauthn_credentials = await cred_repo.get_webauthn_by_user(userid)
@ -302,6 +306,7 @@ async def delete_user_webauthn(request: Request, userid: str, credential_id_b64:
"target_user": await request.app.state.user_repo.get_by_userid(userid),
"webauthn_credentials": webauthn_credentials,
"has_password": password is not None,
"error": None if deleted else "Cannot remove the user's last credential",
},
)

View file

@ -1,3 +1,4 @@
{% if error %}<div role="alert">{{ error }}</div>{% endif %}
<h3>Password</h3>
{% if has_password %}
<p>Password is set.