feat(auth): login JWT en API y las 4 apps
- Modelo Usuario + seed desde lista de vendedores (18 usuarios, bcrypt cost 12) - POST /api/auth/login + /api/auth/me; middleware Bearer en todo /api excepto health, login y webhook Monday; flag AUTH_ENABLED - Username = nombre completo normalizado (case/acentos insensible), password inicial Frasa2026, admin vía ADMIN_PASSWORD - Apps: LoginScreen + AuthGate (StudioBlank), token en localStorage, header Authorization en todas las llamadas, logout en 401 - Tests: api 136, cotizador 71, alta 65, cobranza 71, agente-ia 13, landing 4
This commit is contained in:
80
api-gateway/src/services/auth.service.ts
Normal file
80
api-gateway/src/services/auth.service.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
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 { AppError } from '../utils/errors.js'
|
||||
|
||||
export interface UsuarioPublico {
|
||||
nombre: string
|
||||
rol: string
|
||||
asignacion: string | null
|
||||
}
|
||||
|
||||
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 function aPublico(usuario: Usuario): UsuarioPublico {
|
||||
return { nombre: usuario.nombre, rol: usuario.rol, asignacion: usuario.asignacion }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: 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 {
|
||||
return jwt.verify(token, config.auth.jwtSecret) 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)
|
||||
}
|
||||
Reference in New Issue
Block a user