Files
nlcc-itinerary/server/utils/database.ts
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

641 lines
20 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Database from 'better-sqlite3'
import { join } from 'path'
import bcrypt from 'bcrypt'
import crypto from 'crypto'
let db: Database.Database | null = null
/**
* Generate a cryptographically secure random string
* @param length Length of the string to generate
* @param charset Character set to use (default: alphanumeric + symbols)
*/
function generateSecurePassword(length: number = 16): string {
const lowercase = 'abcdefghijklmnopqrstuvwxyz'
const uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
const numbers = '0123456789'
const symbols = '!@#$%^&*-_=+'
const allChars = lowercase + uppercase + numbers + symbols
let password = ''
// Ensure at least one character from each category
password += lowercase[crypto.randomInt(lowercase.length)]
password += uppercase[crypto.randomInt(uppercase.length)]
password += numbers[crypto.randomInt(numbers.length)]
password += symbols[crypto.randomInt(symbols.length)]
// Fill the rest randomly
for (let i = password.length; i < length; i++) {
password += allChars[crypto.randomInt(allChars.length)]
}
// Shuffle the password to avoid predictable patterns
return password.split('').sort(() => crypto.randomInt(3) - 1).join('')
}
/**
* Generate a cryptographically secure AUTH_SECRET
*/
function generateAuthSecret(): string {
return crypto.randomBytes(32).toString('hex')
}
/**
* Initialize or retrieve AUTH_SECRET from database
* Falls back to environment variable if provided
*/
function initializeAuthSecret(): string {
const db = getDatabase()
const config = useRuntimeConfig()
// Check if AUTH_SECRET is already in database
const existingSecret = getSetting('auth_secret')
if (existingSecret) {
return existingSecret.value
}
// Check if provided via environment (for advanced users)
const envSecret = config.authSecret
if (envSecret && envSecret !== 'change-this-secret-in-production' && envSecret !== '') {
// Store the env-provided secret to database for consistency
setSetting('auth_secret', envSecret)
console.log(' Using AUTH_SECRET from environment variable')
return envSecret
}
// Generate new secure secret
const newSecret = generateAuthSecret()
setSetting('auth_secret', newSecret)
console.log('\n' + '='.repeat(80))
console.log('🔐 GENERATED NEW AUTH_SECRET')
console.log('='.repeat(80))
console.log('A new authentication secret has been generated and stored in the database.')
console.log('This secret is used for session management and will persist across restarts.')
console.log('='.repeat(80) + '\n')
return newSecret
}
/**
* Initialize default admin user if no admin exists
* Generates secure random password and logs it on first creation
*/
function initializeAdminUser(): void {
const db = getDatabase()
const config = useRuntimeConfig()
// Check if ANY admin user exists
const adminExists = db.prepare('SELECT COUNT(*) as count FROM users WHERE is_admin = 1').get() as { count: number }
if (adminExists.count > 0) {
// Admin already exists, nothing to do
return
}
// Determine admin username (allow override via env)
const adminUsername = config.adminUsername || 'admin'
// Check if environment provides a password (for backward compatibility or custom setups)
let adminPassword = config.adminPassword
let passwordWasGenerated = false
if (!adminPassword || adminPassword === 'admin123' || adminPassword === 'Admin123!') {
// Generate secure random password
adminPassword = generateSecurePassword(20)
passwordWasGenerated = true
}
// Hash the password
const saltRounds = 10
const hashedPassword = bcrypt.hashSync(adminPassword, saltRounds)
// Create admin user
db.prepare('INSERT INTO users (username, password, is_admin) VALUES (?, ?, 1)')
.run(adminUsername, hashedPassword)
// Log credentials prominently
console.log('\n' + '='.repeat(80))
console.log('🎉 INITIAL ADMIN ACCOUNT CREATED')
console.log('='.repeat(80))
console.log(`Username: ${adminUsername}`)
console.log(`Password: ${adminPassword}`)
console.log('='.repeat(80))
console.log('⚠️ IMPORTANT: Save these credentials immediately!')
console.log('This password will not be shown again.')
if (passwordWasGenerated) {
console.log('This password was randomly generated for security.')
}
console.log('You can change it after logging in via the Profile page.')
console.log('='.repeat(80) + '\n')
}
export interface Sermon {
id?: number
slug: string
title: string
date: string
dates?: string
bible_references: string
personal_appliance: string
pastors_challenge: string
worship_songs?: string
created_by?: string
created_at?: string
}
export interface User {
id?: number
username: string
password: string
email?: string
first_name?: string
last_name?: string
is_admin: number
failed_login_attempts?: number
locked_until?: string | null
}
export interface PasswordResetCode {
id?: number
email: string
code: string
expires_at: string
created_at?: string
}
export interface SermonNote {
id?: number
user_id: number
sermon_id: number
notes: string
created_at?: string
updated_at?: string
}
export interface Session {
id?: number
token: string
username: string
csrf_token?: string
expires_at: string
created_at?: string
}
export interface RateLimit {
id?: number
identifier: string
endpoint: string
attempts: number
reset_at: string
}
export interface Setting {
id?: number
key: string
value: string
updated_at?: string
}
export function getDatabase() {
if (!db) {
const dbPath = join(process.cwd(), 'data', 'sermons.db')
db = new Database(dbPath)
// Create tables if they don't exist
db.exec(`
CREATE TABLE IF NOT EXISTS sermons (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
date TEXT NOT NULL,
dates TEXT,
bible_references TEXT NOT NULL,
personal_appliance TEXT NOT NULL,
pastors_challenge TEXT NOT NULL,
worship_songs TEXT,
created_by TEXT,
archived INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
// Add created_by column if it doesn't exist (migration)
try {
db.exec(`ALTER TABLE sermons ADD COLUMN created_by TEXT`)
} catch (e) {
// Column already exists, ignore error
}
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
email TEXT,
first_name TEXT,
last_name TEXT,
is_admin INTEGER DEFAULT 0,
failed_login_attempts INTEGER DEFAULT 0,
locked_until DATETIME
)
`)
// Add account lockout columns if they don't exist (migration)
try {
db.exec(`ALTER TABLE users ADD COLUMN failed_login_attempts INTEGER DEFAULT 0`)
} catch (e) {
// Column already exists, ignore error
}
try {
db.exec(`ALTER TABLE users ADD COLUMN locked_until DATETIME`)
} catch (e) {
// Column already exists, ignore error
}
db.exec(`
CREATE TABLE IF NOT EXISTS password_reset_codes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL,
code TEXT NOT NULL,
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
db.exec(`
CREATE TABLE IF NOT EXISTS sermon_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
sermon_id INTEGER NOT NULL,
notes TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (sermon_id) REFERENCES sermons(id) ON DELETE CASCADE,
UNIQUE(user_id, sermon_id)
)
`)
db.exec(`
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT UNIQUE NOT NULL,
username TEXT NOT NULL,
csrf_token TEXT,
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
// Add csrf_token column if it doesn't exist (migration)
try {
db.exec(`ALTER TABLE sessions ADD COLUMN csrf_token TEXT`)
} catch (e) {
// Column already exists, ignore error
}
db.exec(`
CREATE TABLE IF NOT EXISTS rate_limits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
identifier TEXT NOT NULL,
endpoint TEXT NOT NULL,
attempts INTEGER DEFAULT 1,
reset_at DATETIME NOT NULL,
UNIQUE(identifier, endpoint)
)
`)
db.exec(`
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT UNIQUE NOT NULL,
value TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
// Initialize AUTH_SECRET (generate if needed)
initializeAuthSecret()
// Initialize admin user (create if no admin exists)
initializeAdminUser()
}
return db
}
export function getAllSermons(limit?: number, includeArchived: boolean = false) {
const db = getDatabase()
const whereClause = includeArchived ? '' : 'WHERE archived = 0'
if (limit) {
return db.prepare(`SELECT * FROM sermons ${whereClause} ORDER BY date DESC LIMIT ?`).all(limit) as Sermon[]
}
return db.prepare(`SELECT * FROM sermons ${whereClause} ORDER BY date DESC`).all() as Sermon[]
}
export function getArchivedSermons() {
const db = getDatabase()
return db.prepare('SELECT * FROM sermons WHERE archived = 1 ORDER BY date DESC').all() as Sermon[]
}
export function archiveSermon(id: number) {
const db = getDatabase()
return db.prepare('UPDATE sermons SET archived = 1 WHERE id = ?').run(id)
}
export function getSermonBySlug(slug: string) {
const db = getDatabase()
return db.prepare('SELECT * FROM sermons WHERE slug = ?').get(slug) as Sermon | undefined
}
export function createSermon(sermon: Sermon) {
const db = getDatabase()
const stmt = db.prepare(`
INSERT INTO sermons (slug, title, date, dates, bible_references, personal_appliance, pastors_challenge, worship_songs, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
return stmt.run(
sermon.slug,
sermon.title,
sermon.date,
sermon.dates || null,
sermon.bible_references,
sermon.personal_appliance,
sermon.pastors_challenge,
sermon.worship_songs || null,
sermon.created_by || null
)
}
export function getUserByUsername(username: string) {
const db = getDatabase()
return db.prepare('SELECT * FROM users WHERE username = ?').get(username) as User | undefined
}
export function createUser(username: string, password: string, email?: string, firstName?: string, lastName?: string) {
const db = getDatabase()
const saltRounds = 10
const hashedPassword = bcrypt.hashSync(password, saltRounds)
return db.prepare('INSERT INTO users (username, password, email, first_name, last_name, is_admin) VALUES (?, ?, ?, ?, ?, 0)')
.run(username, hashedPassword, email || null, firstName || null, lastName || null)
}
export function getUserByEmail(email: string) {
const db = getDatabase()
return db.prepare('SELECT * FROM users WHERE email = ?').get(email) as User | undefined
}
export function createPasswordResetCode(email: string, code: string, expiresAt: string) {
const db = getDatabase()
// Delete any existing codes for this email
db.prepare('DELETE FROM password_reset_codes WHERE email = ?').run(email)
return db.prepare('INSERT INTO password_reset_codes (email, code, expires_at) VALUES (?, ?, ?)')
.run(email, code, expiresAt)
}
export function getPasswordResetCode(email: string, code: string) {
const db = getDatabase()
return db.prepare("SELECT * FROM password_reset_codes WHERE email = ? AND code = ? AND expires_at > datetime('now')")
.get(email, code) as PasswordResetCode | undefined
}
export function deletePasswordResetCode(email: string) {
const db = getDatabase()
return db.prepare('DELETE FROM password_reset_codes WHERE email = ?').run(email)
}
export function resetPasswordByEmail(email: string, newPassword: string) {
const db = getDatabase()
const saltRounds = 10
const hashedPassword = bcrypt.hashSync(newPassword, saltRounds)
const result = db.prepare('UPDATE users SET password = ? WHERE LOWER(email) = LOWER(?)').run(hashedPassword, email)
return result
}
export function getAllUsers() {
const db = getDatabase()
return db.prepare('SELECT id, username, email, first_name, last_name, is_admin, failed_login_attempts, locked_until FROM users ORDER BY username').all() as Omit<User, 'password'>[]
}
export function deleteUser(id: number) {
const db = getDatabase()
return db.prepare('DELETE FROM users WHERE id = ?').run(id)
}
export function updateUserRole(id: number, isAdmin: number) {
const db = getDatabase()
return db.prepare('UPDATE users SET is_admin = ? WHERE id = ?').run(isAdmin, id)
}
export function resetUserPassword(id: number, newPassword: string) {
const db = getDatabase()
const saltRounds = 10
const hashedPassword = bcrypt.hashSync(newPassword, saltRounds)
return db.prepare('UPDATE users SET password = ? WHERE id = ?').run(hashedPassword, id)
}
/**
* Account Lockout Functions
* Provides protection against distributed brute force attacks
*/
/**
* Check if account is currently locked
* Returns true if account is locked, false if unlocked or lock has expired
*/
export function isAccountLocked(username: string): boolean {
const db = getDatabase()
const user = db.prepare("SELECT locked_until FROM users WHERE username = ? AND locked_until > datetime('now')")
.get(username) as { locked_until: string } | undefined
return !!user
}
/**
* Increment failed login attempts for an account
* Locks account if threshold (10 attempts) is reached
*/
export function incrementFailedAttempts(username: string): void {
const db = getDatabase()
// Get current attempt count
const user = db.prepare('SELECT failed_login_attempts FROM users WHERE username = ?')
.get(username) as { failed_login_attempts: number } | undefined
if (!user) return
const newAttempts = (user.failed_login_attempts || 0) + 1
const maxAttempts = 10
if (newAttempts >= maxAttempts) {
// Lock account for 30 minutes
const lockUntil = new Date(Date.now() + 30 * 60 * 1000).toISOString()
db.prepare('UPDATE users SET failed_login_attempts = ?, locked_until = ? WHERE username = ?')
.run(newAttempts, lockUntil, username)
} else {
// Just increment counter
db.prepare('UPDATE users SET failed_login_attempts = ? WHERE username = ?')
.run(newAttempts, username)
}
}
/**
* Reset failed login attempts on successful login
*/
export function resetFailedAttempts(username: string): void {
const db = getDatabase()
db.prepare('UPDATE users SET failed_login_attempts = 0, locked_until = NULL WHERE username = ?')
.run(username)
}
/**
* Admin function to manually unlock an account
*/
export function unlockAccount(userId: number): void {
const db = getDatabase()
db.prepare('UPDATE users SET failed_login_attempts = 0, locked_until = NULL WHERE id = ?')
.run(userId)
}
export function getSermonNote(userId: number, sermonId: number) {
const db = getDatabase()
return db.prepare('SELECT * FROM sermon_notes WHERE user_id = ? AND sermon_id = ?').get(userId, sermonId) as SermonNote | undefined
}
export function saveSermonNote(userId: number, sermonId: number, notes: string) {
const db = getDatabase()
const existing = getSermonNote(userId, sermonId)
if (existing) {
return db.prepare('UPDATE sermon_notes SET notes = ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ? AND sermon_id = ?').run(notes, userId, sermonId)
} else {
return db.prepare('INSERT INTO sermon_notes (user_id, sermon_id, notes) VALUES (?, ?, ?)').run(userId, sermonId, notes)
}
}
export function deleteSermonNote(userId: number, sermonId: number) {
const db = getDatabase()
return db.prepare('DELETE FROM sermon_notes WHERE user_id = ? AND sermon_id = ?').run(userId, sermonId)
}
// Session management functions
export function createSession(token: string, username: string, expiresAt: string, csrfToken?: string) {
const db = getDatabase()
return db.prepare('INSERT INTO sessions (token, username, csrf_token, expires_at) VALUES (?, ?, ?, ?)')
.run(token, username, csrfToken || null, expiresAt)
}
export function getSessionByToken(token: string) {
const db = getDatabase()
return db.prepare("SELECT * FROM sessions WHERE token = ? AND expires_at > datetime('now')")
.get(token) as Session | undefined
}
export function deleteSession(token: string) {
const db = getDatabase()
return db.prepare('DELETE FROM sessions WHERE token = ?').run(token)
}
export function deleteExpiredSessions() {
const db = getDatabase()
return db.prepare("DELETE FROM sessions WHERE expires_at <= datetime('now')").run()
}
/**
* Delete all sessions for a specific user
* Used when password changes or security events require session invalidation
*/
export function deleteAllUserSessions(username: string) {
const db = getDatabase()
return db.prepare('DELETE FROM sessions WHERE username = ?').run(username)
}
/**
* Delete all sessions except the current one
* Used when user wants to logout other devices but keep current session
*/
export function deleteOtherUserSessions(username: string, currentToken: string) {
const db = getDatabase()
return db.prepare('DELETE FROM sessions WHERE username = ? AND token != ?').run(username, currentToken)
}
// Rate limiting functions
export function checkRateLimit(identifier: string, endpoint: string, maxAttempts: number, windowMinutes: number): boolean {
const db = getDatabase()
// Clean up expired rate limit records
db.prepare("DELETE FROM rate_limits WHERE reset_at <= datetime('now')").run()
const existing = db.prepare('SELECT * FROM rate_limits WHERE identifier = ? AND endpoint = ?')
.get(identifier, endpoint) as RateLimit | undefined
if (!existing) {
// First attempt - create new record
const resetAt = new Date(Date.now() + windowMinutes * 60 * 1000).toISOString()
db.prepare('INSERT INTO rate_limits (identifier, endpoint, attempts, reset_at) VALUES (?, ?, 1, ?)')
.run(identifier, endpoint, resetAt)
return true
}
if (existing.attempts >= maxAttempts) {
// Rate limit exceeded
return false
}
// Increment attempts
db.prepare('UPDATE rate_limits SET attempts = attempts + 1 WHERE identifier = ? AND endpoint = ?')
.run(identifier, endpoint)
return true
}
export function resetRateLimit(identifier: string, endpoint: string) {
const db = getDatabase()
return db.prepare('DELETE FROM rate_limits WHERE identifier = ? AND endpoint = ?')
.run(identifier, endpoint)
}
// Settings management functions
export function getSetting(key: string) {
const db = getDatabase()
return db.prepare('SELECT * FROM settings WHERE key = ?').get(key) as Setting | undefined
}
export function setSetting(key: string, value: string) {
const db = getDatabase()
const existing = getSetting(key)
if (existing) {
return db.prepare('UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?').run(value, key)
} else {
return db.prepare('INSERT INTO settings (key, value) VALUES (?, ?)').run(key, value)
}
}
/**
* Get the AUTH_SECRET from database
* This should always exist after database initialization
*/
export function getAuthSecret(): string {
const secret = getSetting('auth_secret')
if (!secret) {
throw new Error('AUTH_SECRET not found in database. Database may not be properly initialized.')
}
return secret.value
}
// Sermon retention policy functions
export function deleteOldSermons(retentionDays: number) {
const db = getDatabase()
// Calculate the cutoff date based on retention days
const cutoffDate = new Date()
cutoffDate.setDate(cutoffDate.getDate() - retentionDays)
const cutoffDateStr = cutoffDate.toISOString().split('T')[0] // Format as YYYY-MM-DD
// Delete sermons older than the cutoff date
return db.prepare('DELETE FROM sermons WHERE date < ?').run(cutoffDateStr)
}