Some checks are pending
CI - Production Readiness / Verify (push) Waiting to run
487 lines
12 KiB
TypeScript
487 lines
12 KiB
TypeScript
import "server-only";
|
|
import { cookies } from "next/headers";
|
|
import { RoleCode, UserStatus } from "@prisma/client";
|
|
|
|
import { prisma } from "@/lib/prisma";
|
|
|
|
export type UserRole = "super_admin" | "admin_client" | "agent";
|
|
|
|
export type AuthSession = {
|
|
userId: string;
|
|
fullName: string;
|
|
email: string;
|
|
role: UserRole;
|
|
tenantId: string;
|
|
tenantName: string;
|
|
issuedAt: number;
|
|
expiresAt: number;
|
|
extraPermissions: string[];
|
|
};
|
|
|
|
export const SESSION_COOKIE = "wa_inbox_session";
|
|
const DEFAULT_SESSION_TTL_SECONDS = 60 * 60 * 24 * 7;
|
|
const SESSION_TTL_SECONDS = getConfiguredSessionTtlSeconds(process.env.SESSION_TTL_SECONDS);
|
|
export const SESSION_COOKIE_DOMAIN = process.env.SESSION_COOKIE_DOMAIN?.trim() || "";
|
|
export const SESSION_COOKIE_SECURE_ENV = process.env.COOKIE_SECURE?.trim().toLowerCase() || "";
|
|
|
|
function getConfiguredSessionTtlSeconds(raw: string | undefined) {
|
|
if (typeof raw === "string" && raw.trim().length > 0) {
|
|
const parsed = Number(raw.trim());
|
|
if (Number.isFinite(parsed) && parsed > 0) {
|
|
return Math.floor(parsed);
|
|
}
|
|
}
|
|
|
|
const legacyHours = Number(process.env.SESSION_TTL_HOURS);
|
|
if (Number.isFinite(legacyHours) && legacyHours > 0) {
|
|
return Math.floor(legacyHours * 60 * 60);
|
|
}
|
|
|
|
return DEFAULT_SESSION_TTL_SECONDS;
|
|
}
|
|
|
|
export function getSessionTtlSeconds() {
|
|
return SESSION_TTL_SECONDS;
|
|
}
|
|
|
|
function parseCookieDomain() {
|
|
if (!SESSION_COOKIE_DOMAIN) {
|
|
return undefined;
|
|
}
|
|
|
|
if (SESSION_COOKIE_DOMAIN === "localhost" || SESSION_COOKIE_DOMAIN === "127.0.0.1") {
|
|
return undefined;
|
|
}
|
|
|
|
return SESSION_COOKIE_DOMAIN;
|
|
}
|
|
|
|
export function getSessionCookieDomain() {
|
|
return parseCookieDomain();
|
|
}
|
|
const AUTH_SECRET = process.env.AUTH_SECRET;
|
|
const SESSION_ITERATIONS = 120000;
|
|
|
|
const DEFAULT_SECRET = "whatsapp-inbox-dev-secret";
|
|
const resolvedAuthSecret =
|
|
AUTH_SECRET || (process.env.NODE_ENV === "production" ? undefined : DEFAULT_SECRET);
|
|
|
|
if (!resolvedAuthSecret) {
|
|
throw new Error("AUTH_SECRET is required in production for secure session signing.");
|
|
}
|
|
|
|
const textEncoder = new TextEncoder();
|
|
const textDecoder = new TextDecoder();
|
|
|
|
const SESSION_SECRET_FALLBACK = textEncoder.encode(resolvedAuthSecret);
|
|
|
|
type RolePermissionSet = Set<string>;
|
|
|
|
function normalizePermissionToken(value: unknown) {
|
|
if (typeof value !== "string") {
|
|
return null;
|
|
}
|
|
|
|
const trimmed = value.trim();
|
|
return trimmed.length > 0 ? trimmed : null;
|
|
}
|
|
|
|
function collectPermissionGrants(target: RolePermissionSet, rawValue: unknown) {
|
|
const source =
|
|
typeof rawValue === "string" && rawValue.length > 0
|
|
? rawValue.trim()
|
|
: rawValue;
|
|
|
|
if (typeof source === "string") {
|
|
try {
|
|
const parsed = JSON.parse(source);
|
|
collectPermissionGrants(target, parsed);
|
|
return;
|
|
} catch {
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (!source) {
|
|
return;
|
|
}
|
|
|
|
if (Array.isArray(source)) {
|
|
for (const item of source) {
|
|
const token = normalizePermissionToken(item);
|
|
if (!token) {
|
|
continue;
|
|
}
|
|
target.add(token);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (typeof source !== "object" || source === null) {
|
|
return;
|
|
}
|
|
|
|
const entries = Object.entries(source as Record<string, unknown>);
|
|
for (const [key, entry] of entries) {
|
|
if (key === "global" && entry === true) {
|
|
target.add("*");
|
|
continue;
|
|
}
|
|
|
|
if (key === "tenant" && entry === true) {
|
|
target.add("tenant:read");
|
|
continue;
|
|
}
|
|
|
|
if (key === "inbox" && entry === true) {
|
|
target.add("inbox:read");
|
|
target.add("inbox:assign");
|
|
target.add("inbox:status");
|
|
target.add("inbox:reply");
|
|
target.add("inbox:notes");
|
|
target.add("inbox:tags");
|
|
continue;
|
|
}
|
|
|
|
if (key === "admin" && entry === true) {
|
|
target.add("admin:read");
|
|
target.add("admin:manage");
|
|
continue;
|
|
}
|
|
|
|
if (key === "agent" && entry === true) {
|
|
target.add("agent:read");
|
|
target.add("agent:manage");
|
|
continue;
|
|
}
|
|
|
|
if (key === "profile_manage_self" && entry === true) {
|
|
target.add("profile:manage_self");
|
|
continue;
|
|
}
|
|
|
|
if (typeof entry === "string") {
|
|
const token = normalizePermissionToken(entry);
|
|
if (!token) {
|
|
continue;
|
|
}
|
|
target.add(token);
|
|
continue;
|
|
}
|
|
|
|
if (entry === true) {
|
|
const token = normalizePermissionToken(key);
|
|
if (token) {
|
|
target.add(token);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
collectPermissionGrants(target, entry);
|
|
}
|
|
}
|
|
|
|
function parseRolePermissions(rawPermissions: string | null | undefined) {
|
|
const permissionSet: RolePermissionSet = new Set<string>();
|
|
collectPermissionGrants(permissionSet, rawPermissions);
|
|
return Array.from(permissionSet);
|
|
}
|
|
|
|
function uint8ToBase64Url(bytes: Uint8Array) {
|
|
let str = "";
|
|
for (const byte of bytes) {
|
|
str += String.fromCharCode(byte);
|
|
}
|
|
|
|
return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
}
|
|
|
|
function base64UrlToUint8(value: string) {
|
|
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
|
|
const binary = atob(padded);
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let i = 0; i < binary.length; i += 1) {
|
|
bytes[i] = binary.charCodeAt(i);
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
async function getSessionHmacKey() {
|
|
return crypto.subtle.importKey(
|
|
"raw",
|
|
SESSION_SECRET_FALLBACK,
|
|
{ name: "HMAC", hash: "SHA-256" },
|
|
false,
|
|
["sign", "verify"]
|
|
);
|
|
}
|
|
|
|
function equalBytes(a: Uint8Array, b: Uint8Array) {
|
|
if (a.length !== b.length) {
|
|
return false;
|
|
}
|
|
|
|
let diff = 0;
|
|
for (let i = 0; i < a.length; i += 1) {
|
|
diff |= a[i] ^ b[i];
|
|
}
|
|
return diff === 0;
|
|
}
|
|
|
|
function resolveRoleFromCode(code: RoleCode): UserRole {
|
|
switch (code) {
|
|
case RoleCode.SUPER_ADMIN:
|
|
return "super_admin";
|
|
case RoleCode.AGENT:
|
|
return "agent";
|
|
case RoleCode.ADMIN_CLIENT:
|
|
default:
|
|
return "admin_client";
|
|
}
|
|
}
|
|
|
|
export async function serializeSession(session: AuthSession) {
|
|
const payload = `${session.userId}|${session.role}|${session.tenantId}|${session.expiresAt}`;
|
|
const key = await getSessionHmacKey();
|
|
const signature = await crypto.subtle.sign("HMAC", key, textEncoder.encode(payload));
|
|
|
|
return `${uint8ToBase64Url(textEncoder.encode(payload))}.${uint8ToBase64Url(new Uint8Array(signature))}`;
|
|
}
|
|
|
|
export async function parseSessionCookie(raw: string) {
|
|
try {
|
|
const [payload, signature] = raw.split(".");
|
|
if (!payload || !signature) {
|
|
return null;
|
|
}
|
|
|
|
const key = await getSessionHmacKey();
|
|
const payloadBytes = base64UrlToUint8(payload);
|
|
const signatureBytes = base64UrlToUint8(signature);
|
|
const valid = await crypto.subtle.verify("HMAC", key, signatureBytes, payloadBytes);
|
|
if (!valid) {
|
|
return null;
|
|
}
|
|
|
|
const decoded = textDecoder.decode(payloadBytes);
|
|
const [userId, role, tenantId, exp] = decoded.split("|");
|
|
if (!userId || !role || !tenantId || !exp) {
|
|
return null;
|
|
}
|
|
|
|
const expiresAt = Number(exp);
|
|
if (!Number.isFinite(expiresAt) || expiresAt < Math.floor(Date.now() / 1000)) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
userId,
|
|
role: role as UserRole,
|
|
tenantId,
|
|
tenantName: "",
|
|
fullName: "",
|
|
email: "",
|
|
issuedAt: Math.floor(Date.now() / 1000),
|
|
expiresAt
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function getSession() {
|
|
const store = await cookies();
|
|
const raw = store.get(SESSION_COOKIE)?.value;
|
|
if (!raw) {
|
|
return null;
|
|
}
|
|
|
|
const parsed = await parseSessionCookie(raw);
|
|
if (!parsed) {
|
|
return null;
|
|
}
|
|
|
|
const user = await prisma.user.findUnique({
|
|
where: { id: parsed.userId },
|
|
include: { role: true, tenant: true }
|
|
});
|
|
|
|
if (!user) {
|
|
return {
|
|
userId: parsed.userId,
|
|
fullName: "User",
|
|
email: "",
|
|
role: parsed.role,
|
|
tenantId: parsed.tenantId,
|
|
tenantName: parsed.tenantId,
|
|
extraPermissions: [],
|
|
issuedAt: parsed.issuedAt,
|
|
expiresAt: parsed.expiresAt
|
|
};
|
|
}
|
|
|
|
return {
|
|
userId: user.id,
|
|
fullName: user.fullName,
|
|
email: user.email,
|
|
role: resolveRoleFromCode(user.role.code),
|
|
tenantId: user.tenantId,
|
|
tenantName: user.tenant.companyName || user.tenant.name,
|
|
extraPermissions: parseRolePermissions(user.role.permissionsJson),
|
|
issuedAt: parsed.issuedAt,
|
|
expiresAt: parsed.expiresAt
|
|
};
|
|
}
|
|
|
|
function normalizeSalt(value: string) {
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return base64UrlToUint8(value);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function hashPassword(password: string) {
|
|
const salt = new Uint8Array(16);
|
|
crypto.getRandomValues(salt);
|
|
|
|
const passwordBits = textEncoder.encode(password);
|
|
const key = await crypto.subtle.importKey("raw", passwordBits, "PBKDF2", false, ["deriveBits"]);
|
|
const digest = await crypto.subtle.deriveBits(
|
|
{
|
|
name: "PBKDF2",
|
|
hash: "SHA-256",
|
|
salt,
|
|
iterations: SESSION_ITERATIONS
|
|
},
|
|
key,
|
|
32 * 8
|
|
);
|
|
|
|
return `pbkdf2$${SESSION_ITERATIONS}$${uint8ToBase64Url(salt)}$${uint8ToBase64Url(new Uint8Array(digest))}`;
|
|
}
|
|
|
|
export async function verifyPassword(password: string, hash: string) {
|
|
if (!password || !hash) {
|
|
return false;
|
|
}
|
|
|
|
const parts = hash.split("$");
|
|
if (parts.length !== 4 || parts[0] !== "pbkdf2") {
|
|
return false;
|
|
}
|
|
|
|
const [, iterationRaw, salt, expectedDigest] = parts;
|
|
if (!iterationRaw || !salt || !expectedDigest) {
|
|
return false;
|
|
}
|
|
|
|
const iterations = Number(iterationRaw);
|
|
if (!Number.isFinite(iterations) || iterations <= 0) {
|
|
return false;
|
|
}
|
|
|
|
const normalizedSalt = normalizeSalt(salt);
|
|
const normalizedHash = normalizeSalt(expectedDigest);
|
|
if (!normalizedSalt || !normalizedHash) {
|
|
return false;
|
|
}
|
|
|
|
const passwordBits = textEncoder.encode(password);
|
|
const key = await crypto.subtle.importKey("raw", passwordBits, "PBKDF2", false, ["deriveBits"]);
|
|
const derived = await crypto.subtle.deriveBits(
|
|
{
|
|
name: "PBKDF2",
|
|
hash: "SHA-256",
|
|
salt: normalizedSalt,
|
|
iterations
|
|
},
|
|
key,
|
|
normalizedHash.byteLength * 8
|
|
);
|
|
|
|
return equalBytes(new Uint8Array(derived), normalizedHash);
|
|
}
|
|
|
|
export async function authenticateUser(email: string, password: string) {
|
|
if (!email || !password) {
|
|
return null;
|
|
}
|
|
|
|
const user = await prisma.user.findUnique({
|
|
where: { email },
|
|
include: { tenant: true, role: true }
|
|
});
|
|
|
|
if (!user || user.status !== UserStatus.ACTIVE) {
|
|
return null;
|
|
}
|
|
|
|
if (!(await verifyPassword(password, user.passwordHash))) {
|
|
return null;
|
|
}
|
|
|
|
const now = Math.floor(Date.now() / 1000);
|
|
return {
|
|
userId: user.id,
|
|
fullName: user.fullName,
|
|
email: user.email,
|
|
role: resolveRoleFromCode(user.role.code),
|
|
tenantId: user.tenantId,
|
|
tenantName: user.tenant.companyName || user.tenant.name,
|
|
extraPermissions: parseRolePermissions(user.role.permissionsJson),
|
|
issuedAt: now,
|
|
expiresAt: now + SESSION_TTL_SECONDS
|
|
};
|
|
}
|
|
|
|
export function getDefaultPathForRole(role: UserRole) {
|
|
if (role === "super_admin") {
|
|
return "/super-admin";
|
|
}
|
|
|
|
if (role === "agent") {
|
|
return "/agent";
|
|
}
|
|
|
|
return "/dashboard";
|
|
}
|
|
|
|
export function canAccessPath(role: UserRole, pathname: string) {
|
|
if (pathname.startsWith("/super-admin")) {
|
|
return role === "super_admin";
|
|
}
|
|
|
|
if (pathname.startsWith("/agent")) {
|
|
return role === "agent";
|
|
}
|
|
|
|
const adminPaths = [
|
|
"/dashboard",
|
|
"/inbox",
|
|
"/contacts",
|
|
"/templates",
|
|
"/campaigns",
|
|
"/team",
|
|
"/reports",
|
|
"/settings",
|
|
"/billing",
|
|
"/audit-log"
|
|
];
|
|
|
|
if (adminPaths.some((path) => pathname.startsWith(path))) {
|
|
return role === "admin_client";
|
|
}
|
|
|
|
if (pathname.startsWith("/profile") || pathname.startsWith("/notifications") || pathname.startsWith("/search")) {
|
|
return true;
|
|
}
|
|
|
|
return true;
|
|
}
|