Files
ROLAC/docs/superpowers/specs/2026-06-23-change-password-design.md
T
Chris Chen 21e9823008 Add self-service change-password design spec
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 18:58:03 -07:00

6.6 KiB

Change Password (Self-Service) — Design

Date: 2026-06-23 Status: Approved, pending implementation plan

Summary

Add a self-service "change password" feature so authenticated users can change their own password. The UI lives on a new Account Settings page in the user portal, reachable from the user-header menu's currently-disabled Settings item. The backend exposes a new authenticated endpoint that verifies the current password, enforces the existing ASP.NET Identity password policy, and — on success — revokes the user's other sessions while keeping the current one active.

Out of scope (explicitly deferred): admin-driven forced password change / "must change on first login" after an admin reset. The existing admin reset endpoint (POST /api/users/{id}/reset-password) is unchanged.

Existing infrastructure (context)

  • User entity: API/ROLAC.API/Entities/AppUser.csIdentityUser, so PasswordHash / SecurityStamp are inherited.
  • Hashing & policy: ASP.NET Core Identity (PasswordHasher<AppUser>, PBKDF2). Policy in API/ROLAC.API/Program.cs: min length 8, requires digit, uppercase, lowercase, and non-alphanumeric.
  • Auth service / controller: API/ROLAC.API/Services/AuthService.cs, API/ROLAC.API/Controllers/AuthController.cs (/api/auth/login, /refresh, /me, /logout). Refresh tokens stored in DB; the active refresh token is delivered in an HttpOnly cookie.
  • Current user id: read from the sub JWT claim (ClaimTypes.NameIdentifier ?? "sub"), because MapInboundClaims = false and NameClaimType = "sub".
  • Audit: API/ROLAC.API/Services/Logging/AuditLogger.cs — security actions (login success/failure, logout) are logged; password change will be too.
  • Frontend auth: APP/src/app/shared/services/auth.service.ts.
  • User portal: APP/src/app/portals/user-portal/ with components/user-header/user-header.component.ts (user menu with disabled Profile/Settings stubs). Routes in APP/src/app/app.routes.ts carry title/titleZh/section data for the unified system header.
  • Form patterns: APP/src/app/features/users/components/edit-user-dialog/ (Kendo kendo-textbox, kendo-formfield, kendo-formerror, Reactive Forms). Form layout via Tailwind utilities on a neutral wrapper, not per-component SCSS.

Backend

Endpoint

POST /api/auth/change-password — requires authentication.

public record ChangePasswordRequest(string CurrentPassword, string NewPassword);

Flow (AuthService.ChangePasswordAsync)

  1. Resolve the user id from the sub claim; UserManager.FindByIdAsync.
  2. UserManager.ChangePasswordAsync(user, currentPassword, newPassword). This:
    • verifies the current password,
    • enforces the configured Identity password policy on the new password,
    • re-hashes and persists,
    • automatically bumps SecurityStamp. No manual CheckPasswordAsync call is needed.
  3. On failure, return 400 Bad Request with readable error messages:
    • wrong current password → "Incorrect current password",
    • policy failures → mapped to readable messages.
  4. On success:
    • revoke all of the user's refresh tokens except the one presented in the current request's HttpOnly cookie (other devices get logged out; current session stays alive),
    • write a security audit-log entry (same pattern as login logging: action, category Security, entityId = user id, user email, IP).
  5. Return 204 No Content.

The controller reads the current refresh token from the cookie to identify which session to preserve, and passes it to the service. Validation is server-authoritative; client-side checks are UX-only.

Frontend

Route & navigation

  • New route /user-portal/accountAccountSettingsPageComponent, registered in app.routes.ts with [AuthGuard] and data: { title: 'Account Settings', titleZh: '帳戶設定', section: 'Account' } so it uses the unified system header.
  • Wire the disabled Settings item in user-header.component.ts to navigate to this route (remove disabled, add navigation). The Profile stub is left as-is.

Components

  • AccountSettingsPageComponent — page shell hosting a "Change Password" section/card. Room to grow later (profile, language preference).
  • ChangePasswordFormComponent — focused child component owning the form (single responsibility; independently testable).

Form

  • Reactive Forms + Kendo, mirroring edit-user-dialog patterns.
  • Three kendo-textbox type="password" fields: Current password, New password, Confirm new password.
  • Layout via Tailwind utilities (grid grid-cols-1) on a neutral wrapper div — no per-component SCSS for layout.
  • Validators (UX-only; server stays authoritative):
    • new password: min 8, at least one upper, lower, digit, non-alphanumeric,
    • cross-field: new ≠ current, confirm === new.
  • Show password-rule hints and kendo-formerror messages. Submit disabled while invalid or pending.
  • Submit → authService.changePassword(current, next):
    • 204 → success notification, reset form,
    • 400 → surface server message inline (e.g. incorrect current password).
  • Single narrow column → inherently mobile-friendly; no grid/card-list split.

Auth service

Add changePassword(currentPassword, newPassword): Observable<void> calling POST /api/auth/change-password with withCredentials: true so the refresh-token cookie is sent.

Testing

Follow TDD — tests first, then implementation — for both layers.

Backend (xUnit; build with -c Release per build env notes)

  • Success: correct current password + policy-valid new password → succeeds, stored hash changes, 204.
  • Wrong current password → 400, password unchanged.
  • New password failing policy (too short / missing a required class) → 400 with the relevant message.
  • Other refresh tokens revoked; the current cookie's refresh token preserved.
  • Audit entry written.

Frontend (Karma/Jasmine)

  • ChangePasswordFormComponent: validators (weak password invalid, mismatch invalid, new === current invalid); submit disabled when invalid; calls service with correct args; renders server error on 400; resets on success.
  • AuthService.changePassword: issues POST /api/auth/change-password with withCredentials.

Deliverables checklist

  • Backend: ChangePasswordRequest DTO, AuthController action, AuthService method, refresh-token revocation (preserve current), audit logging.
  • Frontend: /user-portal/account route, AccountSettingsPageComponent, ChangePasswordFormComponent, Settings menu wiring, authService.changePassword.
  • Tests: backend unit tests + frontend unit tests.