Files
FRASA-CAS/api-gateway/src/services/auth.service.ts
Consult AS Technology b2634f97ab feat(config): modulo Configuraciones admin + permisos por rol
- Tablas ConfiguracionSistema (7 configs) + RolConfiguracion (6 roles)
- Endpoints: config/sistema GET/PATCH, config/roles GET/PATCH,
  config/usuarios GET/PATCH, config/reset-roles POST, config/acciones GET
- modulosDeRol() async con fallback a MATRIZ estatica
- auth/me devuelve acciones[] segun rol del usuario
- saludMonday.estaDisponible() lee modo_local_forzado de DB (cache 30s)
- Landing: vista Configuraciones con 4 tabs (Modo, Roles, Usuarios, Sistema)
- Mi-dia: boton Descartar se deshabilita si !descartar_lead
- 630 API tests + 24 landing tests + 20 mi-dia tests verdes

Seed: npm run seed:config
2026-09-06 09:46:19 +00:00

145 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import type { Usuario } from '@prisma/client'
import { prisma } from './prisma.js'
import { config } from '../config/index.js'
import { modulosDeRol, type Modulo } from '../config/roles.js'
import { AppError } from '../utils/errors.js'
import * as configService from './config.service.js'
export interface UsuarioPublico {
nombre: string
rol: string
asignacion: string | null
// User ID de Monday vinculado (null = sin vincular; ver PATCH /usuarios/me/monday)
mondayUserId: number | null
// Módulos del ecosistema visibles para el rol (matriz de config/roles.ts)
modulos: Modulo[]
// Acciones granulares del rol (catálogo de config/acciones.ts)
acciones: string[]
}
export interface LoginResult {
token: string
usuario: UsuarioPublico
}
/**
* Normaliza un username: minúsculas, sin acentos, espacios simples.
* El login es case-insensitive y tolerante a espacios extra.
*/
export function normalizarUsername(nombre: string): string {
return nombre
.normalize('NFD')
.replace(/[̀-ͯ]/g, '') // diacríticos U+0300–U+036F
.toLowerCase()
.trim()
.replace(/\s+/g, ' ')
}
export async function aPublico(usuario: Usuario): Promise<UsuarioPublico> {
let acciones: string[] = []
try {
const acc = await configService.obtenerAcciones(usuario.rol)
acciones = Object.entries(acc)
.filter(([, v]) => v)
.map(([k]) => k)
} catch {
// Si falla la lectura de config, devuelve acciones vacías (no rompe)
}
return {
nombre: usuario.nombre,
rol: usuario.rol,
asignacion: usuario.asignacion,
mondayUserId: usuario.mondayUserId,
modulos: await modulosDeRol(usuario.rol),
acciones,
}
}
/**
* Verifica credenciales y emite un JWT. Errores genéricos ("Credenciales
* inválidas") para no filtrar si el usuario existe o está inactivo.
*/
export async function login(username: string, password: string): Promise<LoginResult> {
const invalidas = () => new AppError(401, 'Credenciales inválidas')
const usuario = await prisma.usuario.findUnique({
where: { username: normalizarUsername(username) },
})
if (!usuario || !usuario.activo) throw invalidas()
const ok = await bcrypt.compare(password, usuario.passwordHash)
if (!ok) throw invalidas()
const token = jwt.sign(
{ sub: usuario.id, username: usuario.username, rol: usuario.rol },
config.auth.jwtSecret,
{ expiresIn: config.auth.jwtExpiresIn } as jwt.SignOptions,
)
return { token, usuario: await aPublico(usuario) }
}
/** Payload del JWT emitido por login() */
export interface TokenPayload {
sub: string
username: string
rol: string
}
/** Verifica un Bearer token y devuelve su payload; lanza 401 si no es válido */
export function verificarToken(token: string): TokenPayload {
try {
const payload = jwt.verify(token, config.auth.jwtSecret) as Partial<TokenPayload>
// Los tokens con otro scope (p. ej. la liga de firma remota) no son sesión
if (typeof payload.sub !== 'string' || !payload.sub) throw new Error('sin sub')
return payload as TokenPayload
} catch {
throw new AppError(401, 'Token inválido o expirado')
}
}
/** Devuelve el usuario público de un token válido (GET /auth/me) */
export async function me(payload: TokenPayload): Promise<UsuarioPublico> {
const usuario = await prisma.usuario.findUnique({ where: { id: payload.sub } })
if (!usuario || !usuario.activo) throw new AppError(401, 'Credenciales inválidas')
return aPublico(usuario)
}
const BCRYPT_COST = 12
/**
* Regla de la contraseña nueva: ≥8 caracteres y al menos 1 número.
* Devuelve el mensaje de error o null si es válida (la app cliente replica
* esta misma regla para validar antes de llamar al API).
*/
export function validarPasswordNueva(nueva: string): string | null {
if (nueva.length < 8 || !/\d/.test(nueva)) {
return 'La nueva contraseña debe tener al menos 8 caracteres y un número'
}
return null
}
/**
* POST /auth/cambiar-password: verifica la contraseña actual contra bcrypt y
* re-hashea la nueva (cost 12). Errores genéricos: no se filtra si el usuario
* existe; una actual incorrecta responde 400 sin más detalle.
*/
export async function cambiarPassword(
payload: TokenPayload,
actual: string,
nueva: string,
): Promise<void> {
const errorPolitica = validarPasswordNueva(nueva)
if (errorPolitica) throw new AppError(400, errorPolitica)
const usuario = await prisma.usuario.findUnique({ where: { id: payload.sub } })
if (!usuario || !usuario.activo) throw new AppError(401, 'Credenciales inválidas')
const ok = await bcrypt.compare(actual, usuario.passwordHash)
if (!ok) throw new AppError(400, 'La contraseña actual no es correcta')
const passwordHash = await bcrypt.hash(nueva, BCRYPT_COST)
await prisma.usuario.update({ where: { id: usuario.id }, data: { passwordHash } })
}