Inclusión del dominio suitecoffee.uy al NPM.

Se ajustaron los problemas de renderizado y redirección mayores de https://suitecoffee.uy/
Se re-creó el archivo ~/SuiteCoffee/services/app/src/index.js para mantener un orden adecuado
Las rutas exigen una cookie de seción para cargarse, de o contrario redireccionan a  https://suitecoffee.uy/auth/login para iniciar o crear sesión de usuario, excepto https://suitecoffee.uy/inicio que se mantene de esta manera con motivos de desarrollo
This commit is contained in:
2025-09-09 14:20:05 +00:00
parent 5d078f3932
commit 69f5860b7f
20 changed files with 2758 additions and 790 deletions
+327 -156
View File
@@ -1,126 +1,244 @@
// services/auth/src/ak.js
// ------------------------------------------------------------
// Cliente mínimo y robusto para la API Admin de Authentik (v3)
// Cliente para la API Admin de Authentik (v3)
// - Sin dependencias externas (usa fetch nativo de Node >=18)
// - ESM compatible
// - Timeouts, reintentos opcionales y mensajes de error claros
// - Compatible con services/auth/src/index.js actual
// ------------------------------------------------------------
// Utiliza AUTHENTIK_BASE_URL y AUTHENTIK_TOKEN para validar y devuelve la configuración (base URL y token) desde variables de entorno.
// Devuelve la URL base y el Token que se leyó desde .env
/**
* Lee configuración desde process.env en cada llamada (para evitar problemas
* de orden de imports con dotenv). No falla en import-time.
* @typedef {Object} AkCfg
* @property {string} BASE // p.ej. "https://idp.example.com"
* @property {string} TOKEN // bearer
*/
/**
* @typedef {Object} AkOpts
* @property {Record<string, string|number|boolean|Array<string|number|boolean>>} [qs]
* @property {any} [body]
* @property {number} [timeoutMs=10000]
* @property {number} [retries=0]
* @property {Record<string,string>} [headers]
*/
function getConfig() {
const BASE = (process.env.AUTHENTIK_BASE_URL || '').replace(/\/+$/, '');
const BASE = (process.env.AUTHENTIK_BASE_URL || "").trim().replace(/\/+$/, "");
const TOKEN = process.env.AUTHENTIK_TOKEN || '';
if (!BASE) throw new Error('AK_CONFIG: Falta AUTHENTIK_BASE_URL');
if (!TOKEN) throw new Error('AK_CONFIG: Falta AUTHENTIK_TOKEN');
if (!BASE) throw new Error('[AK_CONFIG] Falta variable AUTHENTIK_BASE_URL');
if (!TOKEN) throw new Error('[AK_CONFIG] Falta variable AUTHENTIK_TOKEN');
return { BASE, TOKEN };
}
// ------------------------------------------------------------
// Utilidades
// ------------------------------------------------------------
// Espera
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// ------------------------------------------------------------
// Helpers de sincronización
// ------------------------------------------------------------
// -- util GET contra la API admin (ajusta si ya tenés un helper igual)
async function akGET(path) {
const base = (process.env.AUTHENTIK_BASE_URL || '').replace(/\/+$/, '');
const url = `${base}${path}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.AUTHENTIK_TOKEN}` },
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`AK GET ${path} -> ${res.status}: ${body}`);
}
return res.json();
export function createAkClient(cfg = getConfig()) {
return {
request: (method, path, opts = {}) => request(method, path, opts, cfg),
akGET: (path, opts) => request("GET", path, opts, cfg),
akPOST: (path, opts) => request("POST", path, opts, cfg),
akPUT: (path, opts) => request("PUT", path, opts, cfg),
akPATCH: (path, opts) => request("PATCH", path, opts, cfg),
akDELETE:(path, opts) => request("DELETE", path, opts, cfg),
};
}
// -- listar grupos con búsqueda por nombre/slug
export async function akListGroups(search = '') {
const q = search ? `?search=${encodeURIComponent(search)}` : '';
const data = await akGET(`/api/v3/core/groups/${q}`);
// algunas versiones devuelven {results:[]}, otras un array directo
return Array.isArray(data) ? data : (data.results || []);
// Listar grupos con búsqueda por nombre/slug
export async function akListGroups(search = "") {
const { akGET } = createAkClient();
const term = String(search ?? "").trim();
const data = await akGET("/core/groups/", {
qs: term ? { search: term } : undefined,
});
if (Array.isArray(data)) return data;
if (data && Array.isArray(data.results)) return data.results;
return [];
}
export async function akPatchUserAttributes(userPk, partialAttrs = {}) {
// PATCH del usuario para asegurar attributes.tenant_uuid
return akRequest('patch', `/api/v3/core/users/${userPk}/`, {
data: { attributes: partialAttrs },
const id = String(userPk ?? "").trim();
if (!id) throw new TypeError("[AK_PATH_USER_ATRIBUTES] atribute `userPk` is required");
if (partialAttrs == null || typeof partialAttrs !== "object" || Array.isArray(partialAttrs)) {
throw new TypeError("[AK_PATH_USER_ATRIBUTES] atribute `partialAttrs` must be a plain object");
}
// Remove undefineds to avoid unintentionally nulling keys server-side
const cleaned = Object.fromEntries(
Object.entries(partialAttrs).filter(([, v]) => v !== undefined)
);
if (Object.keys(cleaned).length === 0) {
throw new TypeError("[AK_PATH_USER_ATRIBUTES] atribute `partialAttrs` is required");
}
// NOTE: pass path WITHOUT /api/v3; the client prefixes it
return akPATCH(`/core/users/${encodeURIComponent(id)}/`, {
body: { attributes: cleaned },
});
}
export async function akEnsureGroupForTenant(tenantHex) {
const groupName = `tenant_${tenantHex}`;
const { akGET, akPOST } = createAkClient();
// buscar por nombre
const data = await akRequest('get', '/api/v3/core/groups/', { params: { name: groupName }});
const g = (data?.results || [])[0];
if (g) return g.pk;
const hex = String(tenantHex ?? "").trim();
if (!hex) throw new TypeError("akEnsureGroupForTenant: `tenantHex` is required");
// crear si no existe
const created = await akRequest('post', '/api/v3/core/groups/', {
data: { name: groupName, attributes: { tenant_uuid: tenantHex } },
});
return created.pk;
const groupName = `tenant_${hex}`;
// 1) Buscar existente (normaliza {results:[]}/[])
const data = await akGET("/core/groups/", { qs: { search: groupName } });
const list = Array.isArray(data) ? data : (Array.isArray(data?.results) ? data.results : []);
const existing = list.find(g => g?.name === groupName);
if (existing?.pk ?? existing?.id) return existing.pk ?? existing.id;
// 2) Crear si no existe
try {
const created = await akPOST("/core/groups/", {
body: { name: groupName, attributes: { tenant_uuid: hex } },
});
return created?.pk ?? created?.id;
} catch (e) {
// 3) Condición de carrera (otro proceso lo creó): reconsulta y devuelve
const msg = String(e?.message || "");
if (/already exists|unique|duplicate|409/i.test(msg)) {
const data2 = await akGET("/core/groups/", { qs: { search: groupName } });
const list2 = Array.isArray(data2) ? data2 : (Array.isArray(data2?.results) ? data2.results : []);
const found = list2.find(g => g?.name === groupName);
if (found?.pk ?? found?.id) return found.pk ?? found.id;
}
throw e;
}
}
export async function akAddUserToGroup(userPk, groupPk) {
// Endpoint de membership (en versiones recientes, POST users/<pk>/groups/)
return akRequest('post', `/api/v3/core/users/${userPk}/groups/`, { data: { group: groupPk } });
const { akPOST } = createAkClient();
const user = String(userPk ?? "").trim();
const group = String(groupPk ?? "").trim();
if (!user) throw new TypeError("akAddUserToGroup: `userPk` is required");
if (!group) throw new TypeError("akAddUserToGroup: `groupPk` is required");
// API reciente: POST /core/users/<pk>/groups/ { group: <pk> }
const path = `/core/users/${encodeURIComponent(user)}/groups/`;
try {
return await akPOST(path, { body: { group } });
} catch (e) {
const msg = String(e?.message || "");
// Si ya es miembro, tratamos como éxito idempotente
if (/already.*member|exists|duplicate|409/i.test(msg)) {
return { ok: true, alreadyMember: true, userPk: user, groupPk: group };
}
// Fallback para instancias viejas: /core/group_memberships/ { user, group }
if (/404|not\s*found/i.test(msg)) {
return await akPOST("/core/group_memberships/", { body: { user, group } });
}
throw e;
}
}
// Utilidad de espera
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/**
* Llamada HTTP genérica con fetch + timeout + manejo de errores.
* @param {('GET'|'POST'|'PUT'|'PATCH'|'DELETE')} method
* @param {string} path - Ruta a partir de /api/v3 (por ej. "/core/users/")
* @param {{qs?:Record<string,string|number|boolean>, body?:any, timeoutMs?:number, retries?:number}} [opts]
* @param {'GET'|'POST'|'PUT'|'PATCH'|'DELETE'} method
* @param {string} path Ruta relativa (ej. "/core/users/") o absoluta; si es relativa se antepone "/api/v3".
* @param {AkOpts} [opts]
* @param {AkCfg} [cfg] Config inyectada; si se omite se usa getConfig()
* @returns {Promise<any|null>}
*/
async function request(method, path, opts = {}) {
const { BASE, TOKEN } = getConfig();
export async function request(method, path, opts = {}, cfg) {
const { BASE, TOKEN } = cfg ?? getConfig();
const {
qs = undefined,
body = undefined,
timeoutMs = 10000,
qs,
body,
timeoutMs = 10_000,
retries = 0,
headers = {},
} = opts;
const url = new URL(`${BASE}/api/v3${path}`);
if (qs) Object.entries(qs).forEach(([k, v]) => url.searchParams.set(k, String(v)));
// Construcción segura de URL + QS
const base = BASE.endsWith("/") ? BASE : `${BASE}/`;
let p = /^https?:\/\//i.test(path) ? path : (path.startsWith("/") ? path : `/${path}`);
if (!/^https?:\/\//i.test(p) && !p.startsWith("/api/")) p = `/api/v3${p}`;
const url = new URL(p, base);
if (qs && typeof qs === "object") {
for (const [k, v] of Object.entries(qs)) {
if (v == null) continue;
if (Array.isArray(v)) v.forEach((x) => url.searchParams.append(k, String(x)));
else url.searchParams.set(k, String(v));
}
}
// Reintentos + timeout
const maxAttempts = Math.max(1, retries + 1);
let lastErr;
for (let attempt = 1; attempt <= Math.max(1, retries + 1); attempt++) {
const controller = new AbortController();
const t = setTimeout(() => controller.abort(new Error('AK_TIMEOUT')), timeoutMs);
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(new Error("AK_TIMEOUT")), timeoutMs);
try {
const res = await fetch(url, {
const init = {
method,
signal: controller.signal,
signal: ctrl.signal,
headers: {
'Authorization': `Bearer ${TOKEN}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
Authorization: `Bearer ${TOKEN}`,
Accept: "application/json",
...headers,
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
};
if (body !== undefined) {
// Sólo forzar JSON si es objeto plano
const isPlainObj = body && typeof body === "object" &&
!(body instanceof FormData) &&
!(body instanceof URLSearchParams) &&
!(typeof Blob !== "undefined" && body instanceof Blob);
if (isPlainObj) {
init.headers["Content-Type"] = init.headers["Content-Type"] || "application/json";
init.body = JSON.stringify(body);
} else {
init.body = body; // deja que fetch maneje el Content-Type
}
}
const res = await fetch(url, init);
clearTimeout(t);
if (res.status === 204) return null; // sin contenido
// intenta parsear JSON; si no es JSON, devuelve texto
const ctype = res.headers.get('content-type') || '';
const payload = ctype.includes('application/json') ? await res.json().catch(() => ({})) : await res.text();
if (res.status === 204 || res.status === 205) return null;
const ctype = res.headers.get("content-type") || "";
const payload = /\bapplication\/json\b/i.test(ctype) ? await res.json().catch(() => ({})) : await res.text();
if (!res.ok) {
const detail = typeof payload === 'string' ? payload : payload?.detail || payload?.error || JSON.stringify(payload);
const err = new Error(`AK ${method} ${url.pathname} HTTP ${res.status}: ${detail}`);
const detail = typeof payload === "string" ? payload : payload?.detail || payload?.error || JSON.stringify(payload);
const err = new Error(`AK ${method} ${url.pathname}${url.search}${res.status}: ${detail}`);
err.status = res.status; // @ts-ignore
// Reintenta 5xx y 429
if ((res.status >= 500 && res.status <= 599) || res.status === 429) {
lastErr = err;
if (attempt < maxAttempts) {
let delay = 500 * 2 ** (attempt - 1);
const ra = parseInt(res.headers.get("retry-after") || "", 10);
if (!Number.isNaN(ra)) delay = Math.max(delay, ra * 1000);
await new Promise(r => setTimeout(r, delay));
continue;
}
}
throw err;
}
@@ -128,11 +246,10 @@ async function request(method, path, opts = {}) {
} catch (e) {
clearTimeout(t);
lastErr = e;
// Reintentos sólo en ECONNREFUSED/timeout/5xx
const msg = String(e?.message || e);
const retriable = msg.includes('ECONNREFUSED') || msg.includes('AK_TIMEOUT') || /\b5\d\d\b/.test(e?.status?.toString?.() || '');
if (!retriable || attempt > retries) throw e;
await sleep(500 * attempt); // backoff lineal suave
const msg = String(e?.message || "");
const retriable = msg.includes("AK_TIMEOUT") || msg.includes("ECONNREFUSED") || msg.includes("fetch failed");
if (!retriable || attempt >= maxAttempts) throw e;
await new Promise(r => setTimeout(r, 500 * 2 ** (attempt - 1)));
}
}
throw lastErr;
@@ -142,124 +259,178 @@ async function request(method, path, opts = {}) {
// Funciones públicas
// ------------------------------------------------------------
/**
* Busca un usuario por email en Authentik (case-insensitive) usando ?search=
* Devuelve el usuario exacto o null si no existe.
*/
export async function akFindUserByEmail(email) {
if (!email) throw new Error('akFindUserByEmail: email requerido');
const data = await request('GET', '/core/users/', { qs: { search: email, page_size: 50 }, retries: 3 });
const list = Array.isArray(data?.results) ? data.results : [];
const lower = String(email).toLowerCase();
return list.find((u) => (u.email || '').toLowerCase() === lower) || null;
const { akGET } = createAkClient();
const needle = String(email ?? "").trim().toLowerCase();
if (!needle) throw new TypeError("akFindUserByEmail: `email` is required");
const PAGE_SIZE = 100;
let page = 1;
const MAX_PAGES = 10;
while (page <= MAX_PAGES) {
const data = await akGET("/core/users/", {
qs: { search: needle, page_size: PAGE_SIZE, page },
retries: 2,
});
const list = Array.isArray(data)
? data
: (Array.isArray(data?.results) ? data.results : []);
const found = list.find(u => String(u?.email || "").toLowerCase() === needle);
if (found) return found || null;
// Continuar paginando sólo si hay más resultados
const hasNext =
Array.isArray(data)
? list.length === PAGE_SIZE // array plano: inferimos por tamaño
: Boolean(data?.next); // DRF: link "next"
if (!hasNext) break;
page += 1;
}
return null;
}
/**
* Crea un usuario en Authentik con atributos de tenant y opcionalmente lo
* agrega a un grupo existente.
* @param {{email:string, displayName?:string, tenantUuid?:string, addToGroupId?: number|string, isActive?: boolean}} p
* @returns {Promise<any>} el objeto usuario creado
*/
export async function akCreateUser(p) {
const email = p?.email;
if (!email) throw new Error('akCreateUser: email requerido');
const name = p?.displayName || email;
const tenantUuid = (p?.tenantUuid || '').replace(/-/g, '');
const isActive = p?.isActive ?? true;
export async function akCreateUser(p = {}) {
const { akPOST } = createAkClient();
// 1) crear usuario
const user = await request('POST', '/core/users/', {
body: {
username: email,
name,
email,
is_active: isActive,
attributes: tenantUuid ? { tenant_uuid: tenantUuid } : {},
},
retries: 3,
});
const email = String(p.email ?? "").trim().toLowerCase();
if (!email) throw new TypeError("akCreateUser: `email` is required");
// 2) agregar a grupo (opcional)
if (p?.addToGroupId) {
const name = String(p.displayName ?? email).trim() || email;
const tenantUuid = String(p.tenantUuid ?? "").replace(/-/g, "").trim();
const isActive = p.isActive ?? true;
const body = {
username: email,
name,
email,
is_active: !!isActive,
attributes: tenantUuid ? { tenant_uuid: tenantUuid } : {},
};
let user;
try {
user = await akPOST("/core/users/", { body, retries: 2 });
} catch (e) {
const msg = String(e?.message || "");
if (/409|already\s*exists|unique|duplicate/i.test(msg)) {
// Idempotencia: si ya existe, lo buscamos por email y lo devolvemos
const existing = await akFindUserByEmail(email);
if (existing) return existing;
}
throw e;
}
// Agregar a grupo (opcional, no rompe el flujo si falla)
const groupId = p.addToGroupId != null ? String(p.addToGroupId).trim() : "";
if (groupId) {
try {
await request('POST', `/core/users/${user.pk}/groups/`, { body: { group: p.addToGroupId }, retries: 2 });
} catch (e) {
// No rompas todo por el grupo; deja registro del error para que el caller decida.
console.warn(`akCreateUser: no se pudo agregar al grupo ${p.addToGroupId}:`, e?.message || e);
const userPk = encodeURIComponent(user.pk ?? user.id);
await akPOST(`/core/users/${userPk}/groups/`, {
body: { group: groupId },
retries: 2,
});
} catch (err) {
console.warn(
`akCreateUser: could not add user ${user.pk ?? user.id} to group ${groupId}:`,
err?.message || err
);
}
}
return user;
}
/**
* Establece/forza una contraseña a un usuario (si tu política lo permite).
* @param {number|string} userPk
* @param {string} password
* @param {boolean} requireChange - si el usuario debe cambiarla al siguiente login
*/
export async function akSetPassword(userPk, password, requireChange = true) {
if (!userPk) throw new Error('akSetPassword: userPk requerido');
if (!password) throw new Error('akSetPassword: password requerida');
const { akPOST } = createAkClient();
const id = String(userPk ?? "").trim();
if (!id) throw new TypeError("akSetPassword: `userPk` is required");
const pwd = String(password ?? "");
if (!pwd) throw new TypeError("akSetPassword: `password` is required");
try {
await request('POST', `/core/users/${userPk}/set_password/`, {
body: { password, require_change: !!requireChange },
await akPOST(`/core/users/${encodeURIComponent(id)}/set_password/`, {
body: { password: pwd, require_change: !!requireChange },
retries: 1,
timeoutMs: 15_000,
});
return true;
} catch (e) {
// Algunas instalaciones no permiten setear password por API (políticas). Propaga un error legible.
const err = new Error(`akSetPassword: no se pudo establecer la contraseña: ${e?.message || e}`);
const status = e?.status ? `HTTP ${e.status}: ` : "";
const err = new Error(`akSetPassword: failed to set password (${status}${e?.message || e})`);
err.cause = e;
throw err;
}
}
/**
* Helper opcional para obtener grupos por nombre/slug si en el futuro lo necesitas
* (no usado por index.js; se deja por conveniencia).
*/
export async function akResolveGroupIdByName(name) {
const data = await akListGroups(name);
const lower = String(name || '').toLowerCase();
const found = data.find(g =>
String(g.name || '').toLowerCase() === lower ||
String(g.slug || '').toLowerCase() === lower
);
return found?.pk ?? null;
const term = String(name ?? "").trim();
if (!term) throw new TypeError("akResolveGroupIdByName: `name` is required");
const needle = term.toLowerCase();
const groups = await akListGroups(term);
if (!Array.isArray(groups) || groups.length === 0) return null;
// Prefer exact slug match, then exact name match
const bySlug = groups.find(g => String(g?.slug ?? "").toLowerCase() === needle);
if (bySlug) return bySlug.pk ?? bySlug.id ?? null;
const byName = groups.find(g => String(g?.name ?? "").toLowerCase() === needle);
return byName?.pk ?? byName?.id ?? null;
}
export async function akResolveGroupId({ id, pk, uuid, name, slug } = {}) {
// si te pasan pk/id directo, devolvelo
if (pk != null) return Number(pk);
if (id != null) return Number(id);
const toPk = (v) => {
if (v == null || v === "") return null;
const n = Number(v);
return Number.isFinite(n) ? n : String(v);
};
// por UUID (devuelve objeto con pk)
if (uuid) {
// 1) Direct pk/id
const direct = pk ?? id;
const directPk = toPk(direct);
if (directPk != null) return directPk;
const { akGET } = createAkClient();
// 2) By UUID (detail endpoint)
const uuidStr = String(uuid ?? "").trim();
if (uuidStr) {
try {
const g = await akGET(`/api/v3/core/groups/${encodeURIComponent(uuid)}/`);
if (g?.pk != null) return Number(g.pk);
} catch (e) {
// sigue intentando por nombre/slug
}
const g = await akGET(`/core/groups/${encodeURIComponent(uuidStr)}/`, { retries: 1 });
const fromDetail = toPk(g?.pk ?? g?.id);
if (fromDetail != null) return fromDetail;
} catch { /* continue with name/slug */ }
}
// por nombre/slug
if (name || slug) {
const needle = (name || slug);
const list = await akListGroups(needle);
const lower = String(needle || '').toLowerCase();
const found = list.find(g =>
String(g.name || '').toLowerCase() === lower ||
String(g.slug || '').toLowerCase() === lower
);
if (found?.pk != null) return Number(found.pk);
// 3) By exact name/slug
const needle = String(name ?? slug ?? "").trim();
if (needle) {
const lower = needle.toLowerCase();
const list = await akListGroups(needle); // expects [] or {results:[]}, handled in akListGroups
const found =
list.find(g => String(g?.slug ?? "").toLowerCase() === lower) ||
list.find(g => String(g?.name ?? "").toLowerCase() === lower);
const fromList = toPk(found?.pk ?? found?.id);
if (fromList != null) return fromList;
}
return null;
}
// ------------------------------------------------------------
// Fin
// Exportación de constantes
// ------------------------------------------------------------
export const akGET = (path, opts) => request("GET", path, opts);
export const akPOST = (path, opts) => request("POST", path, opts);
export const akPUT = (path, opts) => request("PUT", path, opts);
export const akPATCH = (path, opts) => request("PATCH", path, opts);
export const akDELETE = (path, opts) => request("DELETE", path, opts);
+162 -42
View File
@@ -5,7 +5,7 @@
// - Sesiones con Redis (compartibles con otros servicios)
// - Vistas EJS (login)
// - Rutas OIDC: /auth/login, /auth/callback, /auth/logout
// - Registro de usuario: /api/users/register (DB + Authentik)
// - Registro de usuario: /auth/api/users/register (DB + Authentik)
// ------------------------------------------------------------
import 'dotenv/config';
@@ -13,23 +13,28 @@ import chalk from 'chalk';
import express from 'express';
import cors from 'cors';
import path from 'node:path';
import fs from 'node:fs/promises';
import { access, readFile } from 'node:fs/promises';
import { constants as fsConstants } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { Pool } from 'pg';
import session from 'express-session';
import { createClient as createRedisClient } from 'redis';
import * as connectRedis from 'connect-redis';
import { createClient } from 'redis';
import expressLayouts from 'express-ejs-layouts';
import { Issuer, generators } from 'openid-client';
import crypto from 'node:crypto';
import { readFile } from 'node:fs/promises';
// -----------------------------------------------------------------------------
// Importaciones desde archivos
// -----------------------------------------------------------------------------
// Helpers de Authentik (admin API)
import { akFindUserByEmail, akCreateUser,
akSetPassword, akResolveGroupId } from './ak.js';
// -----------------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------------
const SESSION_COOKIE_NAME = 'sc.sid';
// Normaliza UUID (acepta con/sin guiones) → "hex" sin guiones
const cleanUuid = (u) => (u ? String(u).toLowerCase().replace(/[^0-9a-f]/g, '') : '');
@@ -37,14 +42,6 @@ const cleanUuid = (u) => (u ? String(u).toLowerCase().replace(/[^0-9a-f]/g, '')
const schemaNameFor = (uuidHex) => `schema_tenant_${uuidHex}`;
const roleNameFor = (uuidHex) => `tenant_${uuidHex}`;
// Helpers de Authentik (admin API)
const {
akFindUserByEmail,
akCreateUser,
akSetPassword,
akResolveGroupId
} = await import('./ak.js');
// Quoter seguro de identificadores SQL (roles, schemas, tablas)
// Identificador SQL (schema, role, table, …)
const qi = (ident) => `"${String(ident).replace(/"/g, '""')}"`;
@@ -282,13 +279,13 @@ let _cachedInitSql = null;
async function loadInitSql() {
if (_cachedInitSql !== null) return _cachedInitSql;
const candidates = [
process.env.TENANT_INIT_SQL, // recomendado via .env
process.env.TENANT_INIT_SQL, // opcional
path.resolve(__dirname, 'db', 'initTenant.sql'),
path.resolve(__dirname, '..', 'src', 'db', 'initTenant.sql'),
].filter(Boolean);
for (const p of candidates) {
try {
await fs.promises.access(p, fs.constants.R_OK);
await access(p, fsConstants.R_OK);
const txt = await readFile(p, 'utf8');
_cachedInitSql = String(txt || '');
console.log(`[TENANT INIT] initTenant.sql: ${p} (${_cachedInitSql.length} bytes)`);
@@ -349,7 +346,6 @@ async function initializeTenantSchemaIfEmpty(schema) {
// -----------------------------------------------------------------------------
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const RedisStore = connectRedis.default || connectRedis.RedisStore;
function requiredEnv(keys) {
const missing = keys.filter((k) => !process.env[k]);
@@ -375,7 +371,7 @@ function genTempPassword(len = 12) {
// Configuración Express
// -----------------------------------------------------------------------------
const app = express();
app.set('trust proxy', 1);
app.set('trust proxy', Number(process.env.TRUST_PROXY_HOPS || 2));
app.use(cors({ origin: true, credentials: true }));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
@@ -384,31 +380,70 @@ app.use(express.urlencoded({ extended: true }));
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
// Archivos estáticos opcionales (ajusta si tu estructura difiere)
app.use(express.static(path.join(__dirname, 'public')));
app.use('/pages', express.static(path.join(__dirname, 'pages')));
// -----------------------------------------------------------------------------
// Sesión (Redis)
// Sesión (Redis) — misma cookie que APP
// -----------------------------------------------------------------------------
requiredEnv(['SESSION_SECRET', 'REDIS_URL']);
const redis = createRedisClient({ url: process.env.REDIS_URL || 'redis://sessions-redis:6379' });
await redis.connect().catch((e) => onFatal(e, 'No se pudo conectar a Redis (sesiones)'));
const SESSION_COOKIE_NAME = process.env.SESSION_NAME || "sc.sid";
const SESSION_SECRET = process.env.SESSION_SECRET || "pon-una-clave-larga-y-unica";
const REDIS_URL = process.env.REDIS_URL || "redis://authentik-redis:6379";
app.use(
session({
name: SESSION_COOKIE_NAME,
store: new RedisStore({ client: redis, prefix: 'sess:' }),
secret: process.env.SESSION_SECRET || 'change-me',
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
},
})
);
// 1) Redis client
const redis = createClient({ url: REDIS_URL /*, legacyMode: true */ });
redis.on("error", (err) => console.error("[Redis] Client Error:", err));
await redis.connect();
console.log("[Redis] connected");
// 2) Resolver RedisStore (soporta:
// - v5: factory CJS -> connectRedis(session)
// - v6/v7: export { RedisStore } ó export default class RedisStore)
async function resolveRedisStore(session) {
const mod = await import("connect-redis"); // ESM/CJS agnóstico
// named export (v6/v7)
if (typeof mod.RedisStore === "function") return mod.RedisStore;
// default export (class ó factory)
if (typeof mod.default === "function") {
// ¿es clase neweable?
if (mod.default.prototype && (mod.default.prototype.get || mod.default.prototype.set)) {
return mod.default; // class RedisStore
}
// si no, asumimos factory antigua
const Store = mod.default(session); // connectRedis(session)
if (typeof Store === "function") return Store; // class devuelta por factory
}
// algunos builds CJS exponen la factory bajo mod (poco común)
if (typeof mod === "function") {
const Store = mod(session);
if (typeof Store === "function") return Store;
}
throw new Error("connect-redis: no se pudo resolver RedisStore (API desconocida).");
}
const RedisStore = await resolveRedisStore(session);
// 3) Session middleware
app.use(session({
name: SESSION_COOKIE_NAME,
secret: SESSION_SECRET,
resave: false,
saveUninitialized: false,
store: new RedisStore({
client: redis,
prefix: "sc:", // opcional
}),
proxy: true,
cookie: {
secure: "auto",
httpOnly: true,
sameSite: "lax",
path: "/", // ¡crítico! visible en / y /auth/*
},
}));
// Exponer usuario a las vistas (no tocar req.session)
app.use((req, res, next) => {
@@ -507,6 +542,89 @@ let oidcClient;
}
})();
// -----------------------------------------------------------------------------
// Vistas
// -----------------------------------------------------------------------------
// Página de login
app.get("/auth/login", (_req, res) => {
return res.render("login", { pageTitle: "Iniciar sesión" });
});
app.post("/auth/login", async (req, res, next) => {
try {
const email = String(req.body.email || "").trim().toLowerCase();
const password = String(req.body.password || "");
const remember = req.body.remember === "on" || req.body.remember === true;
if (!email || !password) {
return res.status(400).render("login", { pageTitle: "Iniciar sesión", error: "Completa email y contraseña." });
}
// Tabla/columnas por defecto; ajustables por env si tu esquema difiere
const USERS_TABLE = process.env.TENANTS_USERS_TABLE || "users";
const COL_ID = process.env.TENANTS_COL_ID || "id";
const COL_EMAIL = process.env.TENANTS_COL_EMAIL || "email";
const COL_HASH = process.env.TENANTS_COL_HASH || "password_hash";
const COL_ROLE = process.env.TENANTS_COL_ROLE || "role";
const COL_TENANT = process.env.TENANTS_COL_TENANT || "tenant_id";
const { rows } = await tenantsPool.query(
`SELECT ${COL_ID} AS id, ${COL_EMAIL} AS email, ${COL_HASH} AS password_hash,
${COL_ROLE} AS role, ${COL_TENANT} AS tenant_id
FROM ${USERS_TABLE}
WHERE ${COL_EMAIL} = $1
LIMIT 1`,
[email]
);
if (rows.length === 0) {
return res.status(401).render("login", { pageTitle: "Iniciar sesión", error: "Credenciales inválidas." });
}
const user = rows[0];
const ok = await bcrypt.compare(password, user.password_hash || "");
if (!ok) {
return res.status(401).render("login", { pageTitle: "Iniciar sesión", error: "Credenciales inválidas." });
}
// (Opcional) registro de acceso en DB principal
try {
await pool.query(
"INSERT INTO auth_audit_log(email, tenant_id, action, at) VALUES ($1, $2, $3, NOW())",
[user.email, user.tenant_id, "login_success"]
);
} catch { /* noop si no existe la tabla */ }
// Sesión compartida
req.session.regenerate((err) => {
if (err) return next(err);
req.session.user = {
id: user.id,
email: user.email,
role: user.role,
tenant_id: user.tenant_id,
loggedAt: Date.now(),
};
if (remember) {
req.session.cookie.maxAge = 30 * 24 * 60 * 60 * 1000;
} else {
req.session.cookie.expires = false;
}
req.session.save((err2) => {
if (err2) return next(err2);
return res.redirect(303, "/"); // "/" → app decide /dashboard o /auth/login
});
});
} catch (e) {
next(e);
}
});
// -----------------------------------------------------------------------------
// Rutas OIDC
// -----------------------------------------------------------------------------
@@ -617,7 +735,7 @@ app.get('/auth/callback', async (req, res, next) => {
if (!res.headersSent) res.status(500).send('No se pudo guardar la sesión.');
return;
}
if (!res.headersSent) return res.redirect('/'); // te llevará a /comandas si ya implementaste ese redirect
if (!res.headersSent) return res.redirect('/');
});
});
@@ -668,9 +786,9 @@ async function akDeleteUser(pkOrUuid) {
}
// ==============================
// POST /api/users/register
// POST /auth/api/users/register
// ==============================
app.post('/api/users/register', async (req, res, next) => {
app.post('/auth/api/users/register', async (req, res, next) => {
// 0) input
const {
email,
@@ -830,7 +948,7 @@ app.post('/auth/password/set', async (req, res, next) => {
// Espera: { email, display_name?, tenant_uuid }
// app.post('/api/users/register', async (req, res, next) => {
// app.post('/auth/auth/api/users/register', async (req, res, next) => {
// const { email, display_name, tenant_uuid: rawTenant, role } = req.body || {};
// if (!email) return res.status(400).json({ error: 'email es obligatorio' });
@@ -893,7 +1011,7 @@ app.post('/auth/password/set', async (req, res, next) => {
// -----------------------------------------------------------------------------
// Healthcheck
// -----------------------------------------------------------------------------
app.get('/health', (_req, res) => res.status(200).json({ status: 'ok' }));
app.get('/health', (_req, res) => res.status(200).json({ status: 'ok', service: 'auth' }));
// -----------------------------------------------------------------------------
// 404 + Manejo de errores
@@ -916,6 +1034,8 @@ const PORT = Number(process.env.PORT || 4040);
console.log(`Activando entorno de -> ${env === 'PRODUCTION' ? chalk.red('PRODUCTION') : chalk.green('DEVELOPMENT')}`);
await verificarConexion();
app.listen(PORT, () => {
console.log(`Servidor de autenticación de SuiteCoffee corriendo en ${chalk.yellow(`http://localhost:${PORT}`)}`);
console.log(`[AUTH] SuiteCoffee corriendo en http://localhost:${PORT}`);
});
})();
export default app;
+164
View File
@@ -0,0 +1,164 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= typeof pageTitle !== 'undefined' ? pageTitle : 'Iniciar sesión · SuiteCoffee' %></title>
<!-- Bootstrap 5 (minimal) -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<style>
:root {
--col-pri: #<%= (process.env.COL_PRI || '452D19') %>;
--col-sec: #<%= (process.env.COL_SEC || 'D7A666') %>;
--col-bg: #<%= (process.env.COL_BG || 'FFA500') %>33; /* con alpha */
}
body { background: radial-gradient(1200px 600px at 10% -10%, var(--col-bg), transparent), #f8f9fa; }
.brand { color: var(--col-pri); }
.btn-sso { background: var(--col-pri); color: #fff; border-color: var(--col-pri); }
.btn-sso:hover { filter: brightness(1.05); color: #fff; }
.card { border-radius: 14px; }
.form-hint { font-size: .875rem; color: #6c757d; }
.divider { display:flex; align-items:center; text-transform:uppercase; font-size:.8rem; color:#6c757d; }
.divider::before, .divider::after { content:""; height:1px; background:#dee2e6; flex:1; }
.divider:not(:empty)::before { margin-right:.75rem; }
.divider:not(:empty)::after { margin-left:.75rem; }
</style>
</head>
<body>
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-12 col-sm-10 col-md-8 col-lg-6 col-xl-5">
<div class="text-center mb-4">
<h1 class="brand fw-bold">SuiteCoffee</h1>
<p class="text-secondary mb-0">Accedé a tu cuenta</p>
</div>
<!-- Mensajes (query ?msg= / ?error=) -->
<div id="flash" class="mb-3" style="display:none"></div>
<div class="card shadow-sm">
<div class="card-body p-4 p-md-5">
<!-- SSO con Authentik -->
<div class="d-grid gap-2 mb-3">
<a href="/auth/login" class="btn btn-sso btn-lg" id="btn-sso">
Ingresar con SSO (Authentik)
</a>
</div>
<div class="divider my-3">o</div>
<!-- Registro mínimo (usa POST /auth/api/users/register) -->
<form id="form-register" class="needs-validation" novalidate>
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" class="form-control" id="email" name="email" placeholder="tu@correo.com" required>
<div class="invalid-feedback">Ingresá un email válido.</div>
</div>
<div class="mb-3">
<label for="display_name" class="form-label">Nombre a mostrar</label>
<input type="text" class="form-control" id="display_name" name="display_name" placeholder="Ej.: Juan Pérez" required>
<div class="invalid-feedback">Ingresá tu nombre.</div>
</div>
<div class="mb-3">
<label for="tenant_uuid" class="form-label">Código de organización (tenant UUID)</label>
<input type="text" class="form-control" id="tenant_uuid" name="tenant_uuid" placeholder="Ej.: 4b8d0f6a-...">
<div class="form-hint">Si te invitaron a una organización existente, pegá aquí su UUID. Si sos el primero de tu empresa, dejalo vacío y el equipo te asignará uno.</div>
</div>
<div class="mb-3">
<label for="role" class="form-label">Rol</label>
<select id="role" name="role" class="form-select">
<option value="owner">Owner</option>
<option value="admin">Admin</option>
<option value="staff">Staff</option>
</select>
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-outline-dark">Crear cuenta</button>
</div>
</form>
<p class="text-center text-muted mt-3 mb-0" style="font-size:.9rem;">
Al continuar aceptás nuestros términos y políticas.
</p>
</div>
</div>
<p class="text-center text-secondary mt-3" style="font-size:.9rem;">
¿Ya tenés cuenta? <a href="/auth/login" class="link-dark">Iniciá sesión con SSO</a>
</p>
</div>
</div>
</div>
<script>
// Mostrar mensajes por querystring (?msg=... / ?error=...)
(function() {
const params = new URLSearchParams(location.search);
const el = document.getElementById('flash');
const msg = params.get('msg');
const err = params.get('error');
if (msg) {
el.innerHTML = `<div class="alert alert-success mb-0" role="alert">${decodeURIComponent(msg)}</div>`;
el.style.display = '';
} else if (err) {
el.innerHTML = `<div class="alert alert-danger mb-0" role="alert">${decodeURIComponent(err)}</div>`;
el.style.display = '';
}
})();
// Validación Bootstrap + envío del registro contra /auth/api/users/register
(function() {
const form = document.getElementById('form-register');
form.addEventListener('submit', async function(e) {
e.preventDefault();
e.stopPropagation();
form.classList.add('was-validated');
if (!form.checkValidity()) return;
const btn = form.querySelector('button[type="submit"]');
btn.disabled = true; btn.innerText = 'Creando...';
try {
const payload = {
email: document.getElementById('email').value.trim(),
display_name: document.getElementById('display_name').value.trim(),
tenant_uuid: document.getElementById('tenant_uuid').value.trim() || undefined,
role: document.getElementById('role').value
};
const res = await fetch('/auth/api/users/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
credentials: 'include'
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data?.error || data?.message || 'No se pudo registrar');
}
// Registro OK → redirigimos a login SSO
const redir = '/auth/login';
location.href = redir + '?msg=' + encodeURIComponent('Registro exitoso. Iniciá sesión con SSO.');
} catch (err) {
alert(err.message || String(err));
} finally {
btn.disabled = false; btn.innerText = 'Crear cuenta';
}
});
})();
</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
</body>
</html>