Commit ebbb3bf
feat(security): Password Policy & Security Hardening (#218)
* feat(66-01): add password policy, lockout, history schema and audit enum values
- Add 9 password policy fields to RegistrationSettings (minPasswordLength, requireUppercase, requireLowercase, requireNumbers, requireSpecialChars, passwordHistoryDepth, passwordExpirationDays, lockoutThreshold, lockoutDurationMinutes)
- Add 4 lockout/session fields to User model (failedLoginAttempts, lockedUntil, passwordChangedAt, mustChangePassword, passwordHistory relation)
- Create PasswordHistory model with userId FK, hash (@omit, no @password), createdAt, composite index, and admin-only access rules
- Add ACCOUNT_LOCKED and ACCOUNT_UNLOCKED to AuditAction enum
* chore(66-01): run pnpm generate - update generated files and push schema to database
- Generated Prisma schema includes PasswordHistory model, failedLoginAttempts, minPasswordLength
- Updated ZenStack hooks for User, RegistrationSettings, and new PasswordHistory
- Database schema pushed with --accept-data-loss (pre-existing kind column drop on Issue table, unrelated to Phase 66)
* refactor(66-01): change requireSpecialChars Boolean to requiredSpecialChars String?
Allow admins to specify which special characters to enforce rather than
a simple boolean toggle. Null means no special chars required; a string
like "!@#$%" means at least one of those characters must be present.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* chore(66-01): regenerate after requiredSpecialChars schema change
Re-ran pnpm generate and prisma db push to update generated files and
database schema for the Boolean→String? field type change.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(66-02): update auditAuthEvent to accept ACCOUNT_LOCKED and ACCOUNT_UNLOCKED action types
- Extended action union type to include ACCOUNT_LOCKED and ACCOUNT_UNLOCKED
- No function body changes needed — captureAuditEvent already accepts any AuditAction value
- Enum values were added to schema in Plan 01
* feat(66-02): harden authorize() with lockout, timing-safe comparison, expiry check, and authMethod filtering
- Add TIMING_DUMMY_HASH constant for timing-safe non-existent user comparison (SECURITY-02)
- Expand user select to include authMethod, failedLoginAttempts, lockedUntil, passwordChangedAt
- Add isCredentialUser guard (INTERNAL or BOTH authMethod) for all lockout logic (SECURITY-04)
- Check lockedUntil before password comparison; return generic null to avoid status leakage (SECURITY-01)
- Atomically increment failedLoginAttempts with Prisma increment operator; set lockedUntil on threshold (T-66-05)
- Fire ACCOUNT_LOCKED audit event when lockout threshold reached
- Reset failedLoginAttempts and lockedUntil on successful login; fire ACCOUNT_UNLOCKED if was locked
- Check passwordExpirationDays at login time; set mustChangePassword flag when expired (POLICY-04)
* feat(66-03): add password history utility functions (isPasswordInHistory, updatePasswordHistory)
- Create testplanit/lib/password-history.ts with two exported utilities
- isPasswordInHistory checks candidate against recent N hashes using bcrypt.compare
- updatePasswordHistory inserts new hash and prunes older entries beyond depth
- Both functions short-circuit when depth <= 0 (history disabled)
- Uses direct PrismaClient (db) not ZenStack-enhanced client per access control design
- Intentional hard delete (deleteMany) for pruning — PasswordHistory is not a business entity
* feat(66-03): add JWT session invalidation on password change (SECURITY-03)
- Add passwordChangedAt?: string | null to JWT module augmentation
- Embed passwordChangedAt in JWT at sign-in via db.user.findUnique select
- Session callback checks DB passwordChangedAt vs token timestamp; returns empty object to force re-auth when DB is newer
- Session invalidation check guarded by authMethod INTERNAL/BOTH (SECURITY-04, SSO users skipped)
- Session invalidation uses direct DB query, not Valkey cache (cache TTL would allow stale sessions)
- Changes applied to both getAuthOptions() (dynamic) and static authOptions
- change-password route now sets passwordChangedAt: new Date() on password update
- change-password route calls invalidateSessionUserCache after update
* feat(67-01): add AuditAction enum values and validatePasswordPolicy utility with tests
- Add PASSWORD_POLICY_CHANGED, FORCE_PASSWORD_CHANGE, PASSWORD_REVOKED to AuditAction enum in schema.zmodel
- Regenerate Prisma client (prisma/schema.prisma, zenstack-openapi.json)
- Create lib/validate-password-policy.ts with validatePasswordPolicy function and PolicyViolation interface
- Create lib/validate-password-policy.test.ts with 12 passing unit tests covering all policy rules
* feat(67-01): wire validatePasswordPolicy and updatePasswordHistory into change-password route
- Import validatePasswordPolicy from ~/lib/validate-password-policy
- Import updatePasswordHistory from ~/lib/password-history
- Remove hardcoded 4-char minimum length check (replaced by policy validation)
- Add policy violation check after current password verification, returns 400 with errors array
- Fetch passwordHistoryDepth from registrationSettings after hashing
- Add mustChangePassword: false to user.update data to clear force-change flag
- Store new hash in PasswordHistory when depth > 0
- Fix test file: makeSettings() returns any to satisfy TypeScript strict mock typing
* test(67-03): add failing test stubs for admin enforcement endpoints and change-password route
- force-change-password: 401/403/404/400 SSO/200 + audit + invalidateSessionUserCache
- bulk-force-change-password: 403/200 updateMany filter/audit scope+count
- revoke-password: 401/403/404/400 no-password/400 no-passwordless/200 + audit
- registration-settings: 401/403/400 no-fields/400 invalid-length/200 diff+audit/200 no-diff
- change-password: validatePasswordPolicy 400 violations/updatePasswordHistory depth/mustChangePassword false
* feat(67-03): implement individual and bulk force-change-password admin endpoints
- force-change: admin-only, validates authMethod (INTERNAL/BOTH only), sets mustChangePassword=true, invalidates session cache, fires FORCE_PASSWORD_CHANGE audit with scope=individual
- bulk force-change: admin-only, updateMany with authMethod/mustChangePassword:false/isDeleted/isActive filters, fires FORCE_PASSWORD_CHANGE audit with scope=bulk and count
- all tests GREEN (13/13)
* feat(67-03): implement revoke-password and registration-settings PATCH admin endpoints
- revoke-password: admin-only, pre-flight check for Magic Link SSO or email server env vars, nulls password+updates passwordChangedAt, invalidates session cache, fires PASSWORD_REVOKED audit with revokedBy
- registration-settings PATCH: admin-only, allowlist POLICY_FIELDS, validates numeric ranges, fetches current settings for diff, fires PASSWORD_POLICY_CHANGED only when calculateDiff returns changes
- all revoke and registration-settings tests GREEN (17/17)
* feat(67-02): add mustChangePassword JWT fields, callbacks, and middleware redirect
- Extend JWT interface with mustChangePassword and mustChangePasswordReason fields
- Add mustChangePasswordCleared session update handler in jwt callback (both getAuthOptions and static authOptions)
- Add mustChangePassword + reason logic to db user select in jwt callback (both variants)
- Expose mustChangePasswordReason from JWT in session callback for UI display
- Add force-change-password redirect in proxy.ts with bypass exemptions for force-change page, force-change API, password-policy API, and auth API routes
* feat(67-02): add force-change-password page, API endpoints, and i18n keys
- Create force-change-password page with policy requirements display (minLength, uppercase, lowercase, numbers, specialChars)
- Contextual messaging based on mustChangePasswordReason (admin vs expired)
- Session update to clear mustChangePassword flag after successful change
- Create force-change-password API: no current password required, guarded by JWT mustChangePassword flag, validates policy, stores history, clears flag, audits with FORCE_PASSWORD_CHANGE
- Create password-policy read endpoint: returns active policy for display (authenticated, user-scoped)
- Create minimal layout for force-change-password page
- Add forceChangePassword i18n keys under auth namespace in en-US.json
* feat(68-01): add AdminMenu Security entry and all phase i18n keys
- Add Shield import and Security menu entry after SSO in AdminMenu.tsx
- Add admin.menu.security key to en-US.json
- Add admin.security namespace with all password policy, lockout, and enforcement keys
- Add admin.users force/revoke password action keys
- Add top-level passwordStrength namespace for strength indicator
* feat(68-01): create Security admin page with password policy, lockout, and bulk enforcement
- New page at /admin/security with use client directive
- Loads current settings via useFindFirstRegistrationSettings
- Syncs 9 policy fields via useEffect (minPasswordLength through lockoutDurationMinutes)
- Password Policy section: min length, uppercase/lowercase/numbers toggles, special chars, history depth, expiration
- Lockout Policy section: threshold and duration inputs
- Enforcement section: Force All Users button opening confirmation dialog
- Dialog shows affected user count via useCountUser with INTERNAL/BOTH auth filter
- Saves via PATCH /api/admin/registration-settings with toast feedback
- Bulk force via POST /api/admin/users/bulk-force-change-password with toast feedback
- Admin guard: returns null if session user is not ADMIN
* feat(68-02): replace icon buttons with DropdownMenu in users columns.tsx
- Add DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger imports
- Add MoreHorizontal import, remove SquarePen and Trash2
- Add tAdmin, onForceChangePassword, onRevokePassword parameters to getColumns
- Replace icon button actions column with three-dot DropdownMenu
- Hide Force Password Change and Revoke Password for SSO-only users (authMethod !== SSO)
- Hide Delete for current user (not disabled)
* feat(68-02): add force-change and revoke handlers with confirmation dialogs to page.tsx
- Import Dialog components from @/components/ui/dialog
- Import toast from sonner
- Add tAdmin useTranslations(admin.users) hook
- Add forcingUser, revokingUser, isForceLoading, isRevokeLoading state
- Add handleForceChangePassword calling /api/admin/users/[id]/force-change-password POST
- Add handleRevokePassword calling /api/admin/users/[id]/revoke-password POST
- Both handlers show toast.success/toast.error feedback
- Update getColumns call with tAdmin, setForcingUser, setRevokingUser params
- Add Force Password Change confirmation dialog with user name interpolation
- Add Revoke Password confirmation dialog with user name interpolation
* feat(68-03): install zxcvbn-ts and create PasswordStrengthIndicator component with tests
- Install @zxcvbn-ts/core and @zxcvbn-ts/language-en packages
- Create PasswordStrengthIndicator component with dynamic import of zxcvbn-ts
- Implement 4-segment strength bar with red->green colors based on score
- Implement policy requirements checklist with real-time updates
- Returns null when password is empty
- Export PasswordPolicy interface for reuse
- Add PasswordStrengthIndicator unit tests (5 tests all passing)
- Add passwordStrength namespace to vitest setup messages mock
* feat(68-03): integrate PasswordStrengthIndicator into signup, change-password, and force-change forms
- force-change-password: import PasswordStrengthIndicator and PasswordPolicy, remove static policy block, render indicator after new password input
- signup: add PasswordStrengthIndicator import, derive policy from registrationSettings via useMemo, use form.watch('password') for real-time value, render indicator after password input
- ChangePasswordModal: add PasswordStrengthIndicator and PasswordPolicy imports, add policy state with fetch from /api/users/[id]/password-policy, render indicator after new password input
- Fix PasswordStrengthIndicator: remove adjacencyGraphs import (not exported by @zxcvbn-ts/language-en@3.0.2)
* fix(68): WR-01 sanitize numeric inputs with parseInt and clamp to valid ranges
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(68): WR-02 use dynamic min password length from server policy in signup Zod schema
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(68): WR-03 use policy min password length instead of hardcoded 4 in change password modal
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(68): replace numeric inputs with shadcn Slider components on admin security page
All 5 numeric policy fields (minPasswordLength, passwordHistoryDepth,
passwordExpirationDays, lockoutThreshold, lockoutDurationMinutes) now use
shadcn Slider with appropriate min/max ranges and real-time value display.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(68): add self-user guard to password actions and use hard redirect for force-change-password
- Hide Force Password Change and Revoke Password menu items for the
logged-in admin user to prevent self-lockout
- Replace router.push with window.location.href on force-change-password
page to ensure middleware re-reads the updated JWT
- Move setIsLoading(false) to error paths only to prevent flash on success
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(68): add shadcn Slider component and radix-ui/react-slider dependency
The Slider component was installed via `npx shadcn add slider` for use
on the admin security page. Commits the generated component and the
package.json dependency entry.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add v0.22.0 upgrade notification for password policy & security hardening
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs: add security settings user guide and sidebar entry
Covers password policy, account lockout, enforcement actions (force
change, revoke, bulk force), password strength indicator, and audit
logging. Added to Admin section in sidebar after SSO.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs: add v0.22.0 blog post for password policy & security hardening
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test(e2e): add security settings and user password actions E2E tests
Tests cover:
- Security page loads with all policy sections and sliders
- Slider value changes and persists after save + reload
- Force All Users dialog shows affected count
- Three-dot menu shows password actions for other internal users
- Three-dot menu hides password actions for the current admin
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: resolve lint errors and leaked timer in 2FA test
- Add afterEach cleanup to two-factor-verify test to prevent input-otp
timer from firing after jsdom teardown
- Use vi.mocked(db, true) for deep mock typing in all API route tests
- Cast password to unknown as string in revoke-password route (schema
defines String but DB column allows null)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: resolve TypeScript build error in session invalidation return type
Replace @ts-expect-error with as Session cast for the empty object
return used to invalidate sessions after password change.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add @radix-ui/react-slider dependency and update security page with new password policy features
- Added @radix-ui/react-slider version 1.3.6 to pnpm-lock.yaml.
- Updated the admin security page to utilize the new Slider component for password policy settings, enhancing user experience with real-time value display.
- Improved localization for security settings in Spanish and French, including new translations for password change requirements and policies.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(e2e): fix selector issues in security and user-updates tests
- Use exact match for 'Password Policy' to avoid matching description
- Scope tabular-nums span to label row instead of broad div filter
- Use correct admin email (admin@example.com) from seed data
- Update user-updates tests to use three-dot dropdown menu instead
of direct icon buttons (columns.tsx now uses DropdownMenu)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* style: run prettier on all changed files
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* chore: add precommit script combining lint and format:check
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs: add security tag to blog tags.yml
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: revert Docusaurus to 3.9.2 and add password dep to PasswordStrengthIndicator
Docusaurus 3.10.0 breaks @acid-info/docusaurus-og plugin (blogListPaginated
undefined). Revert to 3.9.2 until the OG plugin is updated.
Also add password to the zxcvbn lazy-load useEffect dependency array.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use relative link for security settings doc in blog post
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs: update audit-logs, users, and features docs for password policy changes
- audit-logs.md: add PASSWORD_POLICY_CHANGED, FORCE_PASSWORD_CHANGE,
PASSWORD_REVOKED, ACCOUNT_LOCKED, ACCOUNT_UNLOCKED actions
- users.md: update actions column to reflect three-dot dropdown menu
instead of icon buttons, mention password actions
- features.md: add password policy, lockout, enforcement, and strength
indicator to Security & Compliance section
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>1 parent c4da0be commit ebbb3bf
55 files changed
Lines changed: 18629 additions & 1480 deletions
File tree
- docs
- blog
- docs
- user-guide
- testplanit
- app
- [locale]
- admin
- security
- users
- auth
- force-change-password
- two-factor-verify
- signup
- users/profile/[userId]
- api
- admin
- registration-settings
- users
- [userId]
- force-change-password
- revoke-password
- bulk-force-change-password
- users/[userId]
- change-password
- force-change-password
- password-policy
- components
- ui
- dist/workers
- e2e/tests/admin
- security
- users
- lib
- hooks
- openapi
- services
- messages
- prisma
- server
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 63 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
42 | 42 | | |
43 | 43 | | |
44 | 44 | | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
120 | 120 | | |
121 | 121 | | |
122 | 122 | | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
123 | 127 | | |
124 | 128 | | |
125 | 129 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
67 | 67 | | |
68 | 68 | | |
69 | 69 | | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
70 | 80 | | |
71 | 81 | | |
72 | 82 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
28 | 28 | | |
29 | 29 | | |
30 | 30 | | |
31 | | - | |
| 31 | + | |
32 | 32 | | |
33 | 33 | | |
34 | 34 | | |
| |||
49 | 49 | | |
50 | 50 | | |
51 | 51 | | |
52 | | - | |
| 52 | + | |
53 | 53 | | |
54 | 54 | | |
55 | 55 | | |
| |||
71 | 71 | | |
72 | 72 | | |
73 | 73 | | |
74 | | - | |
| 74 | + | |
75 | 75 | | |
76 | 76 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
107 | 107 | | |
108 | 108 | | |
109 | 109 | | |
| 110 | + | |
110 | 111 | | |
111 | 112 | | |
112 | 113 | | |
| |||
0 commit comments