security hardening

This commit is contained in:
Michael Freno
2025-12-28 20:04:29 -05:00
parent aefd467660
commit 1ba20339a8
22 changed files with 5177 additions and 116 deletions

View 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
};
})
});

View File

@@ -3,7 +3,12 @@ import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { v4 as uuidV4 } from "uuid";
import { env } from "~/env/server";
import { ConnectionFactory, hashPassword, checkPassword } from "~/server/utils";
import {
ConnectionFactory,
hashPassword,
checkPassword,
checkPasswordSafe
} from "~/server/utils";
import { SignJWT, jwtVerify } from "jose";
import { setCookie, getCookie } from "vinxi/http";
import type { User } from "~/db/types";
@@ -21,19 +26,106 @@ import {
resetPasswordSchema,
requestPasswordResetSchema
} from "../schemas/user";
import {
setCSRFToken,
csrfProtection,
getClientIP,
getAuditContext,
rateLimitLogin,
rateLimitPasswordReset,
rateLimitRegistration,
rateLimitEmailVerification
} from "~/server/security";
import { logAuditEvent } from "~/server/audit";
/**
* Create JWT with session tracking
* @param userId - User ID
* @param sessionId - Session ID for revocation tracking
* @param expiresIn - Token expiration time (e.g., "14d", "12h")
*/
async function createJWT(
userId: string,
sessionId: string,
expiresIn: string = "14d"
): Promise<string> {
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
const token = await new SignJWT({ id: userId })
const token = await new SignJWT({
id: userId,
sid: sessionId, // Session ID for revocation
iat: Math.floor(Date.now() / 1000)
})
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime(expiresIn)
.sign(secret);
return token;
}
/**
* Create a new session in the database
* @param userId - User ID
* @param expiresIn - Session expiration (e.g., "14d", "12h")
* @param ipAddress - Client IP address
* @param userAgent - Client user agent string
* @returns Session ID
*/
async function createSession(
userId: string,
expiresIn: string,
ipAddress: string,
userAgent: string
): Promise<string> {
const conn = ConnectionFactory();
const sessionId = uuidV4();
// Calculate expiration timestamp
const expiresAt = new Date();
if (expiresIn.endsWith("d")) {
const days = parseInt(expiresIn);
expiresAt.setDate(expiresAt.getDate() + days);
} else if (expiresIn.endsWith("h")) {
const hours = parseInt(expiresIn);
expiresAt.setHours(expiresAt.getHours() + hours);
}
await conn.execute({
sql: `INSERT INTO Session (id, user_id, token_family, expires_at, ip_address, user_agent)
VALUES (?, ?, ?, ?, ?, ?)`,
args: [
sessionId,
userId,
uuidV4(), // token_family for future refresh token rotation
expiresAt.toISOString(),
ipAddress,
userAgent
]
});
return sessionId;
}
/**
* Helper to set authentication cookies including CSRF token
*/
function setAuthCookies(
event: any,
token: string,
options: { maxAge?: number } = {}
) {
const cookieOptions: any = {
path: "/",
httpOnly: true,
secure: true, // Always enforce secure cookies
sameSite: "lax",
...options
};
setCookie(event, "userIDToken", token, cookieOptions);
// Set CSRF token for authenticated session
setCSRFToken(event);
}
async function sendEmail(to: string, subject: string, htmlContent: string) {
const apiKey = env.SENDINBLUE_KEY;
const apiUrl = "https://api.sendinblue.com/v3/smtp/email";
@@ -199,21 +291,58 @@ export const authRouter = createTRPCRouter({
}
}
const token = await createJWT(userId);
// Create session with client info
const clientIP = getClientIP(ctx.event.nativeEvent);
const userAgent =
ctx.event.nativeEvent.request.headers.get("user-agent") || "unknown";
const sessionId = await createSession(
userId,
"14d",
clientIP,
userAgent
);
const token = await createJWT(userId, sessionId);
setCookie(ctx.event.nativeEvent, "userIDToken", token, {
maxAge: 60 * 60 * 24 * 14, // 14 days
path: "/",
httpOnly: true,
secure: env.NODE_ENV === "production",
secure: true, // Always enforce secure cookies
sameSite: "lax"
});
// Set CSRF token for authenticated session
setCSRFToken(ctx.event.nativeEvent);
// Log successful OAuth login
await logAuditEvent({
userId,
eventType: "auth.login.success",
eventData: { method: "github", isNewUser: !res.rows[0] },
ipAddress: clientIP,
userAgent,
success: true
});
return {
success: true,
redirectTo: "/account"
};
} catch (error) {
// Log failed OAuth login
const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent);
await logAuditEvent({
eventType: "auth.login.failed",
eventData: {
method: "github",
reason: error instanceof TRPCError ? error.message : "unknown"
},
ipAddress,
userAgent,
success: false
});
if (error instanceof TRPCError) {
throw error;
}
@@ -345,21 +474,58 @@ export const authRouter = createTRPCRouter({
}
}
const token = await createJWT(userId);
// Create session with client info
const clientIP = getClientIP(ctx.event.nativeEvent);
const userAgent =
ctx.event.nativeEvent.request.headers.get("user-agent") || "unknown";
const sessionId = await createSession(
userId,
"14d",
clientIP,
userAgent
);
const token = await createJWT(userId, sessionId);
setCookie(ctx.event.nativeEvent, "userIDToken", token, {
maxAge: 60 * 60 * 24 * 14, // 14 days
path: "/",
httpOnly: true,
secure: env.NODE_ENV === "production",
secure: true, // Always enforce secure cookies
sameSite: "lax"
});
// Set CSRF token for authenticated session
setCSRFToken(ctx.event.nativeEvent);
// Log successful OAuth login
await logAuditEvent({
userId,
eventType: "auth.login.success",
eventData: { method: "google", isNewUser: !res.rows[0] },
ipAddress: clientIP,
userAgent,
success: true
});
return {
success: true,
redirectTo: "/account"
};
} catch (error) {
// Log failed OAuth login
const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent);
await logAuditEvent({
eventType: "auth.login.failed",
eventData: {
method: "google",
reason: error instanceof TRPCError ? error.message : "unknown"
},
ipAddress,
userAgent,
success: false
});
if (error instanceof TRPCError) {
throw error;
}
@@ -428,12 +594,24 @@ export const authRouter = createTRPCRouter({
const userId = (res.rows[0] as unknown as User).id;
const userToken = await createJWT(userId);
// Create session with client info
const clientIP = getClientIP(ctx.event.nativeEvent);
const userAgent =
ctx.event.nativeEvent.request.headers.get("user-agent") || "unknown";
const expiresIn = rememberMe ? "14d" : "12h";
const sessionId = await createSession(
userId,
expiresIn,
clientIP,
userAgent
);
const userToken = await createJWT(userId, sessionId, expiresIn);
const cookieOptions: any = {
path: "/",
httpOnly: true,
secure: env.NODE_ENV === "production",
secure: true, // Always enforce secure cookies
sameSite: "lax"
};
@@ -448,11 +626,38 @@ export const authRouter = createTRPCRouter({
cookieOptions
);
// Set CSRF token for authenticated session
setCSRFToken(ctx.event.nativeEvent);
// Log successful email link login
await logAuditEvent({
userId,
eventType: "auth.login.success",
eventData: { method: "email_link", rememberMe: rememberMe || false },
ipAddress: clientIP,
userAgent,
success: true
});
return {
success: true,
redirectTo: "/account"
};
} catch (error) {
// Log failed email link login
const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent);
await logAuditEvent({
eventType: "auth.login.failed",
eventData: {
method: "email_link",
email: input.email,
reason: error instanceof TRPCError ? error.message : "unknown"
},
ipAddress,
userAgent,
success: false
});
if (error instanceof TRPCError) {
throw error;
}
@@ -471,7 +676,7 @@ export const authRouter = createTRPCRouter({
token: z.string()
})
)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
const { email, token } = input;
try {
@@ -486,15 +691,47 @@ export const authRouter = createTRPCRouter({
}
const conn = ConnectionFactory();
// Get user ID for audit log
const userRes = await conn.execute({
sql: "SELECT id FROM User WHERE email = ?",
args: [email]
});
const userId = userRes.rows[0] ? (userRes.rows[0] as any).id : null;
const query = `UPDATE User SET email_verified = ? WHERE email = ?`;
const params = [true, email];
await conn.execute({ sql: query, args: params });
// Log successful email verification
const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent);
await logAuditEvent({
userId,
eventType: "auth.email_verified",
eventData: { email },
ipAddress,
userAgent,
success: true
});
return {
success: true,
message: "Email verification success, you may close this window"
};
} catch (error) {
// Log failed email verification
const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent);
await logAuditEvent({
eventType: "auth.email_verified",
eventData: {
email,
reason: error instanceof TRPCError ? error.message : "unknown"
},
ipAddress,
userAgent,
success: false
});
if (error instanceof TRPCError) {
throw error;
}
@@ -511,6 +748,10 @@ export const authRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => {
const { email, password, passwordConfirmation } = input;
// Apply rate limiting
const clientIP = getClientIP(ctx.event.nativeEvent);
rateLimitRegistration(clientIP, ctx.event.nativeEvent);
// Schema already validates password match, but double check
if (password !== passwordConfirmation) {
throw new TRPCError({
@@ -529,18 +770,56 @@ export const authRouter = createTRPCRouter({
args: [userId, email, passwordHash, "email"]
});
const token = await createJWT(userId);
// Create session with client info
const clientIP = getClientIP(ctx.event.nativeEvent);
const userAgent =
ctx.event.nativeEvent.request.headers.get("user-agent") || "unknown";
const sessionId = await createSession(
userId,
"14d",
clientIP,
userAgent
);
const token = await createJWT(userId, sessionId);
setCookie(ctx.event.nativeEvent, "userIDToken", token, {
maxAge: 60 * 60 * 24 * 14, // 14 days
path: "/",
httpOnly: true,
secure: env.NODE_ENV === "production",
secure: true, // Always enforce secure cookies
sameSite: "lax"
});
// Set CSRF token for authenticated session
setCSRFToken(ctx.event.nativeEvent);
// Log successful registration
await logAuditEvent({
userId,
eventType: "auth.registration.success",
eventData: { email, method: "email" },
ipAddress: clientIP,
userAgent,
success: true
});
return { success: true, message: "success" };
} catch (e) {
// Log failed registration
const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent);
await logAuditEvent({
eventType: "auth.registration.failed",
eventData: {
email,
method: "email",
reason: e instanceof Error ? e.message : "unknown"
},
ipAddress,
userAgent,
success: false
});
console.error("Registration error:", e);
throw new TRPCError({
code: "BAD_REQUEST",
@@ -554,31 +833,38 @@ export const authRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => {
const { email, password, rememberMe } = input;
// Apply rate limiting
const clientIP = getClientIP(ctx.event.nativeEvent);
rateLimitLogin(email, clientIP, ctx.event.nativeEvent);
const conn = ConnectionFactory();
const res = await conn.execute({
sql: "SELECT * FROM User WHERE email = ?",
args: [email]
});
if (res.rows.length === 0) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "no-match"
// Always run password check to prevent timing attacks
const user =
res.rows.length > 0 ? (res.rows[0] as unknown as User) : null;
const passwordHash = user?.password_hash || null;
const passwordMatch = await checkPasswordSafe(password, passwordHash);
// Check all conditions after password verification
if (!user || !passwordHash || !passwordMatch) {
// Log failed login attempt
const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent);
await logAuditEvent({
eventType: "auth.login.failed",
eventData: {
email,
method: "password",
reason: "invalid_credentials"
},
ipAddress,
userAgent,
success: false
});
}
const user = res.rows[0] as unknown as User;
if (!user.password_hash) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "no-match"
});
}
const passwordMatch = await checkPassword(password, user.password_hash);
if (!passwordMatch) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "no-match"
@@ -596,12 +882,23 @@ export const authRouter = createTRPCRouter({
}
const expiresIn = rememberMe ? "14d" : "12h";
const token = await createJWT(user.id, expiresIn);
// Create session with client info (reuse clientIP from rate limiting)
const userAgent =
ctx.event.nativeEvent.request.headers.get("user-agent") || "unknown";
const sessionId = await createSession(
user.id,
expiresIn,
clientIP,
userAgent
);
const token = await createJWT(user.id, sessionId, expiresIn);
const cookieOptions: any = {
path: "/",
httpOnly: true,
secure: env.NODE_ENV === "production",
secure: true, // Always enforce secure cookies
sameSite: "lax"
};
@@ -611,6 +908,19 @@ export const authRouter = createTRPCRouter({
setCookie(ctx.event.nativeEvent, "userIDToken", token, cookieOptions);
// Set CSRF token for authenticated session
setCSRFToken(ctx.event.nativeEvent);
// Log successful login
await logAuditEvent({
userId: user.id,
eventType: "auth.login.success",
eventData: { method: "password", rememberMe: rememberMe || false },
ipAddress: clientIP,
userAgent,
success: true
});
return { success: true, message: "success" };
}),
@@ -745,6 +1055,10 @@ export const authRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => {
const { email } = input;
// Apply rate limiting
const clientIP = getClientIP(ctx.event.nativeEvent);
rateLimitPasswordReset(clientIP, ctx.event.nativeEvent);
try {
const requested = getCookie(
ctx.event.nativeEvent,
@@ -830,8 +1144,38 @@ export const authRouter = createTRPCRouter({
}
);
// Log password reset request
const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent);
await logAuditEvent({
userId: user.id,
eventType: "auth.password_reset.requested",
eventData: { email },
ipAddress,
userAgent,
success: true
});
return { success: true, message: "email sent" };
} catch (error) {
// Log failed password reset request (only if not rate limited)
if (
!(error instanceof TRPCError && error.code === "TOO_MANY_REQUESTS")
) {
const { ipAddress, userAgent } = getAuditContext(
ctx.event.nativeEvent
);
await logAuditEvent({
eventType: "auth.password_reset.requested",
eventData: {
email: input.email,
reason: error instanceof TRPCError ? error.message : "unknown"
},
ipAddress,
userAgent,
success: false
});
}
if (error instanceof TRPCError) {
throw error;
}
@@ -921,8 +1265,31 @@ export const authRouter = createTRPCRouter({
path: "/"
});
// Log successful password reset
const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent);
await logAuditEvent({
userId: payload.id,
eventType: "auth.password_reset.completed",
eventData: {},
ipAddress,
userAgent,
success: true
});
return { success: true, message: "success" };
} catch (error) {
// Log failed password reset
const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent);
await logAuditEvent({
eventType: "auth.password_reset.completed",
eventData: {
reason: error instanceof TRPCError ? error.message : "unknown"
},
ipAddress,
userAgent,
success: false
});
if (error instanceof TRPCError) {
throw error;
}
@@ -939,6 +1306,10 @@ export const authRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => {
const { email } = input;
// Apply rate limiting
const clientIP = getClientIP(ctx.event.nativeEvent);
rateLimitEmailVerification(clientIP, ctx.event.nativeEvent);
try {
const requested = getCookie(
ctx.event.nativeEvent,
@@ -971,6 +1342,8 @@ export const authRouter = createTRPCRouter({
});
}
const user = res.rows[0] as unknown as User;
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
const token = await new SignJWT({ email })
.setProtectedHeader({ alg: "HS256" })
@@ -1025,8 +1398,38 @@ export const authRouter = createTRPCRouter({
}
);
// Log email verification request
const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent);
await logAuditEvent({
userId: user.id,
eventType: "auth.email_verification.requested",
eventData: { email },
ipAddress,
userAgent,
success: true
});
return { success: true, message: "Verification email sent" };
} catch (error) {
// Log failed email verification request (only if not rate limited)
if (
!(error instanceof TRPCError && error.code === "TOO_MANY_REQUESTS")
) {
const { ipAddress, userAgent } = getAuditContext(
ctx.event.nativeEvent
);
await logAuditEvent({
eventType: "auth.email_verification.requested",
eventData: {
email: input.email,
reason: error instanceof TRPCError ? error.message : "unknown"
},
ipAddress,
userAgent,
success: false
});
}
if (error instanceof TRPCError) {
throw error;
}
@@ -1052,6 +1455,19 @@ export const authRouter = createTRPCRouter({
}),
signOut: publicProcedure.mutation(async ({ ctx }) => {
// Try to get user ID for audit log before clearing cookies
let userId: string | null = null;
try {
const token = getCookie(ctx.event.nativeEvent, "userIDToken");
if (token) {
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
const { payload } = await jwtVerify(token, secret);
userId = payload.id as string;
}
} catch (e) {
// Ignore token verification errors during signout
}
setCookie(ctx.event.nativeEvent, "userIDToken", "", {
maxAge: 0,
path: "/"
@@ -1061,6 +1477,17 @@ export const authRouter = createTRPCRouter({
path: "/"
});
// Log signout
const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent);
await logAuditEvent({
userId,
eventType: "auth.logout",
eventData: {},
ipAddress,
userAgent,
success: true
});
return { success: true };
})
});