diff --git a/client/src/App.js b/client/src/App.js index 978e0d2..3c01ec7 100644 --- a/client/src/App.js +++ b/client/src/App.js @@ -13,9 +13,12 @@ import CourseTable from "./Pages/CourseTable"; import ConsolidatedTable from "./Pages/ConsolidatedTable"; import CourseConsolidated from "./Pages/courseConsolidated"; import PrivateRoute from "./components/PrivateRoute"; +import TokenRefresher from "./components/TokenRefresher"; function App() { return ( + <> + }> } />} /> @@ -28,6 +31,7 @@ function App() { } />} /> } />} /> + ); } diff --git a/client/src/Pages/ConsolidatedTable.jsx b/client/src/Pages/ConsolidatedTable.jsx index 2b9018f..c8e89eb 100644 --- a/client/src/Pages/ConsolidatedTable.jsx +++ b/client/src/Pages/ConsolidatedTable.jsx @@ -6,39 +6,39 @@ import Navbar from "./Navbar"; const styles = { header: { - background: '#003366', - color: 'white', - padding: '20px 0', - textAlign: 'center', - fontSize: '24px', - marginBottom: '0', + background: "#003366", + color: "white", + padding: "20px 0", + textAlign: "center", + fontSize: "24px", + marginBottom: "0", }, buttonRow: { - display: 'flex', - justifyContent: 'space-around', - alignItems: 'center', - padding: '10px 0', - margin: '0', - backgroundColor: '#003366', - boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)' + display: "flex", + justifyContent: "space-around", + alignItems: "center", + padding: "10px 0", + margin: "0", + backgroundColor: "#003366", + boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)", }, button: { - padding: '10px 20px', - borderRadius: '5px', - color: 'white', - border: 'none', - cursor: 'pointer', - fontSize: '16px', - flex: '1', + padding: "10px 20px", + borderRadius: "5px", + color: "white", + border: "none", + cursor: "pointer", + fontSize: "16px", + flex: "1", }, bulkDownloadButton: { - backgroundColor: '#3fb5a3', + backgroundColor: "#3fb5a3", }, downloadButton: { - backgroundColor: '#28a745', + backgroundColor: "#28a745", }, emailButton: { - backgroundColor: '#ff6f61', + backgroundColor: "#ff6f61", }, tableContainer: { maxHeight: "70vh", @@ -77,11 +77,9 @@ const styles = { }, main: { width: "100%", - } + }, }; - - const ConsolidatedTable = () => { const [searchQuery, setSearchQuery] = useState(""); // State for search input const [data, setData] = useState([]); @@ -94,7 +92,8 @@ const ConsolidatedTable = () => { const fetchData = async () => { try { const response = await axios.get( - "http://localhost:8080/api/table/consolidated-table" + "http://localhost:8080/api/table/consolidated-table", + { withCredentials: true } ); setData(response.data); setLoading(false); @@ -113,8 +112,7 @@ const ConsolidatedTable = () => { // Extract unique faculty names and filter based on the search query const filteredTeachers = [...new Set(data.map((row) => row.Name))].filter( - (teacher) => - teacher.toLowerCase().includes(searchQuery.toLowerCase()) // Filter by search query + (teacher) => teacher.toLowerCase().includes(searchQuery.toLowerCase()) // Filter by search query ); // Pagination logic applied to filtered teachers @@ -151,7 +149,8 @@ const ConsolidatedTable = () => { const facultyId = teacherData[0].facultyId; try { const response = await axios.get( - `http://localhost:8080/api/faculty/${facultyId}` + `http://localhost:8080/api/faculty/${facultyId}`, + { withCredentials: true } ); const facultyEmail = response.data.email; const workbook = createExcelBook(teacherData, teacher); @@ -259,9 +258,7 @@ const ConsolidatedTable = () => { ) } > -

+

{teacher}'s Table

diff --git a/client/src/Pages/Login.jsx b/client/src/Pages/Login.jsx index e9646cd..c9bf093 100644 --- a/client/src/Pages/Login.jsx +++ b/client/src/Pages/Login.jsx @@ -1,4 +1,4 @@ -import React, { useEffect , useState } from "react"; +import React, { useEffect, useState } from "react"; import { Container, Col, Row } from "react-bootstrap"; import { FcGoogle } from "react-icons/fc"; import axios from "axios"; @@ -18,7 +18,6 @@ function AuthPage() { const notifyError = (message) => { toast.error(message); }; - function ToggleSign(event) { event.preventDefault(); @@ -40,38 +39,39 @@ function AuthPage() { async function handleSubmit(event) { event.preventDefault(); - if (!formData.username.trim() && signin) { - notifyError("Username cannot be empty"); - return; - } + if (!formData.username.trim() && signin) { + notifyError("Username cannot be empty"); + return; + } - const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; - if (!emailRegex.test(formData.email)) { - notifyError("Enter a valid email address"); - return; - } + const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; + if (!emailRegex.test(formData.email)) { + notifyError("Enter a valid email address"); + return; + } - // Check password length - if (formData.password.length < 8) { - notifyError("Password must be at least 8 characters long"); - return; - } + // Check password length + if (formData.password.length < 8) { + notifyError("Password must be at least 8 characters long"); + return; + } try { const response = await axios.post( - `http://localhost:8080/api/${ - !signin ? "login" : "register" - }`, - formData + `http://localhost:8080/api/${!signin ? "login" : "register"}`, + formData, + { withCredentials: true } ); - const { user } = response.data; - delete user.password; - const gravatarUrl = `https://www.gravatar.com/avatar/${md5( - user.email - )}?d=identicon`; - user.profilePicture = gravatarUrl; - localStorage.setItem("user", JSON.stringify(user)); - window.location.href = "/Welcom"; + if (response.status === 200) { + const { user } = response.data; + delete user.password; + const gravatarUrl = `https://www.gravatar.com/avatar/${md5( + user.email + )}?d=identicon`; + user.profilePicture = gravatarUrl; + + window.location.href = "/Welcome"; + } } catch (error) { console.error("Authentication error:", error); if ( @@ -88,11 +88,9 @@ function AuthPage() { const handleGoogleLogin = (event) => { event.preventDefault(); - window.location.href = - "http://localhost:8080/auth/google"; + window.location.href = "http://localhost:8080/auth/google"; }; - return ( <> diff --git a/client/src/Pages/Navbar.jsx b/client/src/Pages/Navbar.jsx index 24bdb40..83dfe56 100644 --- a/client/src/Pages/Navbar.jsx +++ b/client/src/Pages/Navbar.jsx @@ -1,9 +1,26 @@ import React from "react"; import { FaUserCircle } from "react-icons/fa"; -import { NavLink } from "react-router-dom"; // Import NavLink for navigation +import { NavLink, useNavigate } from "react-router-dom"; // Import NavLink for navigation import "./Navbar.css"; // Navbar-specific styles +import axios from "axios"; const Navbar = () => { + const navigate = useNavigate(); + + // Handle logout functionality + const handleLogout = async () => { + try { + // Call the logout API + await axios.get("http://localhost:8080/auth/logout", { withCredentials: true }); + + // Redirect to the login page after successful logout + navigate("/"); + } catch (error) { + console.error("Error during logout:", error); + alert("Failed to log out. Please try again."); + } + }; + return (
@@ -21,6 +38,11 @@ const Navbar = () => { Course Consolidated
+
+ +
{/* User icon at the right */} diff --git a/client/src/Pages/courseConsolidated.jsx b/client/src/Pages/courseConsolidated.jsx index 87d3673..856c80b 100644 --- a/client/src/Pages/courseConsolidated.jsx +++ b/client/src/Pages/courseConsolidated.jsx @@ -17,7 +17,8 @@ const CourseConsolidated = () => { const fetchData = async () => { try { const response = await axios.get( - "http://localhost:8080/api/table/course-consolidated" + "http://localhost:8080/api/table/course-consolidated", + { withCredentials: true } ); setData(response.data); setLoading(false); diff --git a/client/src/components/PrivateRoute.js b/client/src/components/PrivateRoute.js index 33ddffe..d9eac70 100644 --- a/client/src/components/PrivateRoute.js +++ b/client/src/components/PrivateRoute.js @@ -1,12 +1,36 @@ -import React from 'react'; -import { Navigate } from 'react-router-dom'; // Use Navigate for redirect -import Cookies from "js-cookie"; +import React, { useEffect, useState } from "react"; +import { Navigate } from "react-router-dom"; +import axios from "axios"; const PrivateRoute = ({ element: Element, ...rest }) => { - const token = Cookies.get("token"); + const [isAuthenticated, setIsAuthenticated] = useState(null); // Track authentication status - // If token exists, render the element. Otherwise, redirect to the login page - return token ? Element : ; + useEffect(() => { + // Call the server to validate the token + const checkAuth = async () => { + try { + const response = await axios.get("http://localhost:8080/api/auth-check", { + withCredentials: true, // Include cookies in the request + }); + + if (response.status === 200) { + setIsAuthenticated(true); // User is authenticated + } + } catch (error) { + setIsAuthenticated(false); // User is not authenticated + } + }; + + checkAuth(); + }, []); + + // If still checking authentication, render nothing or a loading spinner + if (isAuthenticated === null) { + return
Loading...
; + } + + // If authenticated, render the component. Otherwise, redirect to the login page + return isAuthenticated ? Element : ; }; export default PrivateRoute; diff --git a/client/src/components/TokenRefresher.js b/client/src/components/TokenRefresher.js new file mode 100644 index 0000000..343a748 --- /dev/null +++ b/client/src/components/TokenRefresher.js @@ -0,0 +1,37 @@ +import { useEffect } from "react"; +import axios from "axios"; +import Cookies from "js-cookie"; +import { useNavigate } from "react-router-dom"; + +const TokenRefresher = () => { + const navigate = useNavigate(); + + useEffect(() => { + // Function to refresh the token + const refreshToken = async () => { + try { + const response = await axios.post("http://localhost:8080/api/refresh", {}, { + withCredentials: true, // Make sure cookies are sent + }); + } catch (error) { + console.error("Failed to refresh token", error); + + // If refreshing the token fails (e.g., due to expiration), log out the user + Cookies.remove("token"); // Optionally, clear any existing cookies + navigate("/"); // Redirect to login page + } + }; + + // Set the interval to call refresh every 55 minutes (3300000 ms) + const refreshInterval = setInterval(() => { + refreshToken(); + }, 3300000); + + // Clean up the interval on component unmount + return () => clearInterval(refreshInterval); + }, [navigate]); // Empty dependency array to run once when component mounts + + return null; // This component doesn't render anything +}; + +export default TokenRefresher; diff --git a/server/package-lock.json b/server/package-lock.json index 8fb5c17..77b0993 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -12,6 +12,7 @@ "bcryptjs": "^2.4.3", "body-parser": "^1.20.2", "connect-mongo": "^5.1.0", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "crypto": "^1.0.1", "csv-writer": "^1.6.0", @@ -436,6 +437,28 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", diff --git a/server/package.json b/server/package.json index 9cc037b..f85fd00 100644 --- a/server/package.json +++ b/server/package.json @@ -21,6 +21,7 @@ "bcryptjs": "^2.4.3", "body-parser": "^1.20.2", "connect-mongo": "^5.1.0", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "crypto": "^1.0.1", "csv-writer": "^1.6.0", diff --git a/server/server.js b/server/server.js index c203c40..778f4d1 100644 --- a/server/server.js +++ b/server/server.js @@ -7,6 +7,7 @@ const bodyParser = require("body-parser"); const path = require("path"); const bcrypt = require("bcryptjs"); const jwt = require("jsonwebtoken"); +const cookieparser = require("cookie-parser"); require("dotenv").config(); // Import Routes @@ -34,6 +35,7 @@ const PORT = 8080; // Middleware app.use(cors({ origin: "http://localhost:3000", credentials: true })); +app.use(cookieparser()); app.use(express.json()); app.use(bodyParser.urlencoded({ extended: true })); app.use( @@ -54,12 +56,12 @@ require("./config/passport"); // Routes app.use("/password", authRoutes); -app.use("/api/courses", courseRoutes); -app.use("/api/faculty", facultyRoutes); -app.use("/api/appointments", appointmentRoutes); -app.use("/api/options", optionsRoutes); -app.use("/api/data", consolidatedRoutes); -app.use("/api/send-email", emailRoutes); +app.use("/api/courses", courseRoutes); +app.use("/api/faculty", facultyRoutes); +app.use("/api/appointments", appointmentRoutes); +app.use("/api/options", optionsRoutes); +app.use("/api/data", consolidatedRoutes); +app.use("/api/send-email", emailRoutes); // Google OAuth Routes app.get( @@ -73,7 +75,7 @@ app.get( (req, res) => { const token = jwt.sign({ userId: req.user._id }, process.env.JWT_SECRET, { expiresIn: "1h" }); // Set token as a cookie or send it in the response - res.cookie("token", token, { httpOnly: false, secure: false }); + res.cookie("token", token, { httpOnly: true, secure: false, maxAge: 3600000 }); res.redirect("http://localhost:3000/Welcome"); // Redirect to a frontend route after successful login } ); @@ -101,15 +103,15 @@ app.post("/api/register", async (req, res) => { await user.save(); } - req.login(user, (err) => { - if (err) { - console.error("Error logging in user after registration:", err); - return res.status(500).send("Internal server error"); - } - return res.status(200).json({ - message: "Registered and logged in successfully", - user, - }); + // Generate a JWT token using the user's ID + const token = jwt.sign({ userId: user._id }, process.env.JWT_SECRET, { expiresIn: "1h" }); + + // Set the token as a cookie + res.cookie("token", token, { httpOnly: true, secure: false, maxAge: 3600000 }); // 1 hour expiry + + return res.status(200).json({ + message: "Registered and logged in successfully", + user, }); } catch (error) { console.error("Error registering user:", error); @@ -129,27 +131,83 @@ app.post("/api/login", (req, res, next) => { if (err) { return res.status(500).json({ message: "Internal server error" }); } + // Generate a JWT token using the user's ID + const token = jwt.sign({ userId: user._id }, process.env.JWT_SECRET, { expiresIn: "1h" }); + + // Set the token as a cookie + res.cookie("token", token, { httpOnly: true, secure: false, maxAge: 3600000 }); // 1 hour expiry return res.status(200).json({ message: "Login successful", user }); }); })(req, res, next); }); app.get("/auth/logout", function (req, res) { - req.logout((err) => { - if (err) { - console.log(err); - return res.status(500).json({ message: "Error logging out" }); - } - req.session.destroy(function (err) { - if (err) { - console.log(err); - return res.status(500).json({ message: "Error destroying session" }); - } - res.json({ message: "Logout successful" }); + try { + // Clear the token cookie + res.clearCookie("token", { + httpOnly: true, // Ensure it matches the cookie options you set earlier + secure: false, // Match the "secure" option from cookie settings + sameSite: "lax", // Ensure this matches the original cookie configuration }); - }); + + // Destroy the session if used (optional, if sessions are implemented) + if (req.session) { + req.session.destroy((err) => { + if (err) { + console.error("Error destroying session:", err); + return res.status(500).json({ message: "Error logging out" }); + } + res.status(200).json({ message: "Logout successful, session destroyed" }); + }); + } else { + // If no session, simply respond with success + res.status(200).json({ message: "Logout successful, cookie cleared" }); + } + } catch (err) { + console.error("Error logging out:", err); + res.status(500).json({ message: "Error logging out" }); + } }); +// Refresh Token Endpoint +app.post("/api/refresh", (req, res) => { + const refreshToken = req.cookies.token; + console.log(refreshToken); + + if (!refreshToken) { + return res.status(401).json({ message: "No refresh token, authorization denied" }); + } + + try { + const decoded = jwt.verify(refreshToken, process.env.JWT_SECRET); + const newToken = jwt.sign({ userId: decoded.userId }, process.env.JWT_SECRET, { expiresIn: "1h" }); + + return res + .cookie("token", newToken, { httpOnly: true, maxAge: 3600000 }) // Set new access token + .status(200) + .json({ message: "Token refreshed" }); + } catch (err) { + console.error("Error refreshing token:", err); + res.status(401).json({ message: "Invalid or expired refresh token" }); + } +}); + +app.get("/api/auth-check", (req, res) => { + const token = req.cookies.token; // Retrieve the httpOnly cookie + + if (!token) { + return res.status(401).json({ message: "Unauthorized" }); + } + + try { + jwt.verify(token, process.env.JWT_SECRET); // Verify the token + res.status(200).json({ authenticated: true }); // Valid token + } catch (err) { + res.status(401).json({ message: "Unauthorized" }); // Invalid token + } +}); + + // User Profile Route app.get("/api/user/profile", async (req, res) => { try {