security hardening
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { exampleRouter } from "./routers/example";
|
||||
import { authRouter } from "./routers/auth";
|
||||
import { auditRouter } from "./routers/audit";
|
||||
import { databaseRouter } from "./routers/database";
|
||||
import { lineageRouter } from "./routers/lineage";
|
||||
import { miscRouter } from "./routers/misc";
|
||||
@@ -13,6 +14,7 @@ import { createTRPCRouter } from "./utils";
|
||||
export const appRouter = createTRPCRouter({
|
||||
example: exampleRouter,
|
||||
auth: authRouter,
|
||||
audit: auditRouter,
|
||||
database: databaseRouter,
|
||||
lineage: lineageRouter,
|
||||
misc: miscRouter,
|
||||
|
||||
133
src/server/api/routers/audit.ts
Normal file
133
src/server/api/routers/audit.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { createTRPCRouter, adminProcedure } from "../utils";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
queryAuditLogs,
|
||||
getUserSecuritySummary,
|
||||
cleanupOldLogs,
|
||||
getFailedLoginAttempts,
|
||||
detectSuspiciousActivity
|
||||
} from "~/server/audit";
|
||||
|
||||
/**
|
||||
* Audit log router - admin-only endpoints for querying security logs
|
||||
*/
|
||||
export const auditRouter = createTRPCRouter({
|
||||
/**
|
||||
* Query audit logs with filters
|
||||
*/
|
||||
getLogs: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
userId: z.string().optional(),
|
||||
eventType: z.string().optional(),
|
||||
success: z.boolean().optional(),
|
||||
startDate: z.string().optional(),
|
||||
endDate: z.string().optional(),
|
||||
limit: z.number().min(1).max(1000).default(100),
|
||||
offset: z.number().min(0).default(0)
|
||||
})
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const logs = await queryAuditLogs({
|
||||
userId: input.userId,
|
||||
eventType: input.eventType as any,
|
||||
success: input.success,
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
limit: input.limit,
|
||||
offset: input.offset
|
||||
});
|
||||
|
||||
return {
|
||||
logs,
|
||||
count: logs.length,
|
||||
offset: input.offset,
|
||||
limit: input.limit
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get failed login attempts (last 24 hours by default)
|
||||
*/
|
||||
getFailedLogins: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
hours: z.number().min(1).max(168).default(24),
|
||||
limit: z.number().min(1).max(1000).default(100)
|
||||
})
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const attempts = (await getFailedLoginAttempts(
|
||||
input.hours,
|
||||
input.limit
|
||||
)) as Array<any>;
|
||||
|
||||
return {
|
||||
attempts,
|
||||
count: attempts.length,
|
||||
timeWindow: `${input.hours} hours`
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get security summary for a specific user
|
||||
*/
|
||||
getUserSummary: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
userId: z.string(),
|
||||
days: z.number().min(1).max(90).default(30)
|
||||
})
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const summary = await getUserSecuritySummary(input.userId, input.days);
|
||||
|
||||
return {
|
||||
userId: input.userId,
|
||||
summary,
|
||||
timeWindow: `${input.days} days`
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Detect suspicious activity patterns
|
||||
*/
|
||||
getSuspiciousActivity: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
hours: z.number().min(1).max(168).default(24),
|
||||
minFailedAttempts: z.number().min(1).default(5)
|
||||
})
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const suspicious = (await detectSuspiciousActivity(
|
||||
input.hours,
|
||||
input.minFailedAttempts
|
||||
)) as Array<any>;
|
||||
|
||||
return {
|
||||
suspicious,
|
||||
count: suspicious.length,
|
||||
timeWindow: `${input.hours} hours`,
|
||||
threshold: input.minFailedAttempts
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Clean up old logs
|
||||
*/
|
||||
cleanupLogs: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
olderThanDays: z.number().min(1).max(365).default(90)
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
const deleted = await cleanupOldLogs(input.olderThanDays);
|
||||
|
||||
return {
|
||||
deleted,
|
||||
olderThanDays: input.olderThanDays
|
||||
};
|
||||
})
|
||||
});
|
||||
@@ -3,7 +3,12 @@ import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { v4 as uuidV4 } from "uuid";
|
||||
import { env } from "~/env/server";
|
||||
import { ConnectionFactory, hashPassword, checkPassword } from "~/server/utils";
|
||||
import {
|
||||
ConnectionFactory,
|
||||
hashPassword,
|
||||
checkPassword,
|
||||
checkPasswordSafe
|
||||
} from "~/server/utils";
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
import { setCookie, getCookie } from "vinxi/http";
|
||||
import type { User } from "~/db/types";
|
||||
@@ -21,19 +26,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 };
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { validatePassword } from "~/lib/validation";
|
||||
|
||||
/**
|
||||
* User API Validation Schemas
|
||||
@@ -7,6 +8,31 @@ import { z } from "zod";
|
||||
* profile updates, and password management
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Custom Password Validator
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Secure password validation with strength requirements
|
||||
* Minimum 12 characters, uppercase, lowercase, number, and special character
|
||||
*/
|
||||
const securePasswordSchema = z
|
||||
.string()
|
||||
.min(12, "Password must be at least 12 characters")
|
||||
.refine(
|
||||
(password) => {
|
||||
const result = validatePassword(password);
|
||||
return result.isValid;
|
||||
},
|
||||
(password) => {
|
||||
const result = validatePassword(password);
|
||||
return {
|
||||
message:
|
||||
result.errors.join(", ") || "Password does not meet requirements"
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Authentication Schemas
|
||||
// ============================================================================
|
||||
@@ -17,8 +43,8 @@ import { z } from "zod";
|
||||
export const registerUserSchema = z
|
||||
.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
passwordConfirmation: z.string().min(8)
|
||||
password: securePasswordSchema,
|
||||
passwordConfirmation: z.string().min(12)
|
||||
})
|
||||
.refine((data) => data.password === data.passwordConfirmation, {
|
||||
message: "Passwords do not match",
|
||||
@@ -73,10 +99,8 @@ export const updateProfileImageSchema = z.object({
|
||||
export const changePasswordSchema = z
|
||||
.object({
|
||||
oldPassword: z.string().min(1, "Current password is required"),
|
||||
newPassword: z
|
||||
.string()
|
||||
.min(8, "New password must be at least 8 characters"),
|
||||
newPasswordConfirmation: z.string().min(8)
|
||||
newPassword: securePasswordSchema,
|
||||
newPasswordConfirmation: z.string().min(12)
|
||||
})
|
||||
.refine((data) => data.newPassword === data.newPasswordConfirmation, {
|
||||
message: "Passwords do not match",
|
||||
@@ -92,8 +116,8 @@ export const changePasswordSchema = z
|
||||
*/
|
||||
export const setPasswordSchema = z
|
||||
.object({
|
||||
newPassword: z.string().min(8, "Password must be at least 8 characters"),
|
||||
newPasswordConfirmation: z.string().min(8)
|
||||
newPassword: securePasswordSchema,
|
||||
newPasswordConfirmation: z.string().min(12)
|
||||
})
|
||||
.refine((data) => data.newPassword === data.newPasswordConfirmation, {
|
||||
message: "Passwords do not match",
|
||||
@@ -113,8 +137,8 @@ export const requestPasswordResetSchema = z.object({
|
||||
export const resetPasswordSchema = z
|
||||
.object({
|
||||
token: z.string().min(1),
|
||||
newPassword: z.string().min(8, "Password must be at least 8 characters"),
|
||||
newPasswordConfirmation: z.string().min(8)
|
||||
newPassword: securePasswordSchema,
|
||||
newPasswordConfirmation: z.string().min(12)
|
||||
})
|
||||
.refine((data) => data.newPassword === data.newPasswordConfirmation, {
|
||||
message: "Passwords do not match",
|
||||
|
||||
Reference in New Issue
Block a user