Carga completa

This commit is contained in:
2025-09-06 11:19:42 +00:00
parent 237a5427dd
commit 5d078f3932
17 changed files with 3278 additions and 298 deletions
+103
View File
@@ -0,0 +1,103 @@
# ===== Runtime =====
NODE_ENV=development
PORT=4040
AUTH_LOCAL_PORT=4040 # coincide con 'expose' del servicio auth
# ===== Session (usa el Redis del stack) =====
# Para DEV podemos reutilizar el Redis de Authentik. En prod conviene uno separado.
SESSION_SECRET=pon-una-clave-larga-y-unica
REDIS_URL=redis://authentik-redis:6379
# ===== DB principal (metadatos de SuiteCoffee) =====
# Usa el alias de red del servicio 'db' (compose: aliases [dev-db])
DB_HOST=dev-db
DB_PORT=5432
DB_NAME=dev-suitecoffee
DB_USER=dev-user-suitecoffee
DB_PASS=dev-pass-suitecoffee
# ===== DB tenants (Tenants de SuiteCoffee) =====
TENANTS_HOST=dev-tenants
TENANTS_DB=dev-postgres
TENANTS_USER=dev-user-postgres
TENANTS_PASS=dev-pass-postgres
TENANTS_PORT=5432
TENANT_INIT_SQL=/home/mateo/SuiteCoffee/services/auth/src/db/initTenant.sql
# TENANT_INIT_SQL=~/SuiteCoffee/services/app/src/db/01_init.sql
# ===== (Opcional) Colores UI, si alguna vista los lee =====
COL_PRI=452D19 # Marrón oscuro
COL_SEC=D7A666 # Crema / Café
COL_BG=FFA500 # Naranja
# ===== Authentik — Admin API (server-to-server dentro de la red) =====
# Usa el alias de red del servicio 'authentik' y su puerto interno 9000
AUTHENTIK_BASE_URL=http://authentik:9000
AUTHENTIK_TOKEN=eE3bFTLd4Rpt3ZkcidTC1EppDYMIr023ev3SXt4ImHynOfAGRVtAZVBXSNxj
AUTHENTIK_DEFAULT_GROUP_NAME=suitecoffee-users
# ===== OIDC (DEBE coincidir con el Provider) =====
# DEV (todo dentro de la red de Docker):
# - El auth service redirige al navegador a este issuer. Si NO tenés reverse proxy hacia Authentik,
# esta URL interna NO será accesible desde el navegador del host. En ese caso, ver nota más abajo.
OIDC_ISSUER=https://authentik.suitecoffee.mateosaldain.uy/application/o/suitecoffee/
OIDC_CLIENT_ID=ydnp9s9I7G4p9Pwt5OsNlcpk1VKB9auN7AxqqNjC
OIDC_CLIENT_SECRET=yqdI00kYMeQF8VdmhwN5QWUzPLUzRBYeeAH193FynuVD19mo1nBRf5c5IRojzPrxDS0Hk33guUwHFzaj8vjTbTRetwK528uNJ6BfrYGUN2vzxgdMHFLQOHSTR0gR1LtG
# Redirect URI que definiste en el Provider. Usa el alias de red del servicio 'auth' (dev-auth)
# Si accedés desde el host sin proxy, usa mejor http://localhost:4040/auth/callback y añadilo al Provider.
OIDC_REDIRECT_URI=https://suitecoffee.mateosaldain.uy/auth/callback
# Cómo querés que maneje la contraseña Authentik para usuarios NUEVOS creados por tu backend:
# - TEMP_FORCE_CHANGE: crea un password temporal y obliga a cambiar en el primer login (recomendado si usás login con usuario/clave)
# - INVITE_LINK: envías/entregás un link de “establecer contraseña” (necesita flow de Enrollment/Recovery y SMTP configurado)
# - SSO_ONLY: no setea password local; login solo por Google/Microsoft/WebAuthn
AK_PASSWORD_MODE=TEMP_FORCE_CHANGE
# (Opcional) longitud del password temporal
AK_TEMP_PW_LENGTH=12
# 3) Configuración en Authentik (por modo)
# A) TEMP_FORCE_CHANGE (password temporal + cambio obligado)
# Flow de Autenticación
# Entra al Admin de Authentik → Flows → tu Authentication Flow (el que usa tu Provider OIDC).
# Asegurate de que tenga:
# Identification Stage (identifica por email/username),
# Password Stage (para escribir contraseña).
# Con eso, cuando el usuario entre con la clave temporal, Authentik le pedirá cambiarla.
# Provider OIDC (suitecoffee)
# Admin → Applications → Providers → tu provider de SuiteCoffee → Flow settings
# Authentication flow: seleccioná el de arriba.
# (Opcional) Email SMTP
# Si querés notificar o enviar contraseñas temporales/enlaces desde Authentik, configura SMTP en Admin → System → Email.
# Resultado: el usuario se registra en tu app → lo redirigís a /auth/login → Authentik pide email+clave → entra con la temporal → obliga a cambiarla → vuelve a tu app.
# B) INVITE_LINK (enlace de “establecer contraseña”)
# SMTP
# Admin → System → Email: configura SMTP (host, puerto, credenciales, remitente).
# Flow de Enrollment/Recovery
# Admin → Flows → clona/crea un flow de Enrollment/Recovery con:
# Identification Stage (email/username),
# Email Stage (envía el link con token),
# Password Stage (para que defina su clave),
# (opcional) Prompt/ User Write para confirmar.
# Guardalo con un Slug fácil (ej. enroll-set-password).
# Cómo usarlo
# Caminos:
# Manual desde UI: Admin → Directory → Invitations → crear invitación, elegir Flow enroll-set-password, seleccionar usuario, copiar link y enviar.
# Automático (más adelante): podemos automatizar por API la creación de una Invitation y envío de mail. (Si querés, te armo el helper akCreateInvitation(userUUID, flowSlug).)
# Resultado: el registro en tu app no pone password; el usuario recibe un link para establecer la clave y desde ahí inicia normalmente.
# C) SSO_ONLY (sin contraseñas locales)
# Configura un Source (Google Workspace / Microsoft Entra / WebAuthn):
# Admin → Directory → Sources: crea el Source (por ejemplo, Google OAuth o Entra ID).
# Activa Create users (para que se creen en Authentik si no existen).
# Mapea email y name.
# Authentication Flow
# Agrega una Source Stage del proveedor (Google/Microsoft/WebAuthn) en tu Authentication Flow.
# (Podés dejar Password Stage deshabilitado si querés solo SSO.)
# Provider OIDC
# En tu Provider suitecoffee, seleccioná ese Authentication Flow.
# Resultado: el usuario se registra en tu app → al entrar a /auth/login ve botón Iniciar con Google/Microsoft → hace click, vuelve con sesión, tu backend setea sc.sid.
+22
View File
@@ -0,0 +1,22 @@
NODE_ENV=production # Entorno de desarrollo
PORT=4000 # Variables del servicio -> suitecoffee-app
# AUTH_HOST=prod-auth
DB_HOST=prod-db
# Nombre de la base de datos
DB_NAME=suitecoffee
# Usuario y contraseña
DB_USER=suitecoffee
DB_PASS=suitecoffee
# Puertos del servicio de db
DB_LOCAL_PORT=5432
DB_DOCKER_PORT=5432
# Colores personalizados
COL_PRI=452D19 # Marrón oscuro
COL_SEC=D7A666 # Crema / Café
COL_BG=FFA500 # Naranja
+58 -7
View File
@@ -23,6 +23,29 @@ function getConfig() {
// ------------------------------------------------------------
// 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();
}
// -- 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 || []);
}
export async function akPatchUserAttributes(userPk, partialAttrs = {}) {
// PATCH del usuario para asegurar attributes.tenant_uuid
return akRequest('patch', `/api/v3/core/users/${userPk}/`, {
@@ -196,18 +219,46 @@ export async function akSetPassword(userPk, password, requireChange = true) {
* 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 akListGroups(search) {
const data = await request('GET', '/core/groups/', { qs: { search, page_size: 50 }, retries: 2 });
return Array.isArray(data?.results) ? data.results : [];
}
export async function akResolveGroupIdByName(name) {
const data = await akListGroups(name);
const lower = name.toLowerCase();
const found = data.find(g => (g.name || '').toLowerCase() === lower || (g.slug || '').toLowerCase() === lower);
return found?.pk || null;
const lower = String(name || '').toLowerCase();
const found = data.find(g =>
String(g.name || '').toLowerCase() === lower ||
String(g.slug || '').toLowerCase() === lower
);
return found?.pk ?? 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);
// por UUID (devuelve objeto con pk)
if (uuid) {
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
}
}
// 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);
}
return null;
}
// ------------------------------------------------------------
// Fin
File diff suppressed because it is too large Load Diff
+447 -136
View File
@@ -13,6 +13,7 @@ import chalk from 'chalk';
import express from 'express';
import cors from 'cors';
import path from 'node:path';
import fs from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { Pool } from 'pg';
import session from 'express-session';
@@ -21,13 +22,14 @@ import * as connectRedis from 'connect-redis';
import expressLayouts from 'express-ejs-layouts';
import { Issuer, generators } from 'openid-client';
import crypto from 'node:crypto';
import { readFile } from 'node:fs/promises';
// -----------------------------------------------------------------------------
// 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, '') : '');
@@ -36,12 +38,22 @@ const schemaNameFor = (uuidHex) => `schema_tenant_${uuidHex}`;
const roleNameFor = (uuidHex) => `tenant_${uuidHex}`;
// Helpers de Authentik (admin API)
const { akFindUserByEmail, akCreateUser, akSetPassword } = await import('./ak.js');
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, '""')}"`;
const qs = (str) => `'${String(str).replace(/'/g, "''")}'`; // quote string literal seguro
const VALID_IDENT = /^[a-z_][a-z0-9_]*$/i;
// Literal de texto SQL (valores: contraseñas, strings, …)
const qs = (val) => `'${String(val).replace(/'/g, "''")}'`;
const VALID_IDENT = /^[a-zA-Z_][a-zA-Z0-9_:$-]*$/;
// --- Resolver y cachear el grupo por ID/UUID/NOMBRE una sola vez ---
let DEFAULT_GROUP_ID = process.env.AUTHENTIK_DEFAULT_GROUP_ID
@@ -66,6 +78,26 @@ if (!DEFAULT_GROUP_ID) {
})();
}
function nukeSession(req, res, redirectTo = '/auth/login', reason = 'reset') {
try {
// Destruye la sesión en el store (Redis)
req.session?.destroy(() => {
// Limpia la cookie en el navegador
res.clearCookie(SESSION_COOKIE_NAME, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
});
// Reinicia el flujo
return res.redirect(303, `${redirectTo}?reason=${encodeURIComponent(reason)}`);
});
} catch {
// Si algo falla, al menos intentamos redirigir
return res.redirect(303, `${redirectTo}?reason=${encodeURIComponent(reason)}`);
}
}
// Verificar existencia del tenant sin crear (en la DB de tenants)
async function tenantExists(uuidHex) {
if (!uuidHex) return false;
@@ -87,12 +119,12 @@ async function tenantExists(uuidHex) {
// 2) Authentik (attributes.tenant_uuid del usuario)
// 3) valor provisto en el request (si viene)
async function resolveExistingTenantUuid({ email, requestedTenantUuid }) {
const emailLower = String(email).toLowerCase();
const normEmail = String(email).trim().toLowerCase();
// 1) DB principal
const dbRes = await pool.query(
'SELECT tenant_uuid FROM app_user WHERE LOWER(email)=LOWER($1) LIMIT 1',
[emailLower]
[normEmail]
);
if (dbRes.rowCount) {
const fromDb = cleanUuid(dbRes.rows[0].tenant_uuid);
@@ -100,7 +132,7 @@ async function resolveExistingTenantUuid({ email, requestedTenantUuid }) {
}
// 2) Authentik
const akUser = await akFindUserByEmail(emailLower).catch(() => null);
const akUser = await akFindUserByEmail(normEmail).catch(() => null);
const fromAk = cleanUuid(akUser?.attributes?.tenant_uuid);
if (fromAk) return fromAk;
@@ -113,57 +145,198 @@ async function resolveExistingTenantUuid({ email, requestedTenantUuid }) {
// Helper para crear tenant si falta
async function ensureTenant({ tenant_uuid }) {
const client = await tenantsPool.connect();
const admin = await tenantsPool.connect();
try {
await client.query('BEGIN');
await admin.query('BEGIN');
// Si no vino UUID, generamos uno
let uuid = (tenant_uuid || crypto.randomUUID()).toLowerCase();
const uuidNoHyphen = uuid.replace(/-/g, '');
// uuid y nombres
const uuid = (tenant_uuid || crypto.randomUUID()).toLowerCase();
const hex = uuid.replace(/-/g, '');
if (!/^[a-f0-9]{32}$/.test(hex)) throw new Error('tenant_uuid inválido');
const schema = `schema_tenant_${uuidNoHyphen}`;
const role = `tenant_${uuidNoHyphen}`;
const pwd = crypto.randomBytes(18).toString('base64url'); // password del rol
const schema = `schema_tenant_${hex}`;
const role = `tenant_${hex}`;
const pwd = crypto.randomBytes(18).toString('base64url');
if (!VALID_IDENT.test(schema) || !VALID_IDENT.test(role)) {
throw new Error('Identificador de schema/rol inválido');
}
// 1) Crear ROL si no existe
const { rowCount: hasRole } = await client.query(
'SELECT 1 FROM pg_roles WHERE rolname=$1',
[role]
);
if (!hasRole) {
// Para el identificador usamos qi(); el password sí va parametrizado
await client.query(`CREATE ROLE ${qi(role)} LOGIN PASSWORD ${qs(pwd)}`);
// 1) Crear ROL si no existe (PASSWORD debe ser LITERAL, no parámetro)
const r = await admin.query('SELECT 1 FROM pg_roles WHERE rolname=$1', [role]);
if (!r.rowCount) {
await admin.query(`CREATE ROLE ${qi(role)} LOGIN PASSWORD ${qs(pwd)}`);
// Si quisieras rotarla luego:
// await admin.query(`ALTER ROLE ${qi(role)} PASSWORD ${qs(pwd)}`);
}
// 2) Crear SCHEMA si no existe y asignar owner al rol del tenant
const { rowCount: hasSchema } = await client.query(
// 2) Crear SCHEMA si no existe y asignar owner
const s = await admin.query(
'SELECT 1 FROM information_schema.schemata WHERE schema_name=$1',
[schema]
);
if (!hasSchema) {
await client.query(`CREATE SCHEMA ${qi(schema)} AUTHORIZATION ${qi(role)}`);
if (!s.rowCount) {
await admin.query(`CREATE SCHEMA ${qi(schema)} AUTHORIZATION ${qi(role)}`);
} else {
await admin.query(`ALTER SCHEMA ${qi(schema)} OWNER TO ${qi(role)}`);
}
// 3) Permisos mínimos para el rol del tenant en su schema
await client.query(`GRANT USAGE ON SCHEMA ${qi(schema)} TO ${qi(role)}`);
await client.query(
// 3) Permisos por defecto
await admin.query(`GRANT USAGE ON SCHEMA ${qi(schema)} TO ${qi(role)}`);
await admin.query(
`ALTER DEFAULT PRIVILEGES IN SCHEMA ${qi(schema)}
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${qi(role)}`
);
await client.query(
await admin.query(
`ALTER DEFAULT PRIVILEGES IN SCHEMA ${qi(schema)}
GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO ${qi(role)}`
);
await client.query('COMMIT');
// 4) Aplicar 01_init.sql en la misma transacción
const initSql = await loadInitSql(); // tu caché/loader actual
if (initSql && initSql.trim()) {
await admin.query(`SET LOCAL search_path TO ${qi(schema)}, public`);
await admin.query(initSql);
}
await admin.query('COMMIT');
return { tenant_uuid: uuid, schema, role, role_password: pwd };
} catch (e) {
try { await admin.query('ROLLBACK'); } catch {}
throw e;
} finally {
admin.release();
}
}
// async function ensureTenant({ tenant_uuid }) {
// const client = await tenantsPool.connect();
// try {
// await client.query('BEGIN');
// // Si no vino UUID, generamos uno
// let uuid = (tenant_uuid || crypto.randomUUID()).toLowerCase();
// const uuidNoHyphen = uuid.replace(/-/g, '');
// const schema = `schema_tenant_${uuidNoHyphen}`;
// const role = `tenant_${uuidNoHyphen}`;
// const pwd = crypto.randomBytes(18).toString('base64url'); // password del rol
// if (!VALID_IDENT.test(schema) || !VALID_IDENT.test(role)) {
// throw new Error('Identificador de schema/rol inválido');
// }
// // 1) Crear ROL si no existe
// const { rowCount: hasRole } = await client.query(
// 'SELECT 1 FROM pg_roles WHERE rolname=$1',
// [role]
// );
// if (!hasRole) {
// // Para el identificador usamos qi(); el password sí va parametrizado
// await client.query(`CREATE ROLE ${qi(role)} LOGIN PASSWORD ${qs(pwd)}`);
// }
// // 2) Crear SCHEMA si no existe y asignar owner al rol del tenant
// const { rowCount: hasSchema } = await client.query(
// 'SELECT 1 FROM information_schema.schemata WHERE schema_name=$1',
// [schema]
// );
// if (!hasSchema) {
// await client.query(`CREATE SCHEMA ${qi(schema)} AUTHORIZATION ${qi(role)}`);
// }
// // 3) Permisos mínimos para el rol del tenant en su schema
// await client.query(`GRANT USAGE ON SCHEMA ${qi(schema)} TO ${qi(role)}`);
// await client.query(
// `ALTER DEFAULT PRIVILEGES IN SCHEMA ${qi(schema)}
// GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${qi(role)}`
// );
// await client.query(
// `ALTER DEFAULT PRIVILEGES IN SCHEMA ${qi(schema)}
// GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO ${qi(role)}`
// );
// await client.query('COMMIT');
// // 4) Inicialización del esquema con 01_init.sql (solo si está vacío)
// try {
// await initializeTenantSchemaIfEmpty(client, schema);
// } catch (e) {
// // Podés decidir si esto es fatal o "best-effort".
// // Si querés cortar el alta cuando falla la init, usa: throw e;
// console.warn(`[TENANT INIT] Falló inicialización de ${schema}:`, e?.message || e);
// }
// return { tenant_uuid: uuid, schema, role, role_password: pwd };
// } catch (e) {
// try { await client.query('ROLLBACK'); } catch {}
// throw e;
// } finally {
// client.release();
// }
// }
// Carga el 01_init.sql del disco, elimina BEGIN/COMMIT y sustituye el schema.
let _cachedInitSql = null;
async function loadInitSql() {
if (_cachedInitSql !== null) return _cachedInitSql;
const candidates = [
process.env.TENANT_INIT_SQL, // recomendado via .env
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);
const txt = await readFile(p, 'utf8');
_cachedInitSql = String(txt || '');
console.log(`[TENANT INIT] initTenant.sql: ${p} (${_cachedInitSql.length} bytes)`);
return _cachedInitSql;
} catch {}
}
console.warn('[TENANT INIT] initTenant.sql no encontrado (se omitirá).');
_cachedInitSql = '';
return _cachedInitSql;
}
async function isSchemaEmpty(client, schema) {
const { rows } = await client.query(
`SELECT COUNT(*)::int AS c
FROM information_schema.tables
WHERE table_schema = $1`,
[schema]
);
return rows[0].c === 0;
}
/** Ejecuta 01_init.sql para un tenant (solo si el esquema está vacío). */
async function initializeTenantSchemaIfEmpty(schema) {
const sql = await loadInitSql();
if (!sql || !sql.trim()) {
console.warn(`[TENANT INIT] Esquema ${schema}: 01_init.sql vacío/no disponible. Salteando.`);
return;
}
const client = await tenantsPool.connect();
try {
// No usamos LOCAL: queremos que el search_path persista en esta conexión mientras dura el script
await client.query('BEGIN');
await client.query(`SET search_path TO ${qi(schema)}, public`);
const empty = await isSchemaEmpty(client, schema);
if (!empty) {
await client.query('ROLLBACK');
console.log(`[TENANT INIT] Esquema ${schema}: ya tiene tablas. No se aplica 01_init.sql.`);
return;
}
await client.query(sql); // acepta múltiples sentencias separadas por ';'
await client.query('COMMIT');
console.log(`[TENANT INIT] Esquema ${schema}: 01_init.sql aplicado.`);
} catch (e) {
try { await client.query('ROLLBACK'); } catch {}
console.error(`[TENANT INIT] Error aplicando 01_init.sql sobre ${schema}:`, e.message);
throw e;
} finally {
client.release();
@@ -191,6 +364,13 @@ function onFatal(err, msg = 'Error fatal') {
process.exit(1);
}
function genTempPassword(len = 12) {
const base = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@$%';
let out = '';
for (let i = 0; i < len; i++) out += base[Math.floor(Math.random() * base.length)];
return out;
}
// -----------------------------------------------------------------------------
// Configuración Express
// -----------------------------------------------------------------------------
@@ -217,7 +397,7 @@ await redis.connect().catch((e) => onFatal(e, 'No se pudo conectar a Redis (sesi
app.use(
session({
name: 'sc.sid',
name: SESSION_COOKIE_NAME,
store: new RedisStore({ client: redis, prefix: 'sess:' }),
secret: process.env.SESSION_SECRET || 'change-me',
resave: false,
@@ -248,7 +428,6 @@ const tenantsPool = new Pool({
max: 10,
});
// -----------------------------------------------------------------------------
// PostgreSQL — DB principal (metadatos de negocio)
// -----------------------------------------------------------------------------
@@ -328,85 +507,132 @@ let oidcClient;
}
})();
// -----------------------------------------------------------------------------
// Vistas
// -----------------------------------------------------------------------------
app.get('/', (req, res) => res.render('login', { pageTitle: 'Iniciar sesión' }));
// -----------------------------------------------------------------------------
// Rutas OIDC
// -----------------------------------------------------------------------------
app.get('/auth/login', (req, res) => {
const code_verifier = generators.codeVerifier();
const code_challenge = generators.codeChallenge(code_verifier);
const state = generators.state();
// req.session.code_verifier = code_verifier;
app.get('/auth/login', (req, res, next) => {
try {
// guarda todo lo necesario para el callback
req.session.code_verifier = code_verifier;
req.session.state = state;
if (req.session?.oidc) {
return nukeSession(req, res, '/auth/login', 'stale_oidc');
}
// log de depuración
console.log('[OIDC] start login sid=%s state=%s', req.sessionID, state)
const code_verifier = generators.codeVerifier();
const code_challenge = generators.codeChallenge(code_verifier);
const url = oidcClient.authorizationUrl({
scope: 'openid email profile',
code_challenge,
code_challenge_method: 'S256',
state,
});
console.log('[OIDC] auth URL has state? %s', url.includes(`state=${state}`));
return res.redirect(url);
// Podés usar generators.state() y generators.nonce(); ambas son válidas
const state = generators.state(); // crypto.randomBytes(24).toString('base64url') también sirve
const nonce = generators.nonce();
// Guardamos TODO dentro de un objeto para evitar claves sueltas
req.session.oidc = { code_verifier, state, nonce };
// Guardar sesión ANTES de redirigir
req.session.save((err) => {
if (err) return next(err);
const url = oidcClient.authorizationUrl({
scope: 'openid profile email offline_access',
code_challenge,
code_challenge_method: 'S256',
state,
nonce,
});
return res.redirect(url); // importantísimo: return
});
} catch (e) {
return next(e);
}
});
app.get('/auth/callback', async (req, res, next) => {
try {
// Log útil para debug
console.log('[OIDC] cb sid=%s query=%j', req.sessionID, req.query);
const params = oidcClient.callbackParams(req);
const tokenSet = await oidcClient.callback(
process.env.OIDC_REDIRECT_URI,
params,
{ code_verifier: req.session.code_verifier, state: req.session.state }
);
delete req.session.code_verifier;
delete req.session.state;
// Recuperar lo que guardamos en /auth/login
const { oidc } = req.session || {};
const code_verifier = oidc?.code_verifier;
const stateStored = oidc?.state;
const nonceStored = oidc?.nonce;
// Si por algún motivo no está la info (sesión perdida/expirada), reiniciamos el flujo
if (!code_verifier || !stateStored) {
console.warn('[OIDC] Falta code_verifier/state en sesión; reiniciando login');
return res.redirect(303, '/auth/login');
}
const params = oidcClient.callbackParams(req);
// openid-client validará que el "state" recibido coincida con el que pasamos aquí
let tokenSet;
try {
tokenSet = await oidcClient.callback(
process.env.OIDC_REDIRECT_URI,
params,
{ code_verifier, state: stateStored, nonce: nonceStored }
);
} catch (err) {
console.warn('[OIDC] callback error, resetting session:', err?.message || err);
return nukeSession(req, res, '/auth/login', 'callback_error');
}
// Limpiar datos OIDC de la sesión
delete req.session.oidc;
const claims = tokenSet.claims();
const email = (claims.email || '').toLowerCase();
const tenantUuid = (claims.tenant_uuid || '').replace(/-/g, '');
// tenant desde claim, Authentik o fallback a tu DB
let tenantHex = cleanUuid(claims.tenant_uuid);
if (!tenantHex) {
// intenta Authentik
const akUser = await akFindUserByEmail(email).catch(()=>null);
const akUser = await akFindUserByEmail(email).catch(() => null);
tenantHex = cleanUuid(akUser?.attributes?.tenant_uuid);
// último recurso: tu DB
if (!tenantHex) {
const q = await pool.query('SELECT tenant_uuid FROM app_user WHERE LOWER(email)=LOWER($1) LIMIT 1', [email]);
const q = await pool.query(
'SELECT tenant_uuid FROM app_user WHERE LOWER(email)=LOWER($1) LIMIT 1',
[email]
);
tenantHex = cleanUuid(q.rows?.[0]?.tenant_uuid);
}
}
// Regenerar sesión para evitar fijación
// Regenerar sesión para evitar fijación y guardar el usuario
req.session.regenerate((err) => {
if (err) return next(err);
if (err) {
if (!res.headersSent) res.status(500).send('No se pudo crear la sesión.');
return;
}
req.session.user = {
sub: claims.sub,
email,
tenant_uuid: tenantUuid || null,
tenant_uuid: tenantHex || null,
};
req.session.save((e2) => (e2 ? next(e2) : res.redirect('/')));
req.session.save((e2) => {
if (e2) {
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
});
});
return res.redirect('/');
} catch (e) {
next(e);
console.error('[OIDC] callback error:', e);
if (!res.headersSent) return next(e);
}
});
app.post('/auth/logout', (req, res) => {
req.session.destroy(() => {
res.clearCookie('sc.sid');
res.clearCookie(SESSION_COOKIE_NAME);
res.status(204).end();
});
});
@@ -420,104 +646,189 @@ app.get('/auth/me', (req, res) => {
// Registro de usuario (DB principal + Authentik)
// -----------------------------------------------------------------------------
// Helpers defensivos (si ya los tenés, podés omitir estas definiciones)
const extractAkUserUuid = (u) =>
(u && (u.uuid || u?.user?.uuid || (Array.isArray(u.results) && u.results[0]?.uuid))) || null;
const extractAkUserPk = (u) =>
(u && (u.pk ?? u?.user?.pk ?? null));
async function akDeleteUser(pkOrUuid) {
try {
if (!pkOrUuid || !globalThis.fetch) return;
const base = process.env.AUTHENTIK_BASE_URL?.replace(/\/+$/, '') || '';
const id = String(pkOrUuid);
const url = `${base}/api/v3/core/users/${encodeURIComponent(id)}/`;
await fetch(url, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${process.env.AUTHENTIK_TOKEN}` }
});
} catch (e) {
console.warn('[AK] No se pudo borrar usuario (compensación):', e?.message || e);
}
}
// ==============================
// POST /api/users/register
// ==============================
app.post('/api/users/register', async (req, res, next) => {
const { email, display_name, tenant_uuid: requestedTenant, role } = req.body || {};
if (!email) return res.status(400).json({ error: 'email es obligatorio' });
// 0) input
const {
email,
display_name,
role,
tenant_uuid: requestedTenantUuid, // opcional
} = req.body || {};
const emailLower = String(email).toLowerCase();
const normEmail = String(email || '').trim().toLowerCase();
if (!normEmail) return res.status(400).json({ error: 'email requerido' });
// 1) ¿Ya hay tenant conocido (DB o Authentik o request)?
let tenantHex = await resolveExistingTenantUuid({
email: emailLower,
requestedTenantUuid: requestedTenant,
});
// 2) Si viene por request, asegurate que exista (o créalo a demanda)
if (tenantHex) {
const exists = await tenantExists(tenantHex);
if (!exists) {
// Si tu política es NO crear si lo traen y no existe, devolvé 400.
// return res.status(400).json({ error: 'tenant-invalido', detail: 'El tenant indicado no existe' });
// Si preferís crearlo para "reparar", lo creamos sin generar un UUID nuevo:
await ensureTenant({ tenant_uuid: tenantHex });
// 1) Resolver tenant uuid (existente o nuevo)
let tenantHex = null;
try {
if (typeof resolveExistingTenantUuid === 'function') {
tenantHex = await resolveExistingTenantUuid({
email: normEmail,
requestedTenantUuid,
});
} else {
tenantHex = cleanUuid(requestedTenantUuid);
}
// Crear/asegurar tenant en una transacción (ahí adentro corre 01_init.sql)
if (tenantHex) {
// si no existe, ensureTenant lo crea
await ensureTenant({ tenant_uuid: tenantHex });
} else {
const created = await ensureTenant({ tenant_uuid: null }); // genera uuid
tenantHex = cleanUuid(created?.tenant_uuid);
}
} catch (e) {
return next(new Error(`No se pudo preparar el tenant: ${e.message}`));
}
// 3) Si todavía no hay tenant → primer alta de org → crear uno nuevo
if (!tenantHex) {
const created = await ensureTenant({ tenant_uuid: null }); // genera uuid nuevo
tenantHex = cleanUuid(created.tenant_uuid);
}
// 4) Alta transaccional del usuario en TU DB
// 2) Transacción DB principal + Authentik con compensación
const client = await pool.connect();
let createdAkUser = null; // para compensación
try {
await client.query('BEGIN');
// Evitar duplicar usuario por email + tenant (ajusta según tu constraint)
// Duplicados (ajusta a tu constraint real: por email o por (email,tenant))
const dup = await client.query(
'SELECT id FROM app_user WHERE LOWER(email)=LOWER($1) AND tenant_uuid=$2',
[emailLower, tenantHex]
'SELECT id FROM app_user WHERE LOWER(email)=LOWER($1)',
[normEmail]
);
if (dup.rowCount) {
await client.query('ROLLBACK');
return res.status(409).json({
error: 'user-exists',
message: 'Ya existe un usuario con este email en este tenant.',
message: 'Ya existe un usuario con este email.',
next: '/auth/login',
});
}
// Authentik: crear si no existe; si existe, reusar y (opcional) asegurar attributes.tenant_uuid
let akUser = await akFindUserByEmail(emailLower);
// Authentik: buscar o crear
let akUser = await akFindUserByEmail(normEmail).catch(() => null);
if (!akUser) {
akUser = await akCreateUser({
email: emailLower,
displayName: display_name,
tenantUuid: tenantHex, // se guarda en attributes
email: normEmail,
displayName: display_name || null,
tenantUuid: tenantHex, // attributes.tenant_uuid
addToGroupId: DEFAULT_GROUP_ID || null,
isActive: true,
});
} else {
// si existe y no tiene attribute tenant_uuid, lo “reparamos” (opcional):
const akAttrHex = cleanUuid(akUser?.attributes?.tenant_uuid);
if (!akAttrHex) {
try {
// parchea attributes del user en AK (si tu versión permite PATCH)
await akPatchUserAttributes(akUser.pk, { tenant_uuid: tenantHex });
} catch { /* opcional, no crítico */ }
}
createdAkUser = akUser; // marcar que lo creamos nosotros
}
const _role = role || 'owner';
// Asegurar uuid/pk
let akUserUuid = extractAkUserUuid(akUser);
let akUserPk = extractAkUserPk(akUser);
if (!akUserUuid || akUserPk == null) {
const ref = await akFindUserByEmail(normEmail).catch(() => null);
akUserUuid = akUserUuid || extractAkUserUuid(ref);
akUserPk = akUserPk ?? extractAkUserPk(ref);
}
if (!akUserUuid) throw new Error('No se pudo obtener uuid del usuario en Authentik');
// Insert en tu DB principal
const finalRole = role || 'owner';
await client.query(
`INSERT INTO app_user (email, display_name, tenant_uuid, ak_user_uuid, role)
VALUES ($1,$2,$3,$4,$5)`,
[emailLower, display_name || null, tenantHex, akUser.uuid, _role]
[normEmail, display_name || null, tenantHex, akUserUuid, finalRole]
);
await client.query('COMMIT');
return res.status(201).json({
message: 'Usuario registrado',
email: emailLower,
tenant_uuid: tenantHex, // devolvés el mismo
role: _role,
authentik_user_uuid: akUser.uuid,
next: '/auth/login',
// 3) Marcar sesión para set-password (si usás este flujo)
req.session.pendingPassword = {
email: normEmail,
ak_user_uuid: akUserUuid,
ak_user_pk: akUserPk,
exp: Date.now() + 10 * 60 * 1000,
};
return req.session.save(() => {
const accept = String(req.headers['accept'] || '');
if (accept.includes('text/html')) {
return res.redirect(303, '/set-password');
}
return res.status(201).json({
message: 'Usuario registrado',
email: normEmail,
tenant_uuid: tenantHex,
role: finalRole,
authentik_user_uuid: akUserUuid,
next: '/set-password',
});
});
} catch (err) {
// Rollbacks + Compensaciones
try { await client.query('ROLLBACK'); } catch {}
if (err?.code === '23505') { // unique_violation
return res.status(409).json({ error: 'user-exists' });
}
next(err);
try {
// Si creamos el usuario en Authentik y luego falló algo → borrar
if (createdAkUser) {
const id = extractAkUserPk(createdAkUser) ?? extractAkUserUuid(createdAkUser);
if (id) await akDeleteUser(id);
}
} catch {}
return next(err);
} finally {
client.release();
}
});
// Definir contraseña
app.post('/auth/password/set', async (req, res, next) => {
try {
const pp = req.session?.pendingPassword;
if (!pp || (pp.exp && Date.now() > pp.exp)) {
// token de sesión vencido o ausente
if (!res.headersSent) return res.redirect(303, '/set-password');
return;
}
const { password, password2 } = req.body || {};
if (!password || password.length < 8 || password !== password2) {
return res.status(400).send('Contraseña inválida o no coincide.');
}
// Buscar el usuario en Authentik y setear la clave
const u = await akFindUserByEmail(pp.email);
if (!u) return res.status(404).send('No se encontró el usuario en Authentik.');
await akSetPassword(u.pk, password, true); // true = force change handled; ajusta a tu helper
// Limpiar marcador y continuar al SSO
delete req.session.pendingPassword;
return req.session.save(() => res.redirect(303, '/auth/login'));
} catch (e) {
next(e);
}
});
// Espera: { email, display_name?, tenant_uuid }
// app.post('/api/users/register', async (req, res, next) => {
@@ -587,12 +898,12 @@ app.get('/health', (_req, res) => res.status(200).json({ status: 'ok' }));
// -----------------------------------------------------------------------------
// 404 + Manejo de errores
// -----------------------------------------------------------------------------
app.use((req, res) => res.status(404).json({ error: 'not-found', path: req.originalUrl }));
app.use((req, res) => res.status(404).json({ error: 'Error 404, No se encontró la página', path: req.originalUrl }));
app.use((err, _req, res, _next) => {
console.error('❌ Error:', err);
if (res.headersSent) return;
res.status(500).json({ error: 'internal-error', detail: err?.message || String(err) });
res.status(500).json({ error: '¡Oh! A ocurrido un error en el servidor auth.', detail: err.stack || String(err) });
});
// -----------------------------------------------------------------------------
-164
View File
@@ -1,164 +0,0 @@
<!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 /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 /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('/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>