Merge branch 'dev'
This commit is contained in:
@@ -5,7 +5,11 @@
|
|||||||
"dev": "vinxi dev",
|
"dev": "vinxi dev",
|
||||||
"dev-flush": "vinxi dev --env-file=.env",
|
"dev-flush": "vinxi dev --env-file=.env",
|
||||||
"build": "vinxi build",
|
"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": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.953.0",
|
"@aws-sdk/client-s3": "^3.953.0",
|
||||||
|
|||||||
@@ -383,38 +383,69 @@ const SuggestionDecoration = Extension.create({
|
|||||||
return DecorationSet.empty;
|
return DecorationSet.empty;
|
||||||
},
|
},
|
||||||
apply(tr, oldSet, oldState, newState) {
|
apply(tr, oldSet, oldState, newState) {
|
||||||
// Get suggestion from editor storage
|
// Get suggestion and loading state from editor storage
|
||||||
const suggestion =
|
const storage = (editor.storage as any).suggestionDecoration || {};
|
||||||
(editor.storage as any).suggestionDecoration?.text || "";
|
const suggestion = storage.text || "";
|
||||||
|
const isLoading = storage.isLoading || false;
|
||||||
if (!suggestion) {
|
|
||||||
return DecorationSet.empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { selection } = newState;
|
const { selection } = newState;
|
||||||
const pos = selection.$anchor.pos;
|
const pos = selection.$anchor.pos;
|
||||||
|
const decorations = [];
|
||||||
|
|
||||||
// Create a widget decoration at cursor position
|
// Show loading spinner inline if loading
|
||||||
const decoration = Decoration.widget(
|
if (isLoading) {
|
||||||
pos,
|
const loadingDecoration = Decoration.widget(
|
||||||
() => {
|
pos,
|
||||||
const span = document.createElement("span");
|
() => {
|
||||||
span.textContent = suggestion;
|
const span = document.createElement("span");
|
||||||
span.style.color = "rgb(239, 68, 68)"; // Tailwind red-500
|
span.className = "inline-flex items-center ml-1";
|
||||||
span.style.opacity = "0.5";
|
span.style.pointerEvents = "none";
|
||||||
span.style.fontStyle = "italic";
|
|
||||||
span.style.fontFamily = "monospace";
|
|
||||||
span.style.pointerEvents = "none";
|
|
||||||
span.style.whiteSpace = "pre-wrap";
|
|
||||||
span.style.wordWrap = "break-word";
|
|
||||||
return span;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
side: 1 // Place after the cursor
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return DecorationSet.create(newState.doc, [decoration]);
|
// Create a simple spinner using CSS animation
|
||||||
|
const spinner = document.createElement("span");
|
||||||
|
spinner.className =
|
||||||
|
"inline-block w-3 h-3 border-2 border-current border-t-transparent rounded-full animate-spin";
|
||||||
|
spinner.style.color = "rgb(239, 68, 68)"; // Tailwind red-500
|
||||||
|
spinner.style.opacity = "0.5";
|
||||||
|
|
||||||
|
span.appendChild(spinner);
|
||||||
|
return span;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
side: 1 // Place after the cursor
|
||||||
|
}
|
||||||
|
);
|
||||||
|
decorations.push(loadingDecoration);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show suggestion text if present
|
||||||
|
if (suggestion) {
|
||||||
|
const suggestionDecoration = Decoration.widget(
|
||||||
|
pos,
|
||||||
|
() => {
|
||||||
|
const span = document.createElement("span");
|
||||||
|
span.textContent = suggestion;
|
||||||
|
span.style.color = "rgb(239, 68, 68)"; // Tailwind red-500
|
||||||
|
span.style.opacity = "0.5";
|
||||||
|
span.style.fontStyle = "italic";
|
||||||
|
span.style.fontFamily = "monospace";
|
||||||
|
span.style.pointerEvents = "none";
|
||||||
|
span.style.whiteSpace = "pre-wrap";
|
||||||
|
span.style.wordWrap = "break-word";
|
||||||
|
return span;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
side: 1 // Place after the cursor
|
||||||
|
}
|
||||||
|
);
|
||||||
|
decorations.push(suggestionDecoration);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decorations.length === 0) {
|
||||||
|
return DecorationSet.empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DecorationSet.create(newState.doc, decorations);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
@@ -428,7 +459,8 @@ const SuggestionDecoration = Extension.create({
|
|||||||
|
|
||||||
addStorage() {
|
addStorage() {
|
||||||
return {
|
return {
|
||||||
text: ""
|
text: "",
|
||||||
|
isLoading: false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -804,10 +836,14 @@ export default function TextEditor(props: TextEditorProps) {
|
|||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const instance = editor();
|
const instance = editor();
|
||||||
const suggestion = currentSuggestion();
|
const suggestion = currentSuggestion();
|
||||||
|
const loading = isInfillLoading();
|
||||||
|
|
||||||
if (instance) {
|
if (instance) {
|
||||||
// Store suggestion in editor storage (cast to any to avoid TS error)
|
// Store suggestion and loading state in editor storage (cast to any to avoid TS error)
|
||||||
(instance.storage as any).suggestionDecoration = { text: suggestion };
|
(instance.storage as any).suggestionDecoration = {
|
||||||
|
text: suggestion,
|
||||||
|
isLoading: loading
|
||||||
|
};
|
||||||
// Force view update to show/hide decoration
|
// Force view update to show/hide decoration
|
||||||
instance.view.dispatch(instance.state.tr);
|
instance.view.dispatch(instance.state.tr);
|
||||||
}
|
}
|
||||||
@@ -833,13 +869,6 @@ export default function TextEditor(props: TextEditorProps) {
|
|||||||
stream: false
|
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, {
|
const response = await fetch(config.endpoint, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -4130,16 +4159,6 @@ export default function TextEditor(props: TextEditorProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,20 @@ const getBaseUrl = () => {
|
|||||||
return `http://localhost:${process.env.PORT ?? 3000}`;
|
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>({
|
export const api = createTRPCProxyClient<AppRouter>({
|
||||||
links: [
|
links: [
|
||||||
// Only enable logging in development mode
|
// Only enable logging in development mode
|
||||||
@@ -30,7 +44,11 @@ export const api = createTRPCProxyClient<AppRouter>({
|
|||||||
: []),
|
: []),
|
||||||
// identifies what url will handle trpc requests
|
// identifies what url will handle trpc requests
|
||||||
httpBatchLink({
|
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
|
* Validate email format
|
||||||
*/
|
*/
|
||||||
export function isValidEmail(email: string): boolean {
|
export function isValidEmail(email: string): boolean {
|
||||||
|
// Basic email format check
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
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): {
|
export function validatePassword(password: string): {
|
||||||
isValid: boolean;
|
isValid: boolean;
|
||||||
errors: string[];
|
errors: string[];
|
||||||
|
strength: PasswordStrength;
|
||||||
} {
|
} {
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
if (password.length < 8) {
|
// Minimum length: 12 characters
|
||||||
errors.push("Password must be at least 8 characters long");
|
if (password.length < 12) {
|
||||||
|
errors.push("Password must be at least 12 characters long");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional: Add more password requirements
|
// Require uppercase letter
|
||||||
// if (!/[A-Z]/.test(password)) {
|
if (!/[A-Z]/.test(password)) {
|
||||||
// errors.push("Password must contain at least one uppercase letter");
|
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");
|
// Require lowercase letter
|
||||||
// }
|
if (!/[a-z]/.test(password)) {
|
||||||
// if (!/[0-9]/.test(password)) {
|
errors.push("Password must contain at least one lowercase letter");
|
||||||
// errors.push("Password must contain at least one number");
|
}
|
||||||
// }
|
|
||||||
|
// 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 {
|
return {
|
||||||
isValid: errors.length === 0,
|
isValid: errors.length === 0,
|
||||||
errors
|
errors,
|
||||||
|
strength
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export async function POST() {
|
|||||||
setCookie(event, "userIDToken", "", {
|
setCookie(event, "userIDToken", "", {
|
||||||
path: "/",
|
path: "/",
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: true, // Always enforce secure cookies
|
||||||
sameSite: "lax",
|
sameSite: "lax",
|
||||||
maxAge: 0, // Expire immediately
|
maxAge: 0, // Expire immediately
|
||||||
expires: new Date(0) // Set expiry to past date
|
expires: new Date(0) // Set expiry to past date
|
||||||
|
|||||||
@@ -331,12 +331,12 @@ export default function LoginPage() {
|
|||||||
<Show when={error()}>
|
<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">
|
<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"}>
|
<Show when={error() === "passwordMismatch"}>
|
||||||
<div class="text-red text-lg font-semibold">
|
<div class="text-base text-lg font-semibold">
|
||||||
Passwords did not match!
|
Passwords did not match!
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={error() === "duplicate"}>
|
<Show when={error() === "duplicate"}>
|
||||||
<div class="text-red text-lg font-semibold">
|
<div class="text-base text-lg font-semibold">
|
||||||
Email Already Exists!
|
Email Already Exists!
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -347,7 +347,7 @@ export default function LoginPage() {
|
|||||||
error() !== "duplicate"
|
error() !== "duplicate"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="text-red text-sm">{error()}</div>
|
<div class="text-base text-sm">{error()}</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { exampleRouter } from "./routers/example";
|
import { exampleRouter } from "./routers/example";
|
||||||
import { authRouter } from "./routers/auth";
|
import { authRouter } from "./routers/auth";
|
||||||
|
import { auditRouter } from "./routers/audit";
|
||||||
import { databaseRouter } from "./routers/database";
|
import { databaseRouter } from "./routers/database";
|
||||||
import { lineageRouter } from "./routers/lineage";
|
import { lineageRouter } from "./routers/lineage";
|
||||||
import { miscRouter } from "./routers/misc";
|
import { miscRouter } from "./routers/misc";
|
||||||
@@ -13,6 +14,7 @@ import { createTRPCRouter } from "./utils";
|
|||||||
export const appRouter = createTRPCRouter({
|
export const appRouter = createTRPCRouter({
|
||||||
example: exampleRouter,
|
example: exampleRouter,
|
||||||
auth: authRouter,
|
auth: authRouter,
|
||||||
|
audit: auditRouter,
|
||||||
database: databaseRouter,
|
database: databaseRouter,
|
||||||
lineage: lineageRouter,
|
lineage: lineageRouter,
|
||||||
misc: miscRouter,
|
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 { TRPCError } from "@trpc/server";
|
||||||
import { v4 as uuidV4 } from "uuid";
|
import { v4 as uuidV4 } from "uuid";
|
||||||
import { env } from "~/env/server";
|
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 { SignJWT, jwtVerify } from "jose";
|
||||||
import { setCookie, getCookie } from "vinxi/http";
|
import { setCookie, getCookie } from "vinxi/http";
|
||||||
import type { User } from "~/db/types";
|
import type { User } from "~/db/types";
|
||||||
@@ -21,19 +26,123 @@ import {
|
|||||||
resetPasswordSchema,
|
resetPasswordSchema,
|
||||||
requestPasswordResetSchema
|
requestPasswordResetSchema
|
||||||
} from "../schemas/user";
|
} 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(
|
async function createJWT(
|
||||||
userId: string,
|
userId: string,
|
||||||
|
sessionId: string,
|
||||||
expiresIn: string = "14d"
|
expiresIn: string = "14d"
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
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" })
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
.setExpirationTime(expiresIn)
|
.setExpirationTime(expiresIn)
|
||||||
.sign(secret);
|
.sign(secret);
|
||||||
return token;
|
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) {
|
async function sendEmail(to: string, subject: string, htmlContent: string) {
|
||||||
const apiKey = env.SENDINBLUE_KEY;
|
const apiKey = env.SENDINBLUE_KEY;
|
||||||
const apiUrl = "https://api.sendinblue.com/v3/smtp/email";
|
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
|
maxAge: 60 * 60 * 24 * 14, // 14 days
|
||||||
path: "/",
|
path: "/",
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
@@ -209,11 +329,37 @@ export const authRouter = createTRPCRouter({
|
|||||||
sameSite: "lax"
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
redirectTo: "/account"
|
redirectTo: "/account"
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} 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) {
|
if (error instanceof TRPCError) {
|
||||||
throw error;
|
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
|
maxAge: 60 * 60 * 24 * 14, // 14 days
|
||||||
path: "/",
|
path: "/",
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
@@ -355,11 +512,37 @@ export const authRouter = createTRPCRouter({
|
|||||||
sameSite: "lax"
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
redirectTo: "/account"
|
redirectTo: "/account"
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} 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) {
|
if (error instanceof TRPCError) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -428,7 +611,19 @@ export const authRouter = createTRPCRouter({
|
|||||||
|
|
||||||
const userId = (res.rows[0] as unknown as User).id;
|
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 = {
|
const cookieOptions: any = {
|
||||||
path: "/",
|
path: "/",
|
||||||
@@ -442,17 +637,44 @@ export const authRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setCookie(
|
setCookie(
|
||||||
ctx.event.nativeEvent,
|
getH3Event(ctx),
|
||||||
"userIDToken",
|
"userIDToken",
|
||||||
userToken,
|
userToken,
|
||||||
cookieOptions
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
redirectTo: "/account"
|
redirectTo: "/account"
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} 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) {
|
if (error instanceof TRPCError) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -471,7 +693,7 @@ export const authRouter = createTRPCRouter({
|
|||||||
token: z.string()
|
token: z.string()
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const { email, token } = input;
|
const { email, token } = input;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -486,15 +708,47 @@ export const authRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const conn = ConnectionFactory();
|
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 query = `UPDATE User SET email_verified = ? WHERE email = ?`;
|
||||||
const params = [true, email];
|
const params = [true, email];
|
||||||
await conn.execute({ sql: query, args: params });
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Email verification success, you may close this window"
|
message: "Email verification success, you may close this window"
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} 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) {
|
if (error instanceof TRPCError) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -511,6 +765,10 @@ export const authRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const { email, password, passwordConfirmation } = input;
|
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
|
// Schema already validates password match, but double check
|
||||||
if (password !== passwordConfirmation) {
|
if (password !== passwordConfirmation) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
@@ -529,9 +787,20 @@ export const authRouter = createTRPCRouter({
|
|||||||
args: [userId, email, passwordHash, "email"]
|
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
|
maxAge: 60 * 60 * 24 * 14, // 14 days
|
||||||
path: "/",
|
path: "/",
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
@@ -539,8 +808,35 @@ export const authRouter = createTRPCRouter({
|
|||||||
sameSite: "lax"
|
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" };
|
return { success: true, message: "success" };
|
||||||
} catch (e) {
|
} 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);
|
console.error("Registration error:", e);
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
@@ -552,66 +848,131 @@ export const authRouter = createTRPCRouter({
|
|||||||
emailPasswordLogin: publicProcedure
|
emailPasswordLogin: publicProcedure
|
||||||
.input(loginUserSchema)
|
.input(loginUserSchema)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const { email, password, rememberMe } = input;
|
try {
|
||||||
|
const { email, password, rememberMe } = input;
|
||||||
|
|
||||||
const conn = ConnectionFactory();
|
// Apply rate limiting
|
||||||
const res = await conn.execute({
|
const clientIP = getClientIP(getH3Event(ctx));
|
||||||
sql: "SELECT * FROM User WHERE email = ?",
|
rateLimitLogin(email, clientIP, getH3Event(ctx));
|
||||||
args: [email]
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.rows.length === 0) {
|
const conn = ConnectionFactory();
|
||||||
|
const res = await conn.execute({
|
||||||
|
sql: "SELECT * FROM User WHERE email = ?",
|
||||||
|
args: [email]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Always run password check to prevent timing attacks
|
||||||
|
const user =
|
||||||
|
res.rows.length > 0 ? (res.rows[0] as unknown as User) : null;
|
||||||
|
const passwordHash = user?.password_hash || null;
|
||||||
|
const passwordMatch = await checkPasswordSafe(password, passwordHash);
|
||||||
|
|
||||||
|
// Check all conditions after password verification
|
||||||
|
if (!user || !passwordHash || !passwordMatch) {
|
||||||
|
// Debug logging (remove after fixing)
|
||||||
|
console.log("Login failed for:", email);
|
||||||
|
console.log("User found:", !!user);
|
||||||
|
console.log("Password hash exists:", !!passwordHash);
|
||||||
|
console.log("Password match:", passwordMatch);
|
||||||
|
|
||||||
|
// Log failed login attempt (wrap in try-catch to ensure it never blocks auth flow)
|
||||||
|
try {
|
||||||
|
const { ipAddress, userAgent } = getAuditContext(
|
||||||
|
getH3Event(ctx)
|
||||||
|
);
|
||||||
|
await logAuditEvent({
|
||||||
|
eventType: "auth.login.failed",
|
||||||
|
eventData: {
|
||||||
|
email,
|
||||||
|
method: "password",
|
||||||
|
reason: "invalid_credentials"
|
||||||
|
},
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
success: false
|
||||||
|
});
|
||||||
|
} catch (auditError) {
|
||||||
|
console.error("Audit logging failed:", auditError);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "no-match"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!user.provider ||
|
||||||
|
!["email", "google", "github", "apple"].includes(user.provider)
|
||||||
|
) {
|
||||||
|
await conn.execute({
|
||||||
|
sql: "UPDATE User SET provider = ? WHERE id = ?",
|
||||||
|
args: ["email", user.id]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresIn = rememberMe ? "14d" : "12h";
|
||||||
|
|
||||||
|
// Create session with client info (reuse clientIP from rate limiting)
|
||||||
|
const userAgent =
|
||||||
|
getUserAgent(getH3Event(ctx));
|
||||||
|
const sessionId = await createSession(
|
||||||
|
user.id,
|
||||||
|
expiresIn,
|
||||||
|
clientIP,
|
||||||
|
userAgent
|
||||||
|
);
|
||||||
|
|
||||||
|
const token = await createJWT(user.id, sessionId, expiresIn);
|
||||||
|
|
||||||
|
const cookieOptions: any = {
|
||||||
|
path: "/",
|
||||||
|
httpOnly: true,
|
||||||
|
secure: env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax"
|
||||||
|
};
|
||||||
|
|
||||||
|
if (rememberMe) {
|
||||||
|
cookieOptions.maxAge = 60 * 60 * 24 * 14; // 14 days
|
||||||
|
}
|
||||||
|
|
||||||
|
setCookie(getH3Event(ctx), "userIDToken", token, cookieOptions);
|
||||||
|
|
||||||
|
// Set CSRF token for authenticated session
|
||||||
|
setCSRFToken(getH3Event(ctx));
|
||||||
|
|
||||||
|
// Log successful login (wrap in try-catch to ensure it never blocks auth flow)
|
||||||
|
try {
|
||||||
|
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({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
message: "no-match"
|
message: "An error occurred during login",
|
||||||
|
cause: error
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
!user.provider ||
|
|
||||||
!["email", "google", "github", "apple"].includes(user.provider)
|
|
||||||
) {
|
|
||||||
await conn.execute({
|
|
||||||
sql: "UPDATE User SET provider = ? WHERE id = ?",
|
|
||||||
args: ["email", user.id]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const expiresIn = rememberMe ? "14d" : "12h";
|
|
||||||
const token = await createJWT(user.id, expiresIn);
|
|
||||||
|
|
||||||
const cookieOptions: any = {
|
|
||||||
path: "/",
|
|
||||||
httpOnly: true,
|
|
||||||
secure: env.NODE_ENV === "production",
|
|
||||||
sameSite: "lax"
|
|
||||||
};
|
|
||||||
|
|
||||||
if (rememberMe) {
|
|
||||||
cookieOptions.maxAge = 60 * 60 * 24 * 14; // 14 days
|
|
||||||
}
|
|
||||||
|
|
||||||
setCookie(ctx.event.nativeEvent, "userIDToken", token, cookieOptions);
|
|
||||||
|
|
||||||
return { success: true, message: "success" };
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
requestEmailLinkLogin: publicProcedure
|
requestEmailLinkLogin: publicProcedure
|
||||||
@@ -626,7 +987,7 @@ export const authRouter = createTRPCRouter({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const requested = getCookie(
|
const requested = getCookie(
|
||||||
ctx.event.nativeEvent,
|
getH3Event(ctx),
|
||||||
"emailLoginLinkRequested"
|
"emailLoginLinkRequested"
|
||||||
);
|
);
|
||||||
if (requested) {
|
if (requested) {
|
||||||
@@ -705,7 +1066,7 @@ export const authRouter = createTRPCRouter({
|
|||||||
|
|
||||||
const exp = new Date(Date.now() + 2 * 60 * 1000);
|
const exp = new Date(Date.now() + 2 * 60 * 1000);
|
||||||
setCookie(
|
setCookie(
|
||||||
ctx.event.nativeEvent,
|
getH3Event(ctx),
|
||||||
"emailLoginLinkRequested",
|
"emailLoginLinkRequested",
|
||||||
exp.toUTCString(),
|
exp.toUTCString(),
|
||||||
{
|
{
|
||||||
@@ -745,9 +1106,13 @@ export const authRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const { email } = input;
|
const { email } = input;
|
||||||
|
|
||||||
|
// Apply rate limiting
|
||||||
|
const clientIP = getClientIP(getH3Event(ctx));
|
||||||
|
rateLimitPasswordReset(clientIP, getH3Event(ctx));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const requested = getCookie(
|
const requested = getCookie(
|
||||||
ctx.event.nativeEvent,
|
getH3Event(ctx),
|
||||||
"passwordResetRequested"
|
"passwordResetRequested"
|
||||||
);
|
);
|
||||||
if (requested) {
|
if (requested) {
|
||||||
@@ -821,7 +1186,7 @@ export const authRouter = createTRPCRouter({
|
|||||||
|
|
||||||
const exp = new Date(Date.now() + 5 * 60 * 1000);
|
const exp = new Date(Date.now() + 5 * 60 * 1000);
|
||||||
setCookie(
|
setCookie(
|
||||||
ctx.event.nativeEvent,
|
getH3Event(ctx),
|
||||||
"passwordResetRequested",
|
"passwordResetRequested",
|
||||||
exp.toUTCString(),
|
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" };
|
return { success: true, message: "email sent" };
|
||||||
} catch (error) {
|
} 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) {
|
if (error instanceof TRPCError) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -912,17 +1307,40 @@ export const authRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setCookie(ctx.event.nativeEvent, "emailToken", "", {
|
setCookie(getH3Event(ctx), "emailToken", "", {
|
||||||
maxAge: 0,
|
maxAge: 0,
|
||||||
path: "/"
|
path: "/"
|
||||||
});
|
});
|
||||||
setCookie(ctx.event.nativeEvent, "userIDToken", "", {
|
setCookie(getH3Event(ctx), "userIDToken", "", {
|
||||||
maxAge: 0,
|
maxAge: 0,
|
||||||
path: "/"
|
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" };
|
return { success: true, message: "success" };
|
||||||
} catch (error) {
|
} 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) {
|
if (error instanceof TRPCError) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -939,9 +1357,13 @@ export const authRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const { email } = input;
|
const { email } = input;
|
||||||
|
|
||||||
|
// Apply rate limiting
|
||||||
|
const clientIP = getClientIP(getH3Event(ctx));
|
||||||
|
rateLimitEmailVerification(clientIP, getH3Event(ctx));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const requested = getCookie(
|
const requested = getCookie(
|
||||||
ctx.event.nativeEvent,
|
getH3Event(ctx),
|
||||||
"emailVerificationRequested"
|
"emailVerificationRequested"
|
||||||
);
|
);
|
||||||
if (requested) {
|
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 secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
||||||
const token = await new SignJWT({ email })
|
const token = await new SignJWT({ email })
|
||||||
.setProtectedHeader({ alg: "HS256" })
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
@@ -1016,7 +1440,7 @@ export const authRouter = createTRPCRouter({
|
|||||||
await sendEmail(email, "freno.me email verification", htmlContent);
|
await sendEmail(email, "freno.me email verification", htmlContent);
|
||||||
|
|
||||||
setCookie(
|
setCookie(
|
||||||
ctx.event.nativeEvent,
|
getH3Event(ctx),
|
||||||
"emailVerificationRequested",
|
"emailVerificationRequested",
|
||||||
Date.now().toString(),
|
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" };
|
return { success: true, message: "Verification email sent" };
|
||||||
} catch (error) {
|
} 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) {
|
if (error instanceof TRPCError) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -1052,15 +1506,39 @@ export const authRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
signOut: publicProcedure.mutation(async ({ ctx }) => {
|
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,
|
maxAge: 0,
|
||||||
path: "/"
|
path: "/"
|
||||||
});
|
});
|
||||||
setCookie(ctx.event.nativeEvent, "emailToken", "", {
|
setCookie(getH3Event(ctx), "emailToken", "", {
|
||||||
maxAge: 0,
|
maxAge: 0,
|
||||||
path: "/"
|
path: "/"
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Log signout
|
||||||
|
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
||||||
|
await logAuditEvent({
|
||||||
|
userId,
|
||||||
|
eventType: "auth.logout",
|
||||||
|
eventData: {},
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
success: true
|
||||||
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { validatePassword } from "~/lib/validation";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User API Validation Schemas
|
* User API Validation Schemas
|
||||||
@@ -7,6 +8,31 @@ import { z } from "zod";
|
|||||||
* profile updates, and password management
|
* 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
|
// Authentication Schemas
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -17,8 +43,8 @@ import { z } from "zod";
|
|||||||
export const registerUserSchema = z
|
export const registerUserSchema = z
|
||||||
.object({
|
.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
password: securePasswordSchema,
|
||||||
passwordConfirmation: z.string().min(8)
|
passwordConfirmation: z.string().min(12)
|
||||||
})
|
})
|
||||||
.refine((data) => data.password === data.passwordConfirmation, {
|
.refine((data) => data.password === data.passwordConfirmation, {
|
||||||
message: "Passwords do not match",
|
message: "Passwords do not match",
|
||||||
@@ -73,10 +99,8 @@ export const updateProfileImageSchema = z.object({
|
|||||||
export const changePasswordSchema = z
|
export const changePasswordSchema = z
|
||||||
.object({
|
.object({
|
||||||
oldPassword: z.string().min(1, "Current password is required"),
|
oldPassword: z.string().min(1, "Current password is required"),
|
||||||
newPassword: z
|
newPassword: securePasswordSchema,
|
||||||
.string()
|
newPasswordConfirmation: z.string().min(12)
|
||||||
.min(8, "New password must be at least 8 characters"),
|
|
||||||
newPasswordConfirmation: z.string().min(8)
|
|
||||||
})
|
})
|
||||||
.refine((data) => data.newPassword === data.newPasswordConfirmation, {
|
.refine((data) => data.newPassword === data.newPasswordConfirmation, {
|
||||||
message: "Passwords do not match",
|
message: "Passwords do not match",
|
||||||
@@ -92,8 +116,8 @@ export const changePasswordSchema = z
|
|||||||
*/
|
*/
|
||||||
export const setPasswordSchema = z
|
export const setPasswordSchema = z
|
||||||
.object({
|
.object({
|
||||||
newPassword: z.string().min(8, "Password must be at least 8 characters"),
|
newPassword: securePasswordSchema,
|
||||||
newPasswordConfirmation: z.string().min(8)
|
newPasswordConfirmation: z.string().min(12)
|
||||||
})
|
})
|
||||||
.refine((data) => data.newPassword === data.newPasswordConfirmation, {
|
.refine((data) => data.newPassword === data.newPasswordConfirmation, {
|
||||||
message: "Passwords do not match",
|
message: "Passwords do not match",
|
||||||
@@ -113,8 +137,8 @@ export const requestPasswordResetSchema = z.object({
|
|||||||
export const resetPasswordSchema = z
|
export const resetPasswordSchema = z
|
||||||
.object({
|
.object({
|
||||||
token: z.string().min(1),
|
token: z.string().min(1),
|
||||||
newPassword: z.string().min(8, "Password must be at least 8 characters"),
|
newPassword: securePasswordSchema,
|
||||||
newPasswordConfirmation: z.string().min(8)
|
newPasswordConfirmation: z.string().min(12)
|
||||||
})
|
})
|
||||||
.refine((data) => data.newPassword === data.newPasswordConfirmation, {
|
.refine((data) => data.newPassword === data.newPasswordConfirmation, {
|
||||||
message: "Passwords do not match",
|
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 { OAuth2Client } from "google-auth-library";
|
||||||
import type { Row } from "@libsql/client/web";
|
import type { Row } from "@libsql/client/web";
|
||||||
import { env } from "~/env/server";
|
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(
|
export async function getPrivilegeLevel(
|
||||||
event: H3Event
|
event: H3Event
|
||||||
): Promise<"anonymous" | "admin" | "user"> {
|
): Promise<"anonymous" | "admin" | "user"> {
|
||||||
try {
|
try {
|
||||||
const userIDToken = getCookie(event, "userIDToken");
|
const userIDToken = getCookieValue(event, "userIDToken");
|
||||||
|
|
||||||
if (userIDToken) {
|
if (userIDToken) {
|
||||||
try {
|
try {
|
||||||
@@ -16,14 +115,23 @@ export async function getPrivilegeLevel(
|
|||||||
const { payload } = await jwtVerify(userIDToken, secret);
|
const { payload } = await jwtVerify(userIDToken, secret);
|
||||||
|
|
||||||
if (payload.id && typeof payload.id === "string") {
|
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";
|
return payload.id === env.ADMIN_ID ? "admin" : "user";
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Silently clear invalid token (401s are expected for non-authenticated users)
|
// Silently clear invalid token (401s are expected for non-authenticated users)
|
||||||
setCookie(event, "userIDToken", "", {
|
clearCookie(event, "userIDToken");
|
||||||
maxAge: 0,
|
|
||||||
expires: new Date("2016-10-05")
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -34,7 +142,7 @@ export async function getPrivilegeLevel(
|
|||||||
|
|
||||||
export async function getUserID(event: H3Event): Promise<string | null> {
|
export async function getUserID(event: H3Event): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const userIDToken = getCookie(event, "userIDToken");
|
const userIDToken = getCookieValue(event, "userIDToken");
|
||||||
|
|
||||||
if (userIDToken) {
|
if (userIDToken) {
|
||||||
try {
|
try {
|
||||||
@@ -42,14 +150,23 @@ export async function getUserID(event: H3Event): Promise<string | null> {
|
|||||||
const { payload } = await jwtVerify(userIDToken, secret);
|
const { payload } = await jwtVerify(userIDToken, secret);
|
||||||
|
|
||||||
if (payload.id && typeof payload.id === "string") {
|
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;
|
return payload.id;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Silently clear invalid token (401s are expected for non-authenticated users)
|
// Silently clear invalid token (401s are expected for non-authenticated users)
|
||||||
setCookie(event, "userIDToken", "", {
|
clearCookie(event, "userIDToken");
|
||||||
maxAge: 0,
|
|
||||||
expires: new Date("2016-10-05")
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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";
|
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> {
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
const saltRounds = 10;
|
const saltRounds = 10;
|
||||||
const salt = await bcrypt.genSalt(saltRounds);
|
const salt = await bcrypt.genSalt(saltRounds);
|
||||||
@@ -14,3 +22,19 @@ export async function checkPassword(
|
|||||||
const match = await bcrypt.compare(password, hash);
|
const match = await bcrypt.compare(password, hash);
|
||||||
return match;
|
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
|
getUserBasicInfo
|
||||||
} from "./database";
|
} from "./database";
|
||||||
|
|
||||||
export { hashPassword, checkPassword } from "./password";
|
export { hashPassword, checkPassword, checkPasswordSafe } from "./password";
|
||||||
|
|
||||||
export { sendEmailVerification, LINEAGE_JWT_EXPIRY } from "./email";
|
export { sendEmailVerification, LINEAGE_JWT_EXPIRY } from "./email";
|
||||||
|
|||||||
Reference in New Issue
Block a user