1adc4806fa
Security Fixes
File Change
.env.local Placeholder JWT secret (rotate to random value: openssl rand -base64 64)
src/middleware.ts Removed /api/auth/me bypass; API routes return JSON 401 (not 302 redirect)
src/lib/auth.ts sameSite: "lax" → "strict" (create + destroy cookie); SVG name chars are pre-computed (no injection)
src/lib/avatar.ts Added sanitizeName() — strips non-alphanumeric/safe chars, max 50 chars
src/app/api/users/route.ts POST restricted to super_admin; validates role against whitelist ["sales","admin","super_admin","dev"]; validates active defaults to true
src/app/api/users/[id]/route.ts DELETE restricted to super_admin; blocks self-deletion
src/app/api/leads/[id]/route.ts GET: non-admin users filtered by assigned_to; PATCH: ownership check + score validated 0-100 range
src/app/api/leads/route.ts DELETE: ownership + admin check; GET: ownership filter for non-admin users
src/app/api/conversations/[id]/messages/route.ts GET/POST: verify user is a conversation_participant before access
rust-ai/src/main.rs CORS: AllowOrigin::list(["localhost:3006", "127.0.0.1:3006"]) (was Any)
Performance Fixes
File Change
src/app/api/leads/route.ts Added limit (default 50), offset query params; ownership filter in SQL
src/app/api/users/route.ts Added limit (default 50), offset query params
src/app/api/conversations/route.ts Added LIMIT 50
src/app/api/leads/[id]/notes/route.ts Added limit (default 50), offset query params
src/app/api/conversations/[id]/messages/route.ts Added limit (default 100), offset, before query params
src/app/(dashboard)/chats/page.tsx Pruned to 5 cached conversations; useMemo wraps messages/conversation/filteredConversations; otherParticipant moved outside component; added 10MB file size limit; recordingChunksRef cleared on discard
src/app/(dashboard)/leads/[id]/page.tsx Optimistic status update rolls back on fetch failure
database/migrations/003_chat.sql Added composite index idx_messages_conversation_created on (conversation_id, created_at DESC)
Code Quality Fixes
File Change
src/lib/ai.ts Removed dead getInstructions()/updateInstructions() (called nonexistent /ai/instructions)
src/lib/auth.ts bcrypt.hash(password, 12) — kept (acceptable, OWASP-recommended)
src/app/login/page.tsx "Forgot password?" button now shows alert to contact admin (was no-op)
src/components/users/user-form-dialog.tsx Password min 6 → 8 chars
26 silent catch {} blocks across 16 files (see below) Added console.warn("...") context messages
238 lines
6.7 KiB
TypeScript
238 lines
6.7 KiB
TypeScript
import { SignJWT, jwtVerify } from "jose";
|
|
import bcrypt from "bcryptjs";
|
|
import { query } from "@/lib/db";
|
|
import { cookies } from "next/headers";
|
|
|
|
const RAW_SECRET = process.env.JWT_SECRET;
|
|
if (!RAW_SECRET) {
|
|
throw new Error("JWT_SECRET environment variable is required");
|
|
}
|
|
const JWT_SECRET = new TextEncoder().encode(RAW_SECRET);
|
|
|
|
const SESSION_COOKIE = "session";
|
|
const MAX_FAILED_ATTEMPTS = 5;
|
|
const LOCKOUT_DURATION_MINUTES = 15;
|
|
|
|
export interface SessionUser {
|
|
id: string;
|
|
username: string;
|
|
email: string;
|
|
firstName: string;
|
|
lastName: string;
|
|
role: string;
|
|
avatar: string;
|
|
}
|
|
|
|
export async function hashPassword(password: string): Promise<string> {
|
|
return bcrypt.hash(password, 12);
|
|
}
|
|
|
|
export async function comparePassword(
|
|
password: string,
|
|
hash: string,
|
|
): Promise<boolean> {
|
|
return bcrypt.compare(password, hash);
|
|
}
|
|
|
|
export async function signToken(payload: { userId: string; role: string }) {
|
|
return new SignJWT(payload)
|
|
.setProtectedHeader({ alg: "HS256" })
|
|
.setExpirationTime("24h")
|
|
.setIssuedAt()
|
|
.sign(JWT_SECRET);
|
|
}
|
|
|
|
export async function verifyToken(token: string) {
|
|
try {
|
|
const { payload } = await jwtVerify(token, JWT_SECRET);
|
|
return payload as { userId: string; role: string };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function getUserByEmail(email: string) {
|
|
const result = await query(
|
|
` SELECT u.id, u.username, u.email, u.password_hash, u.first_name, u.last_name,
|
|
u.is_active, u.is_locked, u.failed_login_attempts, u.locked_until,
|
|
u.password_change_required, u.avatar_url,
|
|
r.name AS role_name
|
|
FROM users u
|
|
JOIN user_roles ur ON ur.user_id = u.id
|
|
JOIN roles r ON r.id = ur.role_id
|
|
WHERE u.email = $1 AND u.deleted_at IS NULL
|
|
LIMIT 1`,
|
|
[email.toLowerCase().trim()],
|
|
);
|
|
return result.rows[0] || null;
|
|
}
|
|
|
|
export async function getUserByUsername(username: string) {
|
|
const result = await query(
|
|
` SELECT u.id, u.username, u.email, u.password_hash, u.first_name, u.last_name,
|
|
u.is_active, u.is_locked, u.failed_login_attempts, u.locked_until,
|
|
u.password_change_required, u.avatar_url,
|
|
r.name AS role_name
|
|
FROM users u
|
|
JOIN user_roles ur ON ur.user_id = u.id
|
|
JOIN roles r ON r.id = ur.role_id
|
|
WHERE u.username = $1 AND u.deleted_at IS NULL
|
|
LIMIT 1`,
|
|
[username.toLowerCase().trim()],
|
|
);
|
|
return result.rows[0] || null;
|
|
}
|
|
|
|
export async function getUserById(id: string) {
|
|
const result = await query(
|
|
` SELECT u.id, u.username, u.email, u.first_name, u.last_name,
|
|
u.is_active, u.avatar_url,
|
|
r.name AS role_name
|
|
FROM users u
|
|
JOIN user_roles ur ON ur.user_id = u.id
|
|
JOIN roles r ON r.id = ur.role_id
|
|
WHERE u.id = $1 AND u.deleted_at IS NULL
|
|
LIMIT 1`,
|
|
[id],
|
|
);
|
|
return result.rows[0] || null;
|
|
}
|
|
|
|
export async function recordLoginAttempt(
|
|
userId: string | null,
|
|
usernameAttempted: string,
|
|
ipAddress: string,
|
|
userAgent: string | null,
|
|
wasSuccessful: boolean,
|
|
failureReason?: string,
|
|
) {
|
|
await query(
|
|
`INSERT INTO login_attempts (user_id, username_attempted, ip_address, user_agent, was_successful, failure_reason)
|
|
VALUES ($1, $2, $3, $4, $5, $6)`,
|
|
[
|
|
userId,
|
|
usernameAttempted,
|
|
ipAddress,
|
|
userAgent,
|
|
wasSuccessful,
|
|
failureReason || null,
|
|
],
|
|
);
|
|
}
|
|
|
|
export async function incrementFailedAttempts(userId: string) {
|
|
await query(
|
|
`UPDATE users
|
|
SET failed_login_attempts = failed_login_attempts + 1,
|
|
locked_until = CASE
|
|
WHEN failed_login_attempts + 1 >= $2
|
|
THEN NOW() + (INTERVAL '1 minute' * $3)
|
|
ELSE locked_until
|
|
END
|
|
WHERE id = $1`,
|
|
[userId, MAX_FAILED_ATTEMPTS, LOCKOUT_DURATION_MINUTES],
|
|
);
|
|
}
|
|
|
|
export async function resetFailedAttempts(userId: string) {
|
|
await query(
|
|
`UPDATE users
|
|
SET failed_login_attempts = 0,
|
|
locked_until = NULL,
|
|
last_login_at = NOW()
|
|
WHERE id = $1`,
|
|
[userId],
|
|
);
|
|
}
|
|
|
|
export async function isAccountLocked(user: {
|
|
is_locked: boolean;
|
|
locked_until: Date | null;
|
|
}): Promise<{ locked: boolean; reason?: string }> {
|
|
if (user.is_locked) {
|
|
return {
|
|
locked: true,
|
|
reason: "Account has been locked by an administrator.",
|
|
};
|
|
}
|
|
if (user.locked_until && new Date(user.locked_until) > new Date()) {
|
|
const minutes = Math.ceil(
|
|
(new Date(user.locked_until).getTime() - Date.now()) / 60000,
|
|
);
|
|
return {
|
|
locked: true,
|
|
reason: `Account is temporarily locked due to too many failed attempts. Try again in ${minutes} minute(s).`,
|
|
};
|
|
}
|
|
return { locked: false };
|
|
}
|
|
|
|
export function mapDbUserToSessionUser(
|
|
dbUser: Record<string, unknown>,
|
|
): SessionUser {
|
|
const roleName = dbUser.role_name as string;
|
|
|
|
const roleMapping: Record<string, string> = {
|
|
SUPER_ADMIN: "super_admin",
|
|
ADMIN: "admin",
|
|
SALES_USER: "sales",
|
|
DEVELOPER: "dev",
|
|
};
|
|
|
|
const avatarUrl = dbUser.avatar_url as string | null
|
|
|
|
return {
|
|
id: dbUser.id as string,
|
|
username: dbUser.username as string,
|
|
email: dbUser.email as string,
|
|
firstName: dbUser.first_name as string,
|
|
lastName: dbUser.last_name as string,
|
|
role: roleMapping[roleName] || roleName.toLowerCase(),
|
|
avatar: avatarUrl || (() => { const f = ((dbUser.first_name as string)?.[0] || '').toUpperCase(); const l = ((dbUser.last_name as string)?.[0] || '').toUpperCase(); return `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128"><rect width="128" height="128" fill="#1d4ed8"/><text x="64" y="80" font-size="48" fill="white" text-anchor="middle" font-family="Arial,Helvetica,sans-serif">${f}${l}</text></svg>`)}` })(),
|
|
};
|
|
}
|
|
|
|
export async function createSession(userId: string, role: string) {
|
|
const token = await signToken({ userId, role });
|
|
|
|
const cookieStore = await cookies();
|
|
cookieStore.set(SESSION_COOKIE, token, {
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === "production",
|
|
sameSite: "strict",
|
|
path: "/",
|
|
maxAge: 60 * 60 * 24, // 24 hours
|
|
});
|
|
|
|
return token;
|
|
}
|
|
|
|
export async function destroySession() {
|
|
const cookieStore = await cookies();
|
|
cookieStore.set(SESSION_COOKIE, "", {
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === "production",
|
|
sameSite: "strict",
|
|
path: "/",
|
|
maxAge: 0,
|
|
});
|
|
}
|
|
|
|
export async function getSessionUser(): Promise<SessionUser | null> {
|
|
try {
|
|
const cookieStore = await cookies();
|
|
const token = cookieStore.get(SESSION_COOKIE)?.value;
|
|
if (!token) return null;
|
|
|
|
const payload = await verifyToken(token);
|
|
if (!payload) return null;
|
|
|
|
const dbUser = await getUserById(payload.userId);
|
|
if (!dbUser) return null;
|
|
|
|
return mapDbUserToSessionUser(dbUser);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|