1015 lines
35 KiB
JavaScript
1015 lines
35 KiB
JavaScript
// services/auth/src/index.js
|
|
// ------------------------------------------------------------
|
|
// SuiteCoffee — Servicio de Autenticación (Express + OIDC)
|
|
// - ESM compatible (Node >=18)
|
|
// - Sesiones con Redis (compartibles con otros servicios)
|
|
// - Vistas EJS (login)
|
|
// - Rutas OIDC: /auth/login, /auth/callback, /auth/logout
|
|
// - Registro de usuario: /auth/api/users/register (DB + Authentik)
|
|
// ------------------------------------------------------------
|
|
|
|
import 'dotenv/config';
|
|
import chalk from 'chalk';
|
|
import express from 'express';
|
|
import cors from 'cors';
|
|
import path from 'node:path';
|
|
import { access, readFile } from 'node:fs/promises';
|
|
import { constants as fsConstants } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { Pool } from 'pg';
|
|
import session from 'express-session';
|
|
import { createClient } from 'redis';
|
|
import expressLayouts from 'express-ejs-layouts';
|
|
import { Issuer, generators } from 'openid-client';
|
|
import crypto from 'node:crypto';
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Variables globales
|
|
// -----------------------------------------------------------------------------
|
|
let oidcClient;
|
|
let _cachedInitSql = null;
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Importaciones desde archivos
|
|
// -----------------------------------------------------------------------------
|
|
// Helpers de Authentik (admin API)
|
|
import { akFindUserByEmail, akCreateUser,
|
|
akSetPassword, akResolveGroupId } from './ak.js';
|
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Utilidades / Helpers
|
|
// -----------------------------------------------------------------------------
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
// Normaliza UUID (acepta con/sin guiones) → "hex" sin guiones
|
|
const cleanUuid = (u) => (u ? String(u).toLowerCase().replace(/[^0-9a-f]/g, '') : '');
|
|
|
|
// Nombre de schema/rol a partir de uuid limpio
|
|
const schemaNameFor = (uuidHex) => `schema_tenant_${uuidHex}`;
|
|
const roleNameFor = (uuidHex) => `tenant_${uuidHex}`;
|
|
|
|
// Quoter seguro de identificadores SQL (roles, schemas, tablas)
|
|
// Identificador SQL (schema, role, table, …)
|
|
const qi = (ident) => `"${String(ident).replace(/"/g, '""')}"`;
|
|
|
|
// 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_:$-]*$/;
|
|
|
|
// Helpers defensivos
|
|
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));
|
|
|
|
// --- Resolver y cachear el grupo por ID/UUID/NOMBRE una sola vez ---
|
|
let DEFAULT_GROUP_ID = process.env.AUTHENTIK_DEFAULT_GROUP_ID
|
|
? Number(process.env.AUTHENTIK_DEFAULT_GROUP_ID) : null;
|
|
|
|
if (!DEFAULT_GROUP_ID) {
|
|
(async () => {
|
|
try {
|
|
// Si tenés akResolveGroupIdByName, usalo:
|
|
// DEFAULT_GROUP_ID = await akResolveGroupIdByName(process.env.AUTHENTIK_DEFAULT_GROUP_NAME);
|
|
|
|
// Con el helper genérico que te dejé en ak.js:
|
|
DEFAULT_GROUP_ID = await akResolveGroupId({
|
|
uuid: process.env.AUTHENTIK_DEFAULT_GROUP_UUID,
|
|
name: process.env.AUTHENTIK_DEFAULT_GROUP_NAME,
|
|
});
|
|
console.log('[AK] DEFAULT_GROUP_ID resuelto:', DEFAULT_GROUP_ID);
|
|
} catch (e) {
|
|
console.warn('[AK] No se pudo resolver DEFAULT_GROUP_ID:', e?.message || e);
|
|
}
|
|
})();
|
|
}
|
|
|
|
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;
|
|
const schema = schemaNameFor(uuidHex);
|
|
const client = await tenantsPool.connect();
|
|
try {
|
|
const q = await client.query(
|
|
'SELECT 1 FROM information_schema.schemata WHERE schema_name=$1',
|
|
[schema]
|
|
);
|
|
return q.rowCount > 0;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
// Intenta obtener el tenant por orden:
|
|
// 1) DB principal (app_user por email)
|
|
// 2) Authentik (attributes.tenant_uuid del usuario)
|
|
// 3) valor provisto en el request (si viene)
|
|
async function resolveExistingTenantUuid({ email, requestedTenantUuid }) {
|
|
const normEmail = String(email).trim().toLowerCase();
|
|
|
|
// 1) DB principal
|
|
const dbRes = await mainPool.query(
|
|
'SELECT tenant_uuid FROM app_user WHERE LOWER(email)=LOWER($1) LIMIT 1',
|
|
[normEmail]
|
|
);
|
|
if (dbRes.rowCount) {
|
|
const fromDb = cleanUuid(dbRes.rows[0].tenant_uuid);
|
|
if (fromDb) return fromDb;
|
|
}
|
|
|
|
// 2) Authentik
|
|
const akUser = await akFindUserByEmail(normEmail).catch(() => null);
|
|
const fromAk = cleanUuid(akUser?.attributes?.tenant_uuid);
|
|
if (fromAk) return fromAk;
|
|
|
|
// 3) Pedido del request
|
|
const fromReq = cleanUuid(requestedTenantUuid);
|
|
if (fromReq) return fromReq;
|
|
|
|
return null; // no hay tenant conocido
|
|
}
|
|
|
|
// Helper para crear tenant si falta
|
|
async function ensureTenant({ tenant_uuid }) {
|
|
const admin = await tenantsPool.connect();
|
|
try {
|
|
await admin.query('BEGIN');
|
|
|
|
// 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_${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 (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
|
|
const s = await admin.query(
|
|
'SELECT 1 FROM information_schema.schemata WHERE schema_name=$1',
|
|
[schema]
|
|
);
|
|
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 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 admin.query(
|
|
`ALTER DEFAULT PRIVILEGES IN SCHEMA ${qi(schema)}
|
|
GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO ${qi(role)}`
|
|
);
|
|
|
|
// 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();
|
|
}
|
|
}
|
|
|
|
// Carga el 01_init.sql del disco, elimina BEGIN/COMMIT y sustituye el schema.
|
|
requiredEnv(['TENANT_INIT_SQL']);
|
|
async function loadInitSql() {
|
|
if (_cachedInitSql !== null) return _cachedInitSql;
|
|
|
|
const raw = String(process.env.TENANT_INIT_SQL || '').trim();
|
|
if (!raw) return (_cachedInitSql = '');
|
|
|
|
if (raw.startsWith('base64:')) {
|
|
_cachedInitSql = Buffer.from(raw.slice(7), 'base64').toString('utf8');
|
|
return _cachedInitSql;
|
|
}
|
|
|
|
const v = raw.startsWith('@') ? raw.slice(1) : raw;
|
|
|
|
// absoluta o relativa al CWD (/app)
|
|
const candidates = path.isAbsolute(v)
|
|
? [v]
|
|
: [path.resolve(process.cwd(), v), path.resolve('.', v)];
|
|
|
|
for (const c of candidates) {
|
|
try {
|
|
await access(c, fsConstants.R_OK);
|
|
_cachedInitSql = await readFile(c, 'utf8');
|
|
return _cachedInitSql;
|
|
} catch {}
|
|
}
|
|
|
|
// Si no parecía ruta, trátalo como SQL inline
|
|
_cachedInitSql = v;
|
|
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();
|
|
}
|
|
}
|
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Utilidades
|
|
// -----------------------------------------------------------------------------
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
function requiredEnv(keys) {
|
|
const missing = keys.filter((k) => !process.env[k]);
|
|
if (missing.length) {
|
|
console.warn(chalk.yellow(`⚠ Falta configurar variables de entorno: ${missing.join(', ')}`));
|
|
}
|
|
}
|
|
|
|
function onFatal(err, msg = 'Error fatal') {
|
|
console.error(chalk.red(`\n${msg}:`));
|
|
console.error(err);
|
|
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
|
|
// -----------------------------------------------------------------------------
|
|
const app = express();
|
|
app.set('trust proxy', Number(process.env.TRUST_PROXY_HOPS || 2));
|
|
app.use(cors({ origin: true, credentials: true }));
|
|
app.use(express.json());
|
|
app.use(express.urlencoded({ extended: true }));
|
|
|
|
// Vistas EJS
|
|
app.set('views', path.join(__dirname, 'views'));
|
|
app.set('view engine', 'ejs');
|
|
|
|
|
|
// Archivos estáticos opcionales (ajusta si tu estructura difiere)
|
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
app.use('/pages', express.static(path.join(__dirname, 'pages')));
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Sesión (Redis) — misma cookie que APP
|
|
// -----------------------------------------------------------------------------
|
|
requiredEnv(['SESSION_SECRET', 'REDIS_URL']);
|
|
const SESSION_COOKIE_NAME = process.env.SESSION_COOKIE_NAME || "sc.sid";
|
|
const SESSION_SECRET = process.env.SESSION_SECRET || "pon-una-clave-larga-y-unica";
|
|
const REDIS_URL = process.env.REDIS_URL || "redis://authentik-redis:6379";
|
|
|
|
// 1) Redis client
|
|
const redis = createClient({ url: REDIS_URL /*, legacyMode: true */ });
|
|
redis.on("error", (err) => console.error("[Redis] Client Error:", err));
|
|
await redis.connect();
|
|
console.log("[Redis] connected");
|
|
|
|
// 2) Resolver RedisStore (soporta:
|
|
// - v5: factory CJS -> connectRedis(session)
|
|
// - v6/v7: export { RedisStore } ó export default class RedisStore)
|
|
async function resolveRedisStore(session) {
|
|
const mod = await import("connect-redis"); // ESM/CJS agnóstico
|
|
// named export (v6/v7)
|
|
if (typeof mod.RedisStore === "function") return mod.RedisStore;
|
|
// default export (class ó factory)
|
|
if (typeof mod.default === "function") {
|
|
// ¿es clase neweable?
|
|
if (mod.default.prototype && (mod.default.prototype.get || mod.default.prototype.set)) {
|
|
return mod.default; // class RedisStore
|
|
}
|
|
// si no, asumimos factory antigua
|
|
const Store = mod.default(session); // connectRedis(session)
|
|
if (typeof Store === "function") return Store; // class devuelta por factory
|
|
}
|
|
// algunos builds CJS exponen la factory bajo mod (poco común)
|
|
if (typeof mod === "function") {
|
|
const Store = mod(session);
|
|
if (typeof Store === "function") return Store;
|
|
}
|
|
throw new Error("connect-redis: no se pudo resolver RedisStore (API desconocida).");
|
|
}
|
|
|
|
const RedisStore = await resolveRedisStore(session);
|
|
|
|
// 3) Session middleware
|
|
app.use(session({
|
|
name: SESSION_COOKIE_NAME,
|
|
secret: SESSION_SECRET,
|
|
resave: false,
|
|
saveUninitialized: false,
|
|
store: new RedisStore({
|
|
client: redis,
|
|
prefix: "sc:", // opcional
|
|
}),
|
|
proxy: true,
|
|
cookie: {
|
|
secure: "auto",
|
|
httpOnly: true,
|
|
sameSite: "lax",
|
|
path: "/", // ¡crítico! visible en / y /auth/*
|
|
},
|
|
}));
|
|
|
|
// Exponer usuario a las vistas (no tocar req.session)
|
|
app.use((req, res, next) => {
|
|
res.locals.user = req.session?.user || null;
|
|
next();
|
|
});
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// PostgreSQL — DB tenants (usuarios de suitecoffee)
|
|
// -----------------------------------------------------------------------------
|
|
const tenantsPool = new Pool({
|
|
host: process.env.TENANTS_HOST || 'dev-tenants',
|
|
port: Number(process.env.TENANTS_PORT || 5432),
|
|
user: process.env.TENANTS_USER || 'dev-user-postgres',
|
|
password: process.env.TENANTS_PASS || 'dev-pass-postgres',
|
|
database: process.env.TENANTS_DB || 'dev-postgres',
|
|
max: 10,
|
|
});
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// PostgreSQL — DB principal (metadatos de negocio)
|
|
// -----------------------------------------------------------------------------
|
|
requiredEnv(['DB_HOST', 'DB_USER', 'DB_PASS', 'DB_NAME']);
|
|
const mainPool = new Pool({
|
|
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',
|
|
max: 10,
|
|
idleTimeoutMillis: 30_000,
|
|
});
|
|
|
|
// ----------------------------------------------------------
|
|
// Verificación de conexión
|
|
// ----------------------------------------------------------
|
|
|
|
async function verificarConexion() {
|
|
try {
|
|
console.log(`[AUTH] Comprobando accesibilidad a la db ${process.env.DB_NAME} del host ${process.env.DB_HOST} ...`);
|
|
const client = await mainPool.connect();
|
|
const { rows } = await client.query('SELECT NOW() AS ahora');
|
|
console.log(`\n[AUTH] Conexión con ${process.env.DB_NAME} OK. Hora DB:`, rows[0].ahora);
|
|
client.release();
|
|
} catch (error) {
|
|
console.error('[AUTH] Error al conectar con la base de datos al iniciar:', error.message);
|
|
console.error('[AUTH] Revisar DB_HOST/USER/PASS/NAME, accesos de red y firewall.');
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// OIDC (Authentik) — discovery + cliente
|
|
// -----------------------------------------------------------------------------
|
|
requiredEnv(['OIDC_ISSUER', 'OIDC_CLIENT_ID', 'OIDC_CLIENT_SECRET', 'OIDC_REDIRECT_URI']);
|
|
|
|
async function OIDCdiscover(){
|
|
let RETRY_MS = 10_000;
|
|
let _retryTimer = null;
|
|
try {
|
|
const issuer = await discoverOIDCWithRetry(process.env.OIDC_ISSUER, { retries: 60, delayMs: 2000 });
|
|
oidcClient = new issuer.Client({
|
|
client_id: process.env.OIDC_CLIENT_ID,
|
|
client_secret: process.env.OIDC_CLIENT_SECRET,
|
|
redirect_uris: [process.env.OIDC_REDIRECT_URI],
|
|
response_types: ['code'],
|
|
});
|
|
} catch (e) {
|
|
console.error('[OIDC] No se pudo inicializar OIDC aún. Seguirá reintentando cada 10s en background.');
|
|
|
|
if (_retryTimer) return; // ya hay un reintento programado
|
|
|
|
// Mensaje diferido (exactamente como pediste):
|
|
setTimeout(() => {
|
|
console.log('[OIDC] Inicializando en 10 segundos ');
|
|
}, RETRY_MS);
|
|
|
|
// Bucle de reintentos (primer intento dentro de 10s)
|
|
const loop = async () => {
|
|
try {
|
|
const issuer = await Issuer.discover(process.env.OIDC_ISSUER);
|
|
oidcClient = new issuer.Client({
|
|
client_id: process.env.OIDC_CLIENT_ID,
|
|
client_secret: process.env.OIDC_CLIENT_SECRET,
|
|
redirect_uris: [process.env.OIDC_REDIRECT_URI],
|
|
response_types: ['code'],
|
|
});
|
|
console.log('[OIDC] Inicializado correctamente en reintento tardío');
|
|
_retryTimer = null; // limpiar timer
|
|
} catch {
|
|
console.log(`[OIDC] Aún no disponible. Próximo intento en ${RETRY_MS / 1000}s.`);
|
|
_retryTimer = setTimeout(loop, RETRY_MS);
|
|
}
|
|
};
|
|
|
|
_retryTimer = setTimeout(loop, RETRY_MS);
|
|
}
|
|
}
|
|
|
|
async function discoverOIDCWithRetry(issuerUrl, { retries = 30, delayMs = 2000 } = {}) {
|
|
let lastErr;
|
|
for (let i = 1; i <= retries; i++) {
|
|
try {
|
|
const issuer = await Issuer.discover(issuerUrl);
|
|
console.log(`[OIDC] Issuer OK en intento ${i}:`, issuer.metadata.issuer);
|
|
return issuer;
|
|
} catch (err) {
|
|
lastErr = err;
|
|
console.warn(`[OIDC] Intento ${i}/${retries} falló: ${err.code || err.message}`);
|
|
await sleep(delayMs);
|
|
}
|
|
}
|
|
// No abortamos el proceso; dejamos el servidor vivo y seguimos reintentando en background
|
|
throw lastErr;
|
|
}
|
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Vistas
|
|
// -----------------------------------------------------------------------------
|
|
|
|
// Página de login
|
|
app.get("/auth/login", (_req, res) => {
|
|
return res.render("login", { pageTitle: "Iniciar sesión" });
|
|
});
|
|
|
|
app.post("/auth/login", async (req, res, next) => {
|
|
try {
|
|
const email = String(req.body.email || "").trim().toLowerCase();
|
|
const password = String(req.body.password || "");
|
|
const remember = req.body.remember === "on" || req.body.remember === true;
|
|
|
|
if (!email || !password) {
|
|
return res.status(400).render("login", { pageTitle: "Iniciar sesión", error: "Completa email y contraseña." });
|
|
}
|
|
|
|
// Tabla/columnas por defecto; ajustables por env si tu esquema difiere
|
|
const USERS_TABLE = process.env.TENANTS_USERS_TABLE || "users";
|
|
const COL_ID = process.env.TENANTS_COL_ID || "id";
|
|
const COL_EMAIL = process.env.TENANTS_COL_EMAIL || "email";
|
|
const COL_HASH = process.env.TENANTS_COL_HASH || "password_hash";
|
|
const COL_ROLE = process.env.TENANTS_COL_ROLE || "role";
|
|
const COL_TENANT = process.env.TENANTS_COL_TENANT || "tenant_id";
|
|
|
|
const { rows } = await tenantsPool.query(
|
|
`SELECT ${COL_ID} AS id, ${COL_EMAIL} AS email, ${COL_HASH} AS password_hash,
|
|
${COL_ROLE} AS role, ${COL_TENANT} AS tenant_id
|
|
FROM ${USERS_TABLE}
|
|
WHERE ${COL_EMAIL} = $1
|
|
LIMIT 1`,
|
|
[email]
|
|
);
|
|
|
|
if (rows.length === 0) {
|
|
return res.status(401).render("login", { pageTitle: "Iniciar sesión", error: "Credenciales inválidas." });
|
|
}
|
|
|
|
const user = rows[0];
|
|
const ok = await bcrypt.compare(password, user.password_hash || "");
|
|
if (!ok) {
|
|
return res.status(401).render("login", { pageTitle: "Iniciar sesión", error: "Credenciales inválidas." });
|
|
}
|
|
|
|
// (Opcional) registro de acceso en DB principal
|
|
try {
|
|
await mainPool.query(
|
|
"INSERT INTO auth_audit_log(email, tenant_id, action, at) VALUES ($1, $2, $3, NOW())",
|
|
[user.email, user.tenant_id, "login_success"]
|
|
);
|
|
} catch { /* noop si no existe la tabla */ }
|
|
|
|
// Sesión compartida
|
|
req.session.regenerate((err) => {
|
|
if (err) return next(err);
|
|
|
|
req.session.user = {
|
|
id: user.id,
|
|
email: user.email,
|
|
role: user.role,
|
|
tenant_id: user.tenant_id,
|
|
loggedAt: Date.now(),
|
|
};
|
|
|
|
if (remember) {
|
|
req.session.cookie.maxAge = 30 * 24 * 60 * 60 * 1000;
|
|
} else {
|
|
req.session.cookie.expires = false;
|
|
}
|
|
|
|
req.session.save((err2) => {
|
|
if (err2) return next(err2);
|
|
return res.redirect(303, "/"); // "/" → app decide /dashboard o /auth/login
|
|
});
|
|
});
|
|
} catch (e) {
|
|
next(e);
|
|
}
|
|
});
|
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Rutas OIDC
|
|
// -----------------------------------------------------------------------------
|
|
app.get('/auth/login', (req, res, next) => {
|
|
try {
|
|
|
|
if (req.session?.oidc) {
|
|
return nukeSession(req, res, '/auth/login', 'stale_oidc');
|
|
}
|
|
|
|
const code_verifier = generators.codeVerifier();
|
|
const code_challenge = generators.codeChallenge(code_verifier);
|
|
|
|
// 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);
|
|
|
|
// 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();
|
|
|
|
// tenant desde claim, Authentik o fallback a tu DB
|
|
let tenantHex = cleanUuid(claims.tenant_uuid);
|
|
if (!tenantHex) {
|
|
const akUser = await akFindUserByEmail(email).catch(() => null);
|
|
tenantHex = cleanUuid(akUser?.attributes?.tenant_uuid);
|
|
|
|
if (!tenantHex) {
|
|
const q = await mainPool.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 y guardar el usuario
|
|
req.session.regenerate((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: tenantHex || null,
|
|
};
|
|
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('/');
|
|
});
|
|
});
|
|
|
|
return res.redirect('/');
|
|
|
|
} catch (e) {
|
|
console.error('[OIDC] callback error:', e);
|
|
if (!res.headersSent) return next(e);
|
|
}
|
|
});
|
|
|
|
|
|
app.post('/auth/logout', (req, res) => {
|
|
req.session.destroy(() => {
|
|
res.clearCookie(SESSION_COOKIE_NAME);
|
|
res.status(204).end();
|
|
});
|
|
});
|
|
|
|
app.get('/auth/me', (req, res) => {
|
|
if (!req.session?.user) return res.status(401).json({ error: 'no-auth' });
|
|
res.json({ user: req.session.user });
|
|
});
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Registro de usuario (DB principal + Authentik)
|
|
// -----------------------------------------------------------------------------
|
|
|
|
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 /auth/api/users/register
|
|
// ==============================
|
|
app.post('/auth/api/users/register', async (req, res, next) => {
|
|
|
|
const {
|
|
email, display_name, role,
|
|
tenant_uuid: requestedTenantUuid, // opcional
|
|
} = req.body || {};
|
|
|
|
const normEmail = String(email || '').trim().toLowerCase();
|
|
if (!normEmail) return res.status(400).json({ error: 'Email requerido' });
|
|
|
|
// 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);
|
|
console.log("------ Bandera ------");
|
|
}
|
|
|
|
// 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}`));
|
|
}
|
|
|
|
// 2) Transacción DB principal + Authentik con compensación
|
|
const client = await mainPool.connect();
|
|
let createdAkUser = null; // para compensación
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
// 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)',
|
|
[normEmail]
|
|
);
|
|
if (dup.rowCount) {
|
|
await client.query('ROLLBACK');
|
|
return res.status(409).json({
|
|
error: 'user-exists',
|
|
message: 'Ya existe un usuario con este email.',
|
|
next: '/auth/login',
|
|
});
|
|
}
|
|
|
|
// Authentik: buscar o crear
|
|
let akUser = await akFindUserByEmail(normEmail).catch(() => null);
|
|
if (!akUser) {
|
|
akUser = await akCreateUser({
|
|
email: normEmail,
|
|
displayName: display_name || null,
|
|
tenantUuid: tenantHex, // attributes.tenant_uuid
|
|
addToGroupId: DEFAULT_GROUP_ID || null,
|
|
isActive: true,
|
|
});
|
|
createdAkUser = akUser; // marcar que lo creamos nosotros
|
|
}
|
|
|
|
// 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)`,
|
|
[normEmail, display_name || null, tenantHex, akUserUuid, finalRole]
|
|
);
|
|
|
|
await client.query('COMMIT');
|
|
|
|
// 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 {}
|
|
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('/auth/auth/api/users/register', async (req, res, next) => {
|
|
|
|
// const { email, display_name, tenant_uuid: rawTenant, role } = req.body || {};
|
|
// if (!email) return res.status(400).json({ error: 'email es obligatorio' });
|
|
// // Si no vino tenant: lo creamos
|
|
// const { tenant_uuid, schema, role: dbRole } = await ensureTenant({ tenant_uuid: rawTenant });
|
|
|
|
// const client = await mainPool.connect();
|
|
// try {
|
|
// await client.query('BEGIN');
|
|
|
|
// // ¿ya existe en tu DB?
|
|
// const { rows: dup } = await client.query(
|
|
// 'SELECT id FROM app_user WHERE email=$1 AND tenant_uuid=$2',
|
|
// [email.toLowerCase(), tenant_uuid.replace(/-/g, '')]
|
|
// );
|
|
// if (dup.length) {
|
|
// await client.query('ROLLBACK');
|
|
// return res.status(409).json({ error: 'El usuario ya existe en SuiteCoffee' });
|
|
// }
|
|
|
|
// // Authentik: crear si no existe
|
|
// let akUser = await akFindUserByEmail(email);
|
|
// if (!akUser) {
|
|
// akUser = await akCreateUser({
|
|
// email,
|
|
// displayName: display_name,
|
|
// tenantUuid: tenant_uuid, // se normaliza dentro de ak.js
|
|
// addToGroupId: DEFAULT_GROUP_ID || null,
|
|
// isActive: true,
|
|
// });
|
|
// // Si querés forzar clave inicial (opcional; depende de tus políticas):
|
|
// // await akSetPassword(akUser.pk, 'ClaveTemporal123!', true);
|
|
// }
|
|
|
|
// const _role = role || 'owner';
|
|
// await client.query(
|
|
// `INSERT INTO app_user (email, display_name, tenant_uuid, ak_user_uuid, role)
|
|
// VALUES ($1,$2,$3,$4,$5)`,
|
|
// [email.toLowerCase(), display_name || null, tenant_uuid.replace(/-/g, ''), akUser.uuid, _role]
|
|
// );
|
|
|
|
// await client.query('COMMIT');
|
|
// return res.status(201).json({
|
|
// message: 'Usuario registrado',
|
|
// email,
|
|
// tenant_uuid,
|
|
// role: _role,
|
|
// authentik_user_uuid: akUser.uuid,
|
|
// next: '/auth/login'
|
|
// });
|
|
// } catch (err) {
|
|
// try { await client.query('ROLLBACK'); } catch {}
|
|
// next(err);
|
|
// } finally {
|
|
// client.release();
|
|
// }
|
|
// });
|
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Healthcheck
|
|
// -----------------------------------------------------------------------------
|
|
app.get('/health', (_req, res) => res.status(200).json({ status: 'ok', service: 'auth' }));
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// 404 + Manejo de errores
|
|
// -----------------------------------------------------------------------------
|
|
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('[AUTH] ', err);
|
|
if (res.headersSent) return;
|
|
res.status(500).json({ error: '¡Oh! A ocurrido un error en el servidor auth.', detail: err.stack || String(err) });
|
|
});
|
|
|
|
/*
|
|
-----------------------------------------------------------------------------
|
|
Exportación principal del módulo.
|
|
Es típico exportar la instancia (app) y arrancarla en otro archivo.
|
|
- Facilita tests (p.ej. con supertest: import app from './app.js')
|
|
- Evita que el servidor se inicie al importar el módulo.
|
|
|
|
# Default
|
|
export default app; // importar: import app from './app.js'
|
|
|
|
# Con nombre
|
|
export const app = express(); // importar: import { app } from './app.js'
|
|
-----------------------------------------------------------------------------
|
|
*/
|
|
export default app;
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Arranque
|
|
// -----------------------------------------------------------------------------
|
|
const PORT = Number(process.env.PORT || 4040);
|
|
app.listen(PORT, () => {
|
|
console.log(`[AUTH] Servicio de autenticación escuchando en http://localhost:${PORT}`);
|
|
verificarConexion();
|
|
OIDCdiscover();
|
|
}); |