import type { Context, Next } from "hono"; import { deleteCookie, getCookie, setCookie } from "hono/cookie"; import { withIamTenant, findUserByUsernameAnyTenant } from "./iam_db.ts"; import { getPlatformDb } from "./platform_db.ts"; import { config } from "./config.ts"; import { createSession, getSession, revokeSession, revokeAllSessionsForUser } from "./sessions.ts"; import { hashPassword, verifyPassword } from "./crypto.ts"; export type AuthRealm = "app" | "platform"; 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_id: number | null; role_code: string; is_owner: boolean; status: string; realm: AuthRealm; must_change_password: boolean; }; const COOKIE = "po_session"; const TTL_SECONDS = 60 * 60 * 24 * 7; const USER_SELECT = ` SELECT u.id, u.username, u.password_hash, u.display_name, u.company_id, u.tenant_id, u.role_id, r.code AS role_code, r.is_system AS role_is_system, r.tenant_id AS role_tenant_id, u.status, u.must_change_password, u.email FROM users u JOIN roles r ON r.id = u.role_id WHERE u.id = ?`; export async function createSessionCookie( c: Context, userId: number, realm: AuthRealm = "app", tenantId: number | null = null, ) { const id = await createSession(userId, realm, tenantId); setCookie(c, COOKIE, id, { httpOnly: true, path: "/", sameSite: "Lax", secure: config.cookieSecure, maxAge: TTL_SECONDS, }); } export async function clearSession(c: Context) { const id = getCookie(c, COOKIE); if (id) await revokeSession(id); deleteCookie(c, COOKIE, { path: "/" }); } function isOwnerRole(row: Record): boolean { return row.role_code === "tenant_admin" && Boolean(row.role_is_system) && row.role_tenant_id == null; } function asAppUser(row: Record): AuthUser { 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_id: row.role_id == null ? null : Number(row.role_id), role_code: String(row.role_code ?? "user"), is_owner: isOwnerRole(row), status: String(row.status ?? "activo"), 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_id: null, role_code: "platform_admin", is_owner: true, status: "activo", realm: "platform", must_change_password: false, }; } async function userFromCookie(c: Context): Promise { const id = getCookie(c, COOKIE); if (!id) return null; const session = await getSession(id); if (!session) return null; if (session.realm === "platform") { const pdb = await getPlatformDb(); const row = await pdb.prepare( `SELECT id, username, display_name FROM platform_users WHERE id = ? AND status = 'activo'`, ).get(session.userId) as { id: number; username: string; display_name: string } | undefined; return row ? asPlatformUser(row) : null; } return await getFreshAppUser(session.userId, session.tenantId); } /** Relee un usuario app (iam) fresco de la base y lo enriquece con su * empresa (core) -- usado tras login y tras cambiar contraseña. */ export async function getFreshAppUser(userId: number, tenantId: number | null): Promise { const row = await withIamTenant(tenantId, async (db) => { return await db.prepare(USER_SELECT).get(userId); }); if (!row) return null; if (String(row.status ?? "activo") !== "activo") return null; const company = await enrichWithCompanyViaCore((row.company_id as number) ?? null); return asAppUser({ ...row, ...company }); } 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)) { const tenantHeader = c.req.header("x-tenant-id") ?? ""; const tenantId = Number(tenantHeader); if (!tenantHeader || !Number.isInteger(tenantId) || tenantId <= 0) { return c.json({ error: "X-API-Key requiere X-Tenant-Id" }, 400); } 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: tenantId, role_id: null, role_code: "api_key", is_owner: false, status: "activo", 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_code !== "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(); const pass = password.trim(); if (!user || !pass) return null; const appRow = await findUserByUsernameAnyTenant(user); if (appRow && await verifyPassword(pass, appRow.password_hash as string)) { if (String(appRow.status ?? "activo") !== "activo") { throw Object.assign(new Error("Usuario dado de baja"), { code: "USER_INACTIVE" }); } const authUser = await withIamTenant( appRow.tenant_id == null ? null : Number(appRow.tenant_id), async () => { const company = await enrichWithCompanyViaCore(Number(appRow.company_id) || null); return asAppUser({ ...appRow, ...company }); }, ); const pdb = await getPlatformDb(); const { tenantAccessBlocked } = await import("./saas.ts"); const blocked = await tenantAccessBlocked(pdb, authUser.tenant_id); if (blocked) { throw Object.assign(new Error(blocked), { code: "TENANT_BLOCKED" }); } return authUser; } const pdb = await getPlatformDb(); const plat = await 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(pass, plat.password_hash)) { return asPlatformUser(plat); } return null; } /** company_id de iam.users es una referencia lógica a core.companies(id) * (sin FK -- esquemas aislados). Esta función vive en auth.ts para no * crear un import cruzado iam<->core; usa la conexión core con el rol de * runtime normal (companies no está sujeta a RLS por-fila salvo por * tenant_id, así que basta con conocer el tenant ya resuelto). */ async function enrichWithCompanyViaCore(companyId: number | null) { if (companyId == null) return { company_code: null, company_name: null, company_kind: null }; const { getCoreDb } = await import("./db.ts"); const db = await getCoreDb(); const row = await db.prepare("SELECT code, name, kind FROM companies WHERE id = ?").get( companyId, ); return { company_code: (row?.code as string) ?? null, company_name: (row?.name as string) ?? null, company_kind: (row?.kind as string) ?? null, }; } export async function changePassword( userId: number, tenantId: number | null, 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" }; return await withIamTenant(tenantId, async (db) => { const row = await 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); await db.prepare( "UPDATE users SET password_hash = ?, must_change_password = false WHERE id = ?", ).run(hash, userId); await revokeAllSessionsForUser(userId, "app"); return {}; }); }