21e9823008
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
149 lines
6.6 KiB
Markdown
149 lines
6.6 KiB
Markdown
# 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.
|