feat: upgrade authentication to JWT with refresh tokens

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 01:20:13 +00:00
parent ca7b816c0e
commit 6b5e270984
9 changed files with 245 additions and 146 deletions

View File

@@ -9,3 +9,7 @@ EMAIL_PASS=tu_api_key
# Token de Banxico para tipo de cambio
BANXICO_TOKEN=tu_token_banxico
# JWT Authentication
JWT_SECRET=change-this-secret-in-production
JWT_REFRESH_SECRET=change-this-refresh-secret-in-production

View File

@@ -13,11 +13,14 @@
"description": "",
"dependencies": {
"axios": "^1.13.2",
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"csv-parser": "^3.2.0",
"dotenv": "^17.2.2",
"express": "^5.1.0",
"express-validator": "^7.2.1",
"jsonwebtoken": "^9.0.2",
"nodemailer": "^7.0.10",
"pg": "^8.16.3",
"stripe": "^20.1.2",

View File

@@ -1,16 +1,21 @@
const express = require('express');
const app = express();
const cors = require('cors');
const cookieParser = require('cookie-parser');
require("dotenv").config();
const { authMiddleware } = require('./middlewares/authMiddleware');
const corsOptions = {
origin: process.env.URL_CORS, // sin slash al final
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization"]
allowedHeaders: ["Content-Type", "Authorization"],
credentials: true
};
app.use(cors(corsOptions));
app.use(express.json());
app.use(cookieParser());
//rutas
const authRoutes = require('./routes/auth.routes');
@@ -30,22 +35,24 @@ const hotelRoutes = require('./routes/hotelpl.routes');
const restaurantRoutes = require('./routes/restaurantpl.routes');
const incomehrxRoutes = require('./routes/incomehrx.routes');
//Prefijo
app.use('/api/employees', employeeRoutes);
app.use('/api/contracts', contractsRoutes);
app.use('/api/reportcontracts', reportcontractRoutes);
app.use('/api/products', productRoutes);
//Prefijo - Auth routes are public (no middleware)
app.use('/api/auth', authRoutes);
app.use('/api/expenses', expenseRoutes);
app.use('/api/status', statusRoutes);
app.use('/api/payment', paymentRoutes);
app.use('/api/settings',settingsRoutes);
app.use('/api/emails',emailRoutes);
app.use('/api/incomes',incomeRoutes);
app.use('/api/incomeshrx',incomehrxRoutes);
app.use('/api/purchases',purchaseRoutes);
app.use('/api/exchange',exchangeRoutes);
app.use('/api/hotelpl',hotelRoutes);
app.use('/api/restaurantpl',restaurantRoutes);
//Prefijo - All other routes are protected with JWT auth middleware
app.use('/api/employees', authMiddleware, employeeRoutes);
app.use('/api/contracts', authMiddleware, contractsRoutes);
app.use('/api/reportcontracts', authMiddleware, reportcontractRoutes);
app.use('/api/products', authMiddleware, productRoutes);
app.use('/api/expenses', authMiddleware, expenseRoutes);
app.use('/api/status', authMiddleware, statusRoutes);
app.use('/api/payment', authMiddleware, paymentRoutes);
app.use('/api/settings', authMiddleware, settingsRoutes);
app.use('/api/emails', authMiddleware, emailRoutes);
app.use('/api/incomes', authMiddleware, incomeRoutes);
app.use('/api/incomeshrx', authMiddleware, incomehrxRoutes);
app.use('/api/purchases', authMiddleware, purchaseRoutes);
app.use('/api/exchange', authMiddleware, exchangeRoutes);
app.use('/api/hotelpl', authMiddleware, hotelRoutes);
app.use('/api/restaurantpl', authMiddleware, restaurantRoutes);
module.exports = app;

View File

@@ -1,14 +1,19 @@
const jwt = require('jsonwebtoken');
const pool = require('../db/connection');
const transporter = require('../services/mailService');
const crypto = require('crypto');
const JWT_SECRET = process.env.JWT_SECRET || 'hotel-system-secret-key-change-in-production';
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'hotel-system-refresh-secret-key-change-in-production';
const ACCESS_TOKEN_EXPIRY = '15m';
const REFRESH_TOKEN_EXPIRY = '7d';
const login = async (req, res) => {
const { name_mail_user, user_pass } = req.body;
try {
const result = await pool.query('SELECT * from validarusuario($1, $2)', [name_mail_user, user_pass]);
const { status, rol, user_id,user_name } = result.rows[0];
const { status, rol, user_id, user_name } = result.rows[0];
let message = '';
switch (status) {
@@ -16,34 +21,132 @@ const login = async (req, res) => {
message = 'Usuario autenticado correctamente';
break;
case 2:
message = 'Usuario o contraseña incorrectos';
message = 'Usuario o contrasena incorrectos';
break;
default:
message = 'Error desconocido en la validación';
message = 'Error desconocido en la validacion';
}
if (status !== 1) {
return res.json({ rol: 0, user_id, user_name, message });
}
// Generate JWT access token
const accessToken = jwt.sign(
{ user_id, user_name, rol },
JWT_SECRET,
{ expiresIn: ACCESS_TOKEN_EXPIRY }
);
// Generate refresh token
const refreshToken = jwt.sign(
{ user_id, user_name, rol },
JWT_REFRESH_SECRET,
{ expiresIn: REFRESH_TOKEN_EXPIRY }
);
// Store refresh token in database
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
await pool.query(
'INSERT INTO refresh_tokens (user_id, token, expires_at) VALUES ($1, $2, $3)',
[user_id, refreshToken, expiresAt]
);
// Set refresh token as httpOnly cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/'
});
res.json({
rol,
user_id,
user_name,
message });
accessToken,
message
});
} catch (error) {
console.error(error);
res.status(500).json({ status: 500, message: 'Error interno del servidor' });
}
};
const refreshTokenHandler = async (req, res) => {
const refreshToken = req.cookies?.refreshToken;
if (!refreshToken) {
return res.status(401).json({ message: 'Refresh token requerido' });
}
try {
// Verify the refresh token
const decoded = jwt.verify(refreshToken, JWT_REFRESH_SECRET);
// Check token exists in DB and is not revoked
const tokenResult = await pool.query(
'SELECT * FROM refresh_tokens WHERE token = $1 AND revoked = false AND expires_at > NOW()',
[refreshToken]
);
if (tokenResult.rows.length === 0) {
return res.status(401).json({ message: 'Refresh token invalido o revocado' });
}
// Issue new access token
const accessToken = jwt.sign(
{ user_id: decoded.user_id, user_name: decoded.user_name, rol: decoded.rol },
JWT_SECRET,
{ expiresIn: ACCESS_TOKEN_EXPIRY }
);
res.json({ accessToken });
} catch (error) {
console.error('Refresh token error:', error);
return res.status(401).json({ message: 'Refresh token invalido' });
}
};
const logoutUser = async (req, res) => {
const refreshToken = req.cookies?.refreshToken;
if (refreshToken) {
try {
// Revoke refresh token in DB
await pool.query(
'UPDATE refresh_tokens SET revoked = true WHERE token = $1',
[refreshToken]
);
} catch (error) {
console.error('Error revoking token:', error);
}
}
// Clear cookie
res.clearCookie('refreshToken', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/'
});
res.json({ message: 'Sesion cerrada correctamente' });
};
const createuser = async (req, res) => {
const { name_user,id_rol, email,user_pass } = req.body;
const { name_user, id_rol, email, user_pass } = req.body;
try {
const result = await pool.query('SELECT createuser($1, $2, $3, $4) AS status', [name_user, id_rol, email, user_pass]);
const status = result.rows[0].status;
res.json({
message: "Se agrego el usuario correctamente",
status});
status
});
} catch (error) {
console.error(error);
res.status(500).json({ status: 500, message: 'No se pudo agregar al usuario' });
@@ -52,7 +155,7 @@ const createuser = async (req, res) => {
const passRecover = async (req, res) => {
try {
const {user_mail } = req.body;
const { user_mail } = req.body;
const new_pass = crypto
.randomBytes(12)
@@ -75,11 +178,11 @@ const passRecover = async (req, res) => {
const mailOptions = {
from: 'soporte@horuxfin.com',
to: user_mail,
subject: `Nueva contraseña`,
subject: `Nueva contrasena`,
html: `
<h2>Recuperación de contraseña</h2>
<p>Se generó una nueva contraseña para el correo <b>${user_mail}</b></p>
<p><b>Contraseña:</b> ${new_pass}</p>
<h2>Recuperacion de contrasena</h2>
<p>Se genero una nueva contrasena para el correo <b>${user_mail}</b></p>
<p><b>Contrasena:</b> ${new_pass}</p>
<p>Por favor mantenla y borra el correo una vez resguardada.</p>
`,
};
@@ -87,16 +190,16 @@ const passRecover = async (req, res) => {
await transporter.sendMail(mailOptions);
res.json({
message: 'Correo enviado con nueva contraseña.',
message: 'Correo enviado con nueva contrasena.',
status
});
} catch (error) {
console.error(error);
res.status(500).json({
message: 'No se pudo mandar el correo de recuperación'
message: 'No se pudo mandar el correo de recuperacion'
});
}
};
module.exports = { login,createuser,passRecover};
module.exports = { login, createuser, passRecover, refreshTokenHandler, logoutUser };

View File

@@ -0,0 +1,24 @@
const jwt = require('jsonwebtoken');
const JWT_SECRET = process.env.JWT_SECRET || 'hotel-system-secret-key-change-in-production';
const authMiddleware = (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ message: 'Token de acceso requerido' });
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, JWT_SECRET);
req.user = decoded;
next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({ message: 'Token expirado', code: 'TOKEN_EXPIRED' });
}
return res.status(403).json({ message: 'Token invalido' });
}
};
module.exports = { authMiddleware, JWT_SECRET };

