Carga completa

This commit is contained in:
Mateo Saldain 2025-09-06 11:19:42 +00:00
parent 237a5427dd
commit 5d078f3932
17 changed files with 3278 additions and 298 deletions

35
.env.development Normal file
View File

@ -0,0 +1,35 @@
# Archivo de variables de entorno para docker-compose.yml
COMPOSE_PROJECT_NAME=suitecoffee_dev
# Entorno de desarrollo
NODE_ENV=development
# app - app
APP_LOCAL_PORT=3030
APP_DOCKER_PORT=3030
# auth - app
AUTH_LOCAL_PORT=4040
AUTH_DOCKER_PORT=4040
# tenants - postgres
TENANTS_DB_NAME=dev-postgres
TENANTS_DB_USER=dev-user-postgres
TENANTS_DB_PASS=dev-pass-postgres
TENANTS_DB_LOCAL_PORT=54321
TENANTS_DB_DOCKER_PORT=5432
# db primaria - postgres
DB_NAME=dev-suitecoffee
DB_USER=dev-user-suitecoffee
DB_PASS=dev-pass-suitecoffee
DB_LOCAL_PORT=54322
DB_DOCKER_PORT=5432
# --- secretos para Authentik
AUTHENTIK_SECRET_KEY=poné_un_valor_largo_y_unico
AUTHENTIK_DB_PASS=cambia_esto
AUTHENTIK_BOOTSTRAP_PASSWORD=cambia_esto
AUTHENTIK_BOOTSTRAP_EMAIL=info.suitecoffee@gmail.com

23
.env.production Normal file
View File

@ -0,0 +1,23 @@
# Archivo de variables de entorno para docker-compose.yml
COMPOSE_PROJECT_NAME=suitecoffee_prod
# Entorno de desarrollo
NODE_ENV=production
# app - app
APP_LOCAL_PORT=3000
APP_DOCKER_PORT=3000
# auth - app
AUTH_LOCAL_PORT=4000
AUTH_DOCKER_PORT=4000
# tenants - postgres
TENANTS_DB_NAME=postgres
TENANTS_DB_USER=postgres
TENANTS_DB_PASS=postgres
# db primaria - postgres
DB_NAME=suitecoffee
DB_USER=suitecoffee
DB_PASS=suitecoffee

2
.gitignore vendored
View File

@ -33,6 +33,6 @@ tests/
.gitmodules
# Ignorar archivos personales o privados (si existen)
.env.*
# .env.*
*.pem
*.key

0
docs/db's.md Normal file
View File

View File

@ -0,0 +1,29 @@
# ===== Runtime =====
NODE_ENV=development
PORT=3030
APP_LOCAL_PORT=3030
# ===== 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) =====
DB_HOST=dev-tenants
DB_PORT=5432
DB_NAME=dev-postgres
DB_USER=dev-user-postgres
DB_PASS=dev-pass-postgres
# ===== 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
# ===== (Opcional) Colores UI, si alguna vista los lee =====
COL_PRI=452D19 # Marrón oscuro
COL_SEC=D7A666 # Crema / Café
COL_BG=FFA500 # Naranja

View File

@ -0,0 +1,22 @@
NODE_ENV=production # Entorno de desarrollo
PORT=3000 # Variables del servicio -> suitecoffee-app
# Variables del servicio -> suitecoffee-db de suitecoffee-app
DB_HOST=prod-tenants
# Nombre de la base de datos
DB_NAME=postgres
# Usuario y contraseña
DB_USER=postgres
DB_PASS=postgres
# 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

View File

@ -19,6 +19,7 @@
"express-ejs-layouts": "^2.5.1",
"express-session": "^1.18.2",
"ioredis": "^5.7.0",
"morgan": "^1.10.1",
"pg": "^8.16.3",
"pg-format": "^1.0.4",
"redis": "^5.8.2",
@ -131,6 +132,24 @@
"version": "1.0.2",
"license": "MIT"
},
"node_modules/basic-auth": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz",
"integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.1.2"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/basic-auth/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
@ -902,6 +921,49 @@
"node": "*"
}
},
"node_modules/morgan": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz",
"integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==",
"license": "MIT",
"dependencies": {
"basic-auth": "~2.0.1",
"debug": "2.6.9",
"depd": "~2.0.0",
"on-finished": "~2.3.0",
"on-headers": "~1.1.0"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/morgan/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/morgan/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/morgan/node_modules/on-finished": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
"integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/ms": {
"version": "2.1.3",
"license": "MIT"

View File

@ -25,6 +25,7 @@
"express-ejs-layouts": "^2.5.1",
"express-session": "^1.18.2",
"ioredis": "^5.7.0",
"morgan": "^1.10.1",
"pg": "^8.16.3",
"pg-format": "^1.0.4",
"redis": "^5.8.2",

View File

@ -1,216 +1,259 @@
// services/app/src/index.js
// -----------------------------------------------------------------------------
// SuiteCoffee — Servicio APP (Express)
// - Carga de entorno robusta (compatible con Docker Compose env_file)
// - Sesiones compartidas via Redis (mismo cookie que AUTH)
// - Middlewares: CORS, JSON, estáticos, EJS opcional
// - Multitenant por esquema: requireAuth + withTenant + done
// - Montaje automático de ENDPOINTS LEGACY sin perder nada
// - Healthcheck, 404 y manejador de errores
// - Conserva y expone pools para que tus endpoints los usen
// -----------------------------------------------------------------------------
// ------------------------------------------------------------
// SuiteCoffee — Servicio APP (UI + APIs negocio)
// - ESM (Node >=18)
// - Vistas EJS en ./views (dashboard.ejs, comandas.ejs, etc.)
// - Sesión compartida con AUTH (cookie: sc.sid, Redis)
// - Monta routes.legacy.js con requireAuth + withTenant
// ------------------------------------------------------------
// === 0) CARGA DE ENTORNO ROBUSTA (no pisa variables ya definidas por Compose)
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import dotenv from 'dotenv';
import chalk from 'chalk';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const ENV = (process.env.NODE_ENV || 'development').toLowerCase();
const envMap = { development: '.env.development', stage: '.env.test', production: '.env.production' };
const envFile = envMap[ENV] || '.env.development';
const candidates = [
path.resolve(process.cwd(), envFile), // /app/.env.development (dentro del contenedor)
path.resolve(__dirname, '..', envFile), // por si queda un nivel arriba
path.resolve(__dirname, envFile), // por si lo ponen junto al src
];
const found = candidates.find((p) => fs.existsSync(p));
if (found) {
dotenv.config({ path: found, override: false });
console.log(`Activando entorno de -> ${ENV.toUpperCase()} ${chalk.gray(`(${found})`)}`);
} else {
console.log(`Activando entorno de -> ${ENV.toUpperCase()} (sin archivo .env; usando variables del proceso)`);
}
// === 1) IMPORTS PRINCIPALES
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import expressLayouts from 'express-ejs-layouts';
import morgan from 'morgan';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import session from 'express-session';
import expressLayouts from 'express-ejs-layouts';
import { createClient as createRedisClient } from 'redis';
import * as connectRedis from 'connect-redis';
import { Pool } from 'pg';
import bcrypt from 'bcrypt'; // <- lo conservamos si ya lo usabas
import crypto from 'node:crypto'; // <- idem
// Tolerante a cambios de export en connect-redis
// -----------------------------------------------------------------------------
// Utilidades base
// -----------------------------------------------------------------------------
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const RedisStore = connectRedis.default || connectRedis.RedisStore;
// === 2) APP y CONFIG BASICA
const qi = (ident) => `"${String(ident).replace(/"/g, '""')}"`;
const CLEAN_HEX = (s) => (String(s || '').toLowerCase().replace(/[^0-9a-f]/g, '') || null);
const REQUIRED = (...keys) => {
const miss = keys.filter((k) => !process.env[k]);
if (miss.length) {
console.warn(`⚠ Faltan variables de entorno: ${miss.join(', ')}`);
}
};
// -----------------------------------------------------------------------------
// Validación de entorno mínimo (ajusta nombres si difieren)
// -----------------------------------------------------------------------------
REQUIRED(
// Sesión
'SESSION_SECRET', 'REDIS_URL',
// DB principal
'DB_HOST', 'DB_NAME', 'DB_USER', 'DB_PASS',
// DB de tenants
'TENANTS_HOST', 'TENANTS_DB', 'TENANTS_USER', 'TENANTS_PASS'
);
// -----------------------------------------------------------------------------
// Pools de PostgreSQL
// -----------------------------------------------------------------------------
const mainPool = new Pool({
host: process.env.DB_HOST,
port: Number(process.env.DB_PORT || 5432),
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASS,
max: 10,
idleTimeoutMillis: 30_000,
});
const tenantsPool = new Pool({
host: process.env.TENANTS_HOST,
port: Number(process.env.TENANTS_PORT || 5432),
database: process.env.TENANTS_DB,
user: process.env.TENANTS_USER,
password: process.env.TENANTS_PASS,
max: 10,
idleTimeoutMillis: 30_000,
});
// Autotest (no rompe si falla; sólo loguea)
(async () => {
try {
const c = await mainPool.connect();
const r = await c.query('SELECT NOW() now');
console.log('[APP] DB principal OK. Hora:', r.rows[0].now);
c.release();
} catch (e) {
console.error('[APP] Error al conectar DB principal:', e.message);
}
try {
const c = await tenantsPool.connect();
const r = await c.query('SELECT NOW() now');
console.log('[APP] DB tenants OK. Hora:', r.rows[0].now);
c.release();
} catch (e) {
console.error('[APP] Error al conectar DB tenants:', e.message);
}
})();
// -----------------------------------------------------------------------------
// Express + EJS
// -----------------------------------------------------------------------------
const app = express();
app.set('trust proxy', 1);
app.use(cors({ origin: true, credentials: true }));
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true }));
// Vistas EJS (si no usás vistas, puedes dejarlo; no rompe)
// Views EJS en ./views
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
app.use(expressLayouts);
app.set('layout', 'layout');
app.set("layout", "layouts/main");
// Estáticos opcionales (ajusta si tu estructura difiere)
app.use(express.static(path.join(__dirname, 'public')));
// Estáticos (si tenés carpeta public/, assets, etc.)
app.use('/public', express.static(path.join(__dirname, 'public')));
// === 3) SESIONES COMPARTIDAS (mismo cookie que AUTH)
const SESSION_SECRET = process.env.SESSION_SECRET || 'change-me-in-dev';
const REDIS_URL = process.env.REDIS_URL || 'redis://authentik-redis:6379';
// Middlewares básicos
app.use(morgan('dev'));
app.use(cors({ origin: true, credentials: true }));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
const redis = createRedisClient({ url: REDIS_URL });
await redis.connect().catch((e) => {
console.warn('⚠ No se pudo conectar a Redis de sesiones:', e?.message || e);
// ----------------------------------------------------------
// Middleware para datos globales
// ----------------------------------------------------------
app.use((req, res, next) => {
res.locals.currentPath = req.path;
res.locals.pageTitle = "SuiteCoffee";
res.locals.pageId = "";
next();
});
// -----------------------------------------------------------------------------
// Sesión (Redis) — misma cookie que AUTH
// -----------------------------------------------------------------------------
const SESSION_COOKIE_NAME = 'sc.sid';
const redis = createRedisClient({ url: process.env.REDIS_URL });
await redis.connect().catch((e) => console.error('[APP] Redis session error:', e.message));
app.use(
session({
name: 'sc.sid', // <- igual que en AUTH
name: SESSION_COOKIE_NAME,
store: new RedisStore({ client: redis, prefix: 'sess:' }),
secret: SESSION_SECRET,
secret: process.env.SESSION_SECRET || 'change-me',
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
// domain: 'suitecoffee.mateosaldain.uy', // (opcional) si lo necesitás
},
})
);
// Exponer el usuario a las vistas (no tocar req.session)
// Exponer usuario a las vistas
app.use((req, res, next) => {
res.locals.user = req.session?.user || null;
next();
});
// === 4) POOLS A BASES DE DATOS ===
// 4.1) Base principal (si la usás en APP). Conservamos variables usadas en el repo.
const DB = {
host: process.env.DB_HOST || 'dev-db',
port: Number(process.env.DB_PORT || 5432),
user: process.env.DB_USER || 'dev-user-suitecoffee',
password: process.env.DB_PASS || 'dev-pass-suitecoffee',
database: process.env.DB_NAME || 'dev-suitecoffee',
};
export const mainPool = new Pool({ ...DB, max: 10, idleTimeoutMillis: 30_000 });
async function verificarConexion() {
try {
const c = await mainPool.connect();
const { rows } = await c.query('SELECT NOW() AS now');
console.log(`DB principal OK @ ${rows[0].now}`);
c.release();
} catch (e) {
console.warn('⚠ No se pudo verificar DB principal:', e?.message || e);
// -----------------------------------------------------------------------------
// Middlewares de Auth/Tenant para routes.legacy.js
// -----------------------------------------------------------------------------
function requireAuth(req, res, next) {
if (!req.session?.user) {
// Si querés devolver 401 en lugar de redirigir, cambia esta línea
return res.redirect('/auth/login');
}
next();
}
// 4.2) Base multi-tenant (un solo DB con esquemas por tenant)
const TENANTS = {
host: process.env.TENANTS_HOST || 'dev-tenants',
port: Number(process.env.TENANTS_PORT || 5432),
user: process.env.TENANTS_USER || 'postgres',
password: process.env.TENANTS_PASS || 'postgres',
database: process.env.TENANTS_DB || 'dev-postgres',
};
export const tenantsPool = new Pool({ ...TENANTS, max: 20, idleTimeoutMillis: 30_000 });
// === 5) MIDDLEWARES DE AUTENTICACIÓN Y TENANT ===
export function requireAuth(req, res, next) {
if (req.session?.user) return next();
// Fallback DEV: permitir si el front envía explícitamente el tenant (para pruebas)
if (req.get('x-tenant-uuid')) return next();
return res.status(401).json({ error: 'no-auth' });
}
function getTenantUuid(req) {
const h = req.get('x-tenant-uuid');
if (h) return String(h).replace(/-/g, '');
const s = req.session?.user?.tenant_uuid;
if (s) return String(s).replace(/-/g, '');
throw new Error('Tenant no especificado');
}
export async function withTenant(req, res, next) {
const client = await tenantsPool.connect();
// Abre un client al DB de tenants y fija search_path al esquema del usuario
async function withTenant(req, res, next) {
try {
await client.query('BEGIN');
const uuid = getTenantUuid(req);
const schema = `schema_tenant_${uuid}`;
const hex = CLEAN_HEX(req.session?.user?.tenant_uuid);
if (!hex) return res.status(400).json({ error: 'tenant-missing' });
// Si creaste la función en DB: SELECT public.f_set_search_path($1)
// await client.query('SELECT public.f_set_search_path($1)', [schema]);
await client.query(`SET LOCAL search_path TO ${schema.replace(/"/g, '')}`);
const schema = `schema_tenant_${hex}`;
const client = await tenantsPool.connect();
// Fijar search_path para que las consultas apunten al esquema del tenant
await client.query(`SET SESSION search_path TO ${qi(schema)}, public`);
// Hacemos el client accesible para los handlers de routes.legacy.js
req.pg = client;
req.pgSchema = schema;
// Liberar el client al finalizar la respuesta
const release = () => {
try { client.release(); } catch {}
};
res.on('finish', release);
res.on('close', release);
next();
} catch (e) {
try { await client.query('ROLLBACK'); } catch {}
client.release();
return res.status(400).json({ error: e.message });
next(e);
}
}
export async function done(req, res, next) {
try {
if (req.pg) await req.pg.query('COMMIT');
} catch (e) {
try { if (req.pg) await req.pg.query('ROLLBACK'); } catch {}
} finally {
if (req.pg) req.pg.release();
// No-op (compatibilidad con el archivo legacy si lo pasa al final)
function done(_req, _res, next) { return next && next(); }
// -----------------------------------------------------------------------------
// Home / Landing
// -----------------------------------------------------------------------------
app.get('/', (req, res) => {
if (req.session?.user) {
return res.redirect('/comandas'); // ya logueado → dashboard
}
next?.();
}
return res.render('login', { pageTitle: 'Iniciar sesión' });
});
// === 6) RUTAS BÁSICAS / HEALTH ===
app.get('/health', (_req, res) => res.status(200).json({ status: 'ok' }));
app.get('/api/health', (_req, res) => res.status(200).json({ status: 'ok' }));
// Página para definir contraseña (el form envía al servicio AUTH)
app.get('/set-password', (req, res) => {
const pp = req.session?.pendingPassword;
if (!pp) return req.session?.user ? res.redirect('/comandas') : res.redirect('/auth/login');
// === 7) MONTAJE AUTOMÁTICO DE ENDPOINTS LEGACY ===
// Para NO PERDER NADA de tu archivo original:
// 1) Crea services/app/src/routes.legacy.js y exporta por defecto una función:
// export default function mount(app, ctx) { /* pega aquí TODOS tus app.get/post/... */ }
// // ctx trae: { requireAuth, withTenant, done, mainPool, tenantsPool, express }
// 2) O exporta un Router en routes.legacy.js como: export const router = Router();
// 3) Este bloque intentará montarlo si existe.
try {
const legacy = await import('./routes.legacy.js');
if (legacy?.default) {
legacy.default(app, { requireAuth, withTenant, done, mainPool, tenantsPool, express });
console.log('✔ Endpoints legacy montados (función default)');
} else if (legacy?.router) {
app.use(legacy.router);
console.log('✔ Endpoints legacy montados (router)');
}
} catch {
console.log(' No se encontró routes.legacy.js; continúa sólo con las rutas nuevas');
}
res.type('html').send(`
<!doctype html><meta charset="utf-8">
<title>SuiteCoffee · Definir contraseña</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<div class="container py-5" style="max-width:520px;">
<h2 class="mb-4">Definir contraseña</h2>
<form method="post" action="/auth/password/set" class="vstack gap-3">
<input class="form-control" type="password" name="password" placeholder="Nueva contraseña" minlength="8" required>
<input class="form-control" type="password" name="password2" placeholder="Repetí la contraseña" minlength="8" required>
<button class="btn btn-primary" type="submit">Guardar y continuar</button>
<small class="text-muted">Luego te redirigiremos a iniciar sesión por SSO.</small>
</form>
</div>
`);
});
// -----------------------------------------------------------------------------
// Montar rutas legacy (render de EJS y APIs de negocio)
// -----------------------------------------------------------------------------
const legacy = await import('./routes.legacy.js');
legacy.default(app, {
requireAuth,
withTenant,
done,
mainPool,
tenantsPool,
express,
});
// -----------------------------------------------------------------------------
// Health + 404 + errores
// -----------------------------------------------------------------------------
app.get('/health', (_req, res) => res.status(200).json({ status: 'ok', service: 'app' }));
// === 8) 404 + MANEJO DE ERRORES ===
app.use((req, res) => res.status(404).json({ error: 'not-found', path: req.originalUrl }));
app.use((err, _req, res, _next) => {
console.error('❌ Error APP:', err);
console.error('[APP] Error:', err);
if (res.headersSent) return;
res.status(500).json({ error: 'internal-error', detail: err?.message || String(err) });
});
// === 9) ARRANQUE ===
const PORT = Number(process.env.APP_LOCAL_PORT || process.env.PORT || 4000);
(async () => {
console.log(`Entorno -> ${ENV.toUpperCase()} | Puerto -> ${PORT}`);
await verificarConexion();
app.listen(PORT, () => console.log(`SuiteCoffee APP escuchando en ${chalk.yellow(`http://localhost:${PORT}`)}`));
})();
// -----------------------------------------------------------------------------
// Arranque
// -----------------------------------------------------------------------------
const PORT = Number(process.env.PORT || process.env.APP_LOCAL_PORT || 3030);
app.listen(PORT, () => {
console.log(`[APP] SuiteCoffee corriendo en http://localhost:${PORT}`);
});

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.

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

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

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) });
});
// -----------------------------------------------------------------------------

View File

@ -0,0 +1,19 @@
NODE_ENV=development
PORT=3030
DB_HOST=dev-tenants
DB_NAME=manso
# Usuario y contraseña
DB_USER=manso
DB_PASS=manso
# 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

View File

@ -0,0 +1,20 @@
NODE_ENV=production # Entorno de desarrollo
PORT=3000 # Variables del servicio -> suitecoffee-app
# Variables del servicio -> suitecoffee-db de suitecoffee-app
DB_HOST=dev-tenants
DB_NAME=manso
# Usuario y contraseña
DB_USER=manso
DB_PASS=manso
# 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