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:
2026-03-12 15:33:19 -07:00
parent a9ebeb2ba1
commit f262ca7b4e
13 changed files with 412 additions and 24 deletions

View File

@@ -0,0 +1,53 @@
{{define "password_change_form"}}
<form id="password-change-form"
hx-put="/profile/password"
hx-target="#password-change-section"
hx-swap="innerHTML"
hx-headers='{"X-CSRF-Token": "{{.CSRFToken}}"}'
onsubmit="return mciasPwChangeConfirm(this)">
<div class="form-group">
<label for="current_password">Current Password</label>
<input type="password" id="current_password" name="current_password"
class="form-control" autocomplete="current-password"
placeholder="Your current password" required>
</div>
<div class="form-group" style="margin-top:.5rem">
<label for="new_password">New Password</label>
<input type="password" id="new_password" name="new_password"
class="form-control" autocomplete="new-password"
placeholder="Minimum 12 characters" required minlength="12">
</div>
<div class="form-group" style="margin-top:.5rem">
<label for="confirm_password">Confirm New Password</label>
<input type="password" id="confirm_password" name="confirm_password"
class="form-control" autocomplete="new-password"
placeholder="Repeat new password" required minlength="12">
</div>
<div id="pw-change-error" role="alert"
style="display:none;color:var(--color-danger,#c0392b);font-size:.85rem;margin-top:.35rem"></div>
<button type="submit" class="btn btn-primary btn-sm" style="margin-top:.75rem">
Change Password
</button>
</form>
<script>
function mciasPwChangeConfirm(form) {
var pw = form.querySelector('#new_password').value;
var cfm = form.querySelector('#confirm_password').value;
var err = form.querySelector('#pw-change-error');
if (pw !== cfm) {
err.textContent = 'Passwords do not match.';
err.style.display = 'block';
return false;
}
err.style.display = 'none';
return true;
}
</script>
{{end}}
{{define "password_change_result"}}
{{if .Flash}}
<div class="alert alert-success" role="alert">{{.Flash}}</div>
{{end}}
{{template "password_change_form" .}}
{{end}}