View File

@@ -4,8 +4,10 @@ const authController = require('../controllers/auth.controller');
const { loginValidator } = require('../middlewares/validators');
const handleValidation = require('../middlewares/handleValidation');
router.post('/login',loginValidator, handleValidation, authController.login);
router.post('/createuser',authController.createuser);
router.post('/recoverpass',authController.passRecover);
router.post('/login', loginValidator, handleValidation, authController.login);
router.post('/createuser', authController.createuser);
router.post('/recoverpass', authController.passRecover);
router.post('/refresh', authController.refreshTokenHandler);
router.post('/logout', authController.logoutUser);
module.exports = router;

View File

@@ -1,85 +1,54 @@
// context/AuthContext.jsx
import { createContext, useState, useEffect } from "react";
import api from "../services/api";
export const AuthContext = createContext();
function AuthProvider({ children }) {
const [user, setUser] = useState(null); // { id, name, role }
const [user, setUser] = useState(null);
const [userData, setUserData] = useState(null);
const [loading, setLoading] = useState(true);
// Recuperar user de localStorage al iniciar
useEffect(() => {
const savedUser = localStorage.getItem("rol");
if (savedUser) {
const token = sessionStorage.getItem("accessToken");
const savedUser = sessionStorage.getItem("userRole");
const savedUserData = sessionStorage.getItem("userData");
if (token && savedUser) {
try {
setUser(JSON.parse(savedUser));
} catch (error) {
console.error("Error parsing user data:", error);
localStorage.removeItem("rol");
if (savedUserData) setUserData(JSON.parse(savedUserData));
} catch (e) {
console.error("Error restoring session:", e);
}
}
setLoading(false);
}, []);
const login = (userData, data) => {
const login = (role, data, accessToken) => {
setUser(role);
setUserData(data);
setUser(userData);
localStorage.setItem("rol", JSON.stringify(userData));
sessionStorage.setItem("accessToken", accessToken);
sessionStorage.setItem("userRole", JSON.stringify(role));
sessionStorage.setItem("userData", JSON.stringify(data));
};
const logout = () => {
const logout = async () => {
try {
await api.post("/auth/logout");
} catch (e) {
// ignore logout errors
}
setUser(null);
localStorage.removeItem("rol");
setUserData(null);
sessionStorage.removeItem("accessToken");
sessionStorage.removeItem("userRole");
sessionStorage.removeItem("userData");
};
return (
<AuthContext.Provider value={{ user, login, logout, userData }}>
<AuthContext.Provider value={{ user, login, logout, userData, loading }}>
{children}
</AuthContext.Provider>
);
}
export { AuthProvider };
// // context/AuthContext.jsx
// import { createContext, useState, useEffect } from "react";
// export const AuthContext = createContext();
// export const AuthProvider = ({ children }) => {
// const [user, setUser] = useState(null); // { id, name, role }
// const [lang, setLang] = useState("es");//true = español
// // useEffect(() => {
// // const savedUser = localStorage.getItem("rol");
// // if (savedUser && savedUser !== "undefined") {
// // try {
// // setUser(JSON.parse(savedUser));
// // } catch (error) {
// // console.error("Error al parsear el usuario:", error);
// // localStorage.removeItem("rol"); // limpia el valor corrupto
// // }
// // }
// // }, []);
// // Recuperar user de localStorage al iniciar
// useEffect(() => {
// const savedUser = localStorage.getItem("rol");
// if (savedUser) setUser(JSON.parse(savedUser));
// }, []);
// const login = (userData) => {
// setUser(userData);
// localStorage.setItem("rol", JSON.stringify(userData));
// };
// const logout = () => {
// setUser(null);
// localStorage.removeItem("rol");
// };
// return (
// <AuthContext.Provider value={{ user, login, logout,lang ,setLang}}>
// {children}
// </AuthContext.Provider>
// );
// };

View File

@@ -20,7 +20,7 @@ export default function Login() {
user_pass: form.password,
});
if (response.data.rol != 0) {
login(response.data.rol, response.data);
login(response.data.rol, { user_id: response.data.user_id, user_name: response.data.user_name }, response.data.accessToken);
navigate("/app");
} else {
throw new Error(response.data.message || "Error al iniciar sesión");

View File

@@ -3,55 +3,42 @@ import axios from "axios";
const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 15000,
withCredentials: true,
});
export default api;
//Ejemplo con fetch
const API_BASE_URL = 'http://localhost:4000/api';
export async function createProduct(data) {
try {
const response = await fetch(`${API_BASE_URL}/products`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error('Failed to save product');
}
return await response.json();
} catch (error) {
console.error('API error:', error);
throw error;
// Request interceptor: attach access token
api.interceptors.request.use((config) => {
const token = sessionStorage.getItem('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
}
return config;
});
export async function fetchProducts() {
const res = await fetch(`${API_BASE_URL}/products`);
if (!res.ok) throw new Error('Error fetching products');
return await res.json();
}
export async function fetchInventoryProducts() {
const res = await fetch(`${API_BASE_URL}/inventory-products`);
if (!res.ok) throw new Error('Error fetching inventory products');
// forma esperada: objeto, ej: { "PILLOWS": { unitCost: 300, tax: "IVA 16%" }, ... }
return await res.json();
}
export async function createExpense(payload) {
const res = await fetch(`${API_BASE_URL}/expenses`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error('Error creating expense');
return await res.json();
}
// Response interceptor: handle 401 and auto-refresh
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && error.response?.data?.code === 'TOKEN_EXPIRED' && !originalRequest._retry) {
originalRequest._retry = true;
try {
const { data } = await axios.post(
`${import.meta.env.VITE_API_BASE_URL}/auth/refresh`,
{},
{ withCredentials: true }
);
sessionStorage.setItem('accessToken', data.accessToken);
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
return api(originalRequest);
} catch (refreshError) {
sessionStorage.removeItem('accessToken');
window.location.href = '/';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
export default api;