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:
@@ -1,6 +1,7 @@
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import { apiRouter } from './routes/index.js'
|
||||
import { authMiddleware } from './middleware/auth.middleware.js'
|
||||
import { errorHandler } from './middleware/errorHandler.js'
|
||||
import { requestLogger } from './middleware/requestLogger.js'
|
||||
|
||||
@@ -11,7 +12,7 @@ export function crearApp(): express.Express {
|
||||
app.use(express.json({ limit: '10mb' }))
|
||||
app.use(requestLogger)
|
||||
|
||||
app.use('/api', apiRouter)
|
||||
app.use('/api', authMiddleware, apiRouter)
|
||||
|
||||
app.use((_req, res) => {
|
||||
res.status(404).json({ error: 'Ruta no encontrada' })
|
||||
|
||||
@@ -53,6 +53,15 @@ export const config = {
|
||||
// Umbral mínimo de similitud coseno para recuperar contexto (0-1)
|
||||
minScore: Number(env('AI_MIN_SCORE', '0.3')),
|
||||
},
|
||||
auth: {
|
||||
jwtSecret: env('JWT_SECRET'),
|
||||
// Cadena aceptada por jsonwebtoken (p. ej. '7d')
|
||||
jwtExpiresIn: env('JWT_EXPIRES_IN', '7d'),
|
||||
// 'false' desactiva la exigencia de token en /api (tests, arranque temprano)
|
||||
enabled: env('AUTH_ENABLED', 'true') !== 'false',
|
||||
// Password del usuario admin sembrado (seed-usuarios.ts)
|
||||
adminPassword: env('ADMIN_PASSWORD', 'Frasa2026'),
|
||||
},
|
||||
} as const
|
||||
|
||||
export type AppConfig = typeof config
|
||||
|
||||
22
api-gateway/src/controllers/auth.controller.ts
Normal file
22
api-gateway/src/controllers/auth.controller.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { login, me } from '../services/auth.service.js'
|
||||
import { BadRequestError } from '../utils/errors.js'
|
||||
|
||||
const loginSchema = z.object({
|
||||
username: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
})
|
||||
|
||||
/** POST /api/auth/login → { token, usuario } */
|
||||
export async function loginEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const { username, password } = loginSchema.parse(req.body)
|
||||
const resultado = await login(username, password)
|
||||
res.json(resultado)
|
||||
}
|
||||
|
||||
/** GET /api/auth/me (Bearer) → usuario público del token */
|
||||
export async function meEndpoint(req: Request, res: Response): Promise<void> {
|
||||
if (!req.usuarioToken) throw new BadRequestError('Falta el token verificado')
|
||||
res.json(await me(req.usuarioToken))
|
||||
}
|
||||
78
api-gateway/src/middleware/auth.middleware.test.ts
Normal file
78
api-gateway/src/middleware/auth.middleware.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { authMiddleware } from './auth.middleware.js'
|
||||
|
||||
// Auth forzada a activa para este archivo (en vitest el default global es false)
|
||||
vi.mock('../config/index.js', () => ({
|
||||
config: {
|
||||
auth: {
|
||||
enabled: true,
|
||||
jwtSecret: 'test-jwt-secret',
|
||||
jwtExpiresIn: '7d',
|
||||
adminPassword: 'Frasa2026',
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const SECRET = 'test-jwt-secret'
|
||||
|
||||
function reqFake(overrides: Partial<Request> = {}): Request {
|
||||
return { method: 'GET', path: '/unidades', headers: {}, ...overrides } as Request
|
||||
}
|
||||
|
||||
function resFake(): { res: Response; status: ReturnType<typeof vi.fn>; json: ReturnType<typeof vi.fn> } {
|
||||
const json = vi.fn()
|
||||
const status = vi.fn().mockReturnValue({ json })
|
||||
return { res: { status } as unknown as Response, status, json }
|
||||
}
|
||||
|
||||
const next: NextFunction = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('authMiddleware (AUTH_ENABLED=true)', () => {
|
||||
it('401 sin header Authorization', () => {
|
||||
const { res, status } = resFake()
|
||||
authMiddleware(reqFake(), res, next)
|
||||
expect(status).toHaveBeenCalledWith(401)
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('401 con token inválido', () => {
|
||||
const { res, status } = resFake()
|
||||
authMiddleware(reqFake({ headers: { authorization: 'Bearer basura' } }), res, next)
|
||||
expect(status).toHaveBeenCalledWith(401)
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('pasa con un Bearer token válido y deja el payload en req', () => {
|
||||
const token = jwt.sign({ sub: 'u-1', username: 'admin', rol: 'admin' }, SECRET)
|
||||
const req = reqFake({ headers: { authorization: `Bearer ${token}` } })
|
||||
const { res } = resFake()
|
||||
authMiddleware(req, res, next)
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
expect(req.usuarioToken?.username).toBe('admin')
|
||||
})
|
||||
|
||||
it('deja pasar las rutas públicas sin token (health, login, webhook monday)', () => {
|
||||
const casos: Partial<Request>[] = [
|
||||
{ method: 'GET', path: '/health' },
|
||||
{ method: 'POST', path: '/auth/login' },
|
||||
{ method: 'POST', path: '/webhooks/monday' },
|
||||
]
|
||||
for (const c of casos) {
|
||||
vi.clearAllMocks()
|
||||
authMiddleware(reqFake(c), resFake().res, next)
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
}
|
||||
})
|
||||
|
||||
it('protege POST /webhooks/monday con otro método no aplica: GET /webhooks/monday pide token', () => {
|
||||
const { res, status } = resFake()
|
||||
authMiddleware(reqFake({ method: 'GET', path: '/webhooks/monday' }), res, next)
|
||||
expect(status).toHaveBeenCalledWith(401)
|
||||
})
|
||||
})
|
||||
50
api-gateway/src/middleware/auth.middleware.ts
Normal file
50
api-gateway/src/middleware/auth.middleware.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
import { config } from '../config/index.js'
|
||||
import { verificarToken, type TokenPayload } from '../services/auth.service.js'
|
||||
|
||||
// Rutas públicas: no requieren token aunque AUTH_ENABLED=true. Se comparan
|
||||
// contra req.path, que aquí ya está relativo al mount /api.
|
||||
// Monday no puede mandar JWT, así que su webhook queda abierto (se valida por
|
||||
// firma/challenge dentro del controlador).
|
||||
const RUTAS_PUBLICAS: ReadonlyArray<{ metodo: string; ruta: string }> = [
|
||||
{ metodo: 'GET', ruta: '/health' },
|
||||
{ metodo: 'POST', ruta: '/auth/login' },
|
||||
{ metodo: 'POST', ruta: '/webhooks/monday' },
|
||||
]
|
||||
|
||||
declare module 'express-serve-static-core' {
|
||||
interface Request {
|
||||
usuarioToken?: TokenPayload
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exige Bearer JWT en todo /api salvo las rutas públicas.
|
||||
* Con AUTH_ENABLED=false (tests) deja pasar todo.
|
||||
*/
|
||||
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
|
||||
if (!config.auth.enabled) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
const publica = RUTAS_PUBLICAS.some((r) => r.metodo === req.method && req.path === r.ruta)
|
||||
if (publica) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
const header = req.headers.authorization
|
||||
const token = header?.startsWith('Bearer ') ? header.slice('Bearer '.length).trim() : null
|
||||
if (!token) {
|
||||
res.status(401).json({ error: 'Token requerido' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
req.usuarioToken = verificarToken(token)
|
||||
next()
|
||||
} catch {
|
||||
res.status(401).json({ error: 'Token inválido o expirado' })
|
||||
}
|
||||
}
|
||||
@@ -38,11 +38,15 @@ import {
|
||||
obtenerConversacion,
|
||||
subirDocumento,
|
||||
} from '../controllers/ai.controller.js'
|
||||
import { loginEndpoint, meEndpoint } from '../controllers/auth.controller.js'
|
||||
|
||||
export const apiRouter = Router()
|
||||
|
||||
apiRouter.get('/health', health)
|
||||
|
||||
apiRouter.post('/auth/login', loginEndpoint)
|
||||
apiRouter.get('/auth/me', meEndpoint)
|
||||
|
||||
apiRouter.get('/unidades', listarUnidades)
|
||||
apiRouter.get('/unidades/:codigo', obtenerUnidad)
|
||||
|
||||
|
||||
104
api-gateway/src/services/auth.service.test.ts
Normal file
104
api-gateway/src/services/auth.service.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import type { Usuario } from '@prisma/client'
|
||||
import { login, me, normalizarUsername, verificarToken } from './auth.service.js'
|
||||
|
||||
// ---------- Prisma mockeado (los tests no tocan la BD ni la red) ----------
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
usuario: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('./prisma.js', () => ({ prisma: prismaMock }))
|
||||
|
||||
function usuarioFake(overrides: Partial<Usuario> = {}): Usuario {
|
||||
return {
|
||||
id: 'u-1',
|
||||
username: 'erick israel pena ramos',
|
||||
nombre: 'ERICK ISRAEL PEÑA RAMOS',
|
||||
passwordHash: bcrypt.hashSync('Frasa2026', 12),
|
||||
rol: 'vendedor',
|
||||
asignacion: 'MONTEBELLO',
|
||||
empleadoId: 13676,
|
||||
activo: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('normalizarUsername', () => {
|
||||
it('minúsculas, sin acentos ni ñ, espacios simples', () => {
|
||||
expect(normalizarUsername(' ERICK ISRAEL PEÑA RAMOS ')).toBe('erick israel pena ramos')
|
||||
expect(normalizarUsername('Ma. Cristina Barrera López')).toBe('ma. cristina barrera lopez')
|
||||
})
|
||||
})
|
||||
|
||||
describe('login', () => {
|
||||
it('devuelve token y usuario público con credenciales correctas', async () => {
|
||||
prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake())
|
||||
|
||||
const resultado = await login('erick israel pena ramos', 'Frasa2026')
|
||||
|
||||
expect(resultado.token).toBeTruthy()
|
||||
expect(resultado.usuario).toEqual({
|
||||
nombre: 'ERICK ISRAEL PEÑA RAMOS',
|
||||
rol: 'vendedor',
|
||||
asignacion: 'MONTEBELLO',
|
||||
})
|
||||
expect(verificarToken(resultado.token).username).toBe('erick israel pena ramos')
|
||||
})
|
||||
|
||||
it('es case-insensitive y tolerante a espacios extra y acentos', async () => {
|
||||
prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake())
|
||||
|
||||
const resultado = await login(' ERICK Israel PEÑA ramos ', 'Frasa2026')
|
||||
|
||||
expect(resultado.token).toBeTruthy()
|
||||
expect(prismaMock.usuario.findUnique).toHaveBeenCalledWith({
|
||||
where: { username: 'erick israel pena ramos' },
|
||||
})
|
||||
})
|
||||
|
||||
it('rechaza password incorrecto con error genérico', async () => {
|
||||
prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake())
|
||||
await expect(login('erick israel pena ramos', 'otra')).rejects.toThrow('Credenciales inválidas')
|
||||
})
|
||||
|
||||
it('rechaza usuario inexistente con el mismo error genérico', async () => {
|
||||
prismaMock.usuario.findUnique.mockResolvedValue(null)
|
||||
await expect(login('nadie', 'Frasa2026')).rejects.toThrow('Credenciales inválidas')
|
||||
})
|
||||
|
||||
it('rechaza usuario inactivo con el mismo error genérico', async () => {
|
||||
prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake({ activo: false }))
|
||||
await expect(login('erick israel pena ramos', 'Frasa2026')).rejects.toThrow('Credenciales inválidas')
|
||||
})
|
||||
})
|
||||
|
||||
describe('verificarToken', () => {
|
||||
it('lanza 401 con token basura', () => {
|
||||
expect(() => verificarToken('no-es-un-jwt')).toThrow('Token inválido o expirado')
|
||||
})
|
||||
})
|
||||
|
||||
describe('me', () => {
|
||||
it('devuelve el usuario público del token', async () => {
|
||||
prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake())
|
||||
const resultado = await me({ sub: 'u-1', username: 'erick israel pena ramos', rol: 'vendedor' })
|
||||
expect(resultado.nombre).toBe('ERICK ISRAEL PEÑA RAMOS')
|
||||
})
|
||||
|
||||
it('rechaza si el usuario ya no existe o está inactivo', async () => {
|
||||
prismaMock.usuario.findUnique.mockResolvedValue(null)
|
||||
await expect(me({ sub: 'x', username: 'x', rol: 'vendedor' })).rejects.toThrow('Credenciales inválidas')
|
||||
prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake({ activo: false }))
|
||||
await expect(me({ sub: 'u-1', username: 'x', rol: 'vendedor' })).rejects.toThrow('Credenciales inválidas')
|
||||
})
|
||||
})
|
||||
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