feat: upgrade authentication to JWT with refresh tokens
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
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;
|
||||
|
||||
@@ -9,29 +15,126 @@ const login = async (req, res) => {
|
||||
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];
|
||||
|
||||
|
||||
let message = '';
|
||||
switch (status) {
|
||||
case 1:
|
||||
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;
|
||||
|
||||
@@ -40,10 +143,10 @@ const createuser = async (req, res) => {
|
||||
|
||||
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' });
|
||||
@@ -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 };
|
||||
|
||||
24
backend/hotel_hacienda/src/middlewares/authMiddleware.js
Normal file
24
backend/hotel_hacienda/src/middlewares/authMiddleware.js
Normal 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 };
|
||||
@@ -7,5 +7,7 @@ const handleValidation = require('../middlewares/handleValidation');
|
||||
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;
|
||||
@@ -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>
|
||||
// );
|
||||
// };
|
||||
@@ -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");
|
||||
|
||||
@@ -3,55 +3,42 @@ import axios from "axios";
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||
timeout: 15000,
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
// Request interceptor: attach access token
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = sessionStorage.getItem('accessToken');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// 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;
|
||||
|
||||
|
||||
//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;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user