import type { Context, Next } from "hono"; import { deleteCookie, getCookie, setCookie } from "hono/cookie"; import { getDb } from "./db.ts"; import { getPlatformDb } from "./platform_db.ts"; import { config } from "./config.ts"; import { b64urlJson, b64urlJsonParse, hmacSign, hmacVerify, hashPassword, verifyPassword } from "./crypto.ts"; export type AuthRealm = "app" | "platform"; export type AuthRole = "platform_admin" | "tenant_admin" | "user"; export type AuthUser = { id: number; username: string; display_name: string; company_id: number | null; company_code: string | null; company_name: string | null; company_kind: string | null; tenant_id: number | null; role: AuthRole; realm: AuthRealm; must_change_password: boolean; }; const USER_SELECT = `u.id, u.username, u.display_name, u.company_id, u.tenant_id, u.role, COALESCE(u.must_change_password, 0) AS must_change_password, c.code AS company_code, c.name AS company_name, c.kind AS company_kind`; type SessionPayload = { uid: number; exp: number; realm?: AuthRealm }; const COOKIE = "po_session"; const TTL = 60 * 60 * 24 * 7; export async function createSessionCookie(c: Context, userId: number, realm: AuthRealm = "app") { const payload: SessionPayload = { uid: userId, exp: Date.now() + TTL * 1000, realm }; const body = b64urlJson(payload); const sig = await hmacSign(config.sessionSecret, body); setCookie(c, COOKIE, `${body}.${sig}`, { httpOnly: true, path: "/", sameSite: "Lax", secure: config.cookieSecure, maxAge: TTL, }); } export function clearSession(c: Context) { deleteCookie(c, COOKIE, { path: "/" }); } function asAppUser(row: Record): AuthUser { const role = (row.role as string) || "user"; return { id: Number(row.id), username: String(row.username), display_name: String(row.display_name), company_id: row.company_id == null ? null : Number(row.company_id), company_code: (row.company_code as string | null) ?? null, company_name: (row.company_name as string | null) ?? null, company_kind: (row.company_kind as string | null) ?? null, tenant_id: row.tenant_id == null ? null : Number(row.tenant_id), role: role === "tenant_admin" ? "tenant_admin" : "user", realm: "app", must_change_password: Boolean(row.must_change_password), }; } function asPlatformUser(row: { id: number; username: string; display_name: string }): AuthUser { return { id: row.id, username: row.username, display_name: row.display_name, company_id: null, company_code: null, company_name: null, company_kind: null, tenant_id: null, role: "platform_admin", realm: "platform", must_change_password: false, }; } async function userFromCookie(c: Context): Promise { const raw = getCookie(c, COOKIE); if (!raw || !raw.includes(".")) return null; const [body, sig] = raw.split("."); if (!await hmacVerify(config.sessionSecret, body, sig)) return null; let payload: SessionPayload; try { payload = b64urlJsonParse(body); } catch { return null; } if (payload.exp < Date.now()) return null; const realm: AuthRealm = payload.realm === "platform" ? "platform" : "app"; if (realm === "platform") { const pdb = await getPlatformDb(); const row = pdb.prepare( `SELECT id, username, display_name FROM platform_users WHERE id = ? AND status = 'activo'`, ).get(payload.uid) as { id: number; username: string; display_name: string } | undefined; return row ? asPlatformUser(row) : null; } const db = await getDb(); const row = db.prepare( `SELECT ${USER_SELECT} FROM users u LEFT JOIN companies c ON c.id = u.company_id WHERE u.id = ?`, ).get(payload.uid) as Record | undefined; return row ? asAppUser(row) : null; } function apiKeyOk(c: Context): boolean { if (!config.apiKey) return false; const header = c.req.header("x-api-key") ?? ""; if (header.length !== config.apiKey.length) return false; let diff = 0; for (let i = 0; i < header.length; i++) diff |= header.charCodeAt(i) ^ config.apiKey.charCodeAt(i); return diff === 0; } export async function requireAuth(c: Context, next: Next) { if (apiKeyOk(c)) { c.set("user", { id: 0, username: "api", display_name: "API Key", company_id: null, company_code: null, company_name: null, company_kind: null, tenant_id: null, role: "tenant_admin", realm: "app", must_change_password: false, } satisfies AuthUser); await next(); return; } const user = await userFromCookie(c); if (!user) return c.json({ error: "No autenticado" }, 401); c.set("user", user); await next(); } export async function requirePlatformAdmin(c: Context, next: Next) { const user = await userFromCookie(c); if (!user) return c.json({ error: "No autenticado" }, 401); if (user.realm !== "platform" || user.role !== "platform_admin") { return c.json({ error: "Solo administradores SaaS" }, 403); } c.set("user", user); await next(); } export function tenantScope(user: AuthUser): number | null { if (user.realm === "platform") return null; return user.tenant_id; } export async function login(username: string, password: string): Promise { const user = username.trim(); if (!user) return null; const db = await getDb(); const appRow = db.prepare( `SELECT ${USER_SELECT}, u.password_hash FROM users u LEFT JOIN companies c ON c.id = u.company_id WHERE u.username = ?`, ).get(user) as (Record & { password_hash: string }) | undefined; if (appRow && await verifyPassword(password, appRow.password_hash)) { const authUser = asAppUser(appRow); const { tenantAccessBlocked } = await import("./saas.ts"); const pdb = await getPlatformDb(); const blocked = tenantAccessBlocked(pdb, authUser.tenant_id); if (blocked) { throw Object.assign(new Error(blocked), { code: "TENANT_BLOCKED" }); } return authUser; } const pdb = await getPlatformDb(); const plat = pdb.prepare( `SELECT id, username, display_name, password_hash FROM platform_users WHERE username = ? AND status = 'activo'`, ).get(user) as | { id: number; username: string; display_name: string; password_hash: string } | undefined; if (plat && await verifyPassword(password, plat.password_hash)) { return asPlatformUser(plat); } return null; } export async function changePassword( userId: number, currentPassword: string, newPassword: string, ): Promise<{ error?: string }> { const next = (newPassword ?? "").trim(); if (next.length < 8) return { error: "La nueva contraseƱa debe tener al menos 8 caracteres" }; const db = await getDb(); const row = db.prepare("SELECT password_hash FROM users WHERE id = ?").get(userId) as | { password_hash: string } | undefined; if (!row) return { error: "Usuario no encontrado" }; if (!await verifyPassword(currentPassword, row.password_hash)) { return { error: "ContraseƱa actual incorrecta" }; } const hash = await hashPassword(next); db.prepare("UPDATE users SET password_hash = ?, must_change_password = 0 WHERE id = ?").run(hash, userId); return {}; }