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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user