feat: Implement comprehensive security hardening

Security Improvements:
- Auto-generate AUTH_SECRET and admin credentials on first launch
  - Cryptographically secure random generation
  - Stored in database for persistence
  - Logged once to container logs for admin retrieval

- Implement CSRF protection with double-submit cookie pattern
  - Three-way validation: cookie, header, and session database
  - Automatic client-side injection via plugin
  - Server middleware for automatic validation
  - Zero frontend code changes required

- Add session fixation prevention with automatic invalidation
  - Regenerate sessions on password changes
  - Keep current session active, invalidate others on profile password change
  - Invalidate ALL sessions on forgot-password reset
  - Invalidate ALL sessions on admin password reset

- Upgrade password reset codes to 8-char alphanumeric
  - Increased from 1M to 1.8 trillion combinations
  - Uses crypto.randomInt() for cryptographic randomness
  - Excluded confusing characters (I, O) for better UX
  - Case-insensitive verification

- Implement dual-layer account lockout
  - IP-based rate limiting (existing)
  - Per-account lockout: 10 attempts = 30 min lock
  - Automatic unlock after expiration
  - Admin manual unlock via UI
  - Visual status indicators in users table

Database Changes:
- Add csrf_token column to sessions table
- Add failed_login_attempts and locked_until columns to users table
- Add settings table for persistent AUTH_SECRET storage
- All migrations backward-compatible with try-catch

New Files:
- server/utils/csrf.ts - CSRF protection utilities
- server/middleware/csrf.ts - Automatic CSRF validation middleware
- plugins/csrf.client.ts - Automatic CSRF header injection
- server/api/users/unlock/[id].post.ts - Admin unlock endpoint

Modified Files:
- server/utils/database.ts - Core security functions and schema updates
- server/utils/email.ts - Enhanced reset code generation
- server/api/auth/login.post.ts - CSRF + account lockout logic
- server/api/auth/register.post.ts - CSRF token generation
- server/api/auth/logout.post.ts - CSRF cookie cleanup
- server/api/auth/reset-password.post.ts - Session invalidation
- server/api/auth/verify-reset-code.post.ts - Case-insensitive codes
- server/api/profile/update.put.ts - Session invalidation on password change
- server/api/users/password/[id].put.ts - Session invalidation on admin reset
- pages/users.vue - Lock status display and unlock functionality
- docker-compose.yml - Removed default credentials
- nuxt.config.ts - Support auto-generation

All changes follow OWASP best practices and are production-ready.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-05 17:36:31 -05:00
parent 75b7a93bf9
commit 2ff493d804
16 changed files with 754 additions and 61 deletions

View File

@@ -57,6 +57,9 @@
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Role
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
@@ -74,14 +77,46 @@
<div class="text-sm text-gray-900">{{ user.last_name || '-' }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span
<span
:class="user.is_admin ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-800'"
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full"
>
{{ user.is_admin ? 'Admin' : 'User' }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex flex-col gap-1">
<span
v-if="isAccountLocked(user)"
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800"
>
🔒 Locked
</span>
<span
v-else-if="(user.failed_login_attempts || 0) > 0"
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-yellow-100 text-yellow-800"
>
{{ user.failed_login_attempts }} failed attempt{{ user.failed_login_attempts !== 1 ? 's' : '' }}
</span>
<span
v-else
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800"
>
Active
</span>
<span v-if="isAccountLocked(user)" class="text-xs text-gray-500">
Until {{ formatLockTime(user.locked_until!) }}
</span>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
<button
v-if="isAccountLocked(user)"
@click="unlockAccount(user)"
class="text-green-600 hover:text-green-900 font-semibold"
>
Unlock Account
</button>
<button
v-if="!isCurrentUser(user)"
@click="toggleRole(user)"
@@ -190,6 +225,8 @@ interface User {
first_name?: string
last_name?: string
is_admin: number
failed_login_attempts?: number
locked_until?: string | null
}
const { data: authData } = await useFetch('/api/auth/verify')
@@ -221,6 +258,22 @@ function isCurrentUser(user: User) {
return user.username === currentUsername.value
}
function isAccountLocked(user: User): boolean {
if (!user.locked_until) return false
return new Date(user.locked_until) > new Date()
}
function formatLockTime(lockUntil: string): string {
const lockTime = new Date(lockUntil)
const now = new Date()
const diffMs = lockTime.getTime() - now.getTime()
const diffMins = Math.ceil(diffMs / 60000)
if (diffMins <= 0) return 'Expired'
if (diffMins === 1) return '1 minute'
return `${diffMins} minutes`
}
async function loadUsers() {
loading.value = true
error.value = ''
@@ -284,6 +337,22 @@ async function resetPassword() {
}
}
async function unlockAccount(user: User) {
if (!confirm(`Are you sure you want to unlock ${user.username}? This will reset their failed login attempts.`)) {
return
}
try {
const result = await $fetch(`/api/users/unlock/${user.id}`, {
method: 'POST'
})
alert(`Account unlocked successfully!\n\nPrevious failed attempts: ${result.user.previousAttempts}`)
await loadUsers()
} catch (e: any) {
alert(e.data?.message || 'Failed to unlock account')
}
}
async function confirmDelete(user: User) {
if (!confirm(`Are you sure you want to delete ${user.username}? This action cannot be undone.`)) {
return