From 1ba20339a86a4b4e5f8dc0d830f8adcef9afe219 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Sun, 28 Dec 2025 20:04:29 -0500 Subject: [PATCH 1/3] security hardening --- package.json | 6 +- src/components/blog/TextEditor.tsx | 113 +++-- src/lib/api.ts | 20 +- src/lib/validation.ts | 95 +++- src/routes/api/auth/signout.ts | 2 +- src/server/api/root.ts | 2 + src/server/api/routers/audit.ts | 133 ++++++ src/server/api/routers/auth.ts | 487 ++++++++++++++++++-- src/server/api/schemas/user.ts | 44 +- src/server/audit.test.ts | 406 +++++++++++++++++ src/server/audit.ts | 516 +++++++++++++++++++++ src/server/auth.ts | 137 +++++- src/server/password.ts | 24 + src/server/security.ts | 401 ++++++++++++++++ src/server/security/auth.test.ts | 486 ++++++++++++++++++++ src/server/security/authorization.test.ts | 417 +++++++++++++++++ src/server/security/csrf.test.ts | 320 +++++++++++++ src/server/security/injection.test.ts | 522 +++++++++++++++++++++ src/server/security/password.test.ts | 529 ++++++++++++++++++++++ src/server/security/rate-limit.test.ts | 443 ++++++++++++++++++ src/server/security/test-utils.ts | 188 ++++++++ src/server/utils.ts | 2 +- 22 files changed, 5177 insertions(+), 116 deletions(-) create mode 100644 src/server/api/routers/audit.ts create mode 100644 src/server/audit.test.ts create mode 100644 src/server/audit.ts create mode 100644 src/server/security.ts create mode 100644 src/server/security/auth.test.ts create mode 100644 src/server/security/authorization.test.ts create mode 100644 src/server/security/csrf.test.ts create mode 100644 src/server/security/injection.test.ts create mode 100644 src/server/security/password.test.ts create mode 100644 src/server/security/rate-limit.test.ts create mode 100644 src/server/security/test-utils.ts diff --git a/package.json b/package.json index 7376cf5..fb2c75f 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,11 @@ "dev": "vinxi dev", "dev-flush": "vinxi dev --env-file=.env", "build": "vinxi build", - "start": "vinxi start" + "start": "vinxi start", + "test": "bun test", + "test:security": "bun test src/server/security/", + "test:watch": "bun test --watch", + "test:coverage": "bun test --coverage" }, "dependencies": { "@aws-sdk/client-s3": "^3.953.0", diff --git a/src/components/blog/TextEditor.tsx b/src/components/blog/TextEditor.tsx index 0be0b33..726c975 100644 --- a/src/components/blog/TextEditor.tsx +++ b/src/components/blog/TextEditor.tsx @@ -383,38 +383,69 @@ const SuggestionDecoration = Extension.create({ return DecorationSet.empty; }, apply(tr, oldSet, oldState, newState) { - // Get suggestion from editor storage - const suggestion = - (editor.storage as any).suggestionDecoration?.text || ""; - - if (!suggestion) { - return DecorationSet.empty; - } + // Get suggestion and loading state from editor storage + const storage = (editor.storage as any).suggestionDecoration || {}; + const suggestion = storage.text || ""; + const isLoading = storage.isLoading || false; const { selection } = newState; const pos = selection.$anchor.pos; + const decorations = []; - // Create a widget decoration at cursor position - const decoration = Decoration.widget( - pos, - () => { - const span = document.createElement("span"); - span.textContent = suggestion; - span.style.color = "rgb(239, 68, 68)"; // Tailwind red-500 - span.style.opacity = "0.5"; - span.style.fontStyle = "italic"; - span.style.fontFamily = "monospace"; - span.style.pointerEvents = "none"; - span.style.whiteSpace = "pre-wrap"; - span.style.wordWrap = "break-word"; - return span; - }, - { - side: 1 // Place after the cursor - } - ); + // Show loading spinner inline if loading + if (isLoading) { + const loadingDecoration = Decoration.widget( + pos, + () => { + const span = document.createElement("span"); + span.className = "inline-flex items-center ml-1"; + span.style.pointerEvents = "none"; - return DecorationSet.create(newState.doc, [decoration]); + // Create a simple spinner using CSS animation + const spinner = document.createElement("span"); + spinner.className = + "inline-block w-3 h-3 border-2 border-current border-t-transparent rounded-full animate-spin"; + spinner.style.color = "rgb(239, 68, 68)"; // Tailwind red-500 + spinner.style.opacity = "0.5"; + + span.appendChild(spinner); + return span; + }, + { + side: 1 // Place after the cursor + } + ); + decorations.push(loadingDecoration); + } + + // Show suggestion text if present + if (suggestion) { + const suggestionDecoration = Decoration.widget( + pos, + () => { + const span = document.createElement("span"); + span.textContent = suggestion; + span.style.color = "rgb(239, 68, 68)"; // Tailwind red-500 + span.style.opacity = "0.5"; + span.style.fontStyle = "italic"; + span.style.fontFamily = "monospace"; + span.style.pointerEvents = "none"; + span.style.whiteSpace = "pre-wrap"; + span.style.wordWrap = "break-word"; + return span; + }, + { + side: 1 // Place after the cursor + } + ); + decorations.push(suggestionDecoration); + } + + if (decorations.length === 0) { + return DecorationSet.empty; + } + + return DecorationSet.create(newState.doc, decorations); } }, props: { @@ -428,7 +459,8 @@ const SuggestionDecoration = Extension.create({ addStorage() { return { - text: "" + text: "", + isLoading: false }; } }); @@ -804,10 +836,14 @@ export default function TextEditor(props: TextEditorProps) { createEffect(() => { const instance = editor(); const suggestion = currentSuggestion(); + const loading = isInfillLoading(); if (instance) { - // Store suggestion in editor storage (cast to any to avoid TS error) - (instance.storage as any).suggestionDecoration = { text: suggestion }; + // Store suggestion and loading state in editor storage (cast to any to avoid TS error) + (instance.storage as any).suggestionDecoration = { + text: suggestion, + isLoading: loading + }; // Force view update to show/hide decoration instance.view.dispatch(instance.state.tr); } @@ -833,13 +869,6 @@ export default function TextEditor(props: TextEditorProps) { stream: false }; - console.log("[Infill] Request:", { - prefix: context.prefix, - suffix: context.suffix, - prefixLength: context.prefix.length, - suffixLength: context.suffix.length - }); - const response = await fetch(config.endpoint, { method: "POST", headers: { @@ -4130,16 +4159,6 @@ export default function TextEditor(props: TextEditorProps) { - - {/* Infill Loading Indicator */} - -
- - - - AI thinking... -
-
); } diff --git a/src/lib/api.ts b/src/lib/api.ts index c5592a4..ba957ea 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -11,6 +11,20 @@ const getBaseUrl = () => { return `http://localhost:${process.env.PORT ?? 3000}`; }; +/** + * Get CSRF token from cookies + */ +function getCSRFToken(): string | undefined { + if (typeof document === "undefined") return undefined; + + const value = `; ${document.cookie}`; + const parts = value.split(`; csrf-token=`); + if (parts.length === 2) { + return parts.pop()?.split(";").shift(); + } + return undefined; +} + export const api = createTRPCProxyClient({ links: [ // Only enable logging in development mode @@ -30,7 +44,11 @@ export const api = createTRPCProxyClient({ : []), // identifies what url will handle trpc requests httpBatchLink({ - url: `${getBaseUrl()}/api/trpc` + url: `${getBaseUrl()}/api/trpc`, + headers: () => { + const csrfToken = getCSRFToken(); + return csrfToken ? { "x-csrf-token": csrfToken } : {}; + } }) ] }); diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 8338f41..c02f3a7 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -6,37 +6,102 @@ * Validate email format */ export function isValidEmail(email: string): boolean { + // Basic email format check const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - return emailRegex.test(email); + if (!emailRegex.test(email)) { + return false; + } + + // Additional checks for invalid patterns + // Reject consecutive dots + if (email.includes("..")) { + return false; + } + + return true; } /** - * Validate password strength + * Password strength levels + */ +export type PasswordStrength = "weak" | "fair" | "good" | "strong"; + +/** + * Validate password strength with comprehensive requirements */ export function validatePassword(password: string): { isValid: boolean; errors: string[]; + strength: PasswordStrength; } { const errors: string[] = []; - if (password.length < 8) { - errors.push("Password must be at least 8 characters long"); + // Minimum length: 12 characters + if (password.length < 12) { + errors.push("Password must be at least 12 characters long"); } - // Optional: Add more password requirements - // if (!/[A-Z]/.test(password)) { - // errors.push("Password must contain at least one uppercase letter"); - // } - // if (!/[a-z]/.test(password)) { - // errors.push("Password must contain at least one lowercase letter"); - // } - // if (!/[0-9]/.test(password)) { - // errors.push("Password must contain at least one number"); - // } + // Require uppercase letter + if (!/[A-Z]/.test(password)) { + errors.push("Password must contain at least one uppercase letter"); + } + + // Require lowercase letter + if (!/[a-z]/.test(password)) { + errors.push("Password must contain at least one lowercase letter"); + } + + // Require number + if (!/[0-9]/.test(password)) { + errors.push("Password must contain at least one number"); + } + + // Require special character + if (!/[^A-Za-z0-9]/.test(password)) { + errors.push("Password must contain at least one special character"); + } + + // Check for common weak passwords + const commonPasswords = [ + "password", + "12345678", + "qwerty", + "letmein", + "welcome", + "monkey", + "dragon", + "master", + "sunshine", + "princess", + "admin", + "login" + ]; + + const lowerPassword = password.toLowerCase(); + for (const common of commonPasswords) { + if (lowerPassword.includes(common)) { + errors.push("Password contains common patterns and is not secure"); + break; + } + } + + // Calculate password strength + let strength: PasswordStrength = "weak"; + + if (errors.length === 0) { + if (password.length >= 20) { + strength = "strong"; + } else if (password.length >= 16) { + strength = "good"; + } else if (password.length >= 12) { + strength = "fair"; + } + } return { isValid: errors.length === 0, - errors + errors, + strength }; } diff --git a/src/routes/api/auth/signout.ts b/src/routes/api/auth/signout.ts index c0e8aa7..769bcbf 100644 --- a/src/routes/api/auth/signout.ts +++ b/src/routes/api/auth/signout.ts @@ -9,7 +9,7 @@ export async function POST() { setCookie(event, "userIDToken", "", { path: "/", httpOnly: true, - secure: process.env.NODE_ENV === "production", + secure: true, // Always enforce secure cookies sameSite: "lax", maxAge: 0, // Expire immediately expires: new Date(0) // Set expiry to past date diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 02cdb45..f831e99 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -1,5 +1,6 @@ import { exampleRouter } from "./routers/example"; import { authRouter } from "./routers/auth"; +import { auditRouter } from "./routers/audit"; import { databaseRouter } from "./routers/database"; import { lineageRouter } from "./routers/lineage"; import { miscRouter } from "./routers/misc"; @@ -13,6 +14,7 @@ import { createTRPCRouter } from "./utils"; export const appRouter = createTRPCRouter({ example: exampleRouter, auth: authRouter, + audit: auditRouter, database: databaseRouter, lineage: lineageRouter, misc: miscRouter, diff --git a/src/server/api/routers/audit.ts b/src/server/api/routers/audit.ts new file mode 100644 index 0000000..bae6e00 --- /dev/null +++ b/src/server/api/routers/audit.ts @@ -0,0 +1,133 @@ +import { createTRPCRouter, adminProcedure } from "../utils"; +import { z } from "zod"; +import { + queryAuditLogs, + getUserSecuritySummary, + cleanupOldLogs, + getFailedLoginAttempts, + detectSuspiciousActivity +} from "~/server/audit"; + +/** + * Audit log router - admin-only endpoints for querying security logs + */ +export const auditRouter = createTRPCRouter({ + /** + * Query audit logs with filters + */ + getLogs: adminProcedure + .input( + z.object({ + userId: z.string().optional(), + eventType: z.string().optional(), + success: z.boolean().optional(), + startDate: z.string().optional(), + endDate: z.string().optional(), + limit: z.number().min(1).max(1000).default(100), + offset: z.number().min(0).default(0) + }) + ) + .query(async ({ input }) => { + const logs = await queryAuditLogs({ + userId: input.userId, + eventType: input.eventType as any, + success: input.success, + startDate: input.startDate, + endDate: input.endDate, + limit: input.limit, + offset: input.offset + }); + + return { + logs, + count: logs.length, + offset: input.offset, + limit: input.limit + }; + }), + + /** + * Get failed login attempts (last 24 hours by default) + */ + getFailedLogins: adminProcedure + .input( + z.object({ + hours: z.number().min(1).max(168).default(24), + limit: z.number().min(1).max(1000).default(100) + }) + ) + .query(async ({ input }) => { + const attempts = (await getFailedLoginAttempts( + input.hours, + input.limit + )) as Array; + + return { + attempts, + count: attempts.length, + timeWindow: `${input.hours} hours` + }; + }), + + /** + * Get security summary for a specific user + */ + getUserSummary: adminProcedure + .input( + z.object({ + userId: z.string(), + days: z.number().min(1).max(90).default(30) + }) + ) + .query(async ({ input }) => { + const summary = await getUserSecuritySummary(input.userId, input.days); + + return { + userId: input.userId, + summary, + timeWindow: `${input.days} days` + }; + }), + + /** + * Detect suspicious activity patterns + */ + getSuspiciousActivity: adminProcedure + .input( + z.object({ + hours: z.number().min(1).max(168).default(24), + minFailedAttempts: z.number().min(1).default(5) + }) + ) + .query(async ({ input }) => { + const suspicious = (await detectSuspiciousActivity( + input.hours, + input.minFailedAttempts + )) as Array; + + return { + suspicious, + count: suspicious.length, + timeWindow: `${input.hours} hours`, + threshold: input.minFailedAttempts + }; + }), + + /** + * Clean up old logs + */ + cleanupLogs: adminProcedure + .input( + z.object({ + olderThanDays: z.number().min(1).max(365).default(90) + }) + ) + .mutation(async ({ input }) => { + const deleted = await cleanupOldLogs(input.olderThanDays); + + return { + deleted, + olderThanDays: input.olderThanDays + }; + }) +}); diff --git a/src/server/api/routers/auth.ts b/src/server/api/routers/auth.ts index 3beeee1..4844e77 100644 --- a/src/server/api/routers/auth.ts +++ b/src/server/api/routers/auth.ts @@ -3,7 +3,12 @@ import { z } from "zod"; import { TRPCError } from "@trpc/server"; import { v4 as uuidV4 } from "uuid"; import { env } from "~/env/server"; -import { ConnectionFactory, hashPassword, checkPassword } from "~/server/utils"; +import { + ConnectionFactory, + hashPassword, + checkPassword, + checkPasswordSafe +} from "~/server/utils"; import { SignJWT, jwtVerify } from "jose"; import { setCookie, getCookie } from "vinxi/http"; import type { User } from "~/db/types"; @@ -21,19 +26,106 @@ import { resetPasswordSchema, requestPasswordResetSchema } from "../schemas/user"; +import { + setCSRFToken, + csrfProtection, + getClientIP, + getAuditContext, + rateLimitLogin, + rateLimitPasswordReset, + rateLimitRegistration, + rateLimitEmailVerification +} from "~/server/security"; +import { logAuditEvent } from "~/server/audit"; +/** + * Create JWT with session tracking + * @param userId - User ID + * @param sessionId - Session ID for revocation tracking + * @param expiresIn - Token expiration time (e.g., "14d", "12h") + */ async function createJWT( userId: string, + sessionId: string, expiresIn: string = "14d" ): Promise { const secret = new TextEncoder().encode(env.JWT_SECRET_KEY); - const token = await new SignJWT({ id: userId }) + const token = await new SignJWT({ + id: userId, + sid: sessionId, // Session ID for revocation + iat: Math.floor(Date.now() / 1000) + }) .setProtectedHeader({ alg: "HS256" }) .setExpirationTime(expiresIn) .sign(secret); return token; } +/** + * Create a new session in the database + * @param userId - User ID + * @param expiresIn - Session expiration (e.g., "14d", "12h") + * @param ipAddress - Client IP address + * @param userAgent - Client user agent string + * @returns Session ID + */ +async function createSession( + userId: string, + expiresIn: string, + ipAddress: string, + userAgent: string +): Promise { + const conn = ConnectionFactory(); + const sessionId = uuidV4(); + + // Calculate expiration timestamp + const expiresAt = new Date(); + if (expiresIn.endsWith("d")) { + const days = parseInt(expiresIn); + expiresAt.setDate(expiresAt.getDate() + days); + } else if (expiresIn.endsWith("h")) { + const hours = parseInt(expiresIn); + expiresAt.setHours(expiresAt.getHours() + hours); + } + + await conn.execute({ + sql: `INSERT INTO Session (id, user_id, token_family, expires_at, ip_address, user_agent) + VALUES (?, ?, ?, ?, ?, ?)`, + args: [ + sessionId, + userId, + uuidV4(), // token_family for future refresh token rotation + expiresAt.toISOString(), + ipAddress, + userAgent + ] + }); + + return sessionId; +} + +/** + * Helper to set authentication cookies including CSRF token + */ +function setAuthCookies( + event: any, + token: string, + options: { maxAge?: number } = {} +) { + const cookieOptions: any = { + path: "/", + httpOnly: true, + secure: true, // Always enforce secure cookies + sameSite: "lax", + ...options + }; + + setCookie(event, "userIDToken", token, cookieOptions); + + // Set CSRF token for authenticated session + setCSRFToken(event); +} + async function sendEmail(to: string, subject: string, htmlContent: string) { const apiKey = env.SENDINBLUE_KEY; const apiUrl = "https://api.sendinblue.com/v3/smtp/email"; @@ -199,21 +291,58 @@ export const authRouter = createTRPCRouter({ } } - const token = await createJWT(userId); + // Create session with client info + const clientIP = getClientIP(ctx.event.nativeEvent); + const userAgent = + ctx.event.nativeEvent.request.headers.get("user-agent") || "unknown"; + const sessionId = await createSession( + userId, + "14d", + clientIP, + userAgent + ); + + const token = await createJWT(userId, sessionId); setCookie(ctx.event.nativeEvent, "userIDToken", token, { maxAge: 60 * 60 * 24 * 14, // 14 days path: "/", httpOnly: true, - secure: env.NODE_ENV === "production", + secure: true, // Always enforce secure cookies sameSite: "lax" }); + // Set CSRF token for authenticated session + setCSRFToken(ctx.event.nativeEvent); + + // Log successful OAuth login + await logAuditEvent({ + userId, + eventType: "auth.login.success", + eventData: { method: "github", isNewUser: !res.rows[0] }, + ipAddress: clientIP, + userAgent, + success: true + }); + return { success: true, redirectTo: "/account" }; } catch (error) { + // Log failed OAuth login + const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent); + await logAuditEvent({ + eventType: "auth.login.failed", + eventData: { + method: "github", + reason: error instanceof TRPCError ? error.message : "unknown" + }, + ipAddress, + userAgent, + success: false + }); + if (error instanceof TRPCError) { throw error; } @@ -345,21 +474,58 @@ export const authRouter = createTRPCRouter({ } } - const token = await createJWT(userId); + // Create session with client info + const clientIP = getClientIP(ctx.event.nativeEvent); + const userAgent = + ctx.event.nativeEvent.request.headers.get("user-agent") || "unknown"; + const sessionId = await createSession( + userId, + "14d", + clientIP, + userAgent + ); + + const token = await createJWT(userId, sessionId); setCookie(ctx.event.nativeEvent, "userIDToken", token, { maxAge: 60 * 60 * 24 * 14, // 14 days path: "/", httpOnly: true, - secure: env.NODE_ENV === "production", + secure: true, // Always enforce secure cookies sameSite: "lax" }); + // Set CSRF token for authenticated session + setCSRFToken(ctx.event.nativeEvent); + + // Log successful OAuth login + await logAuditEvent({ + userId, + eventType: "auth.login.success", + eventData: { method: "google", isNewUser: !res.rows[0] }, + ipAddress: clientIP, + userAgent, + success: true + }); + return { success: true, redirectTo: "/account" }; } catch (error) { + // Log failed OAuth login + const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent); + await logAuditEvent({ + eventType: "auth.login.failed", + eventData: { + method: "google", + reason: error instanceof TRPCError ? error.message : "unknown" + }, + ipAddress, + userAgent, + success: false + }); + if (error instanceof TRPCError) { throw error; } @@ -428,12 +594,24 @@ export const authRouter = createTRPCRouter({ const userId = (res.rows[0] as unknown as User).id; - const userToken = await createJWT(userId); + // Create session with client info + const clientIP = getClientIP(ctx.event.nativeEvent); + const userAgent = + ctx.event.nativeEvent.request.headers.get("user-agent") || "unknown"; + const expiresIn = rememberMe ? "14d" : "12h"; + const sessionId = await createSession( + userId, + expiresIn, + clientIP, + userAgent + ); + + const userToken = await createJWT(userId, sessionId, expiresIn); const cookieOptions: any = { path: "/", httpOnly: true, - secure: env.NODE_ENV === "production", + secure: true, // Always enforce secure cookies sameSite: "lax" }; @@ -448,11 +626,38 @@ export const authRouter = createTRPCRouter({ cookieOptions ); + // Set CSRF token for authenticated session + setCSRFToken(ctx.event.nativeEvent); + + // Log successful email link login + await logAuditEvent({ + userId, + eventType: "auth.login.success", + eventData: { method: "email_link", rememberMe: rememberMe || false }, + ipAddress: clientIP, + userAgent, + success: true + }); + return { success: true, redirectTo: "/account" }; } catch (error) { + // Log failed email link login + const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent); + await logAuditEvent({ + eventType: "auth.login.failed", + eventData: { + method: "email_link", + email: input.email, + reason: error instanceof TRPCError ? error.message : "unknown" + }, + ipAddress, + userAgent, + success: false + }); + if (error instanceof TRPCError) { throw error; } @@ -471,7 +676,7 @@ export const authRouter = createTRPCRouter({ token: z.string() }) ) - .mutation(async ({ input }) => { + .mutation(async ({ input, ctx }) => { const { email, token } = input; try { @@ -486,15 +691,47 @@ export const authRouter = createTRPCRouter({ } const conn = ConnectionFactory(); + + // Get user ID for audit log + const userRes = await conn.execute({ + sql: "SELECT id FROM User WHERE email = ?", + args: [email] + }); + const userId = userRes.rows[0] ? (userRes.rows[0] as any).id : null; + const query = `UPDATE User SET email_verified = ? WHERE email = ?`; const params = [true, email]; await conn.execute({ sql: query, args: params }); + // Log successful email verification + const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent); + await logAuditEvent({ + userId, + eventType: "auth.email_verified", + eventData: { email }, + ipAddress, + userAgent, + success: true + }); + return { success: true, message: "Email verification success, you may close this window" }; } catch (error) { + // Log failed email verification + const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent); + await logAuditEvent({ + eventType: "auth.email_verified", + eventData: { + email, + reason: error instanceof TRPCError ? error.message : "unknown" + }, + ipAddress, + userAgent, + success: false + }); + if (error instanceof TRPCError) { throw error; } @@ -511,6 +748,10 @@ export const authRouter = createTRPCRouter({ .mutation(async ({ input, ctx }) => { const { email, password, passwordConfirmation } = input; + // Apply rate limiting + const clientIP = getClientIP(ctx.event.nativeEvent); + rateLimitRegistration(clientIP, ctx.event.nativeEvent); + // Schema already validates password match, but double check if (password !== passwordConfirmation) { throw new TRPCError({ @@ -529,18 +770,56 @@ export const authRouter = createTRPCRouter({ args: [userId, email, passwordHash, "email"] }); - const token = await createJWT(userId); + // Create session with client info + const clientIP = getClientIP(ctx.event.nativeEvent); + const userAgent = + ctx.event.nativeEvent.request.headers.get("user-agent") || "unknown"; + const sessionId = await createSession( + userId, + "14d", + clientIP, + userAgent + ); + + const token = await createJWT(userId, sessionId); setCookie(ctx.event.nativeEvent, "userIDToken", token, { maxAge: 60 * 60 * 24 * 14, // 14 days path: "/", httpOnly: true, - secure: env.NODE_ENV === "production", + secure: true, // Always enforce secure cookies sameSite: "lax" }); + // Set CSRF token for authenticated session + setCSRFToken(ctx.event.nativeEvent); + + // Log successful registration + await logAuditEvent({ + userId, + eventType: "auth.registration.success", + eventData: { email, method: "email" }, + ipAddress: clientIP, + userAgent, + success: true + }); + return { success: true, message: "success" }; } catch (e) { + // Log failed registration + const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent); + await logAuditEvent({ + eventType: "auth.registration.failed", + eventData: { + email, + method: "email", + reason: e instanceof Error ? e.message : "unknown" + }, + ipAddress, + userAgent, + success: false + }); + console.error("Registration error:", e); throw new TRPCError({ code: "BAD_REQUEST", @@ -554,31 +833,38 @@ export const authRouter = createTRPCRouter({ .mutation(async ({ input, ctx }) => { const { email, password, rememberMe } = input; + // Apply rate limiting + const clientIP = getClientIP(ctx.event.nativeEvent); + rateLimitLogin(email, clientIP, ctx.event.nativeEvent); + const conn = ConnectionFactory(); const res = await conn.execute({ sql: "SELECT * FROM User WHERE email = ?", args: [email] }); - if (res.rows.length === 0) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "no-match" + // Always run password check to prevent timing attacks + const user = + res.rows.length > 0 ? (res.rows[0] as unknown as User) : null; + const passwordHash = user?.password_hash || null; + const passwordMatch = await checkPasswordSafe(password, passwordHash); + + // Check all conditions after password verification + if (!user || !passwordHash || !passwordMatch) { + // Log failed login attempt + const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent); + await logAuditEvent({ + eventType: "auth.login.failed", + eventData: { + email, + method: "password", + reason: "invalid_credentials" + }, + ipAddress, + userAgent, + success: false }); - } - const user = res.rows[0] as unknown as User; - - if (!user.password_hash) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "no-match" - }); - } - - const passwordMatch = await checkPassword(password, user.password_hash); - - if (!passwordMatch) { throw new TRPCError({ code: "UNAUTHORIZED", message: "no-match" @@ -596,12 +882,23 @@ export const authRouter = createTRPCRouter({ } const expiresIn = rememberMe ? "14d" : "12h"; - const token = await createJWT(user.id, expiresIn); + + // Create session with client info (reuse clientIP from rate limiting) + const userAgent = + ctx.event.nativeEvent.request.headers.get("user-agent") || "unknown"; + const sessionId = await createSession( + user.id, + expiresIn, + clientIP, + userAgent + ); + + const token = await createJWT(user.id, sessionId, expiresIn); const cookieOptions: any = { path: "/", httpOnly: true, - secure: env.NODE_ENV === "production", + secure: true, // Always enforce secure cookies sameSite: "lax" }; @@ -611,6 +908,19 @@ export const authRouter = createTRPCRouter({ setCookie(ctx.event.nativeEvent, "userIDToken", token, cookieOptions); + // Set CSRF token for authenticated session + setCSRFToken(ctx.event.nativeEvent); + + // Log successful login + await logAuditEvent({ + userId: user.id, + eventType: "auth.login.success", + eventData: { method: "password", rememberMe: rememberMe || false }, + ipAddress: clientIP, + userAgent, + success: true + }); + return { success: true, message: "success" }; }), @@ -745,6 +1055,10 @@ export const authRouter = createTRPCRouter({ .mutation(async ({ input, ctx }) => { const { email } = input; + // Apply rate limiting + const clientIP = getClientIP(ctx.event.nativeEvent); + rateLimitPasswordReset(clientIP, ctx.event.nativeEvent); + try { const requested = getCookie( ctx.event.nativeEvent, @@ -830,8 +1144,38 @@ export const authRouter = createTRPCRouter({ } ); + // Log password reset request + const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent); + await logAuditEvent({ + userId: user.id, + eventType: "auth.password_reset.requested", + eventData: { email }, + ipAddress, + userAgent, + success: true + }); + return { success: true, message: "email sent" }; } catch (error) { + // Log failed password reset request (only if not rate limited) + if ( + !(error instanceof TRPCError && error.code === "TOO_MANY_REQUESTS") + ) { + const { ipAddress, userAgent } = getAuditContext( + ctx.event.nativeEvent + ); + await logAuditEvent({ + eventType: "auth.password_reset.requested", + eventData: { + email: input.email, + reason: error instanceof TRPCError ? error.message : "unknown" + }, + ipAddress, + userAgent, + success: false + }); + } + if (error instanceof TRPCError) { throw error; } @@ -921,8 +1265,31 @@ export const authRouter = createTRPCRouter({ path: "/" }); + // Log successful password reset + const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent); + await logAuditEvent({ + userId: payload.id, + eventType: "auth.password_reset.completed", + eventData: {}, + ipAddress, + userAgent, + success: true + }); + return { success: true, message: "success" }; } catch (error) { + // Log failed password reset + const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent); + await logAuditEvent({ + eventType: "auth.password_reset.completed", + eventData: { + reason: error instanceof TRPCError ? error.message : "unknown" + }, + ipAddress, + userAgent, + success: false + }); + if (error instanceof TRPCError) { throw error; } @@ -939,6 +1306,10 @@ export const authRouter = createTRPCRouter({ .mutation(async ({ input, ctx }) => { const { email } = input; + // Apply rate limiting + const clientIP = getClientIP(ctx.event.nativeEvent); + rateLimitEmailVerification(clientIP, ctx.event.nativeEvent); + try { const requested = getCookie( ctx.event.nativeEvent, @@ -971,6 +1342,8 @@ export const authRouter = createTRPCRouter({ }); } + const user = res.rows[0] as unknown as User; + const secret = new TextEncoder().encode(env.JWT_SECRET_KEY); const token = await new SignJWT({ email }) .setProtectedHeader({ alg: "HS256" }) @@ -1025,8 +1398,38 @@ export const authRouter = createTRPCRouter({ } ); + // Log email verification request + const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent); + await logAuditEvent({ + userId: user.id, + eventType: "auth.email_verification.requested", + eventData: { email }, + ipAddress, + userAgent, + success: true + }); + return { success: true, message: "Verification email sent" }; } catch (error) { + // Log failed email verification request (only if not rate limited) + if ( + !(error instanceof TRPCError && error.code === "TOO_MANY_REQUESTS") + ) { + const { ipAddress, userAgent } = getAuditContext( + ctx.event.nativeEvent + ); + await logAuditEvent({ + eventType: "auth.email_verification.requested", + eventData: { + email: input.email, + reason: error instanceof TRPCError ? error.message : "unknown" + }, + ipAddress, + userAgent, + success: false + }); + } + if (error instanceof TRPCError) { throw error; } @@ -1052,6 +1455,19 @@ export const authRouter = createTRPCRouter({ }), signOut: publicProcedure.mutation(async ({ ctx }) => { + // Try to get user ID for audit log before clearing cookies + let userId: string | null = null; + try { + const token = getCookie(ctx.event.nativeEvent, "userIDToken"); + if (token) { + const secret = new TextEncoder().encode(env.JWT_SECRET_KEY); + const { payload } = await jwtVerify(token, secret); + userId = payload.id as string; + } + } catch (e) { + // Ignore token verification errors during signout + } + setCookie(ctx.event.nativeEvent, "userIDToken", "", { maxAge: 0, path: "/" @@ -1061,6 +1477,17 @@ export const authRouter = createTRPCRouter({ path: "/" }); + // Log signout + const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent); + await logAuditEvent({ + userId, + eventType: "auth.logout", + eventData: {}, + ipAddress, + userAgent, + success: true + }); + return { success: true }; }) }); diff --git a/src/server/api/schemas/user.ts b/src/server/api/schemas/user.ts index 48fd50a..d8c0029 100644 --- a/src/server/api/schemas/user.ts +++ b/src/server/api/schemas/user.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { validatePassword } from "~/lib/validation"; /** * User API Validation Schemas @@ -7,6 +8,31 @@ import { z } from "zod"; * profile updates, and password management */ +// ============================================================================ +// Custom Password Validator +// ============================================================================ + +/** + * Secure password validation with strength requirements + * Minimum 12 characters, uppercase, lowercase, number, and special character + */ +const securePasswordSchema = z + .string() + .min(12, "Password must be at least 12 characters") + .refine( + (password) => { + const result = validatePassword(password); + return result.isValid; + }, + (password) => { + const result = validatePassword(password); + return { + message: + result.errors.join(", ") || "Password does not meet requirements" + }; + } + ); + // ============================================================================ // Authentication Schemas // ============================================================================ @@ -17,8 +43,8 @@ import { z } from "zod"; export const registerUserSchema = z .object({ email: z.string().email(), - password: z.string().min(8, "Password must be at least 8 characters"), - passwordConfirmation: z.string().min(8) + password: securePasswordSchema, + passwordConfirmation: z.string().min(12) }) .refine((data) => data.password === data.passwordConfirmation, { message: "Passwords do not match", @@ -73,10 +99,8 @@ export const updateProfileImageSchema = z.object({ export const changePasswordSchema = z .object({ oldPassword: z.string().min(1, "Current password is required"), - newPassword: z - .string() - .min(8, "New password must be at least 8 characters"), - newPasswordConfirmation: z.string().min(8) + newPassword: securePasswordSchema, + newPasswordConfirmation: z.string().min(12) }) .refine((data) => data.newPassword === data.newPasswordConfirmation, { message: "Passwords do not match", @@ -92,8 +116,8 @@ export const changePasswordSchema = z */ export const setPasswordSchema = z .object({ - newPassword: z.string().min(8, "Password must be at least 8 characters"), - newPasswordConfirmation: z.string().min(8) + newPassword: securePasswordSchema, + newPasswordConfirmation: z.string().min(12) }) .refine((data) => data.newPassword === data.newPasswordConfirmation, { message: "Passwords do not match", @@ -113,8 +137,8 @@ export const requestPasswordResetSchema = z.object({ export const resetPasswordSchema = z .object({ token: z.string().min(1), - newPassword: z.string().min(8, "Password must be at least 8 characters"), - newPasswordConfirmation: z.string().min(8) + newPassword: securePasswordSchema, + newPasswordConfirmation: z.string().min(12) }) .refine((data) => data.newPassword === data.newPasswordConfirmation, { message: "Passwords do not match", diff --git a/src/server/audit.test.ts b/src/server/audit.test.ts new file mode 100644 index 0000000..d5c84ca --- /dev/null +++ b/src/server/audit.test.ts @@ -0,0 +1,406 @@ +/** + * Audit Logging Tests + * Tests for audit logging system including log creation, querying, and analysis + */ + +import { describe, it, expect, beforeEach } from "bun:test"; +import { + logAuditEvent, + queryAuditLogs, + getFailedLoginAttempts, + getUserSecuritySummary, + detectSuspiciousActivity, + cleanupOldLogs +} from "~/server/audit"; +import { ConnectionFactory } from "~/server/database"; + +// Helper to clean up test audit logs +async function cleanupTestLogs() { + const conn = ConnectionFactory(); + await conn.execute({ + sql: "DELETE FROM AuditLog WHERE event_data LIKE '%test-%'" + }); +} + +describe("Audit Logging System", () => { + beforeEach(async () => { + await cleanupTestLogs(); + }); + + describe("logAuditEvent", () => { + it("should create audit log with all fields", async () => { + await logAuditEvent({ + eventType: "auth.login.success", + eventData: { method: "password", test: "test-1" }, + ipAddress: "192.168.1.100", + userAgent: "Test Browser 1.0", + success: true + }); + + const logs = await queryAuditLogs({ + ipAddress: "192.168.1.100", + limit: 1 + }); + + expect(logs.length).toBe(1); + expect(logs[0].userId).toBeNull(); + expect(logs[0].eventType).toBe("auth.login.success"); + expect(logs[0].ipAddress).toBe("192.168.1.100"); + expect(logs[0].userAgent).toBe("Test Browser 1.0"); + expect(logs[0].success).toBe(true); + + expect(logs[0].eventData.method).toBe("password"); + }); + + it("should create audit log without user ID", async () => { + await logAuditEvent({ + eventType: "auth.login.failed", + eventData: { email: "test@example.com", test: "test-2" }, + ipAddress: "192.168.1.2", + userAgent: "Test Browser 1.0", + success: false + }); + + const logs = await queryAuditLogs({ + eventType: "auth.login.failed", + limit: 1 + }); + + expect(logs.length).toBe(1); + expect(logs[0].userId).toBeNull(); + expect(logs[0].success).toBe(false); + }); + + it("should handle missing optional fields gracefully", async () => { + await logAuditEvent({ + eventType: "auth.logout", + success: true + }); + + const logs = await queryAuditLogs({ + eventType: "auth.logout", + limit: 1 + }); + + expect(logs.length).toBe(1); + expect(logs[0].ipAddress).toBeNull(); + expect(logs[0].userAgent).toBeNull(); + expect(logs[0].eventData).toBeNull(); + }); + + it("should not throw errors on logging failures", async () => { + // This should not throw even if there's an invalid event type + await expect( + logAuditEvent({ + eventType: "invalid.test.event", + success: true + }) + ).resolves.toBeUndefined(); + }); + }); + + describe("queryAuditLogs", () => { + beforeEach(async () => { + // Create test logs + await logAuditEvent({ + eventType: "auth.login.success", + eventData: { test: "test-query-1", testUser: "user-1" }, + ipAddress: "192.168.1.10", + success: true + }); + + await logAuditEvent({ + eventType: "auth.login.failed", + eventData: { test: "test-query-2", testUser: "user-1" }, + ipAddress: "192.168.1.10", + success: false + }); + + await logAuditEvent({ + eventType: "auth.login.success", + eventData: { test: "test-query-3", testUser: "user-2" }, + ipAddress: "192.168.1.20", + success: true + }); + + await logAuditEvent({ + eventType: "auth.password_reset.requested", + eventData: { test: "test-query-4", testUser: "user-2" }, + ipAddress: "192.168.1.20", + success: true + }); + }); + + it("should query logs by IP address (simulating user)", async () => { + const logs = await queryAuditLogs({ ipAddress: "192.168.1.10" }); + + expect(logs.length).toBeGreaterThanOrEqual(2); + expect(logs.every((log) => log.ipAddress === "192.168.1.10")).toBe(true); + }); + + it("should query logs by event type", async () => { + const logs = await queryAuditLogs({ + eventType: "auth.login.success" + }); + + expect(logs.length).toBeGreaterThanOrEqual(2); + expect(logs.every((log) => log.eventType === "auth.login.success")).toBe( + true + ); + }); + + it("should query logs by success status", async () => { + const successLogs = await queryAuditLogs({ success: true }); + const failedLogs = await queryAuditLogs({ success: false }); + + expect(successLogs.length).toBeGreaterThanOrEqual(3); + expect(failedLogs.length).toBeGreaterThanOrEqual(1); + expect(successLogs.every((log) => log.success === true)).toBe(true); + expect(failedLogs.every((log) => log.success === false)).toBe(true); + }); + + it("should respect limit parameter", async () => { + const logs = await queryAuditLogs({ limit: 2 }); + + expect(logs.length).toBeLessThanOrEqual(2); + }); + + it("should respect offset parameter", async () => { + const firstPage = await queryAuditLogs({ limit: 2, offset: 0 }); + const secondPage = await queryAuditLogs({ limit: 2, offset: 2 }); + + expect(firstPage.length).toBeGreaterThan(0); + if (secondPage.length > 0) { + expect(firstPage[0].id).not.toBe(secondPage[0].id); + } + }); + + it("should filter by date range", async () => { + const now = new Date(); + const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000); + const oneDayFromNow = new Date(now.getTime() + 24 * 60 * 60 * 1000); + + const logs = await queryAuditLogs({ + startDate: oneHourAgo.toISOString(), + endDate: oneDayFromNow.toISOString() + }); + + expect(logs.length).toBeGreaterThanOrEqual(4); + }); + + it("should return empty array when no logs match", async () => { + const logs = await queryAuditLogs({ + userId: "nonexistent-user-xyz" + }); + + expect(logs).toEqual([]); + }); + }); + + describe("getFailedLoginAttempts", () => { + beforeEach(async () => { + // Create failed login attempts + for (let i = 0; i < 5; i++) { + await logAuditEvent({ + eventType: "auth.login.failed", + eventData: { + email: `test${i}@example.com`, + test: `test-failed-${i}` + }, + ipAddress: `192.168.1.${i}`, + success: false + }); + } + + // Create successful logins (should be excluded) + await logAuditEvent({ + eventType: "auth.login.success", + eventData: { test: "test-success-1" }, + success: true + }); + }); + + it("should return only failed login attempts", async () => { + const attempts = await getFailedLoginAttempts(24, 10); + + expect(attempts.length).toBeGreaterThanOrEqual(5); + expect( + attempts.every((attempt) => attempt.event_type === "auth.login.failed") + ).toBe(true); + expect(attempts.every((attempt) => attempt.success === 0)).toBe(true); + }); + + it("should respect limit parameter", async () => { + const attempts = await getFailedLoginAttempts(24, 3); + + expect(attempts.length).toBeLessThanOrEqual(3); + }); + + it("should filter by time window", async () => { + const attemptsIn24h = await getFailedLoginAttempts(24, 100); + const attemptsIn1h = await getFailedLoginAttempts(1, 100); + + expect(attemptsIn24h.length).toBeGreaterThanOrEqual(5); + // Recent attempts should be within 1 hour + expect(attemptsIn1h.length).toBeGreaterThanOrEqual(5); + }); + }); + + describe("getUserSecuritySummary", () => { + beforeEach(async () => { + // Create various events for summary (without user IDs due to FK constraint) + await logAuditEvent({ + eventType: "auth.login.success", + eventData: { test: "test-summary-1", testSummaryUser: "summary-123" }, + ipAddress: "192.168.99.1", + success: true + }); + + await logAuditEvent({ + eventType: "auth.login.failed", + eventData: { test: "test-summary-2", testSummaryUser: "summary-123" }, + ipAddress: "192.168.99.1", + success: false + }); + + await logAuditEvent({ + eventType: "auth.login.failed", + eventData: { test: "test-summary-3", testSummaryUser: "summary-123" }, + ipAddress: "192.168.99.1", + success: false + }); + + await logAuditEvent({ + eventType: "auth.password_reset.requested", + eventData: { test: "test-summary-4", testSummaryUser: "summary-123" }, + ipAddress: "192.168.99.1", + success: true + }); + }); + + it("should return zero counts for non-existent user", async () => { + // Since we can't use real user IDs in tests, this test verifies query works + const summary = await getUserSecuritySummary("nonexistent-user", 30); + + expect(summary).toHaveProperty("totalEvents"); + expect(summary).toHaveProperty("successfulEvents"); + expect(summary).toHaveProperty("failedEvents"); + expect(summary).toHaveProperty("eventTypes"); + expect(summary).toHaveProperty("uniqueIPs"); + }); + + it("should return summary structure", async () => { + const summary = await getUserSecuritySummary("nonexistent-user", 30); + + expect(summary.totalEvents).toBe(0); + expect(summary.successfulEvents).toBe(0); + expect(summary.failedEvents).toBe(0); + expect(summary.eventTypes.length).toBe(0); + expect(summary.uniqueIPs.length).toBe(0); + }); + }); + + describe("detectSuspiciousActivity", () => { + beforeEach(async () => { + // Create suspicious pattern: many failed logins from same IP + for (let i = 0; i < 10; i++) { + await logAuditEvent({ + eventType: "auth.login.failed", + eventData: { + email: `victim${i}@example.com`, + test: `test-suspicious-${i}` + }, + ipAddress: "10.0.0.1", + success: false + }); + } + + // Create normal activity + await logAuditEvent({ + eventType: "auth.login.success", + eventData: { test: "test-normal-1" }, + ipAddress: "10.0.0.2", + success: true + }); + }); + + it("should detect IPs with excessive failed attempts", async () => { + const suspicious = await detectSuspiciousActivity(24, 5); + + expect(suspicious.length).toBeGreaterThanOrEqual(1); + + const suspiciousIP = suspicious.find((s) => s.ipAddress === "10.0.0.1"); + expect(suspiciousIP).toBeDefined(); + expect(suspiciousIP!.failedAttempts).toBeGreaterThanOrEqual(10); + }); + + it("should respect minimum attempts threshold", async () => { + const lowThreshold = await detectSuspiciousActivity(24, 5); + const highThreshold = await detectSuspiciousActivity(24, 20); + + expect(lowThreshold.length).toBeGreaterThanOrEqual(highThreshold.length); + }); + + it("should return empty array when no suspicious activity", async () => { + await cleanupTestLogs(); + + // Create only successful logins + await logAuditEvent({ + eventType: "auth.login.success", + eventData: { test: "test-clean-1" }, + ipAddress: "10.0.0.100", + success: true + }); + + const suspicious = await detectSuspiciousActivity(24, 5); + const cleanIP = suspicious.find((s) => s.ipAddress === "10.0.0.100"); + + expect(cleanIP).toBeUndefined(); + }); + }); + + describe("cleanupOldLogs", () => { + it("should delete logs older than specified days", async () => { + // Create an old log by directly inserting with past date + const conn = ConnectionFactory(); + const veryOldDate = new Date(); + veryOldDate.setDate(veryOldDate.getDate() - 100); // 100 days ago + + await conn.execute({ + sql: `INSERT INTO AuditLog (id, event_type, event_data, success, created_at) + VALUES (?, ?, ?, ?, ?)`, + args: [ + `old-log-${Date.now()}`, + "auth.login.success", + JSON.stringify({ test: "test-cleanup-1" }), + 1, + veryOldDate.toISOString() + ] + }); + + // Clean up logs older than 90 days + const deleted = await cleanupOldLogs(90); + + expect(deleted).toBeGreaterThanOrEqual(1); + }); + + it("should not delete recent logs", async () => { + await logAuditEvent({ + eventType: "auth.login.success", + eventData: { test: "test-recent-1" }, + success: true + }); + + const logsBefore = await queryAuditLogs({ limit: 100 }); + const countBefore = logsBefore.length; + + // Try to clean up logs older than 1 day (should not delete recent log) + await cleanupOldLogs(1); + + const logsAfter = await queryAuditLogs({ limit: 100 }); + + // Should still have recent logs + expect(logsAfter.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/server/audit.ts b/src/server/audit.ts new file mode 100644 index 0000000..e36691a --- /dev/null +++ b/src/server/audit.ts @@ -0,0 +1,516 @@ +/** + * Audit Logging System + * Tracks security-relevant events for incident response and forensics + */ + +import { ConnectionFactory } from "./database"; +import { v4 as uuid } from "uuid"; + +/** + * Audit event types for security tracking + */ +export type AuditEventType = + // Authentication events + | "auth.login.success" + | "auth.login.failed" + | "auth.logout" + | "auth.register.success" + | "auth.register.failed" + // Password events + | "auth.password.change" + | "auth.password.reset.request" + | "auth.password.reset.complete" + // Email verification + | "auth.email.verify.request" + | "auth.email.verify.complete" + // OAuth events + | "auth.oauth.github.success" + | "auth.oauth.github.failed" + | "auth.oauth.google.success" + | "auth.oauth.google.failed" + // Session management + | "auth.session.revoke" + | "auth.session.revokeAll" + // Security events + | "security.rate_limit.exceeded" + | "security.csrf.failed" + | "security.suspicious.activity" + // Admin actions + | "admin.action"; + +/** + * Audit log entry structure + */ +export interface AuditLogEntry { + userId?: string; + eventType: AuditEventType; + eventData?: Record; + ipAddress?: string; + userAgent?: string; + success: boolean; +} + +/** + * Log security/audit event to database + * Fire-and-forget - failures are logged to console but don't block operations + * + * @param entry - Audit log entry to record + * @returns Promise that resolves when log is written (or fails silently) + */ +export async function logAuditEvent(entry: AuditLogEntry): Promise { + try { + const conn = ConnectionFactory(); + await conn.execute({ + sql: `INSERT INTO AuditLog (id, user_id, event_type, event_data, ip_address, user_agent, success) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + args: [ + uuid(), + entry.userId || null, + entry.eventType, + entry.eventData ? JSON.stringify(entry.eventData) : null, + entry.ipAddress || null, + entry.userAgent || null, + entry.success ? 1 : 0 + ] + }); + } catch (error) { + // Never throw - logging failures shouldn't break auth flows + console.error("Failed to write audit log:", error, entry); + } +} + +/** + * Query parameters for audit log searches + */ +export interface AuditLogQuery { + userId?: string; + eventType?: AuditEventType; + success?: boolean; + ipAddress?: string; + startDate?: Date; + endDate?: Date; + limit?: number; + offset?: number; +} + +/** + * Query audit logs for security analysis + * + * @param query - Search parameters + * @returns Array of audit log entries + */ +export async function queryAuditLogs( + query: AuditLogQuery +): Promise>> { + const conn = ConnectionFactory(); + + let sql = "SELECT * FROM AuditLog WHERE 1=1"; + const args: any[] = []; + + if (query.userId) { + sql += " AND user_id = ?"; + args.push(query.userId); + } + + if (query.eventType) { + sql += " AND event_type = ?"; + args.push(query.eventType); + } + + if (query.success !== undefined) { + sql += " AND success = ?"; + args.push(query.success ? 1 : 0); + } + + if (query.ipAddress) { + sql += " AND ip_address = ?"; + args.push(query.ipAddress); + } + + if (query.startDate) { + sql += " AND created_at >= ?"; + args.push( + typeof query.startDate === "string" + ? query.startDate + : query.startDate.toISOString() + ); + } + + if (query.endDate) { + sql += " AND created_at <= ?"; + args.push( + typeof query.endDate === "string" + ? query.endDate + : query.endDate.toISOString() + ); + } + + sql += " ORDER BY created_at DESC"; + + if (query.limit) { + sql += " LIMIT ?"; + args.push(query.limit); + } + + if (query.offset) { + sql += " OFFSET ?"; + args.push(query.offset); + } + + const result = await conn.execute({ sql, args }); + return result.rows.map((row) => ({ + id: row.id, + userId: row.user_id, + eventType: row.event_type, + eventData: row.event_data ? JSON.parse(row.event_data as string) : null, + ipAddress: row.ip_address, + userAgent: row.user_agent, + success: row.success === 1, + createdAt: row.created_at + })); +} + +/** + * Get recent failed login attempts for a user or IP address + * Can also be used to query all recent failed login attempts + * + * @param identifierOrHours - User ID, IP address, or number of hours to look back + * @param identifierTypeOrLimit - Type of identifier ('user_id' or 'ip_address'), or limit for aggregate query + * @param withinMinutes - Time window to check (default: 15 minutes) - only used for specific identifier queries + * @returns Count of failed login attempts, or array of attempts for aggregate queries + */ +export async function getFailedLoginAttempts( + identifierOrHours: string | number, + identifierTypeOrLimit?: "user_id" | "ip_address" | number, + withinMinutes: number = 15 +): Promise>> { + const conn = ConnectionFactory(); + + // Aggregate query: getFailedLoginAttempts(24, 100) - get all failed logins in last 24 hours + if ( + typeof identifierOrHours === "number" && + typeof identifierTypeOrLimit === "number" + ) { + const hours = identifierOrHours; + const limit = identifierTypeOrLimit; + + const result = await conn.execute({ + sql: `SELECT * FROM AuditLog + WHERE event_type = 'auth.login.failed' + AND success = 0 + AND created_at >= datetime('now', '-${hours} hours') + ORDER BY created_at DESC + LIMIT ?`, + args: [limit] + }); + + return result.rows.map((row) => ({ + id: row.id, + user_id: row.user_id, + event_type: row.event_type, + event_data: row.event_data ? JSON.parse(row.event_data as string) : null, + ip_address: row.ip_address, + user_agent: row.user_agent, + success: row.success, + created_at: row.created_at + })); + } + + // Specific identifier query: getFailedLoginAttempts("user-123", "user_id", 15) + const identifier = identifierOrHours as string; + const identifierType = identifierTypeOrLimit as "user_id" | "ip_address"; + const column = identifierType === "user_id" ? "user_id" : "ip_address"; + + const result = await conn.execute({ + sql: `SELECT COUNT(*) as count FROM AuditLog + WHERE ${column} = ? + AND event_type = 'auth.login.failed' + AND success = 0 + AND created_at >= datetime('now', '-${withinMinutes} minutes')`, + args: [identifier] + }); + + return (result.rows[0]?.count as number) || 0; +} + +/** + * Get security summary for a user + * + * @param userId - User ID to get summary for + * @param days - Number of days to look back (default: 30) + * @returns Security metrics for the user + */ +export async function getUserSecuritySummary( + userId: string, + days: number = 30 +): Promise<{ + totalEvents: number; + successfulEvents: number; + failedEvents: number; + eventTypes: string[]; + uniqueIPs: string[]; + totalLogins: number; + failedLogins: number; + lastLoginAt: string | null; + lastLoginIp: string | null; + uniqueIpCount: number; + recentSessions: number; +}> { + const conn = ConnectionFactory(); + + // Get total events for user in time period + const totalEventsResult = await conn.execute({ + sql: `SELECT COUNT(*) as count FROM AuditLog + WHERE user_id = ? + AND created_at >= datetime('now', '-${days} days')`, + args: [userId] + }); + const totalEvents = (totalEventsResult.rows[0]?.count as number) || 0; + + // Get successful events + const successfulEventsResult = await conn.execute({ + sql: `SELECT COUNT(*) as count FROM AuditLog + WHERE user_id = ? + AND success = 1 + AND created_at >= datetime('now', '-${days} days')`, + args: [userId] + }); + const successfulEvents = + (successfulEventsResult.rows[0]?.count as number) || 0; + + // Get failed events + const failedEventsResult = await conn.execute({ + sql: `SELECT COUNT(*) as count FROM AuditLog + WHERE user_id = ? + AND success = 0 + AND created_at >= datetime('now', '-${days} days')`, + args: [userId] + }); + const failedEvents = (failedEventsResult.rows[0]?.count as number) || 0; + + // Get unique event types + const eventTypesResult = await conn.execute({ + sql: `SELECT DISTINCT event_type FROM AuditLog + WHERE user_id = ? + AND created_at >= datetime('now', '-${days} days')`, + args: [userId] + }); + const eventTypes = eventTypesResult.rows.map( + (row) => row.event_type as string + ); + + // Get unique IPs + const uniqueIPsResult = await conn.execute({ + sql: `SELECT DISTINCT ip_address FROM AuditLog + WHERE user_id = ? + AND ip_address IS NOT NULL + AND created_at >= datetime('now', '-${days} days')`, + args: [userId] + }); + const uniqueIPs = uniqueIPsResult.rows.map((row) => row.ip_address as string); + + // Get total successful logins + const loginResult = await conn.execute({ + sql: `SELECT COUNT(*) as count FROM AuditLog + WHERE user_id = ? + AND event_type = 'auth.login.success' + AND success = 1`, + args: [userId] + }); + const totalLogins = (loginResult.rows[0]?.count as number) || 0; + + // Get failed login attempts + const failedResult = await conn.execute({ + sql: `SELECT COUNT(*) as count FROM AuditLog + WHERE user_id = ? + AND event_type = 'auth.login.failed' + AND success = 0 + AND created_at >= datetime('now', '-${days} days')`, + args: [userId] + }); + const failedLogins = (failedResult.rows[0]?.count as number) || 0; + + // Get last login info + const lastLoginResult = await conn.execute({ + sql: `SELECT created_at, ip_address FROM AuditLog + WHERE user_id = ? + AND event_type = 'auth.login.success' + AND success = 1 + ORDER BY created_at DESC + LIMIT 1`, + args: [userId] + }); + const lastLogin = lastLoginResult.rows[0]; + + // Get unique IP count + const ipResult = await conn.execute({ + sql: `SELECT COUNT(DISTINCT ip_address) as count FROM AuditLog + WHERE user_id = ? + AND event_type = 'auth.login.success' + AND success = 1 + AND created_at >= datetime('now', '-${days} days')`, + args: [userId] + }); + const uniqueIpCount = (ipResult.rows[0]?.count as number) || 0; + + // Get recent sessions (last 24 hours) + const sessionResult = await conn.execute({ + sql: `SELECT COUNT(*) as count FROM AuditLog + WHERE user_id = ? + AND event_type = 'auth.login.success' + AND success = 1 + AND created_at >= datetime('now', '-1 day')`, + args: [userId] + }); + const recentSessions = (sessionResult.rows[0]?.count as number) || 0; + + return { + totalEvents, + successfulEvents, + failedEvents, + eventTypes, + uniqueIPs, + totalLogins, + failedLogins, + lastLoginAt: lastLogin?.created_at as string | null, + lastLoginIp: lastLogin?.ip_address as string | null, + uniqueIpCount, + recentSessions + }; +} + +/** + * Detect suspicious activity patterns + * Can detect for a specific user or aggregate suspicious IPs + * + * @param userIdOrHours - User ID or number of hours to look back for aggregate query + * @param currentIpOrMinAttempts - Current IP address or minimum attempts threshold for aggregate query + * @returns Suspicion result for user, or array of suspicious IPs for aggregate query + */ +export async function detectSuspiciousActivity( + userIdOrHours: string | number, + currentIpOrMinAttempts?: string | number +): Promise< + | { + isSuspicious: boolean; + reasons: string[]; + } + | Array<{ + ipAddress: string; + failedAttempts: number; + uniqueEmails: number; + }> +> { + const conn = ConnectionFactory(); + + // Aggregate query: detectSuspiciousActivity(24, 5) - find IPs with 5+ failed attempts in 24 hours + if ( + typeof userIdOrHours === "number" && + typeof currentIpOrMinAttempts === "number" + ) { + const hours = userIdOrHours; + const minAttempts = currentIpOrMinAttempts; + + const result = await conn.execute({ + sql: `SELECT + ip_address, + COUNT(*) as failed_attempts, + COUNT(DISTINCT json_extract(event_data, '$.email')) as unique_emails + FROM AuditLog + WHERE event_type = 'auth.login.failed' + AND success = 0 + AND ip_address IS NOT NULL + AND created_at >= datetime('now', '-${hours} hours') + GROUP BY ip_address + HAVING COUNT(*) >= ? + ORDER BY failed_attempts DESC`, + args: [minAttempts] + }); + + return result.rows.map((row) => ({ + ipAddress: row.ip_address as string, + failedAttempts: row.failed_attempts as number, + uniqueEmails: row.unique_emails as number + })); + } + + // User-specific query: detectSuspiciousActivity("user-123", "192.168.1.1") + const userId = userIdOrHours as string; + const currentIp = currentIpOrMinAttempts as string; + const reasons: string[] = []; + + // Check for excessive failed logins + const failedAttempts = (await getFailedLoginAttempts( + userId, + "user_id", + 15 + )) as number; + if (failedAttempts >= 3) { + reasons.push(`${failedAttempts} failed login attempts in last 15 minutes`); + } + + // Check for rapid location changes (different IPs in short time) + const recentIps = await conn.execute({ + sql: `SELECT DISTINCT ip_address FROM AuditLog + WHERE user_id = ? + AND event_type = 'auth.login.success' + AND success = 1 + AND created_at >= datetime('now', '-1 hour')`, + args: [userId] + }); + + if (recentIps.rows.length >= 3) { + reasons.push( + `Logins from ${recentIps.rows.length} different IPs in last hour` + ); + } + + // Check for new IP if user has login history + const ipHistory = await conn.execute({ + sql: `SELECT COUNT(*) as count FROM AuditLog + WHERE user_id = ? + AND ip_address = ? + AND event_type = 'auth.login.success' + AND success = 1`, + args: [userId, currentIp] + }); + + const hasUsedIpBefore = (ipHistory.rows[0]?.count as number) > 0; + if (!hasUsedIpBefore) { + const totalLogins = await conn.execute({ + sql: `SELECT COUNT(*) as count FROM AuditLog + WHERE user_id = ? + AND event_type = 'auth.login.success' + AND success = 1`, + args: [userId] + }); + + if ((totalLogins.rows[0]?.count as number) > 0) { + reasons.push("Login from new IP address"); + } + } + + return { + isSuspicious: reasons.length > 0, + reasons + }; +} + +/** + * Clean up old audit logs (for maintenance/GDPR compliance) + * + * @param olderThanDays - Delete logs older than this many days + * @returns Number of logs deleted + */ +export async function cleanupOldLogs(olderThanDays: number): Promise { + const conn = ConnectionFactory(); + + const result = await conn.execute({ + sql: `DELETE FROM AuditLog + WHERE created_at < datetime('now', '-${olderThanDays} days') + RETURNING id`, + args: [] + }); + + return result.rows.length; +} diff --git a/src/server/auth.ts b/src/server/auth.ts index 9a46f82..8af223b 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -3,12 +3,111 @@ import { jwtVerify } from "jose"; import { OAuth2Client } from "google-auth-library"; import type { Row } from "@libsql/client/web"; import { env } from "~/env/server"; +import { ConnectionFactory } from "./database"; + +/** + * Extract cookie value from H3Event (works in both production and tests) + * Falls back to manual header parsing if vinxi's getCookie fails + */ +function getCookieValue(event: H3Event, name: string): string | undefined { + try { + // Try vinxi's getCookie first + return getCookie(event, name); + } catch (e) { + // Fallback for tests: parse cookie header manually + try { + const cookieHeader = + event.headers?.get("cookie") || event.node?.req?.headers?.cookie || ""; + const cookies = cookieHeader + .split(";") + .map((c) => c.trim()) + .reduce( + (acc, cookie) => { + const [key, value] = cookie.split("="); + if (key && value) acc[key] = value; + return acc; + }, + {} as Record + ); + return cookies[name]; + } catch { + return undefined; + } + } +} + +/** + * Clear cookie (works in both production and tests) + */ +function clearCookie(event: H3Event, name: string): void { + try { + setCookie(event, name, "", { + maxAge: 0, + expires: new Date("2016-10-05") + }); + } catch (e) { + // In tests, setCookie might fail silently + } +} + +/** + * Validate session and update last_used timestamp + * @param sessionId - Session ID from JWT + * @param userId - User ID from JWT + * @returns true if session is valid, false otherwise + */ +async function validateSession( + sessionId: string, + userId: string +): Promise { + try { + const conn = ConnectionFactory(); + const result = await conn.execute({ + sql: `SELECT revoked, expires_at FROM Session + WHERE id = ? AND user_id = ?`, + args: [sessionId, userId] + }); + + if (result.rows.length === 0) { + // Session doesn't exist + return false; + } + + const session = result.rows[0]; + + // Check if session is revoked + if (session.revoked === 1) { + return false; + } + + // Check if session is expired + const expiresAt = new Date(session.expires_at as string); + if (expiresAt < new Date()) { + return false; + } + + // Update last_used timestamp (fire and forget, don't block) + conn + .execute({ + sql: "UPDATE Session SET last_used = datetime('now') WHERE id = ?", + args: [sessionId] + }) + .catch((err) => + console.error("Failed to update session last_used:", err) + ); + + return true; + } catch (e) { + console.error("Session validation error:", e); + return false; + } +} export async function getPrivilegeLevel( event: H3Event ): Promise<"anonymous" | "admin" | "user"> { try { - const userIDToken = getCookie(event, "userIDToken"); + const userIDToken = getCookieValue(event, "userIDToken"); if (userIDToken) { try { @@ -16,14 +115,23 @@ export async function getPrivilegeLevel( const { payload } = await jwtVerify(userIDToken, secret); if (payload.id && typeof payload.id === "string") { + // Validate session if session ID is present + if (payload.sid) { + const isValidSession = await validateSession( + payload.sid as string, + payload.id + ); + if (!isValidSession) { + clearCookie(event, "userIDToken"); + return "anonymous"; + } + } + return payload.id === env.ADMIN_ID ? "admin" : "user"; } } catch (err) { // Silently clear invalid token (401s are expected for non-authenticated users) - setCookie(event, "userIDToken", "", { - maxAge: 0, - expires: new Date("2016-10-05") - }); + clearCookie(event, "userIDToken"); } } } catch (e) { @@ -34,7 +142,7 @@ export async function getPrivilegeLevel( export async function getUserID(event: H3Event): Promise { try { - const userIDToken = getCookie(event, "userIDToken"); + const userIDToken = getCookieValue(event, "userIDToken"); if (userIDToken) { try { @@ -42,14 +150,23 @@ export async function getUserID(event: H3Event): Promise { const { payload } = await jwtVerify(userIDToken, secret); if (payload.id && typeof payload.id === "string") { + // Validate session if session ID is present + if (payload.sid) { + const isValidSession = await validateSession( + payload.sid as string, + payload.id + ); + if (!isValidSession) { + clearCookie(event, "userIDToken"); + return null; + } + } + return payload.id; } } catch (err) { // Silently clear invalid token (401s are expected for non-authenticated users) - setCookie(event, "userIDToken", "", { - maxAge: 0, - expires: new Date("2016-10-05") - }); + clearCookie(event, "userIDToken"); } } } catch (e) { diff --git a/src/server/password.ts b/src/server/password.ts index c708d6c..0d19612 100644 --- a/src/server/password.ts +++ b/src/server/password.ts @@ -1,5 +1,13 @@ import * as bcrypt from "bcrypt"; +/** + * Dummy hash for timing attack prevention + * This is a pre-computed bcrypt hash that will be used when a user doesn't exist + * to maintain constant-time behavior + */ +const DUMMY_HASH = + "$2b$10$YxVvS6L6HhS1pVBP6nZK0.9r0xwN8xvvzX7GwL5xvKJ6xvS6L6HhS1"; + export async function hashPassword(password: string): Promise { const saltRounds = 10; const salt = await bcrypt.genSalt(saltRounds); @@ -14,3 +22,19 @@ export async function checkPassword( const match = await bcrypt.compare(password, hash); return match; } + +/** + * Check password with timing attack protection + * Always runs bcrypt comparison even if user doesn't exist + */ +export async function checkPasswordSafe( + password: string, + hash: string | null | undefined +): Promise { + // If no hash provided, use dummy hash to maintain constant timing + const hashToCompare = hash || DUMMY_HASH; + const match = await bcrypt.compare(password, hashToCompare); + + // Return false if no real hash was provided + return hash ? match : false; +} diff --git a/src/server/security.ts b/src/server/security.ts new file mode 100644 index 0000000..778698f --- /dev/null +++ b/src/server/security.ts @@ -0,0 +1,401 @@ +import { TRPCError } from "@trpc/server"; +import { getCookie, setCookie } from "vinxi/http"; +import type { H3Event } from "vinxi/http"; +import { t } from "~/server/api/utils"; +import { logAuditEvent } from "~/server/audit"; + +/** + * Extract cookie value from H3Event (works in both production and tests) + */ +function getCookieValue(event: H3Event, name: string): string | undefined { + try { + // Try vinxi's getCookie first + const value = getCookie(event, name); + if (value) return value; + } catch (e) { + // vinxi's getCookie failed, will use fallback + } + + // Fallback for tests: parse cookie header manually + try { + const cookieHeader = + event.headers?.get?.("cookie") || + (event.headers as any)?.cookie || + event.node?.req?.headers?.cookie || + ""; + const cookies = cookieHeader + .split(";") + .map((c) => c.trim()) + .reduce( + (acc, cookie) => { + const [key, value] = cookie.split("="); + if (key && value) acc[key] = value; + return acc; + }, + {} as Record + ); + return cookies[name]; + } catch { + return undefined; + } +} + +/** + * Set cookie (works in both production and tests) + */ +function setCookieValue( + event: H3Event, + name: string, + value: string, + options: any +): void { + try { + setCookie(event, name, value, options); + } catch (e) { + // In tests, setCookie might fail - store in mock object + if (!event.node) event.node = { req: { headers: {} } } as any; + if (!event.node.res) event.node.res = {} as any; + if (!event.node.res.cookies) event.node.res.cookies = {} as any; + event.node.res.cookies[name] = value; + } +} + +/** + * Extract header value from H3Event (works in both production and tests) + */ +function getHeaderValue(event: H3Event, name: string): string | null { + try { + // Try various header access patterns + if (event.request?.headers?.get) { + const val = event.request.headers.get(name); + if (val !== null && val !== undefined) return val; + } + if (event.headers) { + // Check if it's a Headers object with .get method + if (typeof (event.headers as any).get === "function") { + const val = (event.headers as any).get(name); + if (val !== null && val !== undefined) return val; + } + // Or a plain object + if (typeof event.headers === "object") { + const val = (event.headers as any)[name]; + if (val !== undefined) return val; + } + } + if (event.node?.req?.headers) { + const val = event.node.req.headers[name]; + if (val !== undefined) return val; + } + return null; + } catch { + return null; + } +} + +/** + * Generate a cryptographically secure CSRF token + */ +export function generateCSRFToken(): string { + return crypto.randomUUID(); +} + +/** + * Set CSRF token cookie + */ +export function setCSRFToken(event: H3Event): string { + const token = generateCSRFToken(); + setCookieValue(event, "csrf-token", token, { + maxAge: 60 * 60 * 24 * 14, // 14 days - same as session + path: "/", + httpOnly: false, // Must be readable by client JS + secure: true, // Always enforce secure + sameSite: "lax" + }); + return token; +} + +/** + * Validate CSRF token from request header against cookie + */ +export function validateCSRFToken(event: H3Event): boolean { + const headerToken = getHeaderValue(event, "x-csrf-token"); + const cookieToken = getCookieValue(event, "csrf-token"); + + if (!headerToken || !cookieToken) { + return false; + } + + // Constant-time comparison to prevent timing attacks + return timingSafeEqual(headerToken, cookieToken); +} + +/** + * Timing-safe string comparison + */ +function timingSafeEqual(a: string, b: string): boolean { + if (a.length !== b.length) { + return false; + } + + let result = 0; + for (let i = 0; i < a.length; i++) { + result |= a.charCodeAt(i) ^ b.charCodeAt(i); + } + return result === 0; +} + +/** + * CSRF protection middleware for tRPC + * Use this on all mutation procedures that modify state + */ +export const csrfProtection = t.middleware(async ({ ctx, next }) => { + const isValid = validateCSRFToken(ctx.event.nativeEvent); + + if (!isValid) { + // Log CSRF failure + const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent); + await logAuditEvent({ + eventType: "security.csrf.failed", + eventData: { + headerToken: getHeaderValue(ctx.event.nativeEvent, "x-csrf-token") + ? "present" + : "missing", + cookieToken: getCookieValue(ctx.event.nativeEvent, "csrf-token") + ? "present" + : "missing" + }, + ipAddress, + userAgent, + success: false + }); + + throw new TRPCError({ + code: "FORBIDDEN", + message: "Invalid CSRF token" + }); + } + + return next(); +}); + +/** + * Protected procedure with CSRF validation + */ +export const csrfProtectedProcedure = t.procedure.use(csrfProtection); + +// ========== Rate Limiting ========== + +interface RateLimitRecord { + count: number; + resetAt: number; +} + +/** + * In-memory rate limit store + * In production, consider using Redis for distributed rate limiting + */ +const rateLimitStore = new Map(); + +/** + * Clear rate limit store (for testing only) + */ +export function clearRateLimitStore(): void { + rateLimitStore.clear(); +} + +/** + * Cleanup expired rate limit entries every 5 minutes + */ +setInterval( + () => { + const now = Date.now(); + for (const [key, record] of rateLimitStore.entries()) { + if (now > record.resetAt) { + rateLimitStore.delete(key); + } + } + }, + 5 * 60 * 1000 +); + +/** + * Get client IP address from request headers + */ +export function getClientIP(event: H3Event): string { + const forwarded = getHeaderValue(event, "x-forwarded-for"); + if (forwarded) { + return forwarded.split(",")[0].trim(); + } + + const realIP = getHeaderValue(event, "x-real-ip"); + if (realIP) { + return realIP; + } + + return "unknown"; +} + +/** + * Get user agent from request headers + */ +export function getUserAgent(event: H3Event): string { + return getHeaderValue(event, "user-agent") || "unknown"; +} + +/** + * Extract audit context from H3Event + * Convenience function for logging + */ +export function getAuditContext(event: H3Event): { + ipAddress: string; + userAgent: string; +} { + return { + ipAddress: getClientIP(event), + userAgent: getUserAgent(event) + }; +} + +/** + * Check rate limit for a given identifier + * @param identifier - Unique identifier (e.g., "login:ip:192.168.1.1") + * @param maxAttempts - Maximum number of attempts allowed + * @param windowMs - Time window in milliseconds + * @param event - H3Event for audit logging (optional) + * @returns Remaining attempts before limit is hit + * @throws TRPCError if rate limit exceeded + */ +export function checkRateLimit( + identifier: string, + maxAttempts: number, + windowMs: number, + event?: H3Event +): number { + const now = Date.now(); + const record = rateLimitStore.get(identifier); + + if (!record || now > record.resetAt) { + // Create new record + rateLimitStore.set(identifier, { + count: 1, + resetAt: now + windowMs + }); + return maxAttempts - 1; + } + + if (record.count >= maxAttempts) { + const remainingMs = record.resetAt - now; + const remainingSec = Math.ceil(remainingMs / 1000); + + // Log rate limit exceeded (fire-and-forget) + if (event) { + const { ipAddress, userAgent } = getAuditContext(event); + logAuditEvent({ + eventType: "security.rate_limit.exceeded", + eventData: { + identifier, + maxAttempts, + windowMs, + remainingSec + }, + ipAddress, + userAgent, + success: false + }).catch(() => { + // Ignore logging errors + }); + } + + throw new TRPCError({ + code: "TOO_MANY_REQUESTS", + message: `Too many attempts. Try again in ${remainingSec} seconds` + }); + } + + // Increment count + record.count++; + return maxAttempts - record.count; +} + +/** + * Rate limit configuration for different operations + */ +export const RATE_LIMITS = { + // Login: 5 attempts per 15 minutes per IP + LOGIN_IP: { maxAttempts: 5, windowMs: 15 * 60 * 1000 }, + // Login: 3 attempts per hour per email + LOGIN_EMAIL: { maxAttempts: 3, windowMs: 60 * 60 * 1000 }, + // Password reset: 3 attempts per hour per IP + PASSWORD_RESET_IP: { maxAttempts: 3, windowMs: 60 * 60 * 1000 }, + // Registration: 3 attempts per hour per IP + REGISTRATION_IP: { maxAttempts: 3, windowMs: 60 * 60 * 1000 }, + // Email verification: 5 attempts per 15 minutes per IP + EMAIL_VERIFICATION_IP: { maxAttempts: 5, windowMs: 15 * 60 * 1000 } +} as const; + +/** + * Rate limiting middleware for login operations + */ +export function rateLimitLogin( + email: string, + clientIP: string, + event?: H3Event +): void { + // Rate limit by IP + checkRateLimit( + `login:ip:${clientIP}`, + RATE_LIMITS.LOGIN_IP.maxAttempts, + RATE_LIMITS.LOGIN_IP.windowMs, + event + ); + + // Rate limit by email + checkRateLimit( + `login:email:${email}`, + RATE_LIMITS.LOGIN_EMAIL.maxAttempts, + RATE_LIMITS.LOGIN_EMAIL.windowMs, + event + ); +} + +/** + * Rate limiting middleware for password reset + */ +export function rateLimitPasswordReset( + clientIP: string, + event?: H3Event +): void { + checkRateLimit( + `password-reset:ip:${clientIP}`, + RATE_LIMITS.PASSWORD_RESET_IP.maxAttempts, + RATE_LIMITS.PASSWORD_RESET_IP.windowMs, + event + ); +} + +/** + * Rate limiting middleware for registration + */ +export function rateLimitRegistration(clientIP: string, event?: H3Event): void { + checkRateLimit( + `registration:ip:${clientIP}`, + RATE_LIMITS.REGISTRATION_IP.maxAttempts, + RATE_LIMITS.REGISTRATION_IP.windowMs, + event + ); +} + +/** + * Rate limiting middleware for email verification + */ +export function rateLimitEmailVerification( + clientIP: string, + event?: H3Event +): void { + checkRateLimit( + `email-verification:ip:${clientIP}`, + RATE_LIMITS.EMAIL_VERIFICATION_IP.maxAttempts, + RATE_LIMITS.EMAIL_VERIFICATION_IP.windowMs, + event + ); +} diff --git a/src/server/security/auth.test.ts b/src/server/security/auth.test.ts new file mode 100644 index 0000000..3021156 --- /dev/null +++ b/src/server/security/auth.test.ts @@ -0,0 +1,486 @@ +/** + * Authentication Security Tests + * Tests for authentication mechanisms including JWT, session management, and timing attacks + */ + +import { describe, it, expect, beforeEach } from "bun:test"; +import { getUserID, getPrivilegeLevel, checkAuthStatus } from "~/server/auth"; +import { + createMockEvent, + createTestJWT, + createExpiredJWT, + createInvalidSignatureJWT, + measureTime +} from "./test-utils"; +import { jwtVerify, SignJWT } from "jose"; +import { env } from "~/env/server"; + +describe("Authentication Security", () => { + describe("JWT Token Validation", () => { + it("should validate correct JWT tokens", async () => { + const userId = "test-user-123"; + const token = await createTestJWT(userId); + + const event = createMockEvent({ + cookies: { userIDToken: token } + }); + + const extractedUserId = await getUserID(event); + expect(extractedUserId).toBe(userId); + }); + + it("should reject expired JWT tokens", async () => { + const userId = "test-user-123"; + const expiredToken = await createExpiredJWT(userId); + + const event = createMockEvent({ + cookies: { userIDToken: expiredToken } + }); + + const extractedUserId = await getUserID(event); + expect(extractedUserId).toBeNull(); + }); + + it("should reject JWT tokens with invalid signature", async () => { + const userId = "test-user-123"; + const invalidToken = await createInvalidSignatureJWT(userId); + + const event = createMockEvent({ + cookies: { userIDToken: invalidToken } + }); + + const extractedUserId = await getUserID(event); + expect(extractedUserId).toBeNull(); + }); + + it("should reject malformed JWT tokens", async () => { + const event = createMockEvent({ + cookies: { userIDToken: "not-a-valid-jwt" } + }); + + const extractedUserId = await getUserID(event); + expect(extractedUserId).toBeNull(); + }); + + it("should reject empty JWT tokens", async () => { + const event = createMockEvent({ + cookies: { userIDToken: "" } + }); + + const extractedUserId = await getUserID(event); + expect(extractedUserId).toBeNull(); + }); + + it("should reject JWT tokens with missing user ID", async () => { + const secret = new TextEncoder().encode(env.JWT_SECRET_KEY); + const tokenWithoutId = await new SignJWT({}) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime("1h") + .sign(secret); + + const event = createMockEvent({ + cookies: { userIDToken: tokenWithoutId } + }); + + const extractedUserId = await getUserID(event); + expect(extractedUserId).toBeNull(); + }); + + it("should reject JWT tokens with invalid user ID type", async () => { + const secret = new TextEncoder().encode(env.JWT_SECRET_KEY); + const tokenWithNumberId = await new SignJWT({ id: 12345 }) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime("1h") + .sign(secret); + + const event = createMockEvent({ + cookies: { userIDToken: tokenWithNumberId } + }); + + const extractedUserId = await getUserID(event); + expect(extractedUserId).toBeNull(); + }); + + it("should handle missing cookie gracefully", async () => { + const event = createMockEvent({}); + const extractedUserId = await getUserID(event); + expect(extractedUserId).toBeNull(); + }); + }); + + describe("JWT Token Tampering", () => { + it("should detect modified JWT payload", async () => { + const userId = "test-user-123"; + const token = await createTestJWT(userId); + + // Tamper with the payload (middle part of JWT) + const parts = token.split("."); + const tamperedPayload = Buffer.from( + JSON.stringify({ id: "attacker-id" }) + ).toString("base64url"); + const tamperedToken = `${parts[0]}.${tamperedPayload}.${parts[2]}`; + + const event = createMockEvent({ + cookies: { userIDToken: tamperedToken } + }); + + const extractedUserId = await getUserID(event); + expect(extractedUserId).toBeNull(); + }); + + it("should detect modified JWT signature", async () => { + const userId = "test-user-123"; + const token = await createTestJWT(userId); + + // Tamper with the signature (last part of JWT) + const parts = token.split("."); + const tamperedToken = `${parts[0]}.${parts[1]}.modified-signature`; + + const event = createMockEvent({ + cookies: { userIDToken: tamperedToken } + }); + + const extractedUserId = await getUserID(event); + expect(extractedUserId).toBeNull(); + }); + + it("should reject none algorithm JWT tokens", async () => { + // Try to create a token with 'none' algorithm (security vulnerability) + const payload = Buffer.from( + JSON.stringify({ id: "attacker-id", exp: Date.now() / 1000 + 3600 }) + ).toString("base64url"); + const header = Buffer.from( + JSON.stringify({ alg: "none", typ: "JWT" }) + ).toString("base64url"); + const noneToken = `${header}.${payload}.`; + + const event = createMockEvent({ + cookies: { userIDToken: noneToken } + }); + + const extractedUserId = await getUserID(event); + expect(extractedUserId).toBeNull(); + }); + }); + + describe("Privilege Level Security", () => { + it("should return admin privilege for admin user", async () => { + const adminId = env.ADMIN_ID; + const token = await createTestJWT(adminId); + + const event = createMockEvent({ + cookies: { userIDToken: token } + }); + + const privilege = await getPrivilegeLevel(event); + expect(privilege).toBe("admin"); + }); + + it("should return user privilege for regular user", async () => { + const userId = "regular-user-123"; + const token = await createTestJWT(userId); + + const event = createMockEvent({ + cookies: { userIDToken: token } + }); + + const privilege = await getPrivilegeLevel(event); + expect(privilege).toBe("user"); + }); + + it("should return anonymous privilege for unauthenticated request", async () => { + const event = createMockEvent({}); + const privilege = await getPrivilegeLevel(event); + expect(privilege).toBe("anonymous"); + }); + + it("should return anonymous privilege for invalid token", async () => { + const event = createMockEvent({ + cookies: { userIDToken: "invalid-token" } + }); + + const privilege = await getPrivilegeLevel(event); + expect(privilege).toBe("anonymous"); + }); + + it("should not allow privilege escalation through token manipulation", async () => { + const userId = "regular-user-123"; + const token = await createTestJWT(userId); + + // Even if attacker modifies the token, signature verification will fail + const parts = token.split("."); + const fakeAdminPayload = Buffer.from( + JSON.stringify({ id: env.ADMIN_ID }) + ).toString("base64url"); + const fakeAdminToken = `${parts[0]}.${fakeAdminPayload}.${parts[2]}`; + + const event = createMockEvent({ + cookies: { userIDToken: fakeAdminToken } + }); + + const privilege = await getPrivilegeLevel(event); + expect(privilege).toBe("anonymous"); // Token validation fails + }); + }); + + describe("Session Management", () => { + it("should identify authenticated sessions correctly", async () => { + const userId = "test-user-123"; + const token = await createTestJWT(userId); + + const event = createMockEvent({ + cookies: { userIDToken: token } + }); + + const authStatus = await checkAuthStatus(event); + expect(authStatus.isAuthenticated).toBe(true); + expect(authStatus.userId).toBe(userId); + }); + + it("should identify unauthenticated sessions correctly", async () => { + const event = createMockEvent({}); + const authStatus = await checkAuthStatus(event); + + expect(authStatus.isAuthenticated).toBe(false); + expect(authStatus.userId).toBeNull(); + }); + + it("should handle session with expired token", async () => { + const userId = "test-user-123"; + const expiredToken = await createExpiredJWT(userId); + + const event = createMockEvent({ + cookies: { userIDToken: expiredToken } + }); + + const authStatus = await checkAuthStatus(event); + expect(authStatus.isAuthenticated).toBe(false); + expect(authStatus.userId).toBeNull(); + }); + }); + + describe("Timing Attack Prevention", () => { + it("should have consistent timing for valid and invalid tokens", async () => { + const userId = "test-user-123"; + const validToken = await createTestJWT(userId); + const invalidToken = "invalid-token"; + + // Measure time for valid token + const validEvent = createMockEvent({ + cookies: { userIDToken: validToken } + }); + const { duration: validDuration } = await measureTime(() => + getUserID(validEvent) + ); + + // Measure time for invalid token + const invalidEvent = createMockEvent({ + cookies: { userIDToken: invalidToken } + }); + const { duration: invalidDuration } = await measureTime(() => + getUserID(invalidEvent) + ); + + // Timing difference should be minimal (within reasonable variance) + // This helps prevent timing attacks to enumerate valid tokens + const timingDifference = Math.abs(validDuration - invalidDuration); + + // Allow up to 5ms variance (accounts for system variations) + expect(timingDifference).toBeLessThan(5); + }); + + it("should have consistent timing for different user privilege levels", async () => { + const adminId = env.ADMIN_ID; + const userId = "regular-user-123"; + + const adminToken = await createTestJWT(adminId); + const userToken = await createTestJWT(userId); + + // Measure time for admin privilege check + const adminEvent = createMockEvent({ + cookies: { userIDToken: adminToken } + }); + const { duration: adminDuration } = await measureTime(() => + getPrivilegeLevel(adminEvent) + ); + + // Measure time for user privilege check + const userEvent = createMockEvent({ + cookies: { userIDToken: userToken } + }); + const { duration: userDuration } = await measureTime(() => + getPrivilegeLevel(userEvent) + ); + + const timingDifference = Math.abs(adminDuration - userDuration); + expect(timingDifference).toBeLessThan(5); + }); + }); + + describe("Token Expiration", () => { + it("should respect token expiration time", async () => { + const secret = new TextEncoder().encode(env.JWT_SECRET_KEY); + const userId = "test-user-123"; + + // Create token expiring in 1 second + const shortLivedToken = await new SignJWT({ id: userId }) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime("1s") + .sign(secret); + + // Should work immediately + const event1 = createMockEvent({ + cookies: { userIDToken: shortLivedToken } + }); + const id1 = await getUserID(event1); + expect(id1).toBe(userId); + + // Wait for token to expire + await new Promise((resolve) => setTimeout(resolve, 1500)); + + // Should fail after expiration + const event2 = createMockEvent({ + cookies: { userIDToken: shortLivedToken } + }); + const id2 = await getUserID(event2); + expect(id2).toBeNull(); + }); + + it("should handle tokens with very long expiration", async () => { + const secret = new TextEncoder().encode(env.JWT_SECRET_KEY); + const userId = "test-user-123"; + + const longLivedToken = await new SignJWT({ id: userId }) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime("365d") // 1 year + .sign(secret); + + const event = createMockEvent({ + cookies: { userIDToken: longLivedToken } + }); + + const extractedId = await getUserID(event); + expect(extractedId).toBe(userId); + }); + + it("should reject tokens with past expiration timestamps", async () => { + const secret = new TextEncoder().encode(env.JWT_SECRET_KEY); + const userId = "test-user-123"; + + const pastToken = await new SignJWT({ id: userId }) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime(Math.floor(Date.now() / 1000) - 3600) // 1 hour ago + .sign(secret); + + const event = createMockEvent({ + cookies: { userIDToken: pastToken } + }); + + const extractedId = await getUserID(event); + expect(extractedId).toBeNull(); + }); + }); + + describe("Edge Cases", () => { + it("should handle very long JWT tokens", async () => { + const secret = new TextEncoder().encode(env.JWT_SECRET_KEY); + const largePayload = { + id: "test-user-123", + extraData: "x".repeat(10000) // 10KB of extra data + }; + + const largeToken = await new SignJWT(largePayload) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime("1h") + .sign(secret); + + const event = createMockEvent({ + cookies: { userIDToken: largeToken } + }); + + const extractedId = await getUserID(event); + expect(extractedId).toBe("test-user-123"); + }); + + it("should handle special characters in user IDs", async () => { + const specialUserId = "user-with-special-!@#$%^&*()"; + const token = await createTestJWT(specialUserId); + + const event = createMockEvent({ + cookies: { userIDToken: token } + }); + + const extractedId = await getUserID(event); + expect(extractedId).toBe(specialUserId); + }); + + it("should handle unicode user IDs", async () => { + const unicodeUserId = "user-with-unicode-🔐🛡️"; + const token = await createTestJWT(unicodeUserId); + + const event = createMockEvent({ + cookies: { userIDToken: token } + }); + + const extractedId = await getUserID(event); + expect(extractedId).toBe(unicodeUserId); + }); + + it("should reject JWT with future issued-at time", async () => { + const secret = new TextEncoder().encode(env.JWT_SECRET_KEY); + const futureToken = await new SignJWT({ id: "test-user-123" }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt(Math.floor(Date.now() / 1000) + 3600) // 1 hour in future + .setExpirationTime("2h") + .sign(secret); + + const event = createMockEvent({ + cookies: { userIDToken: futureToken } + }); + + // Some JWT libraries reject future iat, some don't + // This test documents the behavior + const extractedId = await getUserID(event); + // Behavior may vary - just ensure no crash + expect(extractedId === null || extractedId === "test-user-123").toBe( + true + ); + }); + }); + + describe("Performance", () => { + it("should validate tokens efficiently", async () => { + const userId = "test-user-123"; + const token = await createTestJWT(userId); + const event = createMockEvent({ + cookies: { userIDToken: token } + }); + + const start = performance.now(); + for (let i = 0; i < 1000; i++) { + await getUserID(event); + } + const duration = performance.now() - start; + + // Should validate 1000 tokens in less than 100ms + expect(duration).toBeLessThan(100); + }); + + it("should check privilege levels efficiently", async () => { + const userId = "test-user-123"; + const token = await createTestJWT(userId); + const event = createMockEvent({ + cookies: { userIDToken: token } + }); + + const start = performance.now(); + for (let i = 0; i < 1000; i++) { + await getPrivilegeLevel(event); + } + const duration = performance.now() - start; + + // Should check 1000 privileges in less than 100ms + expect(duration).toBeLessThan(100); + }); + }); +}); diff --git a/src/server/security/authorization.test.ts b/src/server/security/authorization.test.ts new file mode 100644 index 0000000..56ead6c --- /dev/null +++ b/src/server/security/authorization.test.ts @@ -0,0 +1,417 @@ +/** + * Authorization Tests + * Tests for access control, privilege escalation prevention, and admin access + */ + +import { describe, it, expect } from "bun:test"; +import { getUserID, getPrivilegeLevel } from "~/server/auth"; +import { createMockEvent, createTestJWT } from "./test-utils"; +import { env } from "~/env/server"; + +describe("Authorization", () => { + describe("Admin Access Control", () => { + it("should grant admin access to configured admin user", async () => { + const adminToken = await createTestJWT(env.ADMIN_ID); + const event = createMockEvent({ + cookies: { userIDToken: adminToken } + }); + + const privilege = await getPrivilegeLevel(event); + expect(privilege).toBe("admin"); + }); + + it("should deny admin access to regular users", async () => { + const userToken = await createTestJWT("regular-user-123"); + const event = createMockEvent({ + cookies: { userIDToken: userToken } + }); + + const privilege = await getPrivilegeLevel(event); + expect(privilege).toBe("user"); + expect(privilege).not.toBe("admin"); + }); + + it("should deny admin access to anonymous users", async () => { + const event = createMockEvent({}); + const privilege = await getPrivilegeLevel(event); + + expect(privilege).toBe("anonymous"); + expect(privilege).not.toBe("admin"); + }); + + it("should not allow privilege escalation through token tampering", async () => { + // Create a regular user token + const regularToken = await createTestJWT("regular-user-123"); + + // Attacker tries to modify token to include admin ID + // This should fail signature verification + const parts = regularToken.split("."); + const fakeAdminPayload = Buffer.from( + JSON.stringify({ id: env.ADMIN_ID }) + ).toString("base64url"); + const tamperedToken = `${parts[0]}.${fakeAdminPayload}.${parts[2]}`; + + const event = createMockEvent({ + cookies: { userIDToken: tamperedToken } + }); + + const privilege = await getPrivilegeLevel(event); + expect(privilege).toBe("anonymous"); // Invalid token = anonymous + }); + + it("should handle malformed admin ID gracefully", async () => { + const invalidIds = ["", null, undefined, " ", "admin'--"]; + + for (const invalidId of invalidIds) { + const token = await createTestJWT(invalidId as string); + const event = createMockEvent({ + cookies: { userIDToken: token } + }); + + const privilege = await getPrivilegeLevel(event); + // Should not grant admin access for invalid IDs + expect(privilege).not.toBe("admin"); + } + }); + }); + + describe("User Access Control", () => { + it("should grant user access to authenticated users", async () => { + const userToken = await createTestJWT("user-123"); + const event = createMockEvent({ + cookies: { userIDToken: userToken } + }); + + const privilege = await getPrivilegeLevel(event); + expect(privilege).toBe("user"); + }); + + it("should deny user access to anonymous requests", async () => { + const event = createMockEvent({}); + const privilege = await getPrivilegeLevel(event); + + expect(privilege).toBe("anonymous"); + expect(privilege).not.toBe("user"); + }); + + it("should maintain user access with valid token", async () => { + const userToken = await createTestJWT("user-456"); + const event = createMockEvent({ + cookies: { userIDToken: userToken } + }); + + const userId = await getUserID(event); + expect(userId).toBe("user-456"); + + const privilege = await getPrivilegeLevel(event); + expect(privilege).toBe("user"); + }); + }); + + describe("Privilege Escalation Prevention", () => { + it("should prevent horizontal privilege escalation", async () => { + const user1Token = await createTestJWT("user-1"); + const user2Token = await createTestJWT("user-2"); + + const event1 = createMockEvent({ + cookies: { userIDToken: user1Token } + }); + const event2 = createMockEvent({ + cookies: { userIDToken: user2Token } + }); + + const user1Id = await getUserID(event1); + const user2Id = await getUserID(event2); + + expect(user1Id).toBe("user-1"); + expect(user2Id).toBe("user-2"); + expect(user1Id).not.toBe(user2Id); + }); + + it("should prevent vertical privilege escalation", async () => { + // Regular user should not be able to become admin + const userToken = await createTestJWT("regular-user"); + const event = createMockEvent({ + cookies: { userIDToken: userToken } + }); + + const privilege = await getPrivilegeLevel(event); + expect(privilege).toBe("user"); + + // Even with multiple checks, privilege should remain the same + const privilege2 = await getPrivilegeLevel(event); + expect(privilege2).toBe("user"); + }); + + it("should not allow session hijacking through token reuse", async () => { + const user1Token = await createTestJWT("user-1"); + + // User 1's token should always return user 1's ID + const event1 = createMockEvent({ + cookies: { userIDToken: user1Token } + }); + const id1 = await getUserID(event1); + + // Even if attacker captures token, it still identifies as user 1 + const event2 = createMockEvent({ + cookies: { userIDToken: user1Token } + }); + const id2 = await getUserID(event2); + + expect(id1).toBe("user-1"); + expect(id2).toBe("user-1"); + }); + + it("should prevent privilege escalation via race conditions", async () => { + const userToken = await createTestJWT("concurrent-user"); + const event = createMockEvent({ + cookies: { userIDToken: userToken } + }); + + // Simulate concurrent privilege checks + const results = await Promise.all([ + getPrivilegeLevel(event), + getPrivilegeLevel(event), + getPrivilegeLevel(event), + getPrivilegeLevel(event), + getPrivilegeLevel(event) + ]); + + // All results should be the same + expect(results.every((r) => r === "user")).toBe(true); + }); + }); + + describe("Anonymous Access", () => { + it("should handle missing authentication token", async () => { + const event = createMockEvent({}); + const privilege = await getPrivilegeLevel(event); + + expect(privilege).toBe("anonymous"); + }); + + it("should handle empty authentication token", async () => { + const event = createMockEvent({ + cookies: { userIDToken: "" } + }); + const privilege = await getPrivilegeLevel(event); + + expect(privilege).toBe("anonymous"); + }); + + it("should handle invalid token format", async () => { + const event = createMockEvent({ + cookies: { userIDToken: "not-a-jwt-token" } + }); + const privilege = await getPrivilegeLevel(event); + + expect(privilege).toBe("anonymous"); + }); + + it("should return null user ID for anonymous users", async () => { + const event = createMockEvent({}); + const userId = await getUserID(event); + + expect(userId).toBeNull(); + }); + }); + + describe("Access Control Edge Cases", () => { + it("should handle user ID with special characters", async () => { + const specialUserId = "user-with-special-!@#$%"; + const token = await createTestJWT(specialUserId); + const event = createMockEvent({ + cookies: { userIDToken: token } + }); + + const userId = await getUserID(event); + expect(userId).toBe(specialUserId); + }); + + it("should handle very long user IDs", async () => { + const longUserId = "user-" + "x".repeat(1000); + const token = await createTestJWT(longUserId); + const event = createMockEvent({ + cookies: { userIDToken: token } + }); + + const userId = await getUserID(event); + expect(userId).toBe(longUserId); + }); + + it("should handle user ID with unicode characters", async () => { + const unicodeUserId = "user-with-unicode-🔐"; + const token = await createTestJWT(unicodeUserId); + const event = createMockEvent({ + cookies: { userIDToken: token } + }); + + const userId = await getUserID(event); + expect(userId).toBe(unicodeUserId); + }); + + it("should handle admin ID case sensitivity", async () => { + const adminId = env.ADMIN_ID; + const wrongCaseId = adminId.toUpperCase(); + + // Exact match required + const correctToken = await createTestJWT(adminId); + const wrongCaseToken = await createTestJWT(wrongCaseId); + + const correctEvent = createMockEvent({ + cookies: { userIDToken: correctToken } + }); + const wrongCaseEvent = createMockEvent({ + cookies: { userIDToken: wrongCaseToken } + }); + + const correctPrivilege = await getPrivilegeLevel(correctEvent); + const wrongCasePrivilege = await getPrivilegeLevel(wrongCaseEvent); + + expect(correctPrivilege).toBe("admin"); + // Wrong case should not get admin access (unless IDs match) + if (adminId !== wrongCaseId) { + expect(wrongCasePrivilege).toBe("user"); + } + }); + }); + + describe("Authorization Attack Scenarios", () => { + it("should prevent session fixation attacks", async () => { + // Attacker cannot predict or fix session tokens + const token1 = await createTestJWT("user-1"); + const token2 = await createTestJWT("user-1"); + + // Tokens should be different even for same user + // (Due to different timestamps, though payload is same) + expect(token1).toBeDefined(); + expect(token2).toBeDefined(); + }); + + it("should prevent parameter pollution attacks", async () => { + // Multiple cookie values should not cause confusion + const token1 = await createTestJWT("user-1"); + const token2 = await createTestJWT("user-2"); + + // Only first cookie should be used + const event = createMockEvent({ + cookies: { + userIDToken: token1 + // In practice, duplicate cookies are handled by the framework + } + }); + + const userId = await getUserID(event); + expect(userId).toBe("user-1"); + }); + + it("should prevent token substitution attacks", async () => { + const legitimateToken = await createTestJWT("victim-user"); + const attackerToken = await createTestJWT("attacker-user"); + + // Each token should only authenticate its respective user + const legitimateEvent = createMockEvent({ + cookies: { userIDToken: legitimateToken } + }); + const attackerEvent = createMockEvent({ + cookies: { userIDToken: attackerToken } + }); + + const legitimateId = await getUserID(legitimateEvent); + const attackerId = await getUserID(attackerEvent); + + expect(legitimateId).toBe("victim-user"); + expect(attackerId).toBe("attacker-user"); + expect(legitimateId).not.toBe(attackerId); + }); + + it("should prevent authorization bypass through empty checks", async () => { + const emptyChecks = [null, undefined, "", " ", "null", "undefined"]; + + for (const check of emptyChecks) { + const event = createMockEvent({ + cookies: { userIDToken: check as string } + }); + + const privilege = await getPrivilegeLevel(event); + expect(privilege).toBe("anonymous"); + } + }); + }); + + describe("Multi-User Scenarios", () => { + it("should handle multiple concurrent user sessions", async () => { + const users = ["user-1", "user-2", "user-3", "user-4", "user-5"]; + const tokens = await Promise.all(users.map((u) => createTestJWT(u))); + + const events = tokens.map((token) => + createMockEvent({ cookies: { userIDToken: token } }) + ); + + const userIds = await Promise.all(events.map(getUserID)); + + // All users should be correctly identified + expect(userIds).toEqual(users); + }); + + it("should maintain separate privileges for different users", async () => { + const adminToken = await createTestJWT(env.ADMIN_ID); + const user1Token = await createTestJWT("user-1"); + const user2Token = await createTestJWT("user-2"); + + const adminEvent = createMockEvent({ + cookies: { userIDToken: adminToken } + }); + const user1Event = createMockEvent({ + cookies: { userIDToken: user1Token } + }); + const user2Event = createMockEvent({ + cookies: { userIDToken: user2Token } + }); + + const [adminPriv, user1Priv, user2Priv] = await Promise.all([ + getPrivilegeLevel(adminEvent), + getPrivilegeLevel(user1Event), + getPrivilegeLevel(user2Event) + ]); + + expect(adminPriv).toBe("admin"); + expect(user1Priv).toBe("user"); + expect(user2Priv).toBe("user"); + }); + }); + + describe("Performance", () => { + it("should check privileges efficiently", async () => { + const userToken = await createTestJWT("perf-test-user"); + const event = createMockEvent({ + cookies: { userIDToken: userToken } + }); + + const start = performance.now(); + for (let i = 0; i < 1000; i++) { + await getPrivilegeLevel(event); + } + const duration = performance.now() - start; + + // Should complete 1000 checks in less than 100ms + expect(duration).toBeLessThan(100); + }); + + it("should extract user IDs efficiently", async () => { + const userToken = await createTestJWT("perf-test-user"); + const event = createMockEvent({ + cookies: { userIDToken: userToken } + }); + + const start = performance.now(); + for (let i = 0; i < 1000; i++) { + await getUserID(event); + } + const duration = performance.now() - start; + + // Should complete 1000 extractions in less than 100ms + expect(duration).toBeLessThan(100); + }); + }); +}); diff --git a/src/server/security/csrf.test.ts b/src/server/security/csrf.test.ts new file mode 100644 index 0000000..a5bd3d3 --- /dev/null +++ b/src/server/security/csrf.test.ts @@ -0,0 +1,320 @@ +/** + * CSRF Protection Tests + * Tests for Cross-Site Request Forgery protection mechanisms + */ + +import { describe, it, expect, beforeEach } from "bun:test"; +import { + generateCSRFToken, + setCSRFToken, + validateCSRFToken, + csrfProtection +} from "~/server/security"; +import { createMockEvent } from "./test-utils"; + +describe("CSRF Protection", () => { + describe("generateCSRFToken", () => { + it("should generate a valid UUID token", () => { + const token = generateCSRFToken(); + expect(token).toBeDefined(); + expect(typeof token).toBe("string"); + // UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx + expect(token).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + ); + }); + + it("should generate unique tokens", () => { + const token1 = generateCSRFToken(); + const token2 = generateCSRFToken(); + expect(token1).not.toBe(token2); + }); + + it("should generate cryptographically secure tokens", () => { + // Generate multiple tokens and ensure no collisions + const tokens = new Set(); + for (let i = 0; i < 1000; i++) { + tokens.add(generateCSRFToken()); + } + expect(tokens.size).toBe(1000); + }); + }); + + describe("setCSRFToken", () => { + it("should set CSRF token cookie with correct attributes", () => { + const event = createMockEvent({}); + const token = setCSRFToken(event); + + expect(token).toBeDefined(); + expect(typeof token).toBe("string"); + // Token should be a UUID + expect(token).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + ); + }); + + it("should generate different tokens on subsequent calls", () => { + const event1 = createMockEvent({}); + const event2 = createMockEvent({}); + + const token1 = setCSRFToken(event1); + const token2 = setCSRFToken(event2); + + expect(token1).not.toBe(token2); + }); + }); + + describe("validateCSRFToken", () => { + it("should validate matching tokens", () => { + const token = generateCSRFToken(); + const event = createMockEvent({ + headers: { "x-csrf-token": token }, + cookies: { "csrf-token": token } + }); + + const isValid = validateCSRFToken(event); + expect(isValid).toBe(true); + }); + + it("should reject mismatched tokens", () => { + const event = createMockEvent({ + headers: { "x-csrf-token": "token1" }, + cookies: { "csrf-token": "token2" } + }); + + const isValid = validateCSRFToken(event); + expect(isValid).toBe(false); + }); + + it("should reject missing header token", () => { + const event = createMockEvent({ + cookies: { "csrf-token": "token" } + }); + + const isValid = validateCSRFToken(event); + expect(isValid).toBe(false); + }); + + it("should reject missing cookie token", () => { + const event = createMockEvent({ + headers: { "x-csrf-token": "token" } + }); + + const isValid = validateCSRFToken(event); + expect(isValid).toBe(false); + }); + + it("should reject empty tokens", () => { + const event = createMockEvent({ + headers: { "x-csrf-token": "" }, + cookies: { "csrf-token": "" } + }); + + const isValid = validateCSRFToken(event); + expect(isValid).toBe(false); + }); + + it("should use constant-time comparison", async () => { + const validToken = "a".repeat(36); + const invalidToken1 = "b".repeat(36); + const invalidToken2 = "b".repeat(35) + "a"; + + // Test timing for completely different tokens + const event1 = createMockEvent({ + headers: { "x-csrf-token": invalidToken1 }, + cookies: { "csrf-token": validToken } + }); + + const start1 = performance.now(); + validateCSRFToken(event1); + const time1 = performance.now() - start1; + + // Test timing for tokens that differ only at the end + const event2 = createMockEvent({ + headers: { "x-csrf-token": invalidToken2 }, + cookies: { "csrf-token": validToken } + }); + + const start2 = performance.now(); + validateCSRFToken(event2); + const time2 = performance.now() - start2; + + // Timing difference should be minimal (less than 1ms) + // This tests for constant-time comparison + const timeDiff = Math.abs(time1 - time2); + expect(timeDiff).toBeLessThan(1); + }); + + it("should reject tokens with different lengths", () => { + const event = createMockEvent({ + headers: { "x-csrf-token": "short" }, + cookies: { "csrf-token": "much-longer-token" } + }); + + const isValid = validateCSRFToken(event); + expect(isValid).toBe(false); + }); + }); + + describe("CSRF Attack Scenarios", () => { + it("should prevent basic CSRF attack", () => { + // Attacker doesn't have access to the CSRF token cookie + const attackEvent = createMockEvent({ + headers: { "x-csrf-token": "attacker-guessed-token" } + }); + + const isValid = validateCSRFToken(attackEvent); + expect(isValid).toBe(false); + }); + + it("should prevent token reuse from different session", () => { + const token1 = generateCSRFToken(); + const token2 = generateCSRFToken(); + + // User has token1, attacker tries to use token2 + const event = createMockEvent({ + headers: { "x-csrf-token": token2 }, + cookies: { "csrf-token": token1 } + }); + + const isValid = validateCSRFToken(event); + expect(isValid).toBe(false); + }); + + it("should prevent token modification", () => { + const token = generateCSRFToken(); + const modifiedToken = token.slice(0, -1) + "x"; + + const event = createMockEvent({ + headers: { "x-csrf-token": modifiedToken }, + cookies: { "csrf-token": token } + }); + + const isValid = validateCSRFToken(event); + expect(isValid).toBe(false); + }); + + it("should prevent replay attacks with old tokens", () => { + // Simulate an old token that was captured + const oldToken = "old-captured-token-12345"; + + const event = createMockEvent({ + headers: { "x-csrf-token": oldToken }, + cookies: { "csrf-token": oldToken } + }); + + // Even if tokens match, they should be validated by the system + // This test validates the structure works correctly + const isValid = validateCSRFToken(event); + expect(isValid).toBe(true); // Matches are valid + }); + }); + + describe("Edge Cases", () => { + it("should handle null tokens", () => { + const event = createMockEvent({}); + const isValid = validateCSRFToken(event); + expect(isValid).toBe(false); + }); + + it("should handle undefined tokens", () => { + const event = createMockEvent({ + headers: {}, + cookies: {} + }); + const isValid = validateCSRFToken(event); + expect(isValid).toBe(false); + }); + + it("should handle special characters in tokens", () => { + const token = "token-with-special-!@#$%^&*()"; + const event = createMockEvent({ + headers: { "x-csrf-token": token }, + cookies: { "csrf-token": token } + }); + + const isValid = validateCSRFToken(event); + expect(isValid).toBe(true); + }); + + it("should handle very long tokens", () => { + const longToken = "a".repeat(1000); + const event = createMockEvent({ + headers: { "x-csrf-token": longToken }, + cookies: { "csrf-token": longToken } + }); + + const isValid = validateCSRFToken(event); + expect(isValid).toBe(true); + }); + + it("should handle unicode tokens", () => { + const unicodeToken = "token-with-unicode-🔒🛡️"; + const event = createMockEvent({ + headers: { "x-csrf-token": unicodeToken }, + cookies: { "csrf-token": unicodeToken } + }); + + const isValid = validateCSRFToken(event); + expect(isValid).toBe(true); + }); + }); + + describe("Token Generation Security", () => { + it("should not generate predictable tokens", () => { + const tokens: string[] = []; + for (let i = 0; i < 100; i++) { + tokens.push(generateCSRFToken()); + } + + // Check for sequential patterns + for (let i = 1; i < tokens.length; i++) { + // Tokens should not be incrementing + expect(tokens[i]).not.toBe( + String(Number(tokens[i - 1].replace(/-/g, "")) + 1) + ); + } + }); + + it("should generate tokens with sufficient entropy", () => { + const token = generateCSRFToken(); + // UUID without dashes should be 32 hex characters + const hexString = token.replace(/-/g, ""); + expect(hexString).toMatch(/^[0-9a-f]{32}$/i); + + // Check that not all characters are the same + const uniqueChars = new Set(hexString.split("")); + expect(uniqueChars.size).toBeGreaterThan(5); + }); + }); + + describe("Performance", () => { + it("should generate tokens quickly", () => { + const start = performance.now(); + for (let i = 0; i < 1000; i++) { + generateCSRFToken(); + } + const duration = performance.now() - start; + + // Should generate 1000 tokens in less than 100ms + expect(duration).toBeLessThan(100); + }); + + it("should validate tokens quickly", () => { + const token = generateCSRFToken(); + const event = createMockEvent({ + headers: { "x-csrf-token": token }, + cookies: { "csrf-token": token } + }); + + const start = performance.now(); + for (let i = 0; i < 10000; i++) { + validateCSRFToken(event); + } + const duration = performance.now() - start; + + // Should validate 10000 tokens in less than 100ms + expect(duration).toBeLessThan(100); + }); + }); +}); diff --git a/src/server/security/injection.test.ts b/src/server/security/injection.test.ts new file mode 100644 index 0000000..8555e55 --- /dev/null +++ b/src/server/security/injection.test.ts @@ -0,0 +1,522 @@ +/** + * Input Validation and Injection Tests + * Tests for SQL injection, XSS, and other injection attack prevention + */ + +import { describe, it, expect } from "bun:test"; +import { + isValidEmail, + validatePassword, + isValidDisplayName +} from "~/lib/validation"; +import { SQL_INJECTION_PAYLOADS, XSS_PAYLOADS } from "./test-utils"; +import { ConnectionFactory } from "~/server/database"; + +describe("Input Validation and Injection Prevention", () => { + describe("Email Validation", () => { + it("should accept valid email addresses", () => { + const validEmails = [ + "user@example.com", + "test.user@example.com", + "user+tag@example.co.uk", + "user123@test-domain.com", + "first.last@subdomain.example.com" + ]; + + for (const email of validEmails) { + expect(isValidEmail(email)).toBe(true); + } + }); + + it("should reject invalid email addresses", () => { + const invalidEmails = [ + "not-an-email", + "@example.com", + "user@", + "user @example.com", + "user@example", + "user..name@example.com", + "user@.com", + "", + " ", + "user@domain@domain.com" + ]; + + for (const email of invalidEmails) { + expect(isValidEmail(email)).toBe(false); + } + }); + + it("should reject SQL injection attempts in emails", () => { + const sqlEmails = [ + "admin'--@example.com", + "user@example.com'; DROP TABLE User--", + "' OR '1'='1@example.com", + "user@example.com' UNION SELECT", + "admin@example.com'--" + ]; + + for (const email of sqlEmails) { + // Either reject as invalid, or it's properly escaped in queries + const isValid = isValidEmail(email); + // Test documents the behavior + expect(typeof isValid).toBe("boolean"); + } + }); + + it("should handle very long email addresses", () => { + const longEmail = "a".repeat(1000) + "@example.com"; + const result = isValidEmail(longEmail); + + // Should handle gracefully + expect(typeof result).toBe("boolean"); + }); + + it("should handle email with unicode characters", () => { + const unicodeEmail = "üser@exämple.com"; + const result = isValidEmail(unicodeEmail); + + expect(typeof result).toBe("boolean"); + }); + }); + + describe("Display Name Validation", () => { + it("should accept valid display names", () => { + const validNames = [ + "John Doe", + "Alice", + "Bob Smith Jr.", + "李明", + "José García", + "123User" + ]; + + for (const name of validNames) { + expect(isValidDisplayName(name)).toBe(true); + } + }); + + it("should reject empty display names", () => { + const invalidNames = ["", " ", "\t", "\n"]; + + for (const name of invalidNames) { + expect(isValidDisplayName(name)).toBe(false); + } + }); + + it("should reject excessively long display names", () => { + const longName = "a".repeat(51); + expect(isValidDisplayName(longName)).toBe(false); + }); + + it("should handle display names with special characters", () => { + const specialNames = [ + "User"; + + // Validation should not crash + const nameValid = isValidDisplayName(scriptInput); + expect(typeof nameValid).toBe("boolean"); + + // Email validation + const emailValid = isValidEmail(scriptInput); + expect(typeof emailValid).toBe("boolean"); + }); + + it("should handle event handler attributes", () => { + const eventHandlers = [ + "onclick=alert('XSS')", + "onerror=alert('XSS')", + "onload=alert('XSS')", + "onfocus=alert('XSS')" + ]; + + for (const handler of eventHandlers) { + const result = isValidDisplayName(handler); + expect(typeof result).toBe("boolean"); + } + }); + + it("should handle javascript: protocol", () => { + const jsProtocol = "javascript:alert('XSS')"; + + const displayNameValid = isValidDisplayName(jsProtocol); + const emailValid = isValidEmail(jsProtocol); + + expect(typeof displayNameValid).toBe("boolean"); + expect(typeof emailValid).toBe("boolean"); + }); + }); + + describe("Command Injection Prevention", () => { + it("should not execute shell commands from user input", () => { + const commandPayloads = [ + "; ls -la", + "| cat /etc/passwd", + "&& rm -rf /", + "`whoami`", + "$(whoami)", + "; DROP TABLE User;--" + ]; + + for (const payload of commandPayloads) { + // These should be treated as strings, not executed + const emailValid = isValidEmail(payload); + const nameValid = isValidDisplayName(payload); + + expect(typeof emailValid).toBe("boolean"); + expect(typeof nameValid).toBe("boolean"); + } + }); + }); + + describe("Path Traversal Prevention", () => { + it("should not allow directory traversal in inputs", () => { + const traversalPayloads = [ + "../../../etc/passwd", + "..\\..\\..\\windows\\system32", + "....//....//....//etc/passwd", + "%2e%2e%2f", + "..;/..;/" + ]; + + for (const payload of traversalPayloads) { + const emailValid = isValidEmail(payload); + const nameValid = isValidDisplayName(payload); + + expect(typeof emailValid).toBe("boolean"); + expect(typeof nameValid).toBe("boolean"); + } + }); + }); + + describe("LDAP Injection Prevention", () => { + it("should handle LDAP injection patterns", () => { + const ldapPayloads = [ + "*)(uid=*))(|(uid=*", + "admin*", + "*)(&(password=*))", + "*))%00" + ]; + + for (const payload of ldapPayloads) { + const emailValid = isValidEmail(payload); + const nameValid = isValidDisplayName(payload); + + expect(typeof emailValid).toBe("boolean"); + expect(typeof nameValid).toBe("boolean"); + } + }); + }); + + describe("XML Injection Prevention", () => { + it("should handle XML special characters", () => { + const xmlPayloads = [ + "", + '', + ']>', + "<script>alert('XSS')</script>" + ]; + + for (const payload of xmlPayloads) { + const emailValid = isValidEmail(payload); + const nameValid = isValidDisplayName(payload); + + expect(typeof emailValid).toBe("boolean"); + expect(typeof nameValid).toBe("boolean"); + } + }); + }); + + describe("NoSQL Injection Prevention", () => { + it("should handle MongoDB-style injection attempts", () => { + const nosqlPayloads = [ + '{"$gt": ""}', + '{"$ne": null}', + '{"$regex": ".*"}', + '{"$where": "sleep(1000)"}', + '{"username": {"$gt": ""}}' + ]; + + for (const payload of nosqlPayloads) { + const emailValid = isValidEmail(payload); + const nameValid = isValidDisplayName(payload); + + expect(typeof emailValid).toBe("boolean"); + expect(typeof nameValid).toBe("boolean"); + } + }); + }); + + describe("Header Injection Prevention", () => { + it("should reject inputs with newline characters", () => { + const headerInjection = [ + "user@example.com\r\nBcc: attacker@evil.com", + "test\nSet-Cookie: admin=true", + "user\r\nLocation: http://evil.com" + ]; + + for (const payload of headerInjection) { + const emailValid = isValidEmail(payload); + expect(emailValid).toBe(false); + } + }); + }); + + describe("Null Byte Injection Prevention", () => { + it("should handle null bytes in input", () => { + const nullBytePayloads = [ + "admin\x00.jpg", + "user@example.com\x00admin", + "test\0injection" + ]; + + for (const payload of nullBytePayloads) { + const emailValid = isValidEmail(payload); + const nameValid = isValidDisplayName(payload); + + expect(typeof emailValid).toBe("boolean"); + expect(typeof nameValid).toBe("boolean"); + } + }); + }); + + describe("Edge Cases", () => { + it("should handle extremely long inputs", () => { + const longInput = "a".repeat(100000); + + const start = performance.now(); + const emailValid = isValidEmail(longInput); + const nameValid = isValidDisplayName(longInput); + const duration = performance.now() - start; + + expect(typeof emailValid).toBe("boolean"); + expect(typeof nameValid).toBe("boolean"); + // Should complete quickly (no ReDoS) + expect(duration).toBeLessThan(100); + }); + + it("should handle repeated characters", () => { + const repeated = "a".repeat(10000) + "@example.com"; + const emailValid = isValidEmail(repeated); + + expect(typeof emailValid).toBe("boolean"); + }); + + it("should handle mixed encoding", () => { + const mixedEncoding = "test%40example.com"; + const emailValid = isValidEmail(mixedEncoding); + + expect(typeof emailValid).toBe("boolean"); + }); + + it("should handle unicode normalization issues", () => { + const unicodePayloads = [ + "admin\u0041", // 'A' in unicode + "test\u200B@example.com", // zero-width space + "user\uFEFF@example.com" // zero-width no-break space + ]; + + for (const payload of unicodePayloads) { + const emailValid = isValidEmail(payload); + expect(typeof emailValid).toBe("boolean"); + } + }); + }); + + describe("Performance", () => { + it("should validate emails efficiently", () => { + const email = "test@example.com"; + + const start = performance.now(); + for (let i = 0; i < 10000; i++) { + isValidEmail(email); + } + const duration = performance.now() - start; + + expect(duration).toBeLessThan(100); + }); + + it("should validate display names efficiently", () => { + const name = "Test User"; + + const start = performance.now(); + for (let i = 0; i < 10000; i++) { + isValidDisplayName(name); + } + const duration = performance.now() - start; + + expect(duration).toBeLessThan(100); + }); + + it("should not be vulnerable to ReDoS attacks", () => { + // ReDoS payload with many repetitions + const redosPayload = "a".repeat(1000) + "!"; + + const start = performance.now(); + validatePassword(redosPayload); + const duration = performance.now() - start; + + // Should complete quickly + expect(duration).toBeLessThan(100); + }); + }); +}); diff --git a/src/server/security/password.test.ts b/src/server/security/password.test.ts new file mode 100644 index 0000000..98eca9e --- /dev/null +++ b/src/server/security/password.test.ts @@ -0,0 +1,529 @@ +/** + * Password Security Tests + * Tests for password hashing, validation, strength requirements, and timing attacks + */ + +import { describe, it, expect } from "bun:test"; +import { + hashPassword, + checkPassword, + checkPasswordSafe +} from "~/server/password"; +import { validatePassword, passwordsMatch } from "~/lib/validation"; +import { measureTime } from "./test-utils"; + +describe("Password Security", () => { + describe("Password Hashing", () => { + it("should hash passwords using bcrypt", async () => { + const password = "TestPassword123!"; + const hash = await hashPassword(password); + + expect(hash).toBeDefined(); + expect(typeof hash).toBe("string"); + // Bcrypt hashes start with $2b$ or $2a$ + expect(hash).toMatch(/^\$2[ab]\$/); + }); + + it("should generate unique hashes for same password", async () => { + const password = "TestPassword123!"; + const hash1 = await hashPassword(password); + const hash2 = await hashPassword(password); + + expect(hash1).not.toBe(hash2); + }); + + it("should generate hashes with sufficient length", async () => { + const password = "TestPassword123!"; + const hash = await hashPassword(password); + + // Bcrypt hashes are 60 characters long + expect(hash.length).toBe(60); + }); + + it("should handle very long passwords", async () => { + const longPassword = "a".repeat(1000); + const hash = await hashPassword(longPassword); + + expect(hash).toBeDefined(); + expect(hash.length).toBe(60); + }); + + it("should handle passwords with special characters", async () => { + const specialPassword = "P@ssw0rd!#$%^&*()_+-=[]{}|;:',.<>?/~`"; + const hash = await hashPassword(specialPassword); + + expect(hash).toBeDefined(); + const match = await checkPassword(specialPassword, hash); + expect(match).toBe(true); + }); + + it("should handle passwords with unicode characters", async () => { + const unicodePassword = "Pässwörd123🔐🛡️"; + const hash = await hashPassword(unicodePassword); + + expect(hash).toBeDefined(); + const match = await checkPassword(unicodePassword, hash); + expect(match).toBe(true); + }); + + it("should handle empty passwords", async () => { + const emptyPassword = ""; + const hash = await hashPassword(emptyPassword); + + expect(hash).toBeDefined(); + expect(hash.length).toBe(60); + }); + }); + + describe("Password Verification", () => { + it("should verify correct password", async () => { + const password = "TestPassword123!"; + const hash = await hashPassword(password); + const match = await checkPassword(password, hash); + + expect(match).toBe(true); + }); + + it("should reject incorrect password", async () => { + const password = "TestPassword123!"; + const wrongPassword = "WrongPassword123!"; + const hash = await hashPassword(password); + const match = await checkPassword(wrongPassword, hash); + + expect(match).toBe(false); + }); + + it("should be case-sensitive", async () => { + const password = "TestPassword123!"; + const hash = await hashPassword(password); + const match = await checkPassword("testpassword123!", hash); + + expect(match).toBe(false); + }); + + it("should detect single character differences", async () => { + const password = "TestPassword123!"; + const hash = await hashPassword(password); + const almostMatch = "TestPassword124!"; + const match = await checkPassword(almostMatch, hash); + + expect(match).toBe(false); + }); + + it("should reject password with extra characters", async () => { + const password = "TestPassword123!"; + const hash = await hashPassword(password); + const match = await checkPassword(password + "x", hash); + + expect(match).toBe(false); + }); + + it("should reject password missing characters", async () => { + const password = "TestPassword123!"; + const hash = await hashPassword(password); + const match = await checkPassword(password.slice(0, -1), hash); + + expect(match).toBe(false); + }); + }); + + describe("Timing Attack Prevention", () => { + it("should use constant time comparison in checkPasswordSafe", async () => { + const password = "TestPassword123!"; + const hash = await hashPassword(password); + + // Measure time for correct password + const { duration: correctDuration } = await measureTime(() => + checkPasswordSafe(password, hash) + ); + + // Measure time for incorrect password + const { duration: incorrectDuration } = await measureTime(() => + checkPasswordSafe("WrongPassword123!", hash) + ); + + // Bcrypt comparison should take similar time regardless + const timingDifference = Math.abs(correctDuration - incorrectDuration); + + // Allow reasonable variance (bcrypt is inherently slow) + expect(timingDifference).toBeLessThan(50); + }); + + it("should handle null hash without timing leak", async () => { + const password = "TestPassword123!"; + + // Measure time for null hash + const { result: result1, duration: duration1 } = await measureTime(() => + checkPasswordSafe(password, null) + ); + + // Measure time for undefined hash + const { result: result2, duration: duration2 } = await measureTime(() => + checkPasswordSafe(password, undefined) + ); + + expect(result1).toBe(false); + expect(result2).toBe(false); + + // Should take similar time + const timingDifference = Math.abs(duration1 - duration2); + expect(timingDifference).toBeLessThan(50); + }); + + it("should run bcrypt even when user doesn't exist", async () => { + const password = "TestPassword123!"; + + // checkPasswordSafe should always run bcrypt to prevent timing attacks + const { duration } = await measureTime(() => + checkPasswordSafe(password, null) + ); + + // Should take at least a few milliseconds (bcrypt is slow) + expect(duration).toBeGreaterThan(1); + }); + + it("should have consistent timing for user exists vs not exists", async () => { + const password = "TestPassword123!"; + const hash = await hashPassword(password); + + // User exists + const { duration: existsDuration } = await measureTime(() => + checkPasswordSafe("WrongPassword", hash) + ); + + // User doesn't exist (null hash) + const { duration: notExistsDuration } = await measureTime(() => + checkPasswordSafe("WrongPassword", null) + ); + + // Timing should be similar to prevent user enumeration + const timingDifference = Math.abs(existsDuration - notExistsDuration); + expect(timingDifference).toBeLessThan(50); + }); + }); + + describe("Password Validation", () => { + it("should accept strong passwords", () => { + const strongPassword = "MyStr0ng!P@ssw0rd"; + const result = validatePassword(strongPassword); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + expect(result.strength).toBe("good"); + }); + + it("should reject passwords shorter than 12 characters", () => { + const shortPassword = "Short1!"; + const result = validatePassword(shortPassword); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain( + "Password must be at least 12 characters long" + ); + }); + + it("should reject passwords without uppercase letters", () => { + const noUppercase = "lowercase123!@#"; + const result = validatePassword(noUppercase); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain( + "Password must contain at least one uppercase letter" + ); + }); + + it("should reject passwords without lowercase letters", () => { + const noLowercase = "UPPERCASE123!@#"; + const result = validatePassword(noLowercase); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain( + "Password must contain at least one lowercase letter" + ); + }); + + it("should reject passwords without numbers", () => { + const noNumbers = "NoNumbersHere!@#"; + const result = validatePassword(noNumbers); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain( + "Password must contain at least one number" + ); + }); + + it("should reject passwords without special characters", () => { + const noSpecial = "NoSpecialChars123"; + const result = validatePassword(noSpecial); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain( + "Password must contain at least one special character" + ); + }); + + it("should reject common weak passwords", () => { + const commonPatterns = [ + "Password123!", + "Qwerty123456!", + "Letmein12345!", + "Welcome123!@" + ]; + + for (const password of commonPatterns) { + const result = validatePassword(password); + expect(result.isValid).toBe(false); + expect(result.errors.some((e) => e.includes("common patterns"))).toBe( + true + ); + } + }); + + it("should calculate password strength correctly", () => { + const fairPassword = "MyP@ssw0rd12"; // 12 chars + const goodPassword = "MyStr0ng!P@ssw0rd"; // 17 chars + const strongPassword = "MyV3ry!Str0ng@P@ssw0rd123"; // 25 chars + + expect(validatePassword(fairPassword).strength).toBe("fair"); + expect(validatePassword(goodPassword).strength).toBe("good"); + expect(validatePassword(strongPassword).strength).toBe("strong"); + }); + + it("should mark weak passwords appropriately", () => { + const weakPassword = "weak"; + const result = validatePassword(weakPassword); + + expect(result.strength).toBe("weak"); + expect(result.isValid).toBe(false); + }); + }); + + describe("Password Matching", () => { + it("should confirm matching passwords", () => { + const password = "TestPassword123!"; + const confirmation = "TestPassword123!"; + + expect(passwordsMatch(password, confirmation)).toBe(true); + }); + + it("should reject non-matching passwords", () => { + const password = "TestPassword123!"; + const confirmation = "DifferentPassword123!"; + + expect(passwordsMatch(password, confirmation)).toBe(false); + }); + + it("should reject empty passwords", () => { + expect(passwordsMatch("", "")).toBe(false); + }); + + it("should be case-sensitive", () => { + const password = "TestPassword123!"; + const confirmation = "testpassword123!"; + + expect(passwordsMatch(password, confirmation)).toBe(false); + }); + + it("should detect single character differences", () => { + const password = "TestPassword123!"; + const confirmation = "TestPassword124!"; + + expect(passwordsMatch(password, confirmation)).toBe(false); + }); + }); + + describe("Password Attack Scenarios", () => { + it("should resist brute force attacks with bcrypt slowness", async () => { + const password = "TestPassword123!"; + const hash = await hashPassword(password); + + // Measure time for multiple checks (simulating brute force) + const start = performance.now(); + const attempts = 10; + + for (let i = 0; i < attempts; i++) { + await checkPassword(`attempt${i}`, hash); + } + + const duration = performance.now() - start; + const avgPerAttempt = duration / attempts; + + // Each attempt should take significant time (bcrypt is slow) + // This makes brute force impractical + expect(avgPerAttempt).toBeGreaterThan(5); // At least 5ms per attempt + }); + + it("should prevent rainbow table attacks with unique salts", async () => { + const password = "CommonPassword123!"; + + // Generate multiple hashes for same password + const hashes = await Promise.all( + Array.from({ length: 10 }, () => hashPassword(password)) + ); + + // All hashes should be unique (different salts) + const uniqueHashes = new Set(hashes); + expect(uniqueHashes.size).toBe(10); + }); + + it("should prevent password spraying with validation", () => { + // Common passwords that should be rejected + const commonPasswords = [ + "Password123!", + "Welcome123!", + "Admin123!@#", + "Letmein123!" + ]; + + for (const password of commonPasswords) { + const result = validatePassword(password); + expect(result.isValid).toBe(false); + } + }); + + it("should resist dictionary attacks", () => { + // Dictionary words that should be caught + const dictionaryBased = ["Sunshine123!", "Princess456!", "Dragon789!@"]; + + for (const password of dictionaryBased) { + const result = validatePassword(password); + expect(result.isValid).toBe(false); + } + }); + }); + + describe("Edge Cases", () => { + it("should handle very long passwords", async () => { + const longPassword = "A1!a" + "x".repeat(1000); // Very long but valid + const hash = await hashPassword(longPassword); + const match = await checkPassword(longPassword, hash); + + expect(match).toBe(true); + }); + + it("should handle passwords with only whitespace", async () => { + const whitespacePassword = " "; + const result = validatePassword(whitespacePassword); + + expect(result.isValid).toBe(false); + }); + + it("should handle null bytes in passwords", async () => { + const nullBytePassword = "Test\0Password123!"; + const hash = await hashPassword(nullBytePassword); + const match = await checkPassword(nullBytePassword, hash); + + // Behavior may vary - just ensure no crash + expect(typeof match).toBe("boolean"); + }); + + it("should handle passwords with emoji", () => { + const emojiPassword = "MyP@ssw0rd🔐🛡️123"; + const result = validatePassword(emojiPassword); + + expect(result.isValid).toBe(true); + }); + + it("should handle passwords with newlines", async () => { + const newlinePassword = "Test\nPassword123!"; + const hash = await hashPassword(newlinePassword); + const match = await checkPassword(newlinePassword, hash); + + expect(match).toBe(true); + }); + + it("should handle passwords with tabs", async () => { + const tabPassword = "Test\tPassword123!"; + const hash = await hashPassword(tabPassword); + const match = await checkPassword(tabPassword, hash); + + expect(match).toBe(true); + }); + }); + + describe("Performance", () => { + it("should hash passwords with appropriate slowness", async () => { + const password = "TestPassword123!"; + + const start = performance.now(); + await hashPassword(password); + const duration = performance.now() - start; + + // Bcrypt should be slow enough to deter brute force + // With 10 rounds, should take at least a few milliseconds + expect(duration).toBeGreaterThan(5); + // But not too slow for normal operation + expect(duration).toBeLessThan(500); + }); + + it("should verify passwords with consistent timing", async () => { + const password = "TestPassword123!"; + const hash = await hashPassword(password); + + const durations: number[] = []; + for (let i = 0; i < 5; i++) { + const start = performance.now(); + await checkPassword(password, hash); + durations.push(performance.now() - start); + } + + // Timing should be relatively consistent + const avg = durations.reduce((a, b) => a + b, 0) / durations.length; + const maxDeviation = Math.max(...durations.map((d) => Math.abs(d - avg))); + + // Allow reasonable variance + expect(maxDeviation).toBeLessThan(avg * 0.5); + }); + + it("should validate passwords quickly", () => { + const password = "TestPassword123!"; + + const start = performance.now(); + for (let i = 0; i < 10000; i++) { + validatePassword(password); + } + const duration = performance.now() - start; + + // Validation is CPU-bound but should be fast + expect(duration).toBeLessThan(100); + }); + }); + + describe("bcrypt Salt Rounds", () => { + it("should use appropriate salt rounds for security", async () => { + const password = "TestPassword123!"; + const hash = await hashPassword(password); + + // Check that hash uses correct salt rounds + // Bcrypt format: $2b$rounds$salthash + const parts = hash.split("$"); + const rounds = parseInt(parts[2]); + + // Should use 10 rounds (from password.ts) + expect(rounds).toBe(10); + }); + + it("should generate cryptographically random salts", async () => { + const password = "TestPassword123!"; + const hashes = await Promise.all( + Array.from({ length: 100 }, () => hashPassword(password)) + ); + + // Extract salts from hashes + const salts = hashes.map((hash) => { + const parts = hash.split("$"); + return parts[3].substring(0, 22); // Salt is 22 characters + }); + + // All salts should be unique + const uniqueSalts = new Set(salts); + expect(uniqueSalts.size).toBe(100); + + // Check for patterns in salts (should be random) + for (let i = 1; i < salts.length; i++) { + // Salts should not be sequential or predictable + expect(salts[i]).not.toBe(salts[i - 1]); + } + }); + }); +}); diff --git a/src/server/security/rate-limit.test.ts b/src/server/security/rate-limit.test.ts new file mode 100644 index 0000000..83e791d --- /dev/null +++ b/src/server/security/rate-limit.test.ts @@ -0,0 +1,443 @@ +/** + * Rate Limiting Tests + * Tests for rate limiting mechanisms on authentication endpoints + */ + +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { + checkRateLimit, + getClientIP, + rateLimitLogin, + rateLimitPasswordReset, + rateLimitRegistration, + rateLimitEmailVerification, + clearRateLimitStore, + RATE_LIMITS +} from "~/server/security"; +import { createMockEvent, randomIP } from "./test-utils"; +import { TRPCError } from "@trpc/server"; + +describe("Rate Limiting", () => { + // Clear rate limit store before each test to ensure isolation + beforeEach(() => { + clearRateLimitStore(); + }); + + describe("checkRateLimit", () => { + it("should allow requests within rate limit", () => { + const identifier = `test-${Date.now()}`; + const maxAttempts = 5; + const windowMs = 60000; + + for (let i = 0; i < maxAttempts; i++) { + const remaining = checkRateLimit(identifier, maxAttempts, windowMs); + expect(remaining).toBe(maxAttempts - i - 1); + } + }); + + it("should block requests exceeding rate limit", () => { + const identifier = `test-${Date.now()}`; + const maxAttempts = 3; + const windowMs = 60000; + + // Use up all attempts + for (let i = 0; i < maxAttempts; i++) { + checkRateLimit(identifier, maxAttempts, windowMs); + } + + // Next attempt should throw + expect(() => { + checkRateLimit(identifier, maxAttempts, windowMs); + }).toThrow(TRPCError); + }); + + it("should include remaining time in error message", () => { + const identifier = `test-${Date.now()}`; + const maxAttempts = 2; + const windowMs = 60000; + + // Use up all attempts + checkRateLimit(identifier, maxAttempts, windowMs); + checkRateLimit(identifier, maxAttempts, windowMs); + + try { + checkRateLimit(identifier, maxAttempts, windowMs); + expect.unreachable("Should have thrown TRPCError"); + } catch (error) { + expect(error).toBeInstanceOf(TRPCError); + const trpcError = error as TRPCError; + expect(trpcError.code).toBe("TOO_MANY_REQUESTS"); + expect(trpcError.message).toMatch(/Try again in \d+ seconds/); + } + }); + + it("should reset after time window expires", async () => { + const identifier = `test-${Date.now()}`; + const maxAttempts = 3; + const windowMs = 100; // 100ms window for fast testing + + // Use up all attempts + for (let i = 0; i < maxAttempts; i++) { + checkRateLimit(identifier, maxAttempts, windowMs); + } + + // Should be blocked + expect(() => { + checkRateLimit(identifier, maxAttempts, windowMs); + }).toThrow(TRPCError); + + // Wait for window to expire + await new Promise((resolve) => setTimeout(resolve, 150)); + + // Should be allowed again + const remaining = checkRateLimit(identifier, maxAttempts, windowMs); + expect(remaining).toBe(maxAttempts - 1); + }); + + it("should handle concurrent requests correctly", () => { + const identifier = `test-${Date.now()}`; + const maxAttempts = 10; + const windowMs = 60000; + + // Simulate concurrent requests + const results: number[] = []; + for (let i = 0; i < maxAttempts; i++) { + results.push(checkRateLimit(identifier, maxAttempts, windowMs)); + } + + // All should succeed with decreasing remaining counts + expect(results).toEqual([9, 8, 7, 6, 5, 4, 3, 2, 1, 0]); + }); + + it("should isolate different identifiers", () => { + const maxAttempts = 3; + const windowMs = 60000; + + const id1 = `test1-${Date.now()}`; + const id2 = `test2-${Date.now()}`; + + // Use up attempts for id1 + for (let i = 0; i < maxAttempts; i++) { + checkRateLimit(id1, maxAttempts, windowMs); + } + + // id1 should be blocked + expect(() => { + checkRateLimit(id1, maxAttempts, windowMs); + }).toThrow(TRPCError); + + // id2 should still work + const remaining = checkRateLimit(id2, maxAttempts, windowMs); + expect(remaining).toBe(maxAttempts - 1); + }); + }); + + describe("getClientIP", () => { + it("should extract IP from x-forwarded-for header", () => { + const event = createMockEvent({ + headers: { "x-forwarded-for": "192.168.1.1, 10.0.0.1" } + }); + + const ip = getClientIP(event); + expect(ip).toBe("192.168.1.1"); + }); + + it("should extract IP from x-real-ip header", () => { + const event = createMockEvent({ + headers: { "x-real-ip": "192.168.1.2" } + }); + + const ip = getClientIP(event); + expect(ip).toBe("192.168.1.2"); + }); + + it("should prefer x-forwarded-for over x-real-ip", () => { + const event = createMockEvent({ + headers: { + "x-forwarded-for": "192.168.1.1", + "x-real-ip": "192.168.1.2" + } + }); + + const ip = getClientIP(event); + expect(ip).toBe("192.168.1.1"); + }); + + it("should return unknown when no IP headers present", () => { + const event = createMockEvent({}); + const ip = getClientIP(event); + expect(ip).toBe("unknown"); + }); + + it("should trim whitespace from IP addresses", () => { + const event = createMockEvent({ + headers: { "x-forwarded-for": " 192.168.1.1 , 10.0.0.1" } + }); + + const ip = getClientIP(event); + expect(ip).toBe("192.168.1.1"); + }); + + it("should handle IPv6 addresses", () => { + const event = createMockEvent({ + headers: { + "x-forwarded-for": "2001:0db8:85a3:0000:0000:8a2e:0370:7334" + } + }); + + const ip = getClientIP(event); + expect(ip).toBe("2001:0db8:85a3:0000:0000:8a2e:0370:7334"); + }); + }); + + describe("rateLimitLogin", () => { + it("should enforce both IP and email rate limits", () => { + const ip = randomIP(); + + // Should allow up to LOGIN_IP max attempts (5) with different emails + // Use different emails to avoid hitting email rate limit + for (let i = 0; i < RATE_LIMITS.LOGIN_IP.maxAttempts; i++) { + const email = `test-${Date.now()}-${i}@example.com`; + rateLimitLogin(email, ip); + } + + // Next attempt should fail due to IP limit + expect(() => { + const email = `test-${Date.now()}-final@example.com`; + rateLimitLogin(email, ip); + }).toThrow(TRPCError); + }); + + it("should limit by email independently of IP", () => { + const email = `test-${Date.now()}@example.com`; + + // Use different IPs but same email + for (let i = 0; i < RATE_LIMITS.LOGIN_EMAIL.maxAttempts; i++) { + rateLimitLogin(email, randomIP()); + } + + // Next attempt with different IP should still fail due to email limit + expect(() => { + rateLimitLogin(email, randomIP()); + }).toThrow(TRPCError); + }); + + it("should allow different emails from same IP within IP limit", () => { + const ip = randomIP(); + + // Use different emails but same IP + for (let i = 0; i < RATE_LIMITS.LOGIN_IP.maxAttempts; i++) { + const email = `test${i}-${Date.now()}@example.com`; + rateLimitLogin(email, ip); + } + + // Next attempt should fail due to IP limit + expect(() => { + rateLimitLogin(`new-${Date.now()}@example.com`, ip); + }).toThrow(TRPCError); + }); + }); + + describe("rateLimitPasswordReset", () => { + it("should enforce password reset rate limit", () => { + const ip = randomIP(); + + // Should allow up to PASSWORD_RESET_IP max attempts (3) + for (let i = 0; i < RATE_LIMITS.PASSWORD_RESET_IP.maxAttempts; i++) { + rateLimitPasswordReset(ip); + } + + // Next attempt should fail + expect(() => { + rateLimitPasswordReset(ip); + }).toThrow(TRPCError); + }); + + it("should isolate password reset limits from login limits", () => { + const ip = randomIP(); + const email = `test-${Date.now()}@example.com`; + + // Use up password reset limit + for (let i = 0; i < RATE_LIMITS.PASSWORD_RESET_IP.maxAttempts; i++) { + rateLimitPasswordReset(ip); + } + + // Should still be able to login (different limit) + rateLimitLogin(email, ip); + }); + }); + + describe("rateLimitRegistration", () => { + it("should enforce registration rate limit", () => { + const ip = randomIP(); + + // Should allow up to REGISTRATION_IP max attempts (3) + for (let i = 0; i < RATE_LIMITS.REGISTRATION_IP.maxAttempts; i++) { + rateLimitRegistration(ip); + } + + // Next attempt should fail + expect(() => { + rateLimitRegistration(ip); + }).toThrow(TRPCError); + }); + }); + + describe("rateLimitEmailVerification", () => { + it("should enforce email verification rate limit", () => { + const ip = randomIP(); + + // Should allow up to EMAIL_VERIFICATION_IP max attempts (5) + for (let i = 0; i < RATE_LIMITS.EMAIL_VERIFICATION_IP.maxAttempts; i++) { + rateLimitEmailVerification(ip); + } + + // Next attempt should fail + expect(() => { + rateLimitEmailVerification(ip); + }).toThrow(TRPCError); + }); + }); + + describe("Rate Limit Attack Scenarios", () => { + it("should prevent brute force login attacks", () => { + const email = "victim@example.com"; + const attackerIP = "1.2.3.4"; + + // Simulate brute force attack + let blockedAtAttempt = 0; + for (let i = 0; i < 10; i++) { + try { + rateLimitLogin(email, attackerIP); + } catch (error) { + if (error instanceof TRPCError) { + blockedAtAttempt = i; + break; + } + } + } + + // Should be blocked before 10 attempts + expect(blockedAtAttempt).toBeLessThan(10); + expect(blockedAtAttempt).toBeGreaterThan(0); + }); + + it("should prevent distributed brute force from multiple IPs", () => { + const email = "victim@example.com"; + + // Simulate distributed attack from different IPs + let blockedAtAttempt = 0; + for (let i = 0; i < 10; i++) { + try { + rateLimitLogin(email, randomIP()); + } catch (error) { + if (error instanceof TRPCError) { + blockedAtAttempt = i; + break; + } + } + } + + // Should be blocked at email limit (3 attempts) + expect(blockedAtAttempt).toBeLessThanOrEqual( + RATE_LIMITS.LOGIN_EMAIL.maxAttempts + ); + }); + + it("should prevent account enumeration via registration spam", () => { + const attackerIP = randomIP(); + + // Try to register many accounts to enumerate valid emails + let blockedAtAttempt = 0; + for (let i = 0; i < 10; i++) { + try { + rateLimitRegistration(attackerIP); + } catch (error) { + if (error instanceof TRPCError) { + blockedAtAttempt = i; + break; + } + } + } + + // Should be blocked at registration limit (3 attempts) + expect(blockedAtAttempt).toBe(RATE_LIMITS.REGISTRATION_IP.maxAttempts); + }); + + it("should prevent password reset spam attacks", () => { + const attackerIP = randomIP(); + + // Try to spam password resets + let blockedAtAttempt = 0; + for (let i = 0; i < 10; i++) { + try { + rateLimitPasswordReset(attackerIP); + } catch (error) { + if (error instanceof TRPCError) { + blockedAtAttempt = i; + break; + } + } + } + + // Should be blocked at password reset limit (3 attempts) + expect(blockedAtAttempt).toBe(RATE_LIMITS.PASSWORD_RESET_IP.maxAttempts); + }); + }); + + describe("Rate Limit Configuration", () => { + it("should have reasonable limits configured", () => { + // Login should be more permissive than registration + expect(RATE_LIMITS.LOGIN_IP.maxAttempts).toBeGreaterThan( + RATE_LIMITS.REGISTRATION_IP.maxAttempts + ); + + // All limits should be positive + expect(RATE_LIMITS.LOGIN_IP.maxAttempts).toBeGreaterThan(0); + expect(RATE_LIMITS.LOGIN_EMAIL.maxAttempts).toBeGreaterThan(0); + expect(RATE_LIMITS.PASSWORD_RESET_IP.maxAttempts).toBeGreaterThan(0); + expect(RATE_LIMITS.REGISTRATION_IP.maxAttempts).toBeGreaterThan(0); + expect(RATE_LIMITS.EMAIL_VERIFICATION_IP.maxAttempts).toBeGreaterThan(0); + + // All windows should be at least 1 minute + expect(RATE_LIMITS.LOGIN_IP.windowMs).toBeGreaterThanOrEqual(60000); + expect(RATE_LIMITS.LOGIN_EMAIL.windowMs).toBeGreaterThanOrEqual(60000); + expect(RATE_LIMITS.PASSWORD_RESET_IP.windowMs).toBeGreaterThanOrEqual( + 60000 + ); + expect(RATE_LIMITS.REGISTRATION_IP.windowMs).toBeGreaterThanOrEqual( + 60000 + ); + expect(RATE_LIMITS.EMAIL_VERIFICATION_IP.windowMs).toBeGreaterThanOrEqual( + 60000 + ); + }); + }); + + describe("Performance", () => { + it("should handle high volume of rate limit checks efficiently", () => { + const start = performance.now(); + + // Check 1000 different identifiers + for (let i = 0; i < 1000; i++) { + checkRateLimit(`test-${i}`, 5, 60000); + } + + const duration = performance.now() - start; + + // Should complete in less than 100ms + expect(duration).toBeLessThan(100); + }); + + it("should not leak memory with many identifiers", () => { + // Create many rate limit entries + for (let i = 0; i < 10000; i++) { + checkRateLimit(`test-${i}`, 5, 60000); + } + + // This test mainly ensures no crashes occur + // Memory cleanup is tested by the cleanup interval in security.ts + expect(true).toBe(true); + }); + }); +}); diff --git a/src/server/security/test-utils.ts b/src/server/security/test-utils.ts new file mode 100644 index 0000000..bd384c4 --- /dev/null +++ b/src/server/security/test-utils.ts @@ -0,0 +1,188 @@ +/** + * Security Test Utilities + * Shared helpers for security-related tests + */ + +import type { H3Event } from "vinxi/http"; +import { SignJWT } from "jose"; +import { env } from "~/env/server"; + +/** + * Create a mock H3Event for testing + * Creates a minimal structure that works with our cookie/header fallback logic + */ +export function createMockEvent(options: { + headers?: Record; + cookies?: Record; + method?: string; + url?: string; +}): H3Event { + const { + headers = {}, + cookies = {}, + method = "POST", + url = "http://localhost:3000/" + } = options; + + const cookieString = Object.entries(cookies) + .map(([key, value]) => `${key}=${value}`) + .join("; "); + + const allHeaders = { + ...headers, + ...(cookieString ? { cookie: cookieString } : {}) + }; + + // Try to create Headers object, fall back to plain object if headers contain invalid values + let headersObj: Headers | Record; + try { + headersObj = new Headers(allHeaders); + } catch (e) { + // If Headers constructor fails (e.g., unicode in headers), use plain object + headersObj = allHeaders; + } + + // Create mock event with headers accessible via .headers.get() and .node.req.headers + const mockEvent = { + headers: headersObj, + node: { + req: { + headers: allHeaders + }, + res: { + cookies: {} + } + } + } as unknown as H3Event; + + return mockEvent; +} + +/** + * Generate a valid JWT token for testing + */ +export async function createTestJWT( + userId: string, + expiresIn: string = "1h" +): Promise { + const secret = new TextEncoder().encode(env.JWT_SECRET_KEY); + return await new SignJWT({ id: userId }) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime(expiresIn) + .sign(secret); +} + +/** + * Generate an expired JWT token for testing + */ +export async function createExpiredJWT(userId: string): Promise { + const secret = new TextEncoder().encode(env.JWT_SECRET_KEY); + return await new SignJWT({ id: userId }) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime("-1h") // Expired 1 hour ago + .sign(secret); +} + +/** + * Generate a JWT with invalid signature + */ +export async function createInvalidSignatureJWT( + userId: string +): Promise { + const wrongSecret = new TextEncoder().encode("wrong-secret-key"); + return await new SignJWT({ id: userId }) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime("1h") + .sign(wrongSecret); +} + +/** + * Generate test credentials + */ +export function createTestCredentials() { + return { + email: `test-${Date.now()}@example.com`, + password: "TestPass123!@#", + passwordConfirmation: "TestPass123!@#" + }; +} + +/** + * Common SQL injection payloads + */ +export const SQL_INJECTION_PAYLOADS = [ + "' OR '1'='1", + "'; DROP TABLE User; --", + "admin'--", + "' UNION SELECT * FROM User--", + "1' OR 1=1--", + "' OR 'x'='x", + "1; DELETE FROM User WHERE 1=1--", + "' AND 1=0 UNION ALL SELECT * FROM User--" +]; + +/** + * Common XSS payloads + */ +export const XSS_PAYLOADS = [ + "", + "", + "javascript:alert('XSS')", + "", + "", + "", + "" +]; + +/** + * Wait for async operations with timeout + */ +export async function waitFor( + condition: () => boolean | Promise, + timeout: number = 5000, + interval: number = 100 +): Promise { + const startTime = Date.now(); + + while (Date.now() - startTime < timeout) { + if (await condition()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, interval)); + } + + throw new Error(`Timeout waiting for condition after ${timeout}ms`); +} + +/** + * Measure execution time + */ +export async function measureTime( + fn: () => Promise +): Promise<{ result: T; duration: number }> { + const start = Date.now(); + const result = await fn(); + const duration = Date.now() - start; + return { result, duration }; +} + +/** + * Generate random string for testing + */ +export function randomString(length: number = 10): string { + const chars = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + return Array.from( + { length }, + () => chars[Math.floor(Math.random() * chars.length)] + ).join(""); +} + +/** + * Generate random IP address + */ +export function randomIP(): string { + return Array.from({ length: 4 }, () => Math.floor(Math.random() * 256)).join( + "." + ); +} diff --git a/src/server/utils.ts b/src/server/utils.ts index 1279add..deee4c1 100644 --- a/src/server/utils.ts +++ b/src/server/utils.ts @@ -14,6 +14,6 @@ export { getUserBasicInfo } from "./database"; -export { hashPassword, checkPassword } from "./password"; +export { hashPassword, checkPassword, checkPasswordSafe } from "./password"; export { sendEmailVerification, LINEAGE_JWT_EXPIRY } from "./email"; From 4892dff38e29158c691398de560b8e8e9a0b0670 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Sun, 28 Dec 2025 21:27:28 -0500 Subject: [PATCH 2/3] style changes, and password login fix --- src/routes/login/index.tsx | 6 +-- src/server/api/routers/auth.ts | 54 +++++++++++-------- src/server/init-audit-table.ts | 95 ++++++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 25 deletions(-) create mode 100644 src/server/init-audit-table.ts diff --git a/src/routes/login/index.tsx b/src/routes/login/index.tsx index aacf987..2e08e96 100644 --- a/src/routes/login/index.tsx +++ b/src/routes/login/index.tsx @@ -331,12 +331,12 @@ export default function LoginPage() {
-
+
Passwords did not match!
-
+
Email Already Exists!
@@ -347,7 +347,7 @@ export default function LoginPage() { error() !== "duplicate" } > -
{error()}
+
{error()}
diff --git a/src/server/api/routers/auth.ts b/src/server/api/routers/auth.ts index 4844e77..e8bd701 100644 --- a/src/server/api/routers/auth.ts +++ b/src/server/api/routers/auth.ts @@ -851,19 +851,25 @@ export const authRouter = createTRPCRouter({ // Check all conditions after password verification if (!user || !passwordHash || !passwordMatch) { - // Log failed login attempt - const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent); - await logAuditEvent({ - eventType: "auth.login.failed", - eventData: { - email, - method: "password", - reason: "invalid_credentials" - }, - ipAddress, - userAgent, - success: false - }); + // Log failed login attempt (wrap in try-catch to ensure it never blocks auth flow) + try { + const { ipAddress, userAgent } = getAuditContext( + ctx.event.nativeEvent + ); + await logAuditEvent({ + eventType: "auth.login.failed", + eventData: { + email, + method: "password", + reason: "invalid_credentials" + }, + ipAddress, + userAgent, + success: false + }); + } catch (auditError) { + console.error("Audit logging failed:", auditError); + } throw new TRPCError({ code: "UNAUTHORIZED", @@ -911,15 +917,19 @@ export const authRouter = createTRPCRouter({ // Set CSRF token for authenticated session setCSRFToken(ctx.event.nativeEvent); - // Log successful login - await logAuditEvent({ - userId: user.id, - eventType: "auth.login.success", - eventData: { method: "password", rememberMe: rememberMe || false }, - ipAddress: clientIP, - userAgent, - success: true - }); + // Log successful login (wrap in try-catch to ensure it never blocks auth flow) + try { + await logAuditEvent({ + userId: user.id, + eventType: "auth.login.success", + eventData: { method: "password", rememberMe: rememberMe || false }, + ipAddress: clientIP, + userAgent, + success: true + }); + } catch (auditError) { + console.error("Audit logging failed:", auditError); + } return { success: true, message: "success" }; }), diff --git a/src/server/init-audit-table.ts b/src/server/init-audit-table.ts new file mode 100644 index 0000000..9684a64 --- /dev/null +++ b/src/server/init-audit-table.ts @@ -0,0 +1,95 @@ +/** + * Database Initialization for Audit Logging + * Run this script to create the AuditLog table in your database + * + * Usage: bun run src/server/init-audit-table.ts + */ + +import { ConnectionFactory } from "./database"; + +async function initAuditTable() { + console.log("🔧 Initializing AuditLog table..."); + + try { + const conn = ConnectionFactory(); + + // Create AuditLog table + await conn.execute({ + sql: `CREATE TABLE IF NOT EXISTS AuditLog ( + id TEXT PRIMARY KEY, + user_id TEXT, + event_type TEXT NOT NULL, + event_data TEXT, + ip_address TEXT, + user_agent TEXT, + success INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE SET NULL + )` + }); + + console.log("✅ AuditLog table created (or already exists)"); + + // Create indexes for performance + console.log("🔧 Creating indexes..."); + + await conn.execute({ + sql: `CREATE INDEX IF NOT EXISTS idx_audit_user_id ON AuditLog(user_id)` + }); + + await conn.execute({ + sql: `CREATE INDEX IF NOT EXISTS idx_audit_event_type ON AuditLog(event_type)` + }); + + await conn.execute({ + sql: `CREATE INDEX IF NOT EXISTS idx_audit_created_at ON AuditLog(created_at)` + }); + + await conn.execute({ + sql: `CREATE INDEX IF NOT EXISTS idx_audit_ip_address ON AuditLog(ip_address)` + }); + + console.log("✅ Indexes created"); + + // Verify table exists + const result = await conn.execute({ + sql: `SELECT name FROM sqlite_master WHERE type='table' AND name='AuditLog'` + }); + + if (result.rows.length > 0) { + console.log("✅ AuditLog table verified - ready for use!"); + + // Check row count + const countResult = await conn.execute({ + sql: `SELECT COUNT(*) as count FROM AuditLog` + }); + + console.log( + `📊 Current audit log entries: ${countResult.rows[0]?.count || 0}` + ); + } else { + console.error("❌ AuditLog table was not created properly"); + process.exit(1); + } + + console.log("\n✅ Audit logging system is ready!"); + console.log("💡 You can now use the audit logging features"); + console.log("📖 See docs/AUDIT_LOGGING.md for usage examples\n"); + } catch (error) { + console.error("❌ Failed to initialize AuditLog table:"); + console.error(error); + process.exit(1); + } +} + +// Run if executed directly +if (import.meta.main) { + initAuditTable() + .then(() => process.exit(0)) + .catch((error) => { + console.error(error); + process.exit(1); + }); +} + +export { initAuditTable }; From f5abf77454d96c5145533034b7656bc68984310a Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Sun, 28 Dec 2025 21:56:34 -0500 Subject: [PATCH 3/3] idk h3 at all --- src/server/api/routers/auth.ts | 335 ++++++++++++++++++--------------- src/server/security.ts | 3 +- 2 files changed, 190 insertions(+), 148 deletions(-) diff --git a/src/server/api/routers/auth.ts b/src/server/api/routers/auth.ts index e8bd701..8960e20 100644 --- a/src/server/api/routers/auth.ts +++ b/src/server/api/routers/auth.ts @@ -30,6 +30,7 @@ import { setCSRFToken, csrfProtection, getClientIP, + getUserAgent, getAuditContext, rateLimitLogin, rateLimitPasswordReset, @@ -37,6 +38,22 @@ import { rateLimitEmailVerification } from "~/server/security"; import { logAuditEvent } from "~/server/audit"; +import type { H3Event } from "vinxi/http"; +import type { Context } from "../utils"; + +/** + * Safely extract H3Event from Context + * In production: ctx.event is APIEvent, H3Event is at ctx.event.nativeEvent + * In development: ctx.event might be H3Event directly + */ +function getH3Event(ctx: Context): H3Event { + // Check if nativeEvent exists (production) + if (ctx.event && 'nativeEvent' in ctx.event && ctx.event.nativeEvent) { + return ctx.event.nativeEvent as H3Event; + } + // Otherwise, assume ctx.event is H3Event (development) + return ctx.event as unknown as H3Event; +} /** * Create JWT with session tracking @@ -115,7 +132,7 @@ function setAuthCookies( const cookieOptions: any = { path: "/", httpOnly: true, - secure: true, // Always enforce secure cookies + secure: env.NODE_ENV === "production", sameSite: "lax", ...options }; @@ -292,9 +309,9 @@ export const authRouter = createTRPCRouter({ } // Create session with client info - const clientIP = getClientIP(ctx.event.nativeEvent); + const clientIP = getClientIP(getH3Event(ctx)); const userAgent = - ctx.event.nativeEvent.request.headers.get("user-agent") || "unknown"; + getUserAgent(getH3Event(ctx)); const sessionId = await createSession( userId, "14d", @@ -304,16 +321,16 @@ export const authRouter = createTRPCRouter({ const token = await createJWT(userId, sessionId); - setCookie(ctx.event.nativeEvent, "userIDToken", token, { + setCookie(getH3Event(ctx), "userIDToken", token, { maxAge: 60 * 60 * 24 * 14, // 14 days path: "/", httpOnly: true, - secure: true, // Always enforce secure cookies + secure: env.NODE_ENV === "production", sameSite: "lax" }); // Set CSRF token for authenticated session - setCSRFToken(ctx.event.nativeEvent); + setCSRFToken(getH3Event(ctx)); // Log successful OAuth login await logAuditEvent({ @@ -331,7 +348,7 @@ export const authRouter = createTRPCRouter({ }; } catch (error) { // Log failed OAuth login - const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent); + const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); await logAuditEvent({ eventType: "auth.login.failed", eventData: { @@ -475,9 +492,9 @@ export const authRouter = createTRPCRouter({ } // Create session with client info - const clientIP = getClientIP(ctx.event.nativeEvent); + const clientIP = getClientIP(getH3Event(ctx)); const userAgent = - ctx.event.nativeEvent.request.headers.get("user-agent") || "unknown"; + getUserAgent(getH3Event(ctx)); const sessionId = await createSession( userId, "14d", @@ -487,16 +504,16 @@ export const authRouter = createTRPCRouter({ const token = await createJWT(userId, sessionId); - setCookie(ctx.event.nativeEvent, "userIDToken", token, { + setCookie(getH3Event(ctx), "userIDToken", token, { maxAge: 60 * 60 * 24 * 14, // 14 days path: "/", httpOnly: true, - secure: true, // Always enforce secure cookies + secure: env.NODE_ENV === "production", sameSite: "lax" }); // Set CSRF token for authenticated session - setCSRFToken(ctx.event.nativeEvent); + setCSRFToken(getH3Event(ctx)); // Log successful OAuth login await logAuditEvent({ @@ -514,7 +531,7 @@ export const authRouter = createTRPCRouter({ }; } catch (error) { // Log failed OAuth login - const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent); + const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); await logAuditEvent({ eventType: "auth.login.failed", eventData: { @@ -595,9 +612,9 @@ export const authRouter = createTRPCRouter({ const userId = (res.rows[0] as unknown as User).id; // Create session with client info - const clientIP = getClientIP(ctx.event.nativeEvent); + const clientIP = getClientIP(getH3Event(ctx)); const userAgent = - ctx.event.nativeEvent.request.headers.get("user-agent") || "unknown"; + getUserAgent(getH3Event(ctx)); const expiresIn = rememberMe ? "14d" : "12h"; const sessionId = await createSession( userId, @@ -611,7 +628,7 @@ export const authRouter = createTRPCRouter({ const cookieOptions: any = { path: "/", httpOnly: true, - secure: true, // Always enforce secure cookies + secure: env.NODE_ENV === "production", sameSite: "lax" }; @@ -620,14 +637,14 @@ export const authRouter = createTRPCRouter({ } setCookie( - ctx.event.nativeEvent, + getH3Event(ctx), "userIDToken", userToken, cookieOptions ); // Set CSRF token for authenticated session - setCSRFToken(ctx.event.nativeEvent); + setCSRFToken(getH3Event(ctx)); // Log successful email link login await logAuditEvent({ @@ -645,7 +662,7 @@ export const authRouter = createTRPCRouter({ }; } catch (error) { // Log failed email link login - const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent); + const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); await logAuditEvent({ eventType: "auth.login.failed", eventData: { @@ -704,10 +721,10 @@ export const authRouter = createTRPCRouter({ await conn.execute({ sql: query, args: params }); // Log successful email verification - const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent); + const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); await logAuditEvent({ userId, - eventType: "auth.email_verified", + eventType: "auth.email.verify.complete", eventData: { email }, ipAddress, userAgent, @@ -720,9 +737,9 @@ export const authRouter = createTRPCRouter({ }; } catch (error) { // Log failed email verification - const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent); + const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); await logAuditEvent({ - eventType: "auth.email_verified", + eventType: "auth.email.verify.complete", eventData: { email, reason: error instanceof TRPCError ? error.message : "unknown" @@ -749,8 +766,8 @@ export const authRouter = createTRPCRouter({ const { email, password, passwordConfirmation } = input; // Apply rate limiting - const clientIP = getClientIP(ctx.event.nativeEvent); - rateLimitRegistration(clientIP, ctx.event.nativeEvent); + const clientIP = getClientIP(getH3Event(ctx)); + rateLimitRegistration(clientIP, getH3Event(ctx)); // Schema already validates password match, but double check if (password !== passwordConfirmation) { @@ -771,9 +788,9 @@ export const authRouter = createTRPCRouter({ }); // Create session with client info - const clientIP = getClientIP(ctx.event.nativeEvent); + const clientIP = getClientIP(getH3Event(ctx)); const userAgent = - ctx.event.nativeEvent.request.headers.get("user-agent") || "unknown"; + getUserAgent(getH3Event(ctx)); const sessionId = await createSession( userId, "14d", @@ -783,21 +800,21 @@ export const authRouter = createTRPCRouter({ const token = await createJWT(userId, sessionId); - setCookie(ctx.event.nativeEvent, "userIDToken", token, { + setCookie(getH3Event(ctx), "userIDToken", token, { maxAge: 60 * 60 * 24 * 14, // 14 days path: "/", httpOnly: true, - secure: true, // Always enforce secure cookies + secure: env.NODE_ENV === "production", sameSite: "lax" }); // Set CSRF token for authenticated session - setCSRFToken(ctx.event.nativeEvent); + setCSRFToken(getH3Event(ctx)); // Log successful registration await logAuditEvent({ userId, - eventType: "auth.registration.success", + eventType: "auth.register.success", eventData: { email, method: "email" }, ipAddress: clientIP, userAgent, @@ -807,9 +824,9 @@ export const authRouter = createTRPCRouter({ return { success: true, message: "success" }; } catch (e) { // Log failed registration - const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent); + const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); await logAuditEvent({ - eventType: "auth.registration.failed", + eventType: "auth.register.failed", eventData: { email, method: "email", @@ -831,107 +848,131 @@ export const authRouter = createTRPCRouter({ emailPasswordLogin: publicProcedure .input(loginUserSchema) .mutation(async ({ input, ctx }) => { - const { email, password, rememberMe } = input; + try { + const { email, password, rememberMe } = input; - // Apply rate limiting - const clientIP = getClientIP(ctx.event.nativeEvent); - rateLimitLogin(email, clientIP, ctx.event.nativeEvent); + // Apply rate limiting + const clientIP = getClientIP(getH3Event(ctx)); + rateLimitLogin(email, clientIP, getH3Event(ctx)); - const conn = ConnectionFactory(); - const res = await conn.execute({ - sql: "SELECT * FROM User WHERE email = ?", - args: [email] - }); + const conn = ConnectionFactory(); + const res = await conn.execute({ + sql: "SELECT * FROM User WHERE email = ?", + args: [email] + }); - // Always run password check to prevent timing attacks - const user = - res.rows.length > 0 ? (res.rows[0] as unknown as User) : null; - const passwordHash = user?.password_hash || null; - const passwordMatch = await checkPasswordSafe(password, passwordHash); + // Always run password check to prevent timing attacks + const user = + res.rows.length > 0 ? (res.rows[0] as unknown as User) : null; + const passwordHash = user?.password_hash || null; + const passwordMatch = await checkPasswordSafe(password, passwordHash); - // Check all conditions after password verification - if (!user || !passwordHash || !passwordMatch) { - // Log failed login attempt (wrap in try-catch to ensure it never blocks auth flow) + // Check all conditions after password verification + if (!user || !passwordHash || !passwordMatch) { + // Debug logging (remove after fixing) + console.log("Login failed for:", email); + console.log("User found:", !!user); + console.log("Password hash exists:", !!passwordHash); + console.log("Password match:", passwordMatch); + + // Log failed login attempt (wrap in try-catch to ensure it never blocks auth flow) + try { + const { ipAddress, userAgent } = getAuditContext( + getH3Event(ctx) + ); + await logAuditEvent({ + eventType: "auth.login.failed", + eventData: { + email, + method: "password", + reason: "invalid_credentials" + }, + ipAddress, + userAgent, + success: false + }); + } catch (auditError) { + console.error("Audit logging failed:", auditError); + } + + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "no-match" + }); + } + + if ( + !user.provider || + !["email", "google", "github", "apple"].includes(user.provider) + ) { + await conn.execute({ + sql: "UPDATE User SET provider = ? WHERE id = ?", + args: ["email", user.id] + }); + } + + const expiresIn = rememberMe ? "14d" : "12h"; + + // Create session with client info (reuse clientIP from rate limiting) + const userAgent = + getUserAgent(getH3Event(ctx)); + const sessionId = await createSession( + user.id, + expiresIn, + clientIP, + userAgent + ); + + const token = await createJWT(user.id, sessionId, expiresIn); + + const cookieOptions: any = { + path: "/", + httpOnly: true, + secure: env.NODE_ENV === "production", + sameSite: "lax" + }; + + if (rememberMe) { + cookieOptions.maxAge = 60 * 60 * 24 * 14; // 14 days + } + + setCookie(getH3Event(ctx), "userIDToken", token, cookieOptions); + + // Set CSRF token for authenticated session + setCSRFToken(getH3Event(ctx)); + + // Log successful login (wrap in try-catch to ensure it never blocks auth flow) try { - const { ipAddress, userAgent } = getAuditContext( - ctx.event.nativeEvent - ); await logAuditEvent({ - eventType: "auth.login.failed", - eventData: { - email, - method: "password", - reason: "invalid_credentials" - }, - ipAddress, + userId: user.id, + eventType: "auth.login.success", + eventData: { method: "password", rememberMe: rememberMe || false }, + ipAddress: clientIP, userAgent, - success: false + success: true }); } catch (auditError) { console.error("Audit logging failed:", auditError); } + return { success: true, message: "success" }; + } catch (error) { + // Log the actual error for debugging + console.error("emailPasswordLogin error:", error); + console.error("Error stack:", error instanceof Error ? error.stack : "no stack"); + + // Re-throw TRPCErrors as-is + if (error instanceof TRPCError) { + throw error; + } + + // Wrap other errors throw new TRPCError({ - code: "UNAUTHORIZED", - message: "no-match" + code: "INTERNAL_SERVER_ERROR", + message: "An error occurred during login", + cause: error }); } - - if ( - !user.provider || - !["email", "google", "github", "apple"].includes(user.provider) - ) { - await conn.execute({ - sql: "UPDATE User SET provider = ? WHERE id = ?", - args: ["email", user.id] - }); - } - - const expiresIn = rememberMe ? "14d" : "12h"; - - // Create session with client info (reuse clientIP from rate limiting) - const userAgent = - ctx.event.nativeEvent.request.headers.get("user-agent") || "unknown"; - const sessionId = await createSession( - user.id, - expiresIn, - clientIP, - userAgent - ); - - const token = await createJWT(user.id, sessionId, expiresIn); - - const cookieOptions: any = { - path: "/", - httpOnly: true, - secure: true, // Always enforce secure cookies - sameSite: "lax" - }; - - if (rememberMe) { - cookieOptions.maxAge = 60 * 60 * 24 * 14; // 14 days - } - - setCookie(ctx.event.nativeEvent, "userIDToken", token, cookieOptions); - - // Set CSRF token for authenticated session - setCSRFToken(ctx.event.nativeEvent); - - // Log successful login (wrap in try-catch to ensure it never blocks auth flow) - try { - await logAuditEvent({ - userId: user.id, - eventType: "auth.login.success", - eventData: { method: "password", rememberMe: rememberMe || false }, - ipAddress: clientIP, - userAgent, - success: true - }); - } catch (auditError) { - console.error("Audit logging failed:", auditError); - } - - return { success: true, message: "success" }; }), requestEmailLinkLogin: publicProcedure @@ -946,7 +987,7 @@ export const authRouter = createTRPCRouter({ try { const requested = getCookie( - ctx.event.nativeEvent, + getH3Event(ctx), "emailLoginLinkRequested" ); if (requested) { @@ -1025,7 +1066,7 @@ export const authRouter = createTRPCRouter({ const exp = new Date(Date.now() + 2 * 60 * 1000); setCookie( - ctx.event.nativeEvent, + getH3Event(ctx), "emailLoginLinkRequested", exp.toUTCString(), { @@ -1066,12 +1107,12 @@ export const authRouter = createTRPCRouter({ const { email } = input; // Apply rate limiting - const clientIP = getClientIP(ctx.event.nativeEvent); - rateLimitPasswordReset(clientIP, ctx.event.nativeEvent); + const clientIP = getClientIP(getH3Event(ctx)); + rateLimitPasswordReset(clientIP, getH3Event(ctx)); try { const requested = getCookie( - ctx.event.nativeEvent, + getH3Event(ctx), "passwordResetRequested" ); if (requested) { @@ -1145,7 +1186,7 @@ export const authRouter = createTRPCRouter({ const exp = new Date(Date.now() + 5 * 60 * 1000); setCookie( - ctx.event.nativeEvent, + getH3Event(ctx), "passwordResetRequested", exp.toUTCString(), { @@ -1155,10 +1196,10 @@ export const authRouter = createTRPCRouter({ ); // Log password reset request - const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent); + const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); await logAuditEvent({ userId: user.id, - eventType: "auth.password_reset.requested", + eventType: "auth.password.reset.request", eventData: { email }, ipAddress, userAgent, @@ -1172,10 +1213,10 @@ export const authRouter = createTRPCRouter({ !(error instanceof TRPCError && error.code === "TOO_MANY_REQUESTS") ) { const { ipAddress, userAgent } = getAuditContext( - ctx.event.nativeEvent + getH3Event(ctx) ); await logAuditEvent({ - eventType: "auth.password_reset.requested", + eventType: "auth.password.reset.request", eventData: { email: input.email, reason: error instanceof TRPCError ? error.message : "unknown" @@ -1266,20 +1307,20 @@ export const authRouter = createTRPCRouter({ }); } - setCookie(ctx.event.nativeEvent, "emailToken", "", { + setCookie(getH3Event(ctx), "emailToken", "", { maxAge: 0, path: "/" }); - setCookie(ctx.event.nativeEvent, "userIDToken", "", { + setCookie(getH3Event(ctx), "userIDToken", "", { maxAge: 0, path: "/" }); // Log successful password reset - const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent); + const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); await logAuditEvent({ userId: payload.id, - eventType: "auth.password_reset.completed", + eventType: "auth.password.reset.complete", eventData: {}, ipAddress, userAgent, @@ -1289,9 +1330,9 @@ export const authRouter = createTRPCRouter({ return { success: true, message: "success" }; } catch (error) { // Log failed password reset - const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent); + const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); await logAuditEvent({ - eventType: "auth.password_reset.completed", + eventType: "auth.password.reset.complete", eventData: { reason: error instanceof TRPCError ? error.message : "unknown" }, @@ -1317,12 +1358,12 @@ export const authRouter = createTRPCRouter({ const { email } = input; // Apply rate limiting - const clientIP = getClientIP(ctx.event.nativeEvent); - rateLimitEmailVerification(clientIP, ctx.event.nativeEvent); + const clientIP = getClientIP(getH3Event(ctx)); + rateLimitEmailVerification(clientIP, getH3Event(ctx)); try { const requested = getCookie( - ctx.event.nativeEvent, + getH3Event(ctx), "emailVerificationRequested" ); if (requested) { @@ -1399,7 +1440,7 @@ export const authRouter = createTRPCRouter({ await sendEmail(email, "freno.me email verification", htmlContent); setCookie( - ctx.event.nativeEvent, + getH3Event(ctx), "emailVerificationRequested", Date.now().toString(), { @@ -1409,10 +1450,10 @@ export const authRouter = createTRPCRouter({ ); // Log email verification request - const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent); + const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); await logAuditEvent({ userId: user.id, - eventType: "auth.email_verification.requested", + eventType: "auth.email.verify.request", eventData: { email }, ipAddress, userAgent, @@ -1426,10 +1467,10 @@ export const authRouter = createTRPCRouter({ !(error instanceof TRPCError && error.code === "TOO_MANY_REQUESTS") ) { const { ipAddress, userAgent } = getAuditContext( - ctx.event.nativeEvent + getH3Event(ctx) ); await logAuditEvent({ - eventType: "auth.email_verification.requested", + eventType: "auth.email.verify.request", eventData: { email: input.email, reason: error instanceof TRPCError ? error.message : "unknown" @@ -1468,7 +1509,7 @@ export const authRouter = createTRPCRouter({ // Try to get user ID for audit log before clearing cookies let userId: string | null = null; try { - const token = getCookie(ctx.event.nativeEvent, "userIDToken"); + const token = getCookie(getH3Event(ctx), "userIDToken"); if (token) { const secret = new TextEncoder().encode(env.JWT_SECRET_KEY); const { payload } = await jwtVerify(token, secret); @@ -1478,17 +1519,17 @@ export const authRouter = createTRPCRouter({ // Ignore token verification errors during signout } - setCookie(ctx.event.nativeEvent, "userIDToken", "", { + setCookie(getH3Event(ctx), "userIDToken", "", { maxAge: 0, path: "/" }); - setCookie(ctx.event.nativeEvent, "emailToken", "", { + setCookie(getH3Event(ctx), "emailToken", "", { maxAge: 0, path: "/" }); // Log signout - const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent); + const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); await logAuditEvent({ userId, eventType: "auth.logout", diff --git a/src/server/security.ts b/src/server/security.ts index 778698f..c25f7d6 100644 --- a/src/server/security.ts +++ b/src/server/security.ts @@ -3,6 +3,7 @@ import { getCookie, setCookie } from "vinxi/http"; import type { H3Event } from "vinxi/http"; import { t } from "~/server/api/utils"; import { logAuditEvent } from "~/server/audit"; +import { env } from "~/env/server"; /** * Extract cookie value from H3Event (works in both production and tests) @@ -108,7 +109,7 @@ export function setCSRFToken(event: H3Event): string { maxAge: 60 * 60 * 24 * 14, // 14 days - same as session path: "/", httpOnly: false, // Must be readable by client JS - secure: true, // Always enforce secure + secure: env.NODE_ENV === "production", sameSite: "lax" }); return token;