UI: password change enforcement + migration recovery
- Web UI admin password reset now enforces admin role server-side (was cookie-auth + CSRF only; any logged-in user could previously reset any account's password) - Added self-service password change UI at GET/PUT /profile: current_password + new_password + confirm_password; server-side equality check; lockout + Argon2id verification; revokes all other sessions on success - password_change_form.html fragment and profile.html page - Nav bar actor name now links to /profile - policy: ActionChangePassword + default rule -7 allowing human accounts to change their own password - openapi.yaml: built-in rules count updated to -7 Migration recovery: - mciasdb schema force --version N: new subcommand to clear dirty migration state without running SQL (break-glass) - schema subcommands bypass auto-migration on open so the tool stays usable when the database is dirty - Migrate(): shim no longer overrides schema_migrations when it already has an entry; duplicate-column error on the latest migration is force-cleaned and treated as success (handles columns added outside the runner) Security: - Admin role is now validated in handleAdminResetPassword before any DB access; non-admin receives 403 - handleSelfChangePassword follows identical lockout + constant-time Argon2id path as the REST self-service handler; current password required to prevent token-theft account takeover Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -901,10 +901,32 @@ func (u *UIServer) handleCreatePGCreds(w http.ResponseWriter, r *http.Request) {
|
||||
// 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.
|
||||
// Security: caller must hold the admin role; the check is performed server-side
|
||||
// against the JWT claims so it cannot be bypassed by client-side tricks.
|
||||
// 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) {
|
||||
// Security: enforce admin role; requireCookieAuth only validates the token,
|
||||
// it does not check roles. A non-admin with a valid session must not be
|
||||
// able to reset arbitrary accounts' passwords.
|
||||
callerClaims := claimsFromContext(r.Context())
|
||||
if callerClaims == nil {
|
||||
u.renderError(w, r, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
isAdmin := false
|
||||
for _, role := range callerClaims.Roles {
|
||||
if role == "admin" {
|
||||
isAdmin = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isAdmin {
|
||||
u.renderError(w, r, http.StatusForbidden, "admin role required")
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
u.renderError(w, r, http.StatusBadRequest, "invalid form")
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"git.wntrmute.dev/kyle/mcias/internal/crypto"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/validate"
|
||||
)
|
||||
|
||||
// handleLoginPage renders the login form.
|
||||
@@ -255,3 +256,127 @@ func (u *UIServer) writeAudit(r *http.Request, eventType string, actorID, target
|
||||
u.logger.Warn("write audit event", "type", eventType, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleProfilePage renders the profile page for the currently logged-in user.
|
||||
func (u *UIServer) handleProfilePage(w http.ResponseWriter, r *http.Request) {
|
||||
csrfToken, _ := u.setCSRFCookies(w)
|
||||
u.render(w, "profile", ProfileData{
|
||||
PageData: PageData{
|
||||
CSRFToken: csrfToken,
|
||||
ActorName: u.actorName(r),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// handleSelfChangePassword allows an authenticated human user to change their
|
||||
// own password. The current password must be supplied to prevent a stolen
|
||||
// session token from being used to take over an account.
|
||||
//
|
||||
// Security: current password is verified with Argon2id (constant-time) before
|
||||
// the new hash is written. Lockout is checked first so the endpoint cannot
|
||||
// be used to brute-force the existing password. On success all other active
|
||||
// sessions are revoked; the caller's own session is preserved so they remain
|
||||
// logged in. The plaintext passwords are never logged or returned.
|
||||
func (u *UIServer) handleSelfChangePassword(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
|
||||
}
|
||||
|
||||
claims := claimsFromContext(r.Context())
|
||||
if claims == nil {
|
||||
u.renderError(w, r, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
acct, err := u.db.GetAccountByUUID(claims.Subject)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusUnauthorized, "account not found")
|
||||
return
|
||||
}
|
||||
if acct.AccountType != model.AccountTypeHuman {
|
||||
u.renderError(w, r, http.StatusBadRequest, "password change is only available for human accounts")
|
||||
return
|
||||
}
|
||||
|
||||
currentPassword := r.FormValue("current_password")
|
||||
newPassword := r.FormValue("new_password")
|
||||
confirmPassword := r.FormValue("confirm_password")
|
||||
|
||||
if currentPassword == "" || newPassword == "" {
|
||||
u.renderError(w, r, http.StatusBadRequest, "current and new password are required")
|
||||
return
|
||||
}
|
||||
// Server-side confirmation check mirrors the client-side guard; defends
|
||||
// against direct POST requests that bypass the JavaScript validation.
|
||||
if newPassword != confirmPassword {
|
||||
u.renderError(w, r, http.StatusBadRequest, "passwords do not match")
|
||||
return
|
||||
}
|
||||
|
||||
// Security: check lockout before running Argon2 to prevent brute-force.
|
||||
locked, lockErr := u.db.IsLockedOut(acct.ID)
|
||||
if lockErr != nil {
|
||||
u.logger.Error("lockout check (UI self-service password change)", "error", lockErr)
|
||||
}
|
||||
if locked {
|
||||
u.writeAudit(r, model.EventPasswordChanged, &acct.ID, &acct.ID, `{"result":"locked"}`)
|
||||
u.renderError(w, r, http.StatusTooManyRequests, "account temporarily locked, please try again later")
|
||||
return
|
||||
}
|
||||
|
||||
// Security: verify current password with constant-time Argon2id path used
|
||||
// at login so this endpoint cannot serve as a timing oracle.
|
||||
ok, verifyErr := auth.VerifyPassword(currentPassword, acct.PasswordHash)
|
||||
if verifyErr != nil || !ok {
|
||||
_ = u.db.RecordLoginFailure(acct.ID)
|
||||
u.writeAudit(r, model.EventPasswordChanged, &acct.ID, &acct.ID, `{"result":"wrong_current_password"}`)
|
||||
u.renderError(w, r, http.StatusUnauthorized, "current password is incorrect")
|
||||
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 (UI self-service)", "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: clear failure counter (user proved knowledge of current
|
||||
// password), then revoke all sessions except the current one so stale
|
||||
// tokens are invalidated while the caller stays logged in.
|
||||
_ = u.db.ClearLoginFailures(acct.ID)
|
||||
if err := u.db.RevokeAllUserTokensExcept(acct.ID, claims.JTI, "password_changed"); err != nil {
|
||||
u.logger.Error("revoke other tokens on UI password change", "account_id", acct.ID, "error", err)
|
||||
u.renderError(w, r, http.StatusInternalServerError, "password updated but session revocation failed; revoke tokens manually")
|
||||
return
|
||||
}
|
||||
|
||||
u.writeAudit(r, model.EventPasswordChanged, &acct.ID, &acct.ID, `{"via":"ui_self_service"}`)
|
||||
|
||||
csrfToken, _ := u.setCSRFCookies(w)
|
||||
u.render(w, "password_change_result", ProfileData{
|
||||
PageData: PageData{
|
||||
CSRFToken: csrfToken,
|
||||
ActorName: u.actorName(r),
|
||||
Flash: "Password updated successfully. Other active sessions have been revoked.",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -191,6 +191,7 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
|
||||
"templates/fragments/policy_row.html",
|
||||
"templates/fragments/policy_form.html",
|
||||
"templates/fragments/password_reset_form.html",
|
||||
"templates/fragments/password_change_form.html",
|
||||
}
|
||||
base, err := template.New("").Funcs(funcMap).ParseFS(web.TemplateFS, sharedFiles...)
|
||||
if err != nil {
|
||||
@@ -208,6 +209,7 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
|
||||
"audit_detail": "templates/audit_detail.html",
|
||||
"policies": "templates/policies.html",
|
||||
"pgcreds": "templates/pgcreds.html",
|
||||
"profile": "templates/profile.html",
|
||||
}
|
||||
tmpls := make(map[string]*template.Template, len(pageFiles))
|
||||
for name, file := range pageFiles {
|
||||
@@ -296,6 +298,10 @@ func (u *UIServer) Register(mux *http.ServeMux) {
|
||||
uiMux.Handle("PUT /accounts/{id}/tags", admin(u.handleSetAccountTags))
|
||||
uiMux.Handle("PUT /accounts/{id}/password", admin(u.handleAdminResetPassword))
|
||||
|
||||
// Profile routes — accessible to any authenticated user (not admin-only).
|
||||
uiMux.Handle("GET /profile", adminGet(u.handleProfilePage))
|
||||
uiMux.Handle("PUT /profile/password", auth(u.requireCSRF(http.HandlerFunc(u.handleSelfChangePassword))))
|
||||
|
||||
// Mount the wrapped UI mux on the parent mux. The "/" pattern acts as a
|
||||
// catch-all for all UI paths; the more-specific /v1/ API patterns registered
|
||||
// on the parent mux continue to take precedence per Go's routing rules.
|
||||
@@ -611,6 +617,11 @@ type PoliciesData struct {
|
||||
AllActions []string
|
||||
}
|
||||
|
||||
// ProfileData is the view model for the profile/settings page.
|
||||
type ProfileData struct {
|
||||
PageData
|
||||
}
|
||||
|
||||
// PGCredsData is the view model for the "My PG Credentials" list page.
|
||||
// It shows all pg_credentials sets accessible to the currently logged-in user:
|
||||
// those they own and those they have been granted access to.
|
||||
|
||||
Reference in New Issue
Block a user