diff --git a/server/api/auth/login.post.ts b/server/api/auth/login.post.ts index 3fcc31c..608f96b 100644 --- a/server/api/auth/login.post.ts +++ b/server/api/auth/login.post.ts @@ -1,8 +1,26 @@ -import { getUserByUsername } from '~/server/utils/database' -import { setAuthCookie } from '~/server/utils/auth' +import { getUserByUsername, checkRateLimit, resetRateLimit, createSession } from '~/server/utils/database' +import { setAuthCookie, generateSessionToken } from '~/server/utils/auth' import bcrypt from 'bcrypt' export default defineEventHandler(async (event) => { + // Get client IP for rate limiting + const clientIp = getRequestIP(event) || 'unknown' + + // Log IP for verification (helps ensure correct public IP is captured) + console.log(`[LOGIN ATTEMPT] IP: ${clientIp}, Headers:`, { + 'x-forwarded-for': getHeader(event, 'x-forwarded-for'), + 'x-real-ip': getHeader(event, 'x-real-ip'), + 'cf-connecting-ip': getHeader(event, 'cf-connecting-ip') + }) + + // Check rate limit: 5 attempts per 15 minutes + if (!checkRateLimit(clientIp, 'login', 5, 15)) { + throw createError({ + statusCode: 429, + message: 'Too many login attempts. Please try again in 15 minutes.' + }) + } + const body = await readBody(event) const { username, password } = body @@ -16,6 +34,7 @@ export default defineEventHandler(async (event) => { const user = getUserByUsername(username.toLowerCase()) if (!user) { + console.log(`[LOGIN FAILED] Username not found: ${username.toLowerCase()}, IP: ${clientIp}`) throw createError({ statusCode: 401, message: 'Invalid credentials' @@ -26,13 +45,26 @@ export default defineEventHandler(async (event) => { const passwordMatch = await bcrypt.compare(password, user.password) if (!passwordMatch) { + console.log(`[LOGIN FAILED] Invalid password for user: ${username.toLowerCase()}, IP: ${clientIp}`) throw createError({ statusCode: 401, message: 'Invalid credentials' }) } - setAuthCookie(event, user.username) + // Generate session token and create session + const sessionToken = generateSessionToken() + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() // 24 hours + createSession(sessionToken, user.username, expiresAt) + + // Set session cookie + setAuthCookie(event, sessionToken) + + // Reset rate limit on successful login + resetRateLimit(clientIp, 'login') + + // Log successful login + console.log(`[LOGIN SUCCESS] User: ${user.username}, IP: ${clientIp}`) return { success: true, diff --git a/server/api/auth/logout.post.ts b/server/api/auth/logout.post.ts index bc19e71..f1b5b3a 100644 --- a/server/api/auth/logout.post.ts +++ b/server/api/auth/logout.post.ts @@ -1,6 +1,16 @@ -import { clearAuthCookie } from '~/server/utils/auth' +import { clearAuthCookie, getAuthCookie } from '~/server/utils/auth' +import { deleteSession } from '~/server/utils/database' export default defineEventHandler(async (event) => { + // Get session token from cookie + const sessionToken = getAuthCookie(event) + + // Delete session from database if it exists + if (sessionToken) { + deleteSession(sessionToken) + } + + // Clear the cookie clearAuthCookie(event) return { diff --git a/server/api/auth/register.post.ts b/server/api/auth/register.post.ts index 8ad25dc..aed4e02 100644 --- a/server/api/auth/register.post.ts +++ b/server/api/auth/register.post.ts @@ -1,7 +1,25 @@ -import { createUser, getUserByUsername, getUserByEmail } from '~/server/utils/database' -import { setAuthCookie } from '~/server/utils/auth' +import { createUser, getUserByUsername, getUserByEmail, checkRateLimit, createSession } from '~/server/utils/database' +import { setAuthCookie, generateSessionToken } from '~/server/utils/auth' export default defineEventHandler(async (event) => { + // Get client IP for rate limiting + const clientIp = getRequestIP(event) || 'unknown' + + // Log IP for verification + console.log(`[REGISTER ATTEMPT] IP: ${clientIp}, Headers:`, { + 'x-forwarded-for': getHeader(event, 'x-forwarded-for'), + 'x-real-ip': getHeader(event, 'x-real-ip'), + 'cf-connecting-ip': getHeader(event, 'cf-connecting-ip') + }) + + // Check rate limit: 3 attempts per hour + if (!checkRateLimit(clientIp, 'register', 3, 60)) { + throw createError({ + statusCode: 429, + message: 'Too many registration attempts. Please try again in 1 hour.' + }) + } + const body = await readBody(event) const { username, password, email, firstName, lastName } = body @@ -80,8 +98,16 @@ export default defineEventHandler(async (event) => { // Create the new user with all fields createUser(username.toLowerCase(), password, email.toLowerCase(), firstName, lastName) - // Log them in automatically - setAuthCookie(event, username.toLowerCase()) + // Generate session token and create session for auto-login + const sessionToken = generateSessionToken() + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() // 24 hours + createSession(sessionToken, username.toLowerCase(), expiresAt) + + // Set session cookie + setAuthCookie(event, sessionToken) + + // Log successful registration + console.log(`[REGISTER SUCCESS] User: ${username.toLowerCase()}, IP: ${clientIp}`) return { success: true, diff --git a/server/api/auth/verify.get.ts b/server/api/auth/verify.get.ts index e1a556c..450ff47 100644 --- a/server/api/auth/verify.get.ts +++ b/server/api/auth/verify.get.ts @@ -1,8 +1,8 @@ -import { getAuthCookie, clearAuthCookie } from '~/server/utils/auth' +import { getSessionUsername, clearAuthCookie } from '~/server/utils/auth' import { getUserByUsername } from '~/server/utils/database' export default defineEventHandler(async (event) => { - const username = getAuthCookie(event) + const username = await getSessionUsername(event) if (!username) { return { diff --git a/server/api/sermons/archive/[id].post.ts b/server/api/sermons/archive/[id].post.ts index a367a62..508aa13 100644 --- a/server/api/sermons/archive/[id].post.ts +++ b/server/api/sermons/archive/[id].post.ts @@ -1,15 +1,27 @@ -import { isAuthenticated } from '~/server/utils/auth' -import { archiveSermon } from '~/server/utils/database' +import { getAuthCookie } from '~/server/utils/auth' +import { archiveSermon, getUserByUsername } from '~/server/utils/database' export default defineEventHandler(async (event) => { // Check authentication - if (!isAuthenticated(event)) { + const username = getAuthCookie(event) + + if (!username) { throw createError({ statusCode: 401, message: 'Unauthorized' }) } + // Check admin role + const user = getUserByUsername(username) + + if (!user || user.is_admin !== 1) { + throw createError({ + statusCode: 403, + message: 'Forbidden - Admin access required' + }) + } + const id = getRouterParam(event, 'id') if (!id) { diff --git a/server/api/sermons/delete/[id].delete.ts b/server/api/sermons/delete/[id].delete.ts index 9c80e44..6d0dc44 100644 --- a/server/api/sermons/delete/[id].delete.ts +++ b/server/api/sermons/delete/[id].delete.ts @@ -1,15 +1,27 @@ -import { isAuthenticated } from '~/server/utils/auth' -import { getDatabase } from '~/server/utils/database' +import { getAuthCookie } from '~/server/utils/auth' +import { getDatabase, getUserByUsername } from '~/server/utils/database' export default defineEventHandler(async (event) => { // Check authentication - if (!isAuthenticated(event)) { + const username = getAuthCookie(event) + + if (!username) { throw createError({ statusCode: 401, message: 'Unauthorized' }) } + // Check admin role + const user = getUserByUsername(username) + + if (!user || user.is_admin !== 1) { + throw createError({ + statusCode: 403, + message: 'Forbidden - Admin access required' + }) + } + const id = getRouterParam(event, 'id') if (!id) { diff --git a/server/api/sermons/index.post.ts b/server/api/sermons/index.post.ts index fc07fee..20e8e5a 100644 --- a/server/api/sermons/index.post.ts +++ b/server/api/sermons/index.post.ts @@ -1,15 +1,27 @@ -import { createSermon } from '~/server/utils/database' -import { isAuthenticated } from '~/server/utils/auth' +import { createSermon, getUserByUsername } from '~/server/utils/database' +import { getAuthCookie } from '~/server/utils/auth' export default defineEventHandler(async (event) => { // Check authentication - if (!isAuthenticated(event)) { + const username = getAuthCookie(event) + + if (!username) { throw createError({ statusCode: 401, message: 'Unauthorized' }) } + // Check admin role + const user = getUserByUsername(username) + + if (!user || user.is_admin !== 1) { + throw createError({ + statusCode: 403, + message: 'Forbidden - Admin access required' + }) + } + const body = await readBody(event) const { slug, title, date, dates, bible_references, personal_appliance, pastors_challenge, worship_songs } = body diff --git a/server/api/sermons/unarchive/[id].post.ts b/server/api/sermons/unarchive/[id].post.ts index 42e5822..0865820 100644 --- a/server/api/sermons/unarchive/[id].post.ts +++ b/server/api/sermons/unarchive/[id].post.ts @@ -1,15 +1,27 @@ -import { isAuthenticated } from '~/server/utils/auth' -import { getDatabase } from '~/server/utils/database' +import { getAuthCookie } from '~/server/utils/auth' +import { getDatabase, getUserByUsername } from '~/server/utils/database' export default defineEventHandler(async (event) => { // Check authentication - if (!isAuthenticated(event)) { + const username = getAuthCookie(event) + + if (!username) { throw createError({ statusCode: 401, message: 'Unauthorized' }) } + // Check admin role + const user = getUserByUsername(username) + + if (!user || user.is_admin !== 1) { + throw createError({ + statusCode: 403, + message: 'Forbidden - Admin access required' + }) + } + const id = getRouterParam(event, 'id') if (!id) { diff --git a/server/api/sermons/update/[id].put.ts b/server/api/sermons/update/[id].put.ts index 2ac1a02..7d0bd4a 100644 --- a/server/api/sermons/update/[id].put.ts +++ b/server/api/sermons/update/[id].put.ts @@ -1,15 +1,27 @@ -import { isAuthenticated } from '~/server/utils/auth' -import { getDatabase } from '~/server/utils/database' +import { getAuthCookie } from '~/server/utils/auth' +import { getDatabase, getUserByUsername } from '~/server/utils/database' export default defineEventHandler(async (event) => { // Check authentication - if (!isAuthenticated(event)) { + const username = getAuthCookie(event) + + if (!username) { throw createError({ statusCode: 401, message: 'Unauthorized' }) } + // Check admin role + const user = getUserByUsername(username) + + if (!user || user.is_admin !== 1) { + throw createError({ + statusCode: 403, + message: 'Forbidden - Admin access required' + }) + } + const id = getRouterParam(event, 'id') if (!id) { diff --git a/server/utils/auth.ts b/server/utils/auth.ts index 5871c53..a697cb9 100644 --- a/server/utils/auth.ts +++ b/server/utils/auth.ts @@ -1,29 +1,58 @@ import { H3Event } from 'h3' +import crypto from 'crypto' -export function setAuthCookie(event: H3Event, username: string) { - setCookie(event, 'auth', username, { +// Generate a secure random session token +export function generateSessionToken(): string { + return crypto.randomBytes(32).toString('hex') +} + +export function setAuthCookie(event: H3Event, sessionToken: string) { + setCookie(event, 'session_token', sessionToken, { httpOnly: true, secure: process.env.NODE_ENV === 'production', - maxAge: 60 * 60 * 24 * 7, // 7 days - path: '/' + maxAge: 60 * 60 * 24, // 24 hours (shorter than before for better security) + path: '/', + sameSite: 'lax' }) } -export function getAuthCookie(event: H3Event) { - return getCookie(event, 'auth') +export function getAuthCookie(event: H3Event): string | undefined { + return getCookie(event, 'session_token') +} + +// Async version that validates session and returns username +export async function getAuthUsername(event: H3Event): Promise { + return await getSessionUsername(event) } export function clearAuthCookie(event: H3Event) { - deleteCookie(event, 'auth') + deleteCookie(event, 'session_token') +} + +export async function getSessionUsername(event: H3Event): Promise { + const token = getAuthCookie(event) + if (!token) { + return null + } + + const { getSessionByToken, deleteSession } = await import('./database') + const session = getSessionByToken(token) + + if (!session) { + clearAuthCookie(event) + return null + } + + return session.username } export function isAuthenticated(event: H3Event): boolean { - const auth = getAuthCookie(event) - return !!auth + const token = getAuthCookie(event) + return !!token } export async function getAuthUser(event: H3Event) { - const username = getAuthCookie(event) + const username = await getSessionUsername(event) if (!username) { return null } diff --git a/server/utils/database.ts b/server/utils/database.ts index 035ad85..0c2b4b0 100644 --- a/server/utils/database.ts +++ b/server/utils/database.ts @@ -44,6 +44,22 @@ export interface SermonNote { updated_at?: string } +export interface Session { + id?: number + token: string + username: string + expires_at: string + created_at?: string +} + +export interface RateLimit { + id?: number + identifier: string + endpoint: string + attempts: number + reset_at: string +} + export function getDatabase() { if (!db) { const dbPath = join(process.cwd(), 'data', 'sermons.db') @@ -102,6 +118,27 @@ export function getDatabase() { ) `) + db.exec(` + CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token TEXT UNIQUE NOT NULL, + username TEXT NOT NULL, + expires_at DATETIME NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `) + + 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) + ) + `) + // Insert default admin user from environment variables with hashed password const config = useRuntimeConfig() const adminUsername = config.adminUsername @@ -249,3 +286,61 @@ 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) { + const db = getDatabase() + return db.prepare('INSERT INTO sessions (token, username, expires_at) VALUES (?, ?, ?)') + .run(token, username, 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() +} + +// 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) +}