Merge branch 'dev'
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -383,19 +383,44 @@ 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(
|
||||
// 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";
|
||||
|
||||
// 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");
|
||||
@@ -413,8 +438,14 @@ const SuggestionDecoration = Extension.create({
|
||||
side: 1 // Place after the cursor
|
||||
}
|
||||
);
|
||||
decorations.push(suggestionDecoration);
|
||||
}
|
||||
|
||||
return DecorationSet.create(newState.doc, [decoration]);
|
||||
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) {
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Infill Loading Indicator */}
|
||||
<Show when={isInfillLoading()}>
|
||||
<div class="bg-surface0 border-surface2 text-subtext0 fixed right-4 bottom-4 z-50 animate-pulse rounded border px-3 py-2 text-xs shadow-lg">
|
||||
<span>
|
||||
<Spinner />
|
||||
</span>
|
||||
AI thinking...
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<AppRouter>({
|
||||
links: [
|
||||
// Only enable logging in development mode
|
||||
@@ -30,7 +44,11 @@ export const api = createTRPCProxyClient<AppRouter>({
|
||||
: []),
|
||||
// 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 } : {};
|
||||
}
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -331,12 +331,12 @@ export default function LoginPage() {
|
||||
<Show when={error()}>
|
||||
<div class="border-maroon bg-red mb-4 w-full max-w-md rounded-lg border px-4 py-3 text-center">
|
||||
<Show when={error() === "passwordMismatch"}>
|
||||
<div class="text-red text-lg font-semibold">
|
||||
<div class="text-base text-lg font-semibold">
|
||||
Passwords did not match!
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={error() === "duplicate"}>
|
||||
<div class="text-red text-lg font-semibold">
|
||||
<div class="text-base text-lg font-semibold">
|
||||
Email Already Exists!
|
||||
</div>
|
||||
</Show>
|
||||
@@ -347,7 +347,7 @@ export default function LoginPage() {
|
||||
error() !== "duplicate"
|
||||
}
|
||||
>
|
||||
<div class="text-red text-sm">{error()}</div>
|
||||
<div class="text-base text-sm">{error()}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -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,
|
||||
|
||||
133
src/server/api/routers/audit.ts
Normal file
133
src/server/api/routers/audit.ts
Normal file
@@ -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<any>;
|
||||
|
||||
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<any>;
|
||||
|
||||
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
|
||||
};
|
||||
})
|
||||
});
|
||||
@@ -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,123 @@ import {
|
||||
resetPasswordSchema,
|
||||
requestPasswordResetSchema
|
||||
} from "../schemas/user";
|
||||
import {
|
||||
setCSRFToken,
|
||||
csrfProtection,
|
||||
getClientIP,
|
||||
getUserAgent,
|
||||
getAuditContext,
|
||||
rateLimitLogin,
|
||||
rateLimitPasswordReset,
|
||||
rateLimitRegistration,
|
||||
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
|
||||
* @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<string> {
|
||||
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<string> {
|
||||
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: env.NODE_ENV === "production",
|
||||
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,9 +308,20 @@ export const authRouter = createTRPCRouter({
|
||||
}
|
||||
}
|
||||
|
||||
const token = await createJWT(userId);
|
||||
// Create session with client info
|
||||
const clientIP = getClientIP(getH3Event(ctx));
|
||||
const userAgent =
|
||||
getUserAgent(getH3Event(ctx));
|
||||
const sessionId = await createSession(
|
||||
userId,
|
||||
"14d",
|
||||
clientIP,
|
||||
userAgent
|
||||
);
|
||||
|
||||
setCookie(ctx.event.nativeEvent, "userIDToken", token, {
|
||||
const token = await createJWT(userId, sessionId);
|
||||
|
||||
setCookie(getH3Event(ctx), "userIDToken", token, {
|
||||
maxAge: 60 * 60 * 24 * 14, // 14 days
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
@@ -209,11 +329,37 @@ export const authRouter = createTRPCRouter({
|
||||
sameSite: "lax"
|
||||
});
|
||||
|
||||
// Set CSRF token for authenticated session
|
||||
setCSRFToken(getH3Event(ctx));
|
||||
|
||||
// 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(getH3Event(ctx));
|
||||
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,9 +491,20 @@ export const authRouter = createTRPCRouter({
|
||||
}
|
||||
}
|
||||
|
||||
const token = await createJWT(userId);
|
||||
// Create session with client info
|
||||
const clientIP = getClientIP(getH3Event(ctx));
|
||||
const userAgent =
|
||||
getUserAgent(getH3Event(ctx));
|
||||
const sessionId = await createSession(
|
||||
userId,
|
||||
"14d",
|
||||
clientIP,
|
||||
userAgent
|
||||
);
|
||||
|
||||
setCookie(ctx.event.nativeEvent, "userIDToken", token, {
|
||||
const token = await createJWT(userId, sessionId);
|
||||
|
||||
setCookie(getH3Event(ctx), "userIDToken", token, {
|
||||
maxAge: 60 * 60 * 24 * 14, // 14 days
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
@@ -355,11 +512,37 @@ export const authRouter = createTRPCRouter({
|
||||
sameSite: "lax"
|
||||
});
|
||||
|
||||
// Set CSRF token for authenticated session
|
||||
setCSRFToken(getH3Event(ctx));
|
||||
|
||||
// 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(getH3Event(ctx));
|
||||
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,7 +611,19 @@ 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(getH3Event(ctx));
|
||||
const userAgent =
|
||||
getUserAgent(getH3Event(ctx));
|
||||
const expiresIn = rememberMe ? "14d" : "12h";
|
||||
const sessionId = await createSession(
|
||||
userId,
|
||||
expiresIn,
|
||||
clientIP,
|
||||
userAgent
|
||||
);
|
||||
|
||||
const userToken = await createJWT(userId, sessionId, expiresIn);
|
||||
|
||||
const cookieOptions: any = {
|
||||
path: "/",
|
||||
@@ -442,17 +637,44 @@ export const authRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
setCookie(
|
||||
ctx.event.nativeEvent,
|
||||
getH3Event(ctx),
|
||||
"userIDToken",
|
||||
userToken,
|
||||
cookieOptions
|
||||
);
|
||||
|
||||
// Set CSRF token for authenticated session
|
||||
setCSRFToken(getH3Event(ctx));
|
||||
|
||||
// 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(getH3Event(ctx));
|
||||
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 +693,7 @@ export const authRouter = createTRPCRouter({
|
||||
token: z.string()
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { email, token } = input;
|
||||
|
||||
try {
|
||||
@@ -486,15 +708,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(getH3Event(ctx));
|
||||
await logAuditEvent({
|
||||
userId,
|
||||
eventType: "auth.email.verify.complete",
|
||||
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(getH3Event(ctx));
|
||||
await logAuditEvent({
|
||||
eventType: "auth.email.verify.complete",
|
||||
eventData: {
|
||||
email,
|
||||
reason: error instanceof TRPCError ? error.message : "unknown"
|
||||
},
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: false
|
||||
});
|
||||
|
||||
if (error instanceof TRPCError) {
|
||||
throw error;
|
||||
}
|
||||
@@ -511,6 +765,10 @@ export const authRouter = createTRPCRouter({
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { email, password, passwordConfirmation } = input;
|
||||
|
||||
// Apply rate limiting
|
||||
const clientIP = getClientIP(getH3Event(ctx));
|
||||
rateLimitRegistration(clientIP, getH3Event(ctx));
|
||||
|
||||
// Schema already validates password match, but double check
|
||||
if (password !== passwordConfirmation) {
|
||||
throw new TRPCError({
|
||||
@@ -529,9 +787,20 @@ export const authRouter = createTRPCRouter({
|
||||
args: [userId, email, passwordHash, "email"]
|
||||
});
|
||||
|
||||
const token = await createJWT(userId);
|
||||
// Create session with client info
|
||||
const clientIP = getClientIP(getH3Event(ctx));
|
||||
const userAgent =
|
||||
getUserAgent(getH3Event(ctx));
|
||||
const sessionId = await createSession(
|
||||
userId,
|
||||
"14d",
|
||||
clientIP,
|
||||
userAgent
|
||||
);
|
||||
|
||||
setCookie(ctx.event.nativeEvent, "userIDToken", token, {
|
||||
const token = await createJWT(userId, sessionId);
|
||||
|
||||
setCookie(getH3Event(ctx), "userIDToken", token, {
|
||||
maxAge: 60 * 60 * 24 * 14, // 14 days
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
@@ -539,8 +808,35 @@ export const authRouter = createTRPCRouter({
|
||||
sameSite: "lax"
|
||||
});
|
||||
|
||||
// Set CSRF token for authenticated session
|
||||
setCSRFToken(getH3Event(ctx));
|
||||
|
||||
// Log successful registration
|
||||
await logAuditEvent({
|
||||
userId,
|
||||
eventType: "auth.register.success",
|
||||
eventData: { email, method: "email" },
|
||||
ipAddress: clientIP,
|
||||
userAgent,
|
||||
success: true
|
||||
});
|
||||
|
||||
return { success: true, message: "success" };
|
||||
} catch (e) {
|
||||
// Log failed registration
|
||||
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
||||
await logAuditEvent({
|
||||
eventType: "auth.register.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",
|
||||
@@ -552,33 +848,53 @@ export const authRouter = createTRPCRouter({
|
||||
emailPasswordLogin: publicProcedure
|
||||
.input(loginUserSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const { email, password, rememberMe } = input;
|
||||
|
||||
// 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]
|
||||
});
|
||||
|
||||
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) {
|
||||
// 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);
|
||||
}
|
||||
|
||||
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,7 +912,18 @@ 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 =
|
||||
getUserAgent(getH3Event(ctx));
|
||||
const sessionId = await createSession(
|
||||
user.id,
|
||||
expiresIn,
|
||||
clientIP,
|
||||
userAgent
|
||||
);
|
||||
|
||||
const token = await createJWT(user.id, sessionId, expiresIn);
|
||||
|
||||
const cookieOptions: any = {
|
||||
path: "/",
|
||||
@@ -609,9 +936,43 @@ export const authRouter = createTRPCRouter({
|
||||
cookieOptions.maxAge = 60 * 60 * 24 * 14; // 14 days
|
||||
}
|
||||
|
||||
setCookie(ctx.event.nativeEvent, "userIDToken", token, cookieOptions);
|
||||
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 {
|
||||
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" };
|
||||
} 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: "INTERNAL_SERVER_ERROR",
|
||||
message: "An error occurred during login",
|
||||
cause: error
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
requestEmailLinkLogin: publicProcedure
|
||||
@@ -626,7 +987,7 @@ export const authRouter = createTRPCRouter({
|
||||
|
||||
try {
|
||||
const requested = getCookie(
|
||||
ctx.event.nativeEvent,
|
||||
getH3Event(ctx),
|
||||
"emailLoginLinkRequested"
|
||||
);
|
||||
if (requested) {
|
||||
@@ -705,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(),
|
||||
{
|
||||
@@ -745,9 +1106,13 @@ export const authRouter = createTRPCRouter({
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { email } = input;
|
||||
|
||||
// Apply rate limiting
|
||||
const clientIP = getClientIP(getH3Event(ctx));
|
||||
rateLimitPasswordReset(clientIP, getH3Event(ctx));
|
||||
|
||||
try {
|
||||
const requested = getCookie(
|
||||
ctx.event.nativeEvent,
|
||||
getH3Event(ctx),
|
||||
"passwordResetRequested"
|
||||
);
|
||||
if (requested) {
|
||||
@@ -821,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(),
|
||||
{
|
||||
@@ -830,8 +1195,38 @@ export const authRouter = createTRPCRouter({
|
||||
}
|
||||
);
|
||||
|
||||
// Log password reset request
|
||||
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
||||
await logAuditEvent({
|
||||
userId: user.id,
|
||||
eventType: "auth.password.reset.request",
|
||||
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(
|
||||
getH3Event(ctx)
|
||||
);
|
||||
await logAuditEvent({
|
||||
eventType: "auth.password.reset.request",
|
||||
eventData: {
|
||||
email: input.email,
|
||||
reason: error instanceof TRPCError ? error.message : "unknown"
|
||||
},
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: false
|
||||
});
|
||||
}
|
||||
|
||||
if (error instanceof TRPCError) {
|
||||
throw error;
|
||||
}
|
||||
@@ -912,17 +1307,40 @@ 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(getH3Event(ctx));
|
||||
await logAuditEvent({
|
||||
userId: payload.id,
|
||||
eventType: "auth.password.reset.complete",
|
||||
eventData: {},
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true
|
||||
});
|
||||
|
||||
return { success: true, message: "success" };
|
||||
} catch (error) {
|
||||
// Log failed password reset
|
||||
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
||||
await logAuditEvent({
|
||||
eventType: "auth.password.reset.complete",
|
||||
eventData: {
|
||||
reason: error instanceof TRPCError ? error.message : "unknown"
|
||||
},
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: false
|
||||
});
|
||||
|
||||
if (error instanceof TRPCError) {
|
||||
throw error;
|
||||
}
|
||||
@@ -939,9 +1357,13 @@ export const authRouter = createTRPCRouter({
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { email } = input;
|
||||
|
||||
// Apply rate limiting
|
||||
const clientIP = getClientIP(getH3Event(ctx));
|
||||
rateLimitEmailVerification(clientIP, getH3Event(ctx));
|
||||
|
||||
try {
|
||||
const requested = getCookie(
|
||||
ctx.event.nativeEvent,
|
||||
getH3Event(ctx),
|
||||
"emailVerificationRequested"
|
||||
);
|
||||
if (requested) {
|
||||
@@ -971,6 +1393,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" })
|
||||
@@ -1016,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(),
|
||||
{
|
||||
@@ -1025,8 +1449,38 @@ export const authRouter = createTRPCRouter({
|
||||
}
|
||||
);
|
||||
|
||||
// Log email verification request
|
||||
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
||||
await logAuditEvent({
|
||||
userId: user.id,
|
||||
eventType: "auth.email.verify.request",
|
||||
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(
|
||||
getH3Event(ctx)
|
||||
);
|
||||
await logAuditEvent({
|
||||
eventType: "auth.email.verify.request",
|
||||
eventData: {
|
||||
email: input.email,
|
||||
reason: error instanceof TRPCError ? error.message : "unknown"
|
||||
},
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: false
|
||||
});
|
||||
}
|
||||
|
||||
if (error instanceof TRPCError) {
|
||||
throw error;
|
||||
}
|
||||
@@ -1052,15 +1506,39 @@ export const authRouter = createTRPCRouter({
|
||||
}),
|
||||
|
||||
signOut: publicProcedure.mutation(async ({ ctx }) => {
|
||||
setCookie(ctx.event.nativeEvent, "userIDToken", "", {
|
||||
// Try to get user ID for audit log before clearing cookies
|
||||
let userId: string | null = null;
|
||||
try {
|
||||
const token = getCookie(getH3Event(ctx), "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(getH3Event(ctx), "userIDToken", "", {
|
||||
maxAge: 0,
|
||||
path: "/"
|
||||
});
|
||||
setCookie(ctx.event.nativeEvent, "emailToken", "", {
|
||||
setCookie(getH3Event(ctx), "emailToken", "", {
|
||||
maxAge: 0,
|
||||
path: "/"
|
||||
});
|
||||
|
||||
// Log signout
|
||||
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
||||
await logAuditEvent({
|
||||
userId,
|
||||
eventType: "auth.logout",
|
||||
eventData: {},
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
})
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
406
src/server/audit.test.ts
Normal file
406
src/server/audit.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
516
src/server/audit.ts
Normal file
516
src/server/audit.ts
Normal file
@@ -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<string, any>;
|
||||
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<void> {
|
||||
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<Array<Record<string, any>>> {
|
||||
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<number | Array<Record<string, any>>> {
|
||||
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<number> {
|
||||
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;
|
||||
}
|
||||
@@ -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<string, string>
|
||||
);
|
||||
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<boolean> {
|
||||
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<string | null> {
|
||||
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<string | null> {
|
||||
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) {
|
||||
|
||||
95
src/server/init-audit-table.ts
Normal file
95
src/server/init-audit-table.ts
Normal file
@@ -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 };
|
||||
@@ -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<string> {
|
||||
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<boolean> {
|
||||
// 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;
|
||||
}
|
||||
|
||||
402
src/server/security.ts
Normal file
402
src/server/security.ts
Normal file
@@ -0,0 +1,402 @@
|
||||
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";
|
||||
import { env } from "~/env/server";
|
||||
|
||||
/**
|
||||
* 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<string, string>
|
||||
);
|
||||
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: env.NODE_ENV === "production",
|
||||
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<string, RateLimitRecord>();
|
||||
|
||||
/**
|
||||
* 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
|
||||
);
|
||||
}
|
||||
486
src/server/security/auth.test.ts
Normal file
486
src/server/security/auth.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
417
src/server/security/authorization.test.ts
Normal file
417
src/server/security/authorization.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
320
src/server/security/csrf.test.ts
Normal file
320
src/server/security/csrf.test.ts
Normal file
@@ -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<string>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
522
src/server/security/injection.test.ts
Normal file
522
src/server/security/injection.test.ts
Normal file
@@ -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<script>",
|
||||
"User'--",
|
||||
'User"OR"1"="1',
|
||||
"User & Co",
|
||||
"User@123"
|
||||
];
|
||||
|
||||
for (const name of specialNames) {
|
||||
const isValid = isValidDisplayName(name);
|
||||
// Should either accept and sanitize, or reject
|
||||
expect(typeof isValid).toBe("boolean");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("SQL Injection Prevention", () => {
|
||||
it("should use parameterized queries for user authentication", async () => {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
// Test that SQL injection attempts don't work
|
||||
const maliciousEmail = "admin'--";
|
||||
|
||||
try {
|
||||
// This query uses parameterized args (safe)
|
||||
const result = await conn.execute({
|
||||
sql: "SELECT * FROM User WHERE email = ?",
|
||||
args: [maliciousEmail]
|
||||
});
|
||||
|
||||
// Should return no results (no user with that exact email)
|
||||
expect(result.rows.length).toBe(0);
|
||||
} catch (error) {
|
||||
// If error, ensure it's not a SQL error
|
||||
expect(error).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("should prevent SQL injection in all query parameters", async () => {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
for (const payload of SQL_INJECTION_PAYLOADS) {
|
||||
try {
|
||||
// Test various injection points
|
||||
await conn.execute({
|
||||
sql: "SELECT * FROM User WHERE email = ?",
|
||||
args: [payload]
|
||||
});
|
||||
|
||||
await conn.execute({
|
||||
sql: "SELECT * FROM User WHERE display_name = ?",
|
||||
args: [payload]
|
||||
});
|
||||
|
||||
// Queries should complete without SQL errors
|
||||
expect(true).toBe(true);
|
||||
} catch (error: any) {
|
||||
// If error occurs, should not be SQL injection syntax error
|
||||
expect(error.message).not.toContain("syntax error");
|
||||
expect(error.message).not.toContain("SQL");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("should prevent UNION-based SQL injection", async () => {
|
||||
const conn = ConnectionFactory();
|
||||
const unionPayload = "' UNION SELECT password_hash FROM User--";
|
||||
|
||||
try {
|
||||
const result = await conn.execute({
|
||||
sql: "SELECT email FROM User WHERE email = ?",
|
||||
args: [unionPayload]
|
||||
});
|
||||
|
||||
// Should not return password hashes
|
||||
if (result.rows.length > 0) {
|
||||
for (const row of result.rows) {
|
||||
// Ensure we don't get password_hash column
|
||||
expect(row).not.toHaveProperty("password_hash");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Error is acceptable, SQL injection is not
|
||||
expect(error).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("should prevent blind SQL injection timing attacks", async () => {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
// Timing-based payload
|
||||
const timingPayload = "admin' AND SLEEP(5)--";
|
||||
|
||||
const start = performance.now();
|
||||
try {
|
||||
await conn.execute({
|
||||
sql: "SELECT * FROM User WHERE email = ?",
|
||||
args: [timingPayload]
|
||||
});
|
||||
} catch (error) {
|
||||
// Ignore errors
|
||||
}
|
||||
const duration = performance.now() - start;
|
||||
|
||||
// Should not delay for 5 seconds
|
||||
expect(duration).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
it("should prevent second-order SQL injection", async () => {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
// Store malicious data
|
||||
const maliciousName = "admin'--";
|
||||
|
||||
try {
|
||||
// Insert with parameterized query (safe)
|
||||
await conn.execute({
|
||||
sql: "INSERT INTO User (id, email, display_name, provider) VALUES (?, ?, ?, ?)",
|
||||
args: [
|
||||
"test-user-sqli",
|
||||
"test-sqli@example.com",
|
||||
maliciousName,
|
||||
"email"
|
||||
]
|
||||
});
|
||||
|
||||
// Retrieve and use (should still be safe with parameterized queries)
|
||||
const result = await conn.execute({
|
||||
sql: "SELECT display_name FROM User WHERE email = ?",
|
||||
args: ["test-sqli@example.com"]
|
||||
});
|
||||
|
||||
expect(result.rows.length).toBeGreaterThanOrEqual(0);
|
||||
|
||||
// Cleanup
|
||||
await conn.execute({
|
||||
sql: "DELETE FROM User WHERE email = ?",
|
||||
args: ["test-sqli@example.com"]
|
||||
});
|
||||
} catch (error) {
|
||||
// Should not have SQL syntax errors
|
||||
expect(error).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("XSS Prevention", () => {
|
||||
it("should identify potentially dangerous XSS patterns", () => {
|
||||
// These payloads should be handled by frontend sanitization
|
||||
for (const payload of XSS_PAYLOADS) {
|
||||
// Document that these patterns exist
|
||||
expect(payload).toBeDefined();
|
||||
expect(typeof payload).toBe("string");
|
||||
|
||||
// In practice, these should be sanitized before rendering
|
||||
// or stored as-is and sanitized on output
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle script tags in user input", () => {
|
||||
const scriptInput = "<script>alert('XSS')</script>";
|
||||
|
||||
// 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 = [
|
||||
"<![CDATA[attack]]>",
|
||||
'<?xml version="1.0"?>',
|
||||
'<!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>',
|
||||
"<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);
|
||||
});
|
||||
});
|
||||
});
|
||||
529
src/server/security/password.test.ts
Normal file
529
src/server/security/password.test.ts
Normal file
@@ -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]);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
443
src/server/security/rate-limit.test.ts
Normal file
443
src/server/security/rate-limit.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
188
src/server/security/test-utils.ts
Normal file
188
src/server/security/test-utils.ts
Normal file
@@ -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<string, string>;
|
||||
cookies?: Record<string, string>;
|
||||
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<string, string>;
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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 = [
|
||||
"<script>alert('XSS')</script>",
|
||||
"<img src=x onerror=alert('XSS')>",
|
||||
"javascript:alert('XSS')",
|
||||
"<svg onload=alert('XSS')>",
|
||||
"<iframe src='javascript:alert(\"XSS\")'></iframe>",
|
||||
"<body onload=alert('XSS')>",
|
||||
"<input onfocus=alert('XSS') autofocus>"
|
||||
];
|
||||
|
||||
/**
|
||||
* Wait for async operations with timeout
|
||||
*/
|
||||
export async function waitFor(
|
||||
condition: () => boolean | Promise<boolean>,
|
||||
timeout: number = 5000,
|
||||
interval: number = 100
|
||||
): Promise<void> {
|
||||
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<T>(
|
||||
fn: () => Promise<T>
|
||||
): 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(
|
||||
"."
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user