Checkpoint: password reset, rule expiry, migrations
- Self-service and admin password-change endpoints
(PUT /v1/auth/password, PUT /v1/accounts/{id}/password)
- Policy rule time-scoped expiry (not_before / expires_at)
with migration 000006 and engine filtering
- golang-migrate integration; embedded SQL migrations
- PolicyRecord fieldalignment lint fix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -896,6 +896,97 @@ func (u *UIServer) handleCreatePGCreds(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/pgcreds", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleAdminResetPassword allows an admin to set a new password for any human
|
||||
// account without requiring the current password. On success all active tokens
|
||||
// for the target account are revoked so a compromised account is fully
|
||||
// invalidated.
|
||||
//
|
||||
// Security: new password is validated (minimum 12 chars) and hashed with
|
||||
// Argon2id before storage. The plaintext is never logged or included in any
|
||||
// response. Audit event EventPasswordChanged is recorded on success.
|
||||
func (u *UIServer) handleAdminResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
u.renderError(w, r, http.StatusBadRequest, "invalid form")
|
||||
return
|
||||
}
|
||||
|
||||
id := r.PathValue("id")
|
||||
acct, err := u.db.GetAccountByUUID(id)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusNotFound, "account not found")
|
||||
return
|
||||
}
|
||||
if acct.AccountType != model.AccountTypeHuman {
|
||||
u.renderError(w, r, http.StatusBadRequest, "password can only be reset for human accounts")
|
||||
return
|
||||
}
|
||||
|
||||
newPassword := r.FormValue("new_password")
|
||||
confirmPassword := r.FormValue("confirm_password")
|
||||
if newPassword == "" {
|
||||
u.renderError(w, r, http.StatusBadRequest, "new password is required")
|
||||
return
|
||||
}
|
||||
// Server-side equality check mirrors the client-side guard; defends against
|
||||
// direct POST requests that bypass the JavaScript confirmation.
|
||||
if newPassword != confirmPassword {
|
||||
u.renderError(w, r, http.StatusBadRequest, "passwords do not match")
|
||||
return
|
||||
}
|
||||
// Security (F-13): enforce minimum length before hashing.
|
||||
if err := validate.Password(newPassword); err != nil {
|
||||
u.renderError(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(newPassword, auth.ArgonParams{
|
||||
Time: u.cfg.Argon2.Time,
|
||||
Memory: u.cfg.Argon2.Memory,
|
||||
Threads: u.cfg.Argon2.Threads,
|
||||
})
|
||||
if err != nil {
|
||||
u.logger.Error("hash password (admin reset)", "error", err)
|
||||
u.renderError(w, r, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
if err := u.db.UpdatePasswordHash(acct.ID, hash); err != nil {
|
||||
u.logger.Error("update password hash", "error", err)
|
||||
u.renderError(w, r, http.StatusInternalServerError, "failed to update password")
|
||||
return
|
||||
}
|
||||
|
||||
// Security: revoke all active sessions for the target account so an
|
||||
// attacker who held a valid token cannot continue to use it after reset.
|
||||
// Render an error fragment rather than silently claiming success if
|
||||
// revocation fails.
|
||||
if err := u.db.RevokeAllUserTokens(acct.ID, "password_reset"); err != nil {
|
||||
u.logger.Error("revoke tokens on admin password reset", "account_id", acct.ID, "error", err)
|
||||
u.renderError(w, r, http.StatusInternalServerError, "password updated but session revocation failed; revoke tokens manually")
|
||||
return
|
||||
}
|
||||
|
||||
claims := claimsFromContext(r.Context())
|
||||
var actorID *int64
|
||||
if claims != nil {
|
||||
if actor, err := u.db.GetAccountByUUID(claims.Subject); err == nil {
|
||||
actorID = &actor.ID
|
||||
}
|
||||
}
|
||||
u.writeAudit(r, model.EventPasswordChanged, actorID, &acct.ID, `{"via":"admin_reset"}`)
|
||||
|
||||
// Return a success fragment so HTMX can display confirmation inline.
|
||||
csrfToken, _ := u.setCSRFCookies(w)
|
||||
u.render(w, "password_reset_result", AccountDetailData{
|
||||
PageData: PageData{
|
||||
CSRFToken: csrfToken,
|
||||
Flash: "Password updated and all active sessions revoked.",
|
||||
},
|
||||
Account: acct,
|
||||
})
|
||||
}
|
||||
|
||||
// handleIssueSystemToken issues a long-lived service token for a system account.
|
||||
func (u *UIServer) handleIssueSystemToken(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
|
||||
Reference in New Issue
Block a user