chore: initial project import
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
This commit is contained in:
437
lib/auth.ts
Normal file
437
lib/auth.ts
Normal file
@ -0,0 +1,437 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user