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:
@@ -121,6 +121,10 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("GET /v1/audit", requireAdmin(http.HandlerFunc(s.handleListAudit)))
|
||||
mux.Handle("GET /v1/accounts/{id}/tags", requireAdmin(http.HandlerFunc(s.handleGetTags)))
|
||||
mux.Handle("PUT /v1/accounts/{id}/tags", requireAdmin(http.HandlerFunc(s.handleSetTags)))
|
||||
mux.Handle("PUT /v1/accounts/{id}/password", requireAdmin(http.HandlerFunc(s.handleAdminSetPassword)))
|
||||
|
||||
// Self-service password change (requires valid token; actor must match target account).
|
||||
mux.Handle("PUT /v1/auth/password", requireAuth(http.HandlerFunc(s.handleChangePassword)))
|
||||
mux.Handle("GET /v1/policy/rules", requireAdmin(http.HandlerFunc(s.handleListPolicyRules)))
|
||||
mux.Handle("POST /v1/policy/rules", requireAdmin(http.HandlerFunc(s.handleCreatePolicyRule)))
|
||||
mux.Handle("GET /v1/policy/rules/{id}", requireAdmin(http.HandlerFunc(s.handleGetPolicyRule)))
|
||||
@@ -801,6 +805,183 @@ func (s *Server) handleTOTPRemove(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---- Password change endpoints ----
|
||||
|
||||
// adminSetPasswordRequest is the request body for PUT /v1/accounts/{id}/password.
|
||||
// Used by admins to reset any human account's password without requiring the
|
||||
// current password.
|
||||
type adminSetPasswordRequest struct {
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
// handleAdminSetPassword allows an admin to reset any human account's password.
|
||||
// No current-password verification is required because the admin role already
|
||||
// represents a higher trust level, matching the break-glass recovery pattern.
|
||||
//
|
||||
// Security: new password is validated (minimum length) and hashed with Argon2id
|
||||
// before storage. The plaintext is never logged. All active tokens for the
|
||||
// target account are revoked so that a compromised-account recovery fully
|
||||
// invalidates any outstanding sessions.
|
||||
func (s *Server) handleAdminSetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
acct, ok := s.loadAccount(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if acct.AccountType != model.AccountTypeHuman {
|
||||
middleware.WriteError(w, http.StatusBadRequest, "password can only be set on human accounts", "bad_request")
|
||||
return
|
||||
}
|
||||
|
||||
var req adminSetPasswordRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
// Security (F-13): enforce minimum length before hashing.
|
||||
if err := validate.Password(req.NewPassword); err != nil {
|
||||
middleware.WriteError(w, http.StatusBadRequest, err.Error(), "bad_request")
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(req.NewPassword, auth.ArgonParams{
|
||||
Time: s.cfg.Argon2.Time,
|
||||
Memory: s.cfg.Argon2.Memory,
|
||||
Threads: s.cfg.Argon2.Threads,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Error("hash password (admin reset)", "error", err)
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.UpdatePasswordHash(acct.ID, hash); err != nil {
|
||||
s.logger.Error("update password hash", "error", err)
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
// Security: revoke all active sessions so a compromised account cannot
|
||||
// continue to use old tokens after a password reset. Failure here means
|
||||
// the API's documented guarantee ("all active sessions revoked") cannot be
|
||||
// upheld, so we return 500 rather than silently succeeding.
|
||||
if err := s.db.RevokeAllUserTokens(acct.ID, "password_reset"); err != nil {
|
||||
s.logger.Error("revoke tokens on password reset", "error", err, "account_id", acct.ID)
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
actor := middleware.ClaimsFromContext(r.Context())
|
||||
var actorID *int64
|
||||
if actor != nil {
|
||||
if a, err := s.db.GetAccountByUUID(actor.Subject); err == nil {
|
||||
actorID = &a.ID
|
||||
}
|
||||
}
|
||||
s.writeAudit(r, model.EventPasswordChanged, actorID, &acct.ID, `{"via":"admin_reset"}`)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// changePasswordRequest is the request body for PUT /v1/auth/password.
|
||||
// The current_password is required to prevent token-theft attacks: an attacker
|
||||
// who steals a valid JWT cannot change the password without also knowing the
|
||||
// existing one.
|
||||
type changePasswordRequest struct {
|
||||
CurrentPassword string `json:"current_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
// handleChangePassword allows an authenticated user to change their own password.
|
||||
// The current password must be verified before the new hash is written.
|
||||
//
|
||||
// Security: current password is verified with Argon2id (constant-time).
|
||||
// Lockout is checked and failures are recorded to prevent the endpoint from
|
||||
// being used as an oracle for the current password. On success, all other
|
||||
// active sessions (other JTIs) are revoked so stale tokens cannot be used
|
||||
// after a credential rotation.
|
||||
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
|
||||
acct, err := s.db.GetAccountByUUID(claims.Subject)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusUnauthorized, "account not found", "unauthorized")
|
||||
return
|
||||
}
|
||||
if acct.AccountType != model.AccountTypeHuman {
|
||||
middleware.WriteError(w, http.StatusBadRequest, "password change is only available for human accounts", "bad_request")
|
||||
return
|
||||
}
|
||||
|
||||
var req changePasswordRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.CurrentPassword == "" || req.NewPassword == "" {
|
||||
middleware.WriteError(w, http.StatusBadRequest, "current_password and new_password are required", "bad_request")
|
||||
return
|
||||
}
|
||||
|
||||
// Security: check lockout before verifying (same as login flow) so an
|
||||
// attacker cannot use this endpoint to brute-force the current password.
|
||||
locked, lockErr := s.db.IsLockedOut(acct.ID)
|
||||
if lockErr != nil {
|
||||
s.logger.Error("lockout check (password change)", "error", lockErr)
|
||||
}
|
||||
if locked {
|
||||
s.writeAudit(r, model.EventPasswordChanged, &acct.ID, &acct.ID, `{"result":"locked"}`)
|
||||
middleware.WriteError(w, http.StatusTooManyRequests, "account temporarily locked", "account_locked")
|
||||
return
|
||||
}
|
||||
|
||||
// Security: verify the current password with the same constant-time
|
||||
// Argon2id path used at login to prevent timing oracles.
|
||||
ok, verifyErr := auth.VerifyPassword(req.CurrentPassword, acct.PasswordHash)
|
||||
if verifyErr != nil || !ok {
|
||||
_ = s.db.RecordLoginFailure(acct.ID)
|
||||
s.writeAudit(r, model.EventPasswordChanged, &acct.ID, &acct.ID, `{"result":"wrong_current_password"}`)
|
||||
middleware.WriteError(w, http.StatusUnauthorized, "current password is incorrect", "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
// Security (F-13): enforce minimum length on the new password before hashing.
|
||||
if err := validate.Password(req.NewPassword); err != nil {
|
||||
middleware.WriteError(w, http.StatusBadRequest, err.Error(), "bad_request")
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(req.NewPassword, auth.ArgonParams{
|
||||
Time: s.cfg.Argon2.Time,
|
||||
Memory: s.cfg.Argon2.Memory,
|
||||
Threads: s.cfg.Argon2.Threads,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Error("hash password (self-service change)", "error", err)
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.UpdatePasswordHash(acct.ID, hash); err != nil {
|
||||
s.logger.Error("update password hash", "error", err)
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
// Security: clear the failure counter since the user proved knowledge of
|
||||
// the current password, then revoke all tokens *except* the current one so
|
||||
// the caller retains their active session but any other stolen sessions are
|
||||
// invalidated. Revocation failure breaks the documented guarantee so we
|
||||
// return 500 rather than silently succeeding.
|
||||
_ = s.db.ClearLoginFailures(acct.ID)
|
||||
if err := s.db.RevokeAllUserTokensExcept(acct.ID, claims.JTI, "password_changed"); err != nil {
|
||||
s.logger.Error("revoke other tokens on password change", "error", err, "account_id", acct.ID)
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
s.writeAudit(r, model.EventPasswordChanged, &acct.ID, &acct.ID, `{"via":"self_service"}`)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---- Postgres credential endpoints ----
|
||||
|
||||
type pgCredRequest struct {
|
||||
|
||||
Reference in New Issue
Block a user