Add self-service change-password design spec

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Chris Chen
2026-06-23 18:58:03 -07:00
parent 583408032d
commit 21e9823008
@@ -0,0 +1,148 @@
# 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.cs``IdentityUser`, 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.
```csharp
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/account``AccountSettingsPageComponent`, 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.