Pre-reordenación
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user