feat(auth): let admins reset another user's password (#1078) - #1135
Conversation
📓 Changelog previewThis is what your commits will add to the generated ## [Unreleased]
### Added
- **auth:** Let admins reset another user's password ([#1078](https://github.com/gotempsh/temps/issues/1078))
### Fixed
- **auth:** Close admin password reset races and review findings
- **auth:** Report reset-during-login and invalid CLI input accurately
### Miscellaneous
- **api:** Regenerate API clients after rebasing onto main |
|
8d807f5 to
c37a147
Compare
Address the Greptile review on #1135. - create_session now checks must_change_password and inserts the session under a shared lock on the user row. The admin reset takes the exclusive lock, so the two serialize: a login that verified the old password can no longer insert its session after the reset's delete and survive it. - create_required_password_change_token takes the verified user row and only issues a token if the stored hash is still the one that was verified (new CredentialsChanged error, checked under the row lock). A superseded temporary password can no longer mint a change token. Login answers both cases as invalid credentials instead of a 500. - Reset dialog: the result is keyed to the user it was generated for and the dialog cannot be dismissed while a reset is in flight, so a password can never appear under another user's name. Invalidate every listUsers query via the generated `_id` key so the detail page badge updates immediately. - CLI: parse user IDs strictly for every `users` subcommand; `12x` or `12.9` no longer silently target user 12. - CLAUDE.md: spell out that one-time issuance of a credential minted by the request (API key creation, admin-reset temporary password) is the only exception to masking secrets in responses. Tests: races covered by create_session_waits_for_in_flight_password_reset (fails without the lock) and superseded_temporary_password_cannot_mint_change_token; parseUserId unit tests. Signed-off-by: David Viejo <dviejo@kfs.es>
This comment has been minimized.
This comment has been minimized.
Address the second Greptile pass on #1135. - OIDC: a password reset that lands between identity resolution and the password-change token was surfaced as DiscoveryFailed, telling the user their identity provider was down. Map it to a new OidcError::CredentialsChanged (409, login code `credentials_changed`) with a "start sign-in again" message on the login page. - CLI: an invalid user ID or role now sets a non-zero exit code for every `users` subcommand, so scripts checking the exit status cannot mistake a skipped reset, removal, restore or role change for success. Signed-off-by: David Viejo <dviejo@kfs.es>
|
@greptileai Re the outside-diff note Reset misreported as provider outage ( |
|
Confirmed fixed in |
A user who lost their password had no way back in unless outbound email
was configured: the temporary password handed out at user creation is
shown once and only its Argon2 hash is stored, PATCH /users/{id} cannot
set a password, and `temps reset-password` only targets the first admin.
Add POST /users/{user_id}/password. It generates a temporary password
server-side, returns it once (Cache-Control: no-store), and flags the
account must_change_password, so signing in with it lands on the existing
forced password-change screen and the temporary value cannot be reused.
The reset also revokes every browser session and clears any pending
email-reset or first-login token, in one transaction under a row lock.
- Gated like the sibling admin handlers: users:manage via
authorize_admin_target (no self-target) plus a ResetUserPassword
step-up sensitive action.
- Deleted users are refused with 409; restore them first.
- ADMIN_PASSWORD_RESET audit entry with actor and target; the password is
never logged or audited.
- API keys are deliberately left alone, matching POST /users/me/password;
the UI and CLI say so.
- Web: "Reset password" in the users row menu and on the user detail page,
a one-time password dialog with copy button, and a "Must change
password" badge.
- CLI: `users reset-password --id <n>` with --json and --yes.
- Regenerated the web and CLI OpenAPI clients and CLI docs.
Signed-off-by: David Viejo <dviejo@kfs.es>
Address the Greptile review on #1135. - create_session now checks must_change_password and inserts the session under a shared lock on the user row. The admin reset takes the exclusive lock, so the two serialize: a login that verified the old password can no longer insert its session after the reset's delete and survive it. - create_required_password_change_token takes the verified user row and only issues a token if the stored hash is still the one that was verified (new CredentialsChanged error, checked under the row lock). A superseded temporary password can no longer mint a change token. Login answers both cases as invalid credentials instead of a 500. - Reset dialog: the result is keyed to the user it was generated for and the dialog cannot be dismissed while a reset is in flight, so a password can never appear under another user's name. Invalidate every listUsers query via the generated `_id` key so the detail page badge updates immediately. - CLI: parse user IDs strictly for every `users` subcommand; `12x` or `12.9` no longer silently target user 12. - CLAUDE.md: spell out that one-time issuance of a credential minted by the request (API key creation, admin-reset temporary password) is the only exception to masking secrets in responses. Tests: races covered by create_session_waits_for_in_flight_password_reset (fails without the lock) and superseded_temporary_password_cannot_mint_change_token; parseUserId unit tests. Signed-off-by: David Viejo <dviejo@kfs.es>
Address the second Greptile pass on #1135. - OIDC: a password reset that lands between identity resolution and the password-change token was surfaced as DiscoveryFailed, telling the user their identity provider was down. Map it to a new OidcError::CredentialsChanged (409, login code `credentials_changed`) with a "start sign-in again" message on the login page. - CLI: an invalid user ID or role now sets a non-zero exit code for every `users` subcommand, so scripts checking the exit status cannot mistake a skipped reset, removal, restore or role change for success. Signed-off-by: David Viejo <dviejo@kfs.es>
Regenerated from a server built off the rebased branch: restores reset_user_password in the web and CLI clients (the rebase took main's generated files on conflict), picks up the final endpoint description, and restores the PUT /settings summary fixed on main. Signed-off-by: David Viejo <dviejo@kfs.es>
7f31ec2 to
c78eac8
Compare
Closes #1078
Problem
A user who lost their password had no way back in unless outbound email was configured (the default on a fresh self-hosted install):
PATCH /users/{id}only acceptsemail/name;POST /users/me/passwordonly changes the caller's own password.POST /auth/password-reset/requestreturns 503 without an email provider.temps reset-passwordonly targets the first admin.The only options left were deleting and recreating the user (new id, detached from roles and audit history) or editing the
userstable by hand.Fix
New
POST /users/{user_id}/password. The server generates a temporary password, returns it once, and flags the accountmust_change_password. Signing in with it goes through the existing forced password-change flow, and the temporary value can't be reused as the new password.In one transaction under a row lock, the reset:
must_change_password = trueGuards, same as the sibling admin handlers:
users:manageviaauthorize_admin_target; resetting yourself is refused (use/users/me/password)ResetUserPasswordstep-up sensitive action for browser sessionsCache-Control: no-storeon the response; the password is never logged or auditedADMIN_PASSWORD_RESETaudit entry with actor and targetAPI keys are deliberately not revoked, matching
POST /users/me/password: revoking them would silently break the user's automation. The UI and CLI say so explicitly.The password is generated server-side (20 chars,
rand::rng(), one character from each required class, then shuffled, no ambiguous0O1lI) rather than supplied by the admin. The admin never has to invent one, and it always passesvalidate_password_complexity.Surfaces
/accountinstead.bunx @temps-sdk/cli users reset-password --id <n>(--json,--yes).summaryofPUT /settings: onmainthat doc comment sits on a helper fn, not the handler, so the committed spec was stale.Evidence
Tests
auth_service::tests::admin_password_reset_forces_change_at_next_sign_in(real Postgres) covers the full lifecycle: sessions revoked, old password rejected, temporary password reaches the forced-change state only,create_sessionrefused, stale email-reset token invalid, temporary password rejected as the new one, then a normal session after the change.generated_temporary_passwords_always_pass_complexity_rules(500 samples, no repeats, no ambiguous chars)admin_password_reset_refuses_deleted_user_without_writescargo test -p temps-auth --lib: 389 passed, 0 failed-D warningsclean ontemps-auth/temps-core; web + CLItscclean;spec:checkcanonicalLive server (local instance, curl):
CLI against the same server:
Browser: walked through Users → row menu → Reset password → confirm → one-time password dialog. Then signed in as that user with the temporary password in a fresh session and landed on
/auth/change-password("Choose a new password"). The user detail page shows the "Must change password" badge and the Reset password button.Security review:
security-auditorpass returned APPROVE WITH NITS.PlatformAdmincan't reach the endpoint, the RNG is adequate (~122 bits), nothing leaks through logs, audit, or spans, and the forced-change flow can't be bypassed.