Pre-reordenación

This commit is contained in:
2025-09-05 04:02:39 +00:00
parent 8522d02170
commit 80778c0ed9
10 changed files with 1115 additions and 120 deletions
+46
View File
@@ -0,0 +1,46 @@
// services/auth/src/ak.js
import axios from 'axios';
const AK = axios.create({
baseURL: `${process.env.AUTHENTIK_BASE_URL}/api/v3`,
headers: { Authorization: `Bearer ${process.env.AUTHENTIK_TOKEN}` },
timeout: 10000,
});
// Busca usuario por email (case-insensitive)
export async function akFindUserByEmail(email) {
const { data } = await AK.get('/core/users/', { params: { search: email }});
// filtra exacto por email si querés evitar colisiones de 'search'
return data.results?.find(u => (u.email || '').toLowerCase() === email.toLowerCase()) || null;
}
// Crea usuario en Authentik con atributo tenant_uuid y lo agrega a un grupo (opcional)
export async function akCreateUser({ email, displayName, tenantUuid, addToGroupId }) {
// 1) crear usuario
const { data: user } = await AK.post('/core/users/', {
username: email, // en Authentik el username puede ser el email
name: displayName || email,
email,
is_active: true,
attributes: { tenant_uuid: tenantUuid }, // <-- para tu claim custom
});
// 2) agregar a grupo por defecto (opcional)
if (addToGroupId) {
await AK.post(`/core/users/${user.pk}/groups/`, { group: addToGroupId });
}
return user; // contiene pk y uuid
}
// Opcional: setear/forzar password inicial (si querés flujo con password local en Authentik)
export async function akSetPassword(userPk, password, requireChange = true) {
try {
await AK.post(`/core/users/${userPk}/set_password/`, {
password, require_change: requireChange,
});
} catch (e) {
// Si tu instancia no permite setear password por API, capturá y usá un flow de "reset password"
throw new Error('No se pudo establecer la contraseña en Authentik por API');
}
}
+292 -63
View File
@@ -5,10 +5,22 @@ import expressLayouts from 'express-ejs-layouts';
import cors from 'cors';
import { Pool } from 'pg';
import bcrypt from'bcrypt';
import crypto from 'node:crypto';
import session from 'express-session';
import { createClient } from 'redis';
import * as connectRedis from 'connect-redis';
const RedisStore = connectRedis.default || connectRedis.RedisStore;
const redis = createClient({ url: process.env.REDIS_URL || 'redis://authentik-redis:6379' });
await redis.connect();
import { Issuer, generators } from 'openid-client';
import cookieSession from 'cookie-session';
import { akFindUserByEmail, akCreateUser, akSetPassword } from './ak.js';
// Rutas
import path from 'path';
import { fileURLToPath } from 'url';
@@ -41,6 +53,39 @@ app.use(express.json());
app.set('trust proxy', true);
app.use(express.static(path.join(__dirname, 'pages')));
/* 1) Motor de vistas apuntando a /auth/src/views */
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
/* 2) Estáticos si usás /css/main.css dentro de /auth/src/public */
app.use(express.static(path.join(__dirname, 'public')));
/* 3) Exponer user a las vistas (opcional, cómodo) */
app.use((req, res, next) => {
res.locals.user = req.session?.user || null;
next();
});
/* 4) Página de login (renderiza el EJS de arriba)
- Mantén /auth/login para iniciar OIDC (redirección a Authentik)
- Usa /login para mostrar la página con el botón */
app.get('/login', (req, res) => {
res.render('login'); // -> /auth/src/views/login.ejs
});
app.use(session({
name: 'sc.sid',
store: new RedisStore({ client: redis, prefix: 'sess:' }),
secret: process.env.SESSION_SECRET || 'change-me',
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
},
}));
app.use(cookieSession({
name: 'sid',
@@ -52,7 +97,7 @@ app.use(cookieSession({
// Configuración de conexión PostgreSQL
const dbConfig = {
const poolMeta = {
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
@@ -60,8 +105,23 @@ const dbConfig = {
port: process.env.DB_LOCAL_PORT
};
const pool = new Pool(dbConfig);
const pool = new Pool(poolMeta);
const poolTenants = new Pool({ // apunta al servidor/base multi-tenant
host: process.env.TENANTS_HOST, // dev-tenants
user: process.env.TENANTS_USER,
password: process.env.TENANTS_PASS,
database: process.env.TENANTS_DB, // dev-postgres
port: process.env.TENANTS_PORT,
});
const tenantsPool = new Pool({
host: process.env.TENANTS_HOST, // dev-tenants
user: process.env.TENANTS_USER,
password: process.env.TENANTS_PASS,
database: process.env.TENANTS_DB, // dev-postgres
port: process.env.TENANTS_PORT
});
async function verificarConexion() {
try {
@@ -78,17 +138,27 @@ async function verificarConexion() {
// Descubrimiento OIDC (una sola vez)
let oidcClient;
async function getClient() {
if (oidcClient) return oidcClient;
const ISSUER = process.env.OIDC_ISSUER_INTERNAL; // ej: http://authentik:9000/application/o/suitecoffee/
const issuer = await Issuer.discover(`${ISSUER}.well-known/openid-configuration`);
(async () => {
const issuer = await Issuer.discover(process.env.OIDC_ISSUER); // debe coincidir EXACTO
oidcClient = new issuer.Client({
client_id: process.env.OIDC_CLIENT_ID,
client_secret: process.env.OIDC_CLIENT_SECRET,
redirect_uris: [`${process.env.BASE_URL}${process.env.OIDC_REDIRECT_PATH}`],
response_types: ['code']
redirect_uris: [process.env.OIDC_REDIRECT_URI],
response_types: ['code'],
});
return oidcClient;
})().catch(err => {
console.error('Error inicializando OIDC:', err);
process.exit(1);
});
// util para resolver tenant si aún no usás claim tenant_uuid
async function lookupTenantByEmail(email) {
const { rows } = await poolMeta.query(
`SELECT tenant_uuid FROM app_user WHERE email = $1 ORDER BY id LIMIT 1`,
[email.toLowerCase()]
);
return rows[0]?.tenant_uuid || null;
}
// === Servir páginas estáticas ===
@@ -108,62 +178,125 @@ app.get('/planes', async (req, res) => {
}
});
app.post('/api/users/register', async (req, res, next) => {
const { email, display_name, tenant_uuid, role, password } = req.body;
app.post('/api/registro', async (req, res) => {
const {
nombre_empresa,
rut,
correo,
telefono,
direccion,
logo,
clave_acceso,
plan_id
} = req.body;
if (!email || !tenant_uuid) {
return res.status(400).json({ error: 'email y tenant_uuid son obligatorios' });
}
const client = await poolMeta.connect();
try {
const client = await pool.connect();
await client.query('BEGIN');
// 1. Hashear la contraseña
const hash = await bcrypt.hash(clave_acceso, 10);
// 0) idempotencia: si ya existe en tu DB, devolvés 409 o retornás el existente
const { rows: existing } = await client.query(
`SELECT id, email, ak_sub FROM app_user WHERE email = $1`, [email]
);
if (existing.length) {
await client.query('ROLLBACK');
return res.status(409).json({ error: 'El usuario ya existe en SuiteCoffee' });
}
// 2. Insertar el tenant
const result = await client.query(`
INSERT INTO tenant (
nombre_empresa, rut, correo, telefono, direccion, logo,
clave_acceso, plan_id, nombre_base_datos
) VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, 'TEMPORAL'
)
RETURNING uuid;
`, [
nombre_empresa, rut, correo, telefono, direccion, logo,
hash, plan_id
]);
// 1) crear/obtener usuario en Authentik
let akUser = await akFindUserByEmail(email);
if (!akUser) {
akUser = await akCreateUser({
email,
displayName: display_name,
tenantUuid: tenant_uuid.replace(/-/g, ''),
addToGroupId: process.env.AUTHENTIK_DEFAULT_GROUP_ID || null,
});
// Si querés asignar una clave inicial (no recomendado en prod), descomentá:
// if (password) await akSetPassword(akUser.pk, password, true);
}
const uuid = result.rows[0].uuid;
const nombre_base_datos = `tenantdb_${uuid}`.replace(/-/g, '').substring(0, 24); // ajustamos para longitud segura
// el 'sub' lo tendrás recién tras login OIDC; guardamos el uuid interno si te sirve
const _role = role || 'owner';
// 3. Actualizar el campo nombre_base_datos
await client.query(`
UPDATE tenant SET nombre_base_datos = $1 WHERE uuid = $2
`, [nombre_base_datos, uuid]);
// 2) crear usuario local (sin password, dependemos del SSO)
await client.query(
`INSERT INTO app_user (email, display_name, tenant_uuid, ak_user_uuid, role)
VALUES ($1, $2, $3, $4, $5)`,
[email, display_name || null, tenant_uuid.replace(/-/g, ''), akUser.uuid, _role]
);
client.release();
await client.query('COMMIT');
return res.status(201).json({
message: 'Tenant registrado correctamente',
uuid,
nombre_base_datos
message: 'Usuario registrado',
email, tenant_uuid, role: _role,
authentik_user_uuid: akUser.uuid,
next: '/auth/login' // redirigí a OIDC
});
} catch (err) {
console.error(err);
return res.status(500).json({ error: 'Error al registrar tenant' });
await client.query('ROLLBACK');
next(err);
} finally {
client.release();
}
});
// app.post('/api/registro', async (req, res) => {
// const {
// nombre_empresa, rut, correo, telefono, direccion, logo,
// clave_acceso, plan_id
// } = req.body;
// const clientMeta = await poolMeta.connect();
// const clientTen = await poolTenants.connect();
// try {
// await clientMeta.query('BEGIN');
// // 1) Generar UUID sin guiones
// const uuid = crypto.randomUUID().replace(/-/g, '');
// const hash = await bcrypt.hash(clave_acceso, 10);
// // 2) Provisionar en PG (dev-tenants/dev-postgres)
// const { rows: [prov] } = await clientTen.query(
// `SELECT public.f_tenant_provision($1::text, $2::text) AS data`,
// [uuid, `tenant_${uuid}`]
// );
// const info = prov.data; // { tenant_uuid, schema, role, user, password, ... }
// // 3) Guardar metadatos en suitecoffee_db (tu tabla 'tenant')
// await clientMeta.query(`
// INSERT INTO tenant (
// uuid, nombre_empresa, rut, correo, telefono, direccion, logo,
// clave_acceso, plan_id,
// schema_name, role_name, user_name
// ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
// `, [
// uuid, nombre_empresa, rut, correo, telefono, direccion, logo,
// hash, plan_id,
// info.schema, info.role, info.user
// ]);
// await clientMeta.query('COMMIT');
// // 4) Devolver credenciales del usuario de DB si las necesitás (mejor *no* persistir la password)
// return res.status(201).json({
// message: 'Tenant registrado correctamente',
// uuid,
// schema: info.schema,
// db_user: info.user,
// db_password: info.password // muéstrala *una vez* y recomendación: NO guardarla
// });
// } catch (err) {
// await clientMeta.query('ROLLBACK');
// console.error(err);
// return res.status(500).json({ error: 'Error al registrar tenant' });
// } finally {
// clientMeta.release();
// clientTen.release();
// }
// });
app.post('/api/login', async (req, res) => {
const { correo, clave_acceso } = req.body;
@@ -202,6 +335,56 @@ app.post('/api/login', async (req, res) => {
}
});
app.get('/auth/login', (req, res) => {
const code_verifier = generators.codeVerifier();
const code_challenge = generators.codeChallenge(code_verifier);
req.session.code_verifier = code_verifier;
const url = oidcClient.authorizationUrl({
scope: 'openid profile email offline_access tenant', // incluye tu scope custom “tenant”
code_challenge: code_challenge,
code_challenge_method: 'S256',
});
res.redirect(url);
});
// ------------- Middleware ----------------
function getTenantUuid(req) {
// Ejemplo 1: header
if (req.headers['x-tenant-uuid']) return String(req.headers['x-tenant-uuid']);
// Ejemplo 2: si más adelante usás JWT:
// return req.user?.tenantUuid;
throw new Error('Tenant no especificado');
}
async function withTenant(req, res, next) {
const client = await tenantsPool.connect();
try {
await client.query('BEGIN');
const uuid = getTenantUuid(req).replace(/-/g, '');
const schema = `schema_tenant_${uuid}`;
await client.query(`SELECT public.f_set_search_path($1)`, [schema]);
// guardamos el cliente en req para reutilizar en los handlers
req.pg = client;
req.pgSchema = schema;
next();
} catch (e) {
if (req.pg) await req.pg.query('ROLLBACK');
if (req.pg) req.pg.release();
return res.status(400).json({ error: e.message });
}
}
// Al final de cada handler, hacé COMMIT y release
async function done(req, res, next) {
try { if (req.pg) await req.pg.query('COMMIT'); }
finally { if (req.pg) req.pg.release(); }
}
// --- login: redirige a Authentik con PKCE
app.get('/auth/login', async (req, res) => {
@@ -223,24 +406,70 @@ app.get('/auth/login', async (req, res) => {
res.redirect(authUrl);
});
app.use((req,res,next)=>{ res.locals.user = req.session?.user || null; next(); });
// --- callback: intercambia code por tokens y guarda sesión mínima
app.get(process.env.OIDC_REDIRECT_PATH || '/auth/callback', async (req, res) => {
const client = await getClient();
const { state, code } = req.query;
// --- RUTA: callback (enlaza la sesión con tu usuario local)
app.get('/auth/callback', async (req, res, next) => {
try {
const params = oidcClient.callbackParams(req);
const tokenSet = await oidcClient.callback(
process.env.OIDC_REDIRECT_URI,
params,
{ code_verifier: req.session.code_verifier }
);
const claims = tokenSet.claims(); // { sub, email, tenant_uuid?, ... }
if (!state || state !== req.session.state) {
return res.status(400).send('state inválido');
}
const params = { state, code, code_verifier: req.session.code_verifier };
const tokenSet = await client.callback(`${process.env.BASE_URL}${process.env.OIDC_REDIRECT_PATH}`, params, { state });
const email = (claims.email || '').toLowerCase();
const sub = claims.sub;
const tenantUuid = (claims.tenant_uuid || await lookupTenantByEmail(email))?.replace(/-/g, '');
// Guarda lo que necesites para pruebas (id_token y claims)
req.session.user = tokenSet.claims();
req.session.id_token = tokenSet.id_token;
req.session.access_token = tokenSet.access_token;
if (!tenantUuid) {
return res.status(403).send('No se pudo determinar el tenant del usuario.');
}
// Redirigí a donde quieras (página de bienvenida)
res.redirect('/auth/me');
// Asegurar presencia del usuario en tu DB y enlazar el sub de OIDC
const { rows } = await poolMeta.query(
`SELECT id, ak_sub FROM app_user WHERE email=$1 AND tenant_uuid=$2`,
[email, tenantUuid]
);
let userId;
if (rows.length) {
userId = rows[0].id;
if (!rows[0].ak_sub) {
await poolMeta.query(`UPDATE app_user SET ak_sub=$1 WHERE id=$2`, [sub, userId]);
}
} else {
// “just in time” create (opcional): lo das de alta si no existe aún en tu app
const ins = await poolMeta.query(
`INSERT INTO app_user (email, tenant_uuid, ak_sub, role)
VALUES ($1,$2,$3,'staff') RETURNING id`,
[email, tenantUuid, sub]
);
userId = ins.rows[0].id;
}
// Sesión de aplicación (lo que el resto del backend necesita)
req.session.user = { id: userId, email, tenant_uuid: tenantUuid, sub };
req.session.regenerate(err => {
if (err) return next(err);
req.session.user = { id: userId, email, tenant_uuid: tenantUuid, sub };
req.session.save(err2 => {
if (err2) return next(err2);
return res.redirect('/');
});
});
// redirige a la app (home o dashboard)
res.redirect('/');
} catch (e) { next(e); }
});
// (Opcional) logout “local”
app.post('/auth/logout', (req, res) => {
req.session.destroy(() => res.clearCookie('sc.sid').status(204).end());
});
// --- ver quién soy (para probar)
+25
View File
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8"/>
<title>Iniciar sesión | SuiteCoffee</title>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<link rel="stylesheet" href="/css/main.css"/>
</head>
<body class="container">
<header class="my-4">
<h1>SuiteCoffee — Acceso</h1>
</header>
<% if (user) { %>
<p>Ya iniciaste sesión como <strong><%= user.email %></strong>.</p>
<p>Continuar a <a href="/">la aplicación</a></p>
<% } else { %>
<div class="card p-4">
<p>Usamos inicio de sesión único (SSO) con nuestro Identity Provider.</p>
<!-- Esta URL dispara el flujo OIDC hacia Authentik -->
<a class="btn btn-primary btn-lg" href="/auth/login">Iniciar sesión con SuiteCoffee SSO</a>
</div>
<% } %>
</body>
</html>