Files
Joshua Ryder 2ff493d804 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>
2025-11-05 17:36:31 -05:00

125 lines
3.5 KiB
TypeScript

import { H3Event } from 'h3'
import crypto from 'crypto'
import { getSessionByToken } from './database'
import { getAuthCookie } from './auth'
/**
* Generate a cryptographically secure CSRF token
*/
export function generateCsrfToken(): string {
return crypto.randomBytes(32).toString('hex')
}
/**
* Set CSRF token as readable cookie
* Unlike the session cookie, this is NOT httpOnly so JavaScript can read it
* This enables the "double submit cookie" pattern:
* - Cookie is sent automatically by browser
* - JavaScript reads cookie and adds to X-CSRF-Token header
* - Server validates both match
* - Attacker can't read cookie due to same-origin policy
*/
export function setCsrfCookie(event: H3Event, csrfToken: string) {
setCookie(event, 'csrf_token', csrfToken, {
httpOnly: false, // Must be readable by JavaScript for double-submit pattern
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24, // 24 hours (matches session)
path: '/',
sameSite: 'lax'
})
}
/**
* Get CSRF token from cookie
*/
export function getCsrfCookie(event: H3Event): string | undefined {
return getCookie(event, 'csrf_token')
}
/**
* Get CSRF token from request header
* Frontend should send token in X-CSRF-Token header
*/
export function getCsrfHeader(event: H3Event): string | undefined {
return getHeader(event, 'x-csrf-token')
}
/**
* Clear CSRF cookie on logout
*/
export function clearCsrfCookie(event: H3Event) {
deleteCookie(event, 'csrf_token')
}
/**
* Validate CSRF token
* Implements double-submit cookie pattern:
* 1. Token in httpOnly cookie (attacker can't read via XSS)
* 2. Token in request header (attacker can't forge via CSRF)
* 3. Tokens must match AND be tied to valid session
*/
export async function validateCsrfToken(event: H3Event): Promise<boolean> {
const cookieToken = getCsrfCookie(event)
const headerToken = getCsrfHeader(event)
// Both tokens must be present
if (!cookieToken || !headerToken) {
return false
}
// Tokens must match (constant-time comparison to prevent timing attacks)
if (!crypto.timingSafeEqual(Buffer.from(cookieToken), Buffer.from(headerToken))) {
return false
}
// Verify token is tied to valid session
const sessionToken = getAuthCookie(event)
if (!sessionToken) {
return false
}
const session = getSessionByToken(sessionToken)
if (!session || !session.csrf_token) {
return false
}
// Verify the CSRF token matches the session's CSRF token
if (!crypto.timingSafeEqual(Buffer.from(session.csrf_token), Buffer.from(cookieToken))) {
return false
}
return true
}
/**
* Check if endpoint should be exempt from CSRF protection
* Login and registration endpoints don't need CSRF since they create the session
*/
export function isCsrfExempt(path: string): boolean {
const exemptPaths = [
'/api/auth/login',
'/api/auth/register',
'/api/auth/verify', // GET request, but included for clarity
'/api/auth/forgot-password', // Public endpoint, doesn't require auth
'/api/auth/verify-reset-code', // Public endpoint, doesn't require auth
'/api/auth/reset-password' // Public endpoint, doesn't require auth
]
return exemptPaths.some(exempt => path === exempt)
}
/**
* Middleware helper to require CSRF validation
* Returns error response if validation fails
*/
export async function requireCsrfToken(event: H3Event) {
const isValid = await validateCsrfToken(event)
if (!isValid) {
throw createError({
statusCode: 403,
statusMessage: 'Invalid CSRF token'
})
}
}