Skip to content

feat(auth): let admins reset another user's password (#1078) - #1135

Merged
dviejokfs merged 4 commits into
mainfrom
feat/1078-admin-reset-user-password
Sep 25, 2026
Merged

dviejokfs merged 4 commits into
mainfrom
feat/1078-admin-reset-user-password

Conversation

@dviejokfs

Copy link
Copy Markdown
Contributor

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):

  • The temporary password handed out at user creation is shown once and only its Argon2 hash is stored.
  • PATCH /users/{id} only accepts email/name; POST /users/me/password only changes the caller's own password.
  • POST /auth/password-reset/request returns 503 without an email provider.
  • temps reset-password only 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 users table by hand.

Fix

New POST /users/{user_id}/password. The server generates a temporary password, returns it once, and flags the account must_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:

  • replaces the password hash and sets must_change_password = true
  • revokes every browser session of the user
  • clears any pending email-reset or first-login token

Guards, same as the sibling admin handlers:

  • users:manage via authorize_admin_target; resetting yourself is refused (use /users/me/password)
  • ResetUserPassword step-up sensitive action for browser sessions
  • deleted users → 409 ("restore the user first")
  • Cache-Control: no-store on the response; the password is never logged or audited
  • ADMIN_PASSWORD_RESET audit entry with actor and target

API 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 ambiguous 0O1lI) rather than supplied by the admin. The admin never has to invent one, and it always passes validate_password_complexity.

Surfaces

  • Web: "Reset password" in the Users row menu and on the user detail page. The dialog explains the consequences, then shows the password once with a copy button and a "shown only once" warning. The detail page shows a "Must change password" badge while pending. On your own row, the action links to /account instead.
  • CLI: bunx @temps-sdk/cli users reset-password --id <n> (--json, --yes).
  • Regenerated web + CLI OpenAPI clients and CLI docs. The regenerated spec also drops the summary of PUT /settings: on main that 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_session refused, 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_writes
  • cargo test -p temps-auth --lib: 389 passed, 0 failed
  • clippy -D warnings clean on temps-auth/temps-core; web + CLI tsc clean; spec:check canonical

Live server (local instance, curl):

1. Jordan logs in with original password           -> 200
3. Admin POST /users/2/password                      -> 200, cache-control: no-store
   {"temporary_password":"…","must_change_password":true}
4. Jordan's old session                              -> 401 (revoked)
5. Old password                                      -> 401
6. Temporary password                                -> "password_change_required": true, password_change_session cookie only
c. Temporary password reused as the new one          -> 400 "must differ from the temporary password"
d/e/f. New password set, normal login, session works -> 200 / 200 / 200
g. Reset again while Jordan is logged in             -> Jordan's session 401
h. Non-admin resets another user                     -> 403
i. Admin resets self                                 -> 403
j. Unknown user                                      -> 404 "User 999 not found"
l. Deleted user                                      -> 409 "Restore the user before resetting their password."
k. Unauthenticated                                   -> 401
m. Audit: ADMIN_PASSWORD_RESET {actor 1, target_user_id 2, username}; password absent from audit rows and server log

CLI against the same server:

$ bunx @temps-sdk/cli users reset-password --id 3 --yes
✓ Password reset for user 3
  Temporary password: 8PqxDZz+#N$qycPA65A7
$ ... --id 1 --yes   # self
✗ You do not have permission to access this resource

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-auditor pass returned APPROVE WITH NITS.

  • It confirmed deployment tokens and PlatformAdmin can'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.
  • It suggested revoking API keys too. That is intentionally not done (see above).
  • It flagged concurrent admin resets as a race. The later reset wins regardless of where the password is generated, and both are audited.

@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

📓 Changelog preview

This is what your commits will add to the generated CHANGELOG.md at release time (via git-cliff). Do not edit CHANGELOG.md by hand — it is generated from your Conventional Commit messages.

## [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

@greptile-apps

greptile-apps Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[Critical risk] Adds admin password reset endpoint with session revocation.

The PR appears safe to merge, with two non-blocking connection-interface issues worth fixing.

Summary

The PR adds an admin-issued, one-time temporary-password reset across the API, web UI and CLI. Changes since the previous review also add a Git connection detail page, sync-failure status, a connection lookup endpoint and database-backed repository counts.

  • The new connection page does not refresh repository rows during a running sync.
  • The new CLI connection lookup needs strict ID validation and a failing exit status for invalid input.

Reviews (5) · Last reviewed commit: "chore(api): regenerate API clients after..."

Comment thread crates/temps-auth/src/user_service.rs
Comment thread crates/temps-auth/src/user_service.rs
Comment thread web/src/components/users/ResetPasswordDialog.tsx Outdated
Comment thread web/src/components/users/ResetPasswordDialog.tsx Outdated
Comment thread apps/temps-cli/src/commands/users/index.ts Outdated
Comment thread crates/temps-auth/src/handlers.rs
@dviejokfs
dviejokfs force-pushed the feat/1078-admin-reset-user-password branch from 8d807f5 to c37a147 Compare September 25, 2026 00:27
dviejokfs added a commit that referenced this pull request Sep 25, 2026
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>
Comment thread apps/temps-cli/src/commands/users/index.ts
@greptile-apps

This comment has been minimized.

dviejokfs added a commit that referenced this pull request Sep 25, 2026
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>
@dviejokfs

Copy link
Copy Markdown
Contributor Author

@greptileai Re the outside-diff note Reset misreported as provider outage (oidc_handler.rs:403): fixed in 7f31ec2. UserAuthError::CredentialsChanged from create_required_password_change_token now maps to a new OidcError::CredentialsChanged (409, login code credentials_changed), and the login page shows "Your password was reset by an administrator while you were signing in. Start sign-in again." instead of the identity-provider-unreachable message. Other failures at that call site keep their previous mapping.

@greptile-apps

greptile-apps Bot commented Sep 25, 2026

Copy link
Copy Markdown

Confirmed fixed in 7f31ec25e. create_required_password_change_token(&user) now distinguishes UserAuthError::CredentialsChanged and maps it to OidcError::CredentialsChanged; the callback converts that to credentials_changed instead of idp_unreachable, and Login.tsx presents the retry message. Other token-creation failures retain the existing provider-outage mapping. No further issue with this finding.

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>
@dviejokfs
dviejokfs force-pushed the feat/1078-admin-reset-user-password branch from 7f31ec2 to c78eac8 Compare September 25, 2026 06:40
@dviejokfs
dviejokfs merged commit 9edf44a into main Sep 25, 2026
23 of 27 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(auth): let admins reset another user's password (lost temporary password locks the account)

1 participant