feat: implement JWT authentication system
Add complete authentication infrastructure including: - Password hashing utilities with bcrypt - JWT token generation and verification - Auth service with register, login, refresh, and logout - Auth controller with Zod validation - Auth middleware for route protection - Auth routes mounted at /api/auth Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import cors from 'cors';
|
|||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import { env } from './config/env.js';
|
import { env } from './config/env.js';
|
||||||
import { errorMiddleware } from './middlewares/error.middleware.js';
|
import { errorMiddleware } from './middlewares/error.middleware.js';
|
||||||
|
import { authRoutes } from './routes/auth.routes.js';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@@ -22,8 +23,8 @@ app.get('/health', (req, res) => {
|
|||||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
});
|
});
|
||||||
|
|
||||||
// API Routes (to be added)
|
// API Routes
|
||||||
// app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
|
|
||||||
// Error handling
|
// Error handling
|
||||||
app.use(errorMiddleware);
|
app.use(errorMiddleware);
|
||||||
|
|||||||
80
apps/api/src/controllers/auth.controller.ts
Normal file
80
apps/api/src/controllers/auth.controller.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
apps/api/src/middlewares/auth.middleware.ts
Normal file
44
apps/api/src/middlewares/auth.middleware.ts
Normal file
@@ -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();
|
||||||
|
};
|
||||||
|
}
|
||||||
13
apps/api/src/routes/auth.routes.ts
Normal file
13
apps/api/src/routes/auth.routes.ts
Normal file
@@ -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 };
|
||||||
200
apps/api/src/services/auth.service.ts
Normal file
200
apps/api/src/services/auth.service.ts
Normal file
@@ -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<LoginResponse> {
|
||||||
|
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<LoginResponse> {
|
||||||
|
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<void> {
|
||||||
|
await prisma.refreshToken.deleteMany({
|
||||||
|
where: { token },
|
||||||
|
});
|
||||||
|
}
|
||||||
11
apps/api/src/utils/password.ts
Normal file
11
apps/api/src/utils/password.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
|
const SALT_ROUNDS = 12;
|
||||||
|
|
||||||
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
|
return bcrypt.hash(password, SALT_ROUNDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||||
|
return bcrypt.compare(password, hash);
|
||||||
|
}
|
||||||
27
apps/api/src/utils/token.ts
Normal file
27
apps/api/src/utils/token.ts
Normal file
@@ -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<JWTPayload, 'iat' | 'exp'>): string {
|
||||||
|
return jwt.sign(payload, env.JWT_SECRET, {
|
||||||
|
expiresIn: env.JWT_EXPIRES_IN,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateRefreshToken(payload: Omit<JWTPayload, 'iat' | 'exp'>): 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user