Skip to content

feat(security): Password Policy & Security Hardening#218

Merged
therealbrad merged 42 commits intomainfrom
feat/password-policy-security
Apr 18, 2026
Merged

feat(security): Password Policy & Security Hardening#218
therealbrad merged 42 commits intomainfrom
feat/password-policy-security

Conversation

@therealbrad
Copy link
Copy Markdown
Contributor

@therealbrad therealbrad commented Apr 17, 2026

Description

Password Policy & Security Hardening for v0.22.0. Adds a dedicated Admin Security page with configurable password policy, account lockout, and enforcement actions. Includes a real-time password strength indicator on all password forms, server-side policy enforcement, and comprehensive documentation.

Key changes:

  • Admin Security page (/admin/security) with slider controls for password policy (min length, character requirements, history depth, expiration) and lockout settings (threshold, duration)
  • PasswordStrengthIndicator component (zxcvbn-powered) integrated into signup, change-password, and force-change-password forms
  • User table three-dot dropdown menu with Force Password Change and Revoke Password actions (self-user guard prevents admin self-lockout)
  • Force-change-password flow with middleware redirect, JWT flag, and hard redirect after success
  • Schema additions: password policy fields, lockout fields, password history table, audit action enums
  • Server-side enforcement: password validation pipeline, history checks, lockout logic, timing-safe auth, session invalidation on password change

Related Issue

Closes #68

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Refactoring (no functional changes)
  • Performance improvement

How Has This Been Tested?

  • Unit tests — 5656 tests passing, 0 errors
  • Integration tests — 32 API route tests across registration-settings, force-change-password, revoke-password, bulk-force-change-password, and change-password endpoints
  • E2E tests — Security page sliders, save persistence, Force All Users dialog, three-dot menu password action visibility, self-user guard
  • Manual testing — Slider interactions, settings persistence, PasswordStrengthIndicator in all 3 forms, force-change redirect flow

Test Configuration:

  • OS: macOS (Darwin 25.4.0, ARM)
  • Browser: Chromium (Playwright)
  • Node version: 22.x

Checklist

  • My code follows the project's style guidelines
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published
  • I have signed the CLA

Screenshots (if applicable)

Additional Notes

  • Upgrade notification added for v0.22.0 in lib/upgrade-notifications.ts
  • User guide doc: docs/docs/user-guide/security-settings.md
  • Blog post: docs/blog/2026-04-17-password-policy-security-hardening.md
  • Pre-existing server/auth.ts session invalidation return {} type error fixed with as Session cast
  • two-factor-verify.test.tsx leaked timer fixed with afterEach(cleanup)

therealbrad and others added 30 commits April 16, 2026 13:30
…num 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
…ema 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)
…lChars 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>
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>
…NT_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
… 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)
…ory, 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
…Y-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
…tility 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
…to 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
…nd 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
…n 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)
…H 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)
…ware 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
…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
- 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
…, 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
- 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)
…ialogs 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
…omponent 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
…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)
…id ranges

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…signup Zod schema

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…in change password modal

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…in 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>
…t 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>
…dency

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>
… hardening

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
therealbrad and others added 6 commits April 17, 2026 07:36
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
- 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>
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>
…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>
- 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>
@therealbrad therealbrad changed the title feat: Password Policy & Security Hardening (v0.22.0) feat(security): Password Policy & Security Hardening (v0.22.0) Apr 17, 2026
@therealbrad therealbrad changed the title feat(security): Password Policy & Security Hardening (v0.22.0) feat(security): Password Policy & Security Hardening Apr 17, 2026
therealbrad and others added 6 commits April 17, 2026 17:12
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…gthIndicator

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>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… 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>
@therealbrad therealbrad merged commit ebbb3bf into main Apr 18, 2026
5 checks passed
@therealbrad therealbrad deleted the feat/password-policy-security branch April 18, 2026 03:16
@therealbrad
Copy link
Copy Markdown
Contributor Author

🎉 This PR is included in version 0.22.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant