diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 05917a0..fa85ced 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -3,6 +3,7 @@ import cors from 'cors'; import helmet from 'helmet'; import { env } from './config/env.js'; import { errorMiddleware } from './middlewares/error.middleware.js'; +import { authRoutes } from './routes/auth.routes.js'; const app = express(); @@ -22,8 +23,8 @@ app.get('/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); -// API Routes (to be added) -// app.use('/api/auth', authRoutes); +// API Routes +app.use('/api/auth', authRoutes); // Error handling app.use(errorMiddleware); diff --git a/apps/api/src/controllers/auth.controller.ts b/apps/api/src/controllers/auth.controller.ts new file mode 100644 index 0000000..fb09a56 --- /dev/null +++ b/apps/api/src/controllers/auth.controller.ts @@ -0,0 +1,80 @@ +import type { Request, Response, NextFunction } from 'express'; +import { z } from 'zod'; +import * as authService from '../services/auth.service.js'; +import { AppError } from '../middlewares/error.middleware.js'; + +const registerSchema = z.object({ + empresa: z.object({ + nombre: z.string().min(2, 'Nombre de empresa requerido'), + rfc: z.string().min(12).max(13, 'RFC inválido'), + }), + usuario: z.object({ + nombre: z.string().min(2, 'Nombre requerido'), + email: z.string().email('Email inválido'), + password: z.string().min(8, 'La contraseña debe tener al menos 8 caracteres'), + }), +}); + +const loginSchema = z.object({ + email: z.string().email('Email inválido'), + password: z.string().min(1, 'Contraseña requerida'), +}); + +export async function register(req: Request, res: Response, next: NextFunction) { + try { + const data = registerSchema.parse(req.body); + const result = await authService.register(data); + res.status(201).json(result); + } catch (error) { + if (error instanceof z.ZodError) { + return next(new AppError(400, error.errors[0].message)); + } + next(error); + } +} + +export async function login(req: Request, res: Response, next: NextFunction) { + try { + const data = loginSchema.parse(req.body); + const result = await authService.login(data); + res.json(result); + } catch (error) { + if (error instanceof z.ZodError) { + return next(new AppError(400, error.errors[0].message)); + } + next(error); + } +} + +export async function refresh(req: Request, res: Response, next: NextFunction) { + try { + const { refreshToken } = req.body; + if (!refreshToken) { + throw new AppError(400, 'Refresh token requerido'); + } + const tokens = await authService.refreshTokens(refreshToken); + res.json(tokens); + } catch (error) { + next(error); + } +} + +export async function logout(req: Request, res: Response, next: NextFunction) { + try { + const { refreshToken } = req.body; + if (refreshToken) { + await authService.logout(refreshToken); + } + res.json({ message: 'Sesión cerrada exitosamente' }); + } catch (error) { + next(error); + } +} + +export async function me(req: Request, res: Response, next: NextFunction) { + try { + res.json({ user: req.user }); + } catch (error) { + next(error); + } +} diff --git a/apps/api/src/middlewares/auth.middleware.ts b/apps/api/src/middlewares/auth.middleware.ts new file mode 100644 index 0000000..3c68615 --- /dev/null +++ b/apps/api/src/middlewares/auth.middleware.ts @@ -0,0 +1,44 @@ +import type { Request, Response, NextFunction } from 'express'; +import { verifyToken } from '../utils/token.js'; +import { AppError } from './error.middleware.js'; +import type { JWTPayload, Role } from '@horux/shared'; + +declare global { + namespace Express { + interface Request { + user?: JWTPayload; + } + } +} + +export function authenticate(req: Request, res: Response, next: NextFunction) { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return next(new AppError(401, 'Token no proporcionado')); + } + + const token = authHeader.split(' ')[1]; + + try { + const payload = verifyToken(token); + req.user = payload; + next(); + } catch (error) { + next(new AppError(401, 'Token inválido o expirado')); + } +} + +export function authorize(...roles: Role[]) { + return (req: Request, res: Response, next: NextFunction) => { + if (!req.user) { + return next(new AppError(401, 'No autenticado')); + } + + if (roles.length > 0 && !roles.includes(req.user.role)) { + return next(new AppError(403, 'No autorizado')); + } + + next(); + }; +} diff --git a/apps/api/src/routes/auth.routes.ts b/apps/api/src/routes/auth.routes.ts new file mode 100644 index 0000000..c3befc6 --- /dev/null +++ b/apps/api/src/routes/auth.routes.ts @@ -0,0 +1,13 @@ +import { Router } from 'express'; +import * as authController from '../controllers/auth.controller.js'; +import { authenticate } from '../middlewares/auth.middleware.js'; + +const router = Router(); + +router.post('/register', authController.register); +router.post('/login', authController.login); +router.post('/refresh', authController.refresh); +router.post('/logout', authController.logout); +router.get('/me', authenticate, authController.me); + +export { router as authRoutes }; diff --git a/apps/api/src/services/auth.service.ts b/apps/api/src/services/auth.service.ts new file mode 100644 index 0000000..7d296fb --- /dev/null +++ b/apps/api/src/services/auth.service.ts @@ -0,0 +1,200 @@ +import { prisma } from '../config/database.js'; +import { hashPassword, verifyPassword } from '../utils/password.js'; +import { generateAccessToken, generateRefreshToken, verifyToken } from '../utils/token.js'; +import { createTenantSchema } from '../utils/schema-manager.js'; +import { AppError } from '../middlewares/error.middleware.js'; +import { PLANS } from '@horux/shared'; +import type { LoginRequest, RegisterRequest, LoginResponse } from '@horux/shared'; + +export async function register(data: RegisterRequest): Promise { + const existingUser = await prisma.user.findUnique({ + where: { email: data.usuario.email }, + }); + + if (existingUser) { + throw new AppError(400, 'El email ya está registrado'); + } + + const existingTenant = await prisma.tenant.findUnique({ + where: { rfc: data.empresa.rfc }, + }); + + if (existingTenant) { + throw new AppError(400, 'El RFC ya está registrado'); + } + + const schemaName = `tenant_${data.empresa.rfc.toLowerCase().replace(/[^a-z0-9]/g, '')}`; + + const tenant = await prisma.tenant.create({ + data: { + nombre: data.empresa.nombre, + rfc: data.empresa.rfc.toUpperCase(), + plan: 'starter', + schemaName, + cfdiLimit: PLANS.starter.cfdiLimit, + usersLimit: PLANS.starter.usersLimit, + }, + }); + + await createTenantSchema(schemaName); + + const passwordHash = await hashPassword(data.usuario.password); + const user = await prisma.user.create({ + data: { + tenantId: tenant.id, + email: data.usuario.email.toLowerCase(), + passwordHash, + nombre: data.usuario.nombre, + role: 'admin', + }, + }); + + const tokenPayload = { + userId: user.id, + email: user.email, + role: user.role, + tenantId: tenant.id, + schemaName: tenant.schemaName, + }; + + const accessToken = generateAccessToken(tokenPayload); + const refreshToken = generateRefreshToken(tokenPayload); + + await prisma.refreshToken.create({ + data: { + userId: user.id, + token: refreshToken, + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + }, + }); + + return { + accessToken, + refreshToken, + user: { + id: user.id, + email: user.email, + nombre: user.nombre, + role: user.role, + tenantId: tenant.id, + tenantName: tenant.nombre, + }, + }; +} + +export async function login(data: LoginRequest): Promise { + const user = await prisma.user.findUnique({ + where: { email: data.email.toLowerCase() }, + include: { tenant: true }, + }); + + if (!user) { + throw new AppError(401, 'Credenciales inválidas'); + } + + if (!user.active) { + throw new AppError(401, 'Usuario desactivado'); + } + + if (!user.tenant.active) { + throw new AppError(401, 'Empresa desactivada'); + } + + const isValidPassword = await verifyPassword(data.password, user.passwordHash); + + if (!isValidPassword) { + throw new AppError(401, 'Credenciales inválidas'); + } + + await prisma.user.update({ + where: { id: user.id }, + data: { lastLogin: new Date() }, + }); + + const tokenPayload = { + userId: user.id, + email: user.email, + role: user.role, + tenantId: user.tenantId, + schemaName: user.tenant.schemaName, + }; + + const accessToken = generateAccessToken(tokenPayload); + const refreshToken = generateRefreshToken(tokenPayload); + + await prisma.refreshToken.create({ + data: { + userId: user.id, + token: refreshToken, + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + }, + }); + + return { + accessToken, + refreshToken, + user: { + id: user.id, + email: user.email, + nombre: user.nombre, + role: user.role, + tenantId: user.tenantId, + tenantName: user.tenant.nombre, + }, + }; +} + +export async function refreshTokens(token: string): Promise<{ accessToken: string; refreshToken: string }> { + const storedToken = await prisma.refreshToken.findUnique({ + where: { token }, + }); + + if (!storedToken) { + throw new AppError(401, 'Token inválido'); + } + + if (storedToken.expiresAt < new Date()) { + await prisma.refreshToken.delete({ where: { id: storedToken.id } }); + throw new AppError(401, 'Token expirado'); + } + + const payload = verifyToken(token); + + const user = await prisma.user.findUnique({ + where: { id: payload.userId }, + include: { tenant: true }, + }); + + if (!user || !user.active) { + throw new AppError(401, 'Usuario no encontrado o desactivado'); + } + + await prisma.refreshToken.delete({ where: { id: storedToken.id } }); + + const newTokenPayload = { + userId: user.id, + email: user.email, + role: user.role, + tenantId: user.tenantId, + schemaName: user.tenant.schemaName, + }; + + const accessToken = generateAccessToken(newTokenPayload); + const refreshToken = generateRefreshToken(newTokenPayload); + + await prisma.refreshToken.create({ + data: { + userId: user.id, + token: refreshToken, + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + }, + }); + + return { accessToken, refreshToken }; +} + +export async function logout(token: string): Promise { + await prisma.refreshToken.deleteMany({ + where: { token }, + }); +} diff --git a/apps/api/src/utils/password.ts b/apps/api/src/utils/password.ts new file mode 100644 index 0000000..e08a9a6 --- /dev/null +++ b/apps/api/src/utils/password.ts @@ -0,0 +1,11 @@ +import bcrypt from 'bcryptjs'; + +const SALT_ROUNDS = 12; + +export async function hashPassword(password: string): Promise { + return bcrypt.hash(password, SALT_ROUNDS); +} + +export async function verifyPassword(password: string, hash: string): Promise { + return bcrypt.compare(password, hash); +} diff --git a/apps/api/src/utils/token.ts b/apps/api/src/utils/token.ts new file mode 100644 index 0000000..98b951f --- /dev/null +++ b/apps/api/src/utils/token.ts @@ -0,0 +1,27 @@ +import jwt from 'jsonwebtoken'; +import type { JWTPayload } from '@horux/shared'; +import { env } from '../config/env.js'; + +export function generateAccessToken(payload: Omit): string { + return jwt.sign(payload, env.JWT_SECRET, { + expiresIn: env.JWT_EXPIRES_IN, + }); +} + +export function generateRefreshToken(payload: Omit): string { + return jwt.sign(payload, env.JWT_SECRET, { + expiresIn: env.JWT_REFRESH_EXPIRES_IN, + }); +} + +export function verifyToken(token: string): JWTPayload { + return jwt.verify(token, env.JWT_SECRET) as JWTPayload; +} + +export function decodeToken(token: string): JWTPayload | null { + try { + return jwt.decode(token) as JWTPayload; + } catch { + return null; + } +}