mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 15:33:17 +00:00
216 lines
7.1 KiB
TypeScript
216 lines
7.1 KiB
TypeScript
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<string, unknown>): 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<AuthUser | null> {
|
|
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<SessionPayload>(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<string, unknown> | 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<AuthUser | null> {
|
|
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<string, unknown> & { 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 {};
|
|
}
|