438 lines
10 KiB
TypeScript
438 lines
10 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 SESSION_TTL_SECONDS = 60 * 60 * 24 * 7;
|
|
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 null;
|
|
}
|
|
|
|
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;
|
|
}
|