From cf6dd9be9054395974a3faa6cb6df9ff53e366f5 Mon Sep 17 00:00:00 2001 From: jackiettran <41605212+jackiettran@users.noreply.github.com> Date: Wed, 17 Sep 2025 18:37:07 -0400 Subject: [PATCH] more secure token handling --- backend/middleware/auth.js | 32 +- backend/middleware/rateLimiter.js | 58 +++ backend/middleware/security.js | 142 ++++++++ backend/models/User.js | 18 +- backend/routes/auth.js | 490 +++++++++++++++++--------- backend/server.js | 24 +- frontend/public/index.html | 11 +- frontend/src/components/AuthModal.tsx | 9 - frontend/src/contexts/AuthContext.tsx | 100 ++++-- frontend/src/services/api.ts | 154 +++++++- 10 files changed, 807 insertions(+), 231 deletions(-) create mode 100644 backend/middleware/security.js diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index 74184fd..4f51c6c 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -2,11 +2,14 @@ const jwt = require("jsonwebtoken"); const { User } = require("../models"); // Import from models/index.js to get models with associations const authenticateToken = async (req, res, next) => { - const authHeader = req.headers["authorization"]; - const token = authHeader && authHeader.split(" ")[1]; + // First try to get token from cookie + let token = req.cookies?.accessToken; if (!token) { - return res.status(401).json({ error: "Access token required" }); + return res.status(401).json({ + error: "Access token required", + code: "NO_TOKEN", + }); } try { @@ -14,20 +17,37 @@ const authenticateToken = async (req, res, next) => { const userId = decoded.id; if (!userId) { - return res.status(401).json({ error: "Invalid token format" }); + return res.status(401).json({ + error: "Invalid token format", + code: "INVALID_TOKEN_FORMAT", + }); } const user = await User.findByPk(userId); if (!user) { - return res.status(401).json({ error: "User not found" }); + return res.status(401).json({ + error: "User not found", + code: "USER_NOT_FOUND", + }); } req.user = user; next(); } catch (error) { + // Check if token is expired + if (error.name === "TokenExpiredError") { + return res.status(401).json({ + error: "Token expired", + code: "TOKEN_EXPIRED", + }); + } + console.error("Auth middleware error:", error); - return res.status(403).json({ error: "Invalid or expired token" }); + return res.status(403).json({ + error: "Invalid token", + code: "INVALID_TOKEN", + }); } }; diff --git a/backend/middleware/rateLimiter.js b/backend/middleware/rateLimiter.js index 87abdf9..60bc870 100644 --- a/backend/middleware/rateLimiter.js +++ b/backend/middleware/rateLimiter.js @@ -104,12 +104,70 @@ const burstProtection = createUserBasedRateLimiter( "Too many requests in a short period. Please slow down." ); +// Authentication rate limiters +const authRateLimiters = { + // Login rate limiter - stricter to prevent brute force + login: rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 5, // 5 login attempts per 15 minutes + message: { + error: "Too many login attempts. Please try again in 15 minutes.", + retryAfter: 900, // seconds + }, + standardHeaders: true, + legacyHeaders: false, + skipSuccessfulRequests: true, // Don't count successful logins + }), + + // Registration rate limiter + register: rateLimit({ + windowMs: 60 * 60 * 1000, // 1 hour + max: 3, // 3 registration attempts per hour + message: { + error: "Too many registration attempts. Please try again later.", + retryAfter: 3600, + }, + standardHeaders: true, + legacyHeaders: false, + }), + + // Password reset rate limiter + passwordReset: rateLimit({ + windowMs: 60 * 60 * 1000, // 1 hour + max: 3, // 3 password reset requests per hour + message: { + error: "Too many password reset requests. Please try again later.", + retryAfter: 3600, + }, + standardHeaders: true, + legacyHeaders: false, + }), + + // General API rate limiter + general: rateLimit({ + windowMs: 60 * 1000, // 1 minute + max: 100, // 100 requests per minute + message: { + error: "Too many requests. Please slow down.", + retryAfter: 60, + }, + standardHeaders: true, + legacyHeaders: false, + }), +}; + module.exports = { // Individual rate limiters placesAutocomplete: rateLimiters.placesAutocomplete, placeDetails: rateLimiters.placeDetails, geocoding: rateLimiters.geocoding, + // Auth rate limiters + loginLimiter: authRateLimiters.login, + registerLimiter: authRateLimiters.register, + passwordResetLimiter: authRateLimiters.passwordReset, + generalLimiter: authRateLimiters.general, + // Burst protection burstProtection, diff --git a/backend/middleware/security.js b/backend/middleware/security.js new file mode 100644 index 0000000..4eb0d71 --- /dev/null +++ b/backend/middleware/security.js @@ -0,0 +1,142 @@ +// HTTPS enforcement middleware +const enforceHTTPS = (req, res, next) => { + // Skip HTTPS enforcement in development + if ( + process.env.NODE_ENV === "dev" || + process.env.NODE_ENV === "development" + ) { + return next(); + } + + // Check if request is already HTTPS + const isSecure = + req.secure || + req.headers["x-forwarded-proto"] === "https" || + req.protocol === "https"; + + if (!isSecure) { + // Use configured allowed host to prevent Host Header Injection + const allowedHost = process.env.FRONTEND_URL; + + // Log the redirect for monitoring + if (req.headers.host !== allowedHost) { + console.warn("[SECURITY] Host header mismatch during HTTPS redirect:", { + requestHost: req.headers.host, + allowedHost, + ip: req.ip, + url: req.url, + }); + } + + // Redirect to HTTPS with validated host + return res.redirect(301, `https://${allowedHost}${req.url}`); + } + + // Set Strict-Transport-Security header + res.setHeader( + "Strict-Transport-Security", + "max-age=31536000; includeSubDomains; preload" + ); + next(); +}; + +// Security headers middleware +const securityHeaders = (req, res, next) => { + // X-Content-Type-Options + res.setHeader("X-Content-Type-Options", "nosniff"); + + // X-Frame-Options + res.setHeader("X-Frame-Options", "DENY"); + + // Referrer-Policy + res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin"); + + // Permissions-Policy (formerly Feature-Policy) + res.setHeader( + "Permissions-Policy", + "camera=(), microphone=(), geolocation=(self)" + ); + + next(); +}; + +// Request ID middleware for tracking +const requestId = require("crypto"); +const addRequestId = (req, res, next) => { + req.id = requestId.randomBytes(16).toString("hex"); + res.setHeader("X-Request-ID", req.id); + next(); +}; + +// Log security events +const logSecurityEvent = (eventType, details, req) => { + const logEntry = { + timestamp: new Date().toISOString(), + eventType, + requestId: req.id || "unknown", + ip: req.ip || req.connection.remoteAddress, + userAgent: req.get("user-agent"), + userId: req.user?.id || "anonymous", + ...details, + }; + + // In production, this should write to a secure log file or service + if (process.env.NODE_ENV === "production") { + console.log("[SECURITY]", JSON.stringify(logEntry)); + } else { + console.log("[SECURITY]", eventType, details); + } +}; + +// Sanitize error messages to prevent information leakage +const sanitizeError = (err, req, res, next) => { + // Log the full error internally + console.error("Error:", { + requestId: req.id, + error: err.message, + stack: err.stack, + userId: req.user?.id, + }); + + // Send sanitized error to client + const isDevelopment = + process.env.NODE_ENV === "dev" || process.env.NODE_ENV === "development"; + + if (err.status === 400) { + // Client errors can be more specific + return res.status(400).json({ + error: err.message || "Bad Request", + requestId: req.id, + }); + } else if (err.status === 401) { + return res.status(401).json({ + error: "Unauthorized", + requestId: req.id, + }); + } else if (err.status === 403) { + return res.status(403).json({ + error: "Forbidden", + requestId: req.id, + }); + } else if (err.status === 404) { + return res.status(404).json({ + error: "Not Found", + requestId: req.id, + }); + } else { + // Server errors should be generic in production + return res.status(err.status || 500).json({ + error: isDevelopment ? err.message : "Internal Server Error", + requestId: req.id, + ...(isDevelopment && { stack: err.stack }), + }); + } +}; + +module.exports = { + enforceHTTPS, + securityHeaders, + addRequestId, + logSecurityEvent, + sanitizeError, +}; diff --git a/backend/models/User.js b/backend/models/User.js index cbdd997..a3d137f 100644 --- a/backend/models/User.js +++ b/backend/models/User.js @@ -40,7 +40,7 @@ const User = sequelize.define( allowNull: true, }, authProvider: { - type: DataTypes.ENUM("local", "google", "apple"), + type: DataTypes.ENUM("local", "google"), defaultValue: "local", }, providerId: { @@ -141,35 +141,35 @@ const MAX_LOGIN_ATTEMPTS = 5; const LOCK_TIME = 2 * 60 * 60 * 1000; // 2 hours // Check if account is locked -User.prototype.isLocked = function() { +User.prototype.isLocked = function () { return !!(this.lockUntil && this.lockUntil > Date.now()); }; // Increment login attempts and lock account if necessary -User.prototype.incLoginAttempts = async function() { +User.prototype.incLoginAttempts = async function () { // If we have a previous lock that has expired, restart at 1 if (this.lockUntil && this.lockUntil < Date.now()) { return this.update({ loginAttempts: 1, - lockUntil: null + lockUntil: null, }); } - + const updates = { loginAttempts: this.loginAttempts + 1 }; - + // Lock account after max attempts if (this.loginAttempts + 1 >= MAX_LOGIN_ATTEMPTS && !this.isLocked()) { updates.lockUntil = Date.now() + LOCK_TIME; } - + return this.update(updates); }; // Reset login attempts after successful login -User.prototype.resetLoginAttempts = async function() { +User.prototype.resetLoginAttempts = async function () { return this.update({ loginAttempts: 0, - lockUntil: null + lockUntil: null, }); }; diff --git a/backend/routes/auth.js b/backend/routes/auth.js index f438d1a..4d77e08 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -2,170 +2,343 @@ const express = require("express"); const jwt = require("jsonwebtoken"); const { OAuth2Client } = require("google-auth-library"); const { User } = require("../models"); // Import from models/index.js to get models with associations -const { - sanitizeInput, - validateRegistration, - validateLogin, - validateGoogleAuth +const { + sanitizeInput, + validateRegistration, + validateLogin, + validateGoogleAuth, } = require("../middleware/validation"); +const { csrfProtection, getCSRFToken } = require("../middleware/csrf"); +const { loginLimiter, registerLimiter } = require("../middleware/rateLimiter"); const router = express.Router(); const googleClient = new OAuth2Client(process.env.GOOGLE_CLIENT_ID); -router.post("/register", sanitizeInput, validateRegistration, async (req, res) => { - try { - const { username, email, password, firstName, lastName, phone } = req.body; - - const existingUser = await User.findOne({ - where: { - [require("sequelize").Op.or]: [{ email }, { username }], - }, - }); - - if (existingUser) { - return res.status(400).json({ - error: "Registration failed", - details: [{ field: "email", message: "An account with this email already exists" }] - }); - } - - const user = await User.create({ - username, - email, - password, - firstName, - lastName, - phone, - }); - - const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET, { - expiresIn: "7d", - }); - - res.status(201).json({ - user: { - id: user.id, - username: user.username, - email: user.email, - firstName: user.firstName, - lastName: user.lastName, - }, - token, - }); - } catch (error) { - console.error('Registration error:', error); - res.status(500).json({ error: "Registration failed. Please try again." }); - } +// Get CSRF token endpoint +router.get("/csrf-token", (req, res) => { + getCSRFToken(req, res); }); -router.post("/login", sanitizeInput, validateLogin, async (req, res) => { - try { - const { email, password } = req.body; +router.post( + "/register", + registerLimiter, + csrfProtection, + sanitizeInput, + validateRegistration, + async (req, res) => { + try { + const { username, email, password, firstName, lastName, phone } = + req.body; - const user = await User.findOne({ where: { email } }); - - if (!user) { - return res.status(401).json({ error: "Invalid credentials" }); - } - - // Check if account is locked - if (user.isLocked()) { - return res.status(423).json({ - error: "Account is temporarily locked due to too many failed login attempts. Please try again later." + const existingUser = await User.findOne({ + where: { + [require("sequelize").Op.or]: [{ email }, { username }], + }, }); - } - // Verify password - const isPasswordValid = await user.comparePassword(password); - - if (!isPasswordValid) { - // Increment login attempts - await user.incLoginAttempts(); - return res.status(401).json({ error: "Invalid credentials" }); - } - - // Reset login attempts on successful login - await user.resetLoginAttempts(); - - const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET, { - expiresIn: "7d", - }); - - res.json({ - user: { - id: user.id, - username: user.username, - email: user.email, - firstName: user.firstName, - lastName: user.lastName, - }, - token, - }); - } catch (error) { - console.error('Login error:', error); - res.status(500).json({ error: "Login failed. Please try again." }); - } -}); - -router.post("/google", sanitizeInput, validateGoogleAuth, async (req, res) => { - try { - const { idToken } = req.body; - - if (!idToken) { - return res.status(400).json({ error: "ID token is required" }); - } - - // Verify the Google ID token - const ticket = await googleClient.verifyIdToken({ - idToken, - audience: process.env.GOOGLE_CLIENT_ID, - }); - - const payload = ticket.getPayload(); - const { - sub: googleId, - email, - given_name: firstName, - family_name: lastName, - picture, - } = payload; - - if (!email || !firstName || !lastName) { - return res - .status(400) - .json({ error: "Required user information not provided by Google" }); - } - - // Check if user exists by Google ID first - let user = await User.findOne({ - where: { providerId: googleId, authProvider: "google" }, - }); - - if (!user) { - // Check if user exists with same email but different auth provider - const existingUser = await User.findOne({ where: { email } }); if (existingUser) { - return res.status(409).json({ - error: - "An account with this email already exists. Please use email/password login.", + return res.status(400).json({ + error: "Registration failed", + details: [ + { + field: "email", + message: "An account with this email already exists", + }, + ], }); } - // Create new user - user = await User.create({ + const user = await User.create({ + username, email, + password, firstName, lastName, - authProvider: "google", - providerId: googleId, - profileImage: picture, - username: email.split("@")[0] + "_" + googleId.slice(-6), // Generate unique username + phone, }); + + const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET, { + expiresIn: "15m", // Short-lived access token + }); + + const refreshToken = jwt.sign( + { id: user.id, type: "refresh" }, + process.env.JWT_SECRET, + { expiresIn: "7d" } + ); + + // Set tokens as httpOnly cookies + res.cookie("accessToken", token, { + httpOnly: true, + secure: + process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa", + sameSite: "strict", + maxAge: 15 * 60 * 1000, // 15 minutes + }); + + res.cookie("refreshToken", refreshToken, { + httpOnly: true, + secure: + process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa", + sameSite: "strict", + maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days + }); + + res.status(201).json({ + user: { + id: user.id, + username: user.username, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + }, + // Don't send token in response body for security + }); + } catch (error) { + console.error("Registration error:", error); + res.status(500).json({ error: "Registration failed. Please try again." }); + } + } +); + +router.post( + "/login", + loginLimiter, + csrfProtection, + sanitizeInput, + validateLogin, + async (req, res) => { + try { + const { email, password } = req.body; + + const user = await User.findOne({ where: { email } }); + + if (!user) { + return res.status(401).json({ error: "Invalid credentials" }); + } + + // Check if account is locked + if (user.isLocked()) { + return res.status(423).json({ + error: + "Account is temporarily locked due to too many failed login attempts. Please try again later.", + }); + } + + // Verify password + const isPasswordValid = await user.comparePassword(password); + + if (!isPasswordValid) { + // Increment login attempts + await user.incLoginAttempts(); + return res.status(401).json({ error: "Invalid credentials" }); + } + + // Reset login attempts on successful login + await user.resetLoginAttempts(); + + const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET, { + expiresIn: "15m", // Short-lived access token + }); + + const refreshToken = jwt.sign( + { id: user.id, type: "refresh" }, + process.env.JWT_SECRET, + { expiresIn: "7d" } + ); + + // Set tokens as httpOnly cookies + res.cookie("accessToken", token, { + httpOnly: true, + secure: + process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa", + sameSite: "strict", + maxAge: 15 * 60 * 1000, // 15 minutes + }); + + res.cookie("refreshToken", refreshToken, { + httpOnly: true, + secure: + process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa", + sameSite: "strict", + maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days + }); + + res.json({ + user: { + id: user.id, + username: user.username, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + }, + // Don't send token in response body for security + }); + } catch (error) { + console.error("Login error:", error); + res.status(500).json({ error: "Login failed. Please try again." }); + } + } +); + +router.post( + "/google", + loginLimiter, + csrfProtection, + sanitizeInput, + validateGoogleAuth, + async (req, res) => { + try { + const { idToken } = req.body; + + if (!idToken) { + return res.status(400).json({ error: "ID token is required" }); + } + + // Verify the Google ID token + const ticket = await googleClient.verifyIdToken({ + idToken, + audience: process.env.GOOGLE_CLIENT_ID, + }); + + const payload = ticket.getPayload(); + const { + sub: googleId, + email, + given_name: firstName, + family_name: lastName, + picture, + } = payload; + + if (!email || !firstName || !lastName) { + return res + .status(400) + .json({ error: "Required user information not provided by Google" }); + } + + // Check if user exists by Google ID first + let user = await User.findOne({ + where: { providerId: googleId, authProvider: "google" }, + }); + + if (!user) { + // Check if user exists with same email but different auth provider + const existingUser = await User.findOne({ where: { email } }); + if (existingUser) { + return res.status(409).json({ + error: + "An account with this email already exists. Please use email/password login.", + }); + } + + // Create new user + user = await User.create({ + email, + firstName, + lastName, + authProvider: "google", + providerId: googleId, + profileImage: picture, + username: email.split("@")[0] + "_" + googleId.slice(-6), // Generate unique username + }); + } + + // Generate JWT tokens + const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET, { + expiresIn: "15m", + }); + + const refreshToken = jwt.sign( + { id: user.id, type: "refresh" }, + process.env.JWT_SECRET, + { expiresIn: "7d" } + ); + + // Set tokens as httpOnly cookies + res.cookie("accessToken", token, { + httpOnly: true, + secure: + process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa", + sameSite: "strict", + maxAge: 15 * 60 * 1000, + }); + + res.cookie("refreshToken", refreshToken, { + httpOnly: true, + secure: + process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa", + sameSite: "strict", + maxAge: 7 * 24 * 60 * 60 * 1000, + }); + + res.json({ + user: { + id: user.id, + username: user.username, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + profileImage: user.profileImage, + }, + // Don't send token in response body for security + }); + } catch (error) { + if (error.message && error.message.includes("Token used too late")) { + return res + .status(401) + .json({ error: "Google token has expired. Please try again." }); + } + if (error.message && error.message.includes("Invalid token")) { + return res + .status(401) + .json({ error: "Invalid Google token. Please try again." }); + } + if (error.message && error.message.includes("Wrong number of segments")) { + return res + .status(400) + .json({ error: "Malformed Google token. Please try again." }); + } + console.error("Google auth error:", error); + res + .status(500) + .json({ error: "Google authentication failed. Please try again." }); + } + } +); + +// Refresh token endpoint +router.post("/refresh", async (req, res) => { + try { + const { refreshToken } = req.cookies; + + if (!refreshToken) { + return res.status(401).json({ error: "Refresh token required" }); } - // Generate JWT token - const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET, { - expiresIn: "7d", + // Verify refresh token + const decoded = jwt.verify(refreshToken, process.env.JWT_SECRET); + + if (!decoded.id || decoded.type !== "refresh") { + return res.status(401).json({ error: "Invalid refresh token" }); + } + + // Find user + const user = await User.findByPk(decoded.id); + if (!user) { + return res.status(401).json({ error: "User not found" }); + } + + // Generate new access token + const newAccessToken = jwt.sign({ id: user.id }, process.env.JWT_SECRET, { + expiresIn: "15m", + }); + + // Set new access token cookie + res.cookie("accessToken", newAccessToken, { + httpOnly: true, + secure: process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa", + sameSite: "strict", + maxAge: 15 * 60 * 1000, }); res.json({ @@ -175,29 +348,20 @@ router.post("/google", sanitizeInput, validateGoogleAuth, async (req, res) => { email: user.email, firstName: user.firstName, lastName: user.lastName, - profileImage: user.profileImage, }, - token, }); } catch (error) { - if (error.message && error.message.includes("Token used too late")) { - return res - .status(401) - .json({ error: "Google token has expired. Please try again." }); - } - if (error.message && error.message.includes("Invalid token")) { - return res - .status(401) - .json({ error: "Invalid Google token. Please try again." }); - } - if (error.message && error.message.includes("Wrong number of segments")) { - return res - .status(400) - .json({ error: "Malformed Google token. Please try again." }); - } - console.error('Google auth error:', error); - res.status(500).json({ error: "Google authentication failed. Please try again." }); + console.error("Token refresh error:", error); + res.status(401).json({ error: "Invalid or expired refresh token" }); } }); +// Logout endpoint +router.post("/logout", (req, res) => { + // Clear cookies + res.clearCookie("accessToken"); + res.clearCookie("refreshToken"); + res.json({ message: "Logged out successfully" }); +}); + module.exports = router; diff --git a/backend/server.js b/backend/server.js index 7850d5c..80218e0 100644 --- a/backend/server.js +++ b/backend/server.js @@ -27,7 +27,21 @@ const PayoutProcessor = require("./jobs/payoutProcessor"); const app = express(); -// Security headers +// Import security middleware +const { + enforceHTTPS, + securityHeaders, + addRequestId, + sanitizeError, +} = require("./middleware/security"); +const { generalLimiter } = require("./middleware/rateLimiter"); + +// Apply security middleware +app.use(enforceHTTPS); +app.use(addRequestId); +app.use(securityHeaders); + +// Security headers with Helmet app.use( helmet({ contentSecurityPolicy: { @@ -38,7 +52,7 @@ app.use( scriptSrc: ["'self'", "https://accounts.google.com"], imgSrc: ["'self'"], connectSrc: ["'self'"], - frameSrc: ["'self'"], + frameSrc: ["'self'", "https://accounts.google.com"], }, }, }) @@ -47,6 +61,9 @@ app.use( // Cookie parser for CSRF app.use(cookieParser); +// General rate limiting for all routes +app.use("/api/", generalLimiter); + // CORS with security settings app.use( cors({ @@ -93,6 +110,9 @@ app.get("/", (req, res) => { res.json({ message: "CommunityRentals.App API is running!" }); }); +// Error handling middleware (must be last) +app.use(sanitizeError); + const PORT = process.env.PORT || 5000; sequelize diff --git a/frontend/public/index.html b/frontend/public/index.html index 3f19d70..47f6d0e 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -9,11 +9,16 @@ name="description" content="CommunityRentals.App - Rent gym equipment, tools, and musical instruments from your neighbors" /> - CommunityRentals.App - Equipment & Tool Rental Marketplace - - + + diff --git a/frontend/src/components/AuthModal.tsx b/frontend/src/components/AuthModal.tsx index d6890ae..fd4dd45 100644 --- a/frontend/src/components/AuthModal.tsx +++ b/frontend/src/components/AuthModal.tsx @@ -233,15 +233,6 @@ const AuthModal: React.FC = ({ Sign in with Google - -
{mode === "login" diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index 6a57e23..d49edbe 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -6,7 +6,7 @@ import React, { ReactNode, } from "react"; import { User } from "../types"; -import { authAPI, userAPI } from "../services/api"; +import { authAPI, userAPI, fetchCSRFToken, resetCSRFToken, hasAuthIndicators } from "../services/api"; interface AuthContextType { user: User | null; @@ -14,8 +14,9 @@ interface AuthContextType { login: (email: string, password: string) => Promise; register: (data: any) => Promise; googleLogin: (idToken: string) => Promise; - logout: () => void; + logout: () => Promise; updateUser: (user: User) => void; + checkAuth: () => Promise; } const AuthContext = createContext(undefined); @@ -36,46 +37,86 @@ export const AuthProvider: React.FC = ({ children }) => { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); - useEffect(() => { - const token = localStorage.getItem("token"); - if (token) { - userAPI - .getProfile() - .then((response) => { - setUser(response.data); - }) - .catch((error) => { - localStorage.removeItem("token"); - }) - .finally(() => { - setLoading(false); - }); - } else { - setLoading(false); + const checkAuth = async () => { + try { + const response = await userAPI.getProfile(); + setUser(response.data); + } catch (error: any) { + // Only log actual errors, not "user not logged in" + if (error.response?.data?.code !== "NO_TOKEN") { + console.error("Auth check failed:", error); + } + setUser(null); } + }; + + useEffect(() => { + // Initialize authentication + const initializeAuth = async () => { + try { + // Check if we have any auth indicators before making API call + if (hasAuthIndicators()) { + // Only check auth if we have some indication of being logged in + // This avoids unnecessary 401 errors in the console + await checkAuth(); + } else { + // No auth indicators - skip the API call + setUser(null); + } + + // Always fetch CSRF token for subsequent requests + await fetchCSRFToken(); + } catch (error) { + console.error("Failed to initialize auth:", error); + // Even on error, try to get CSRF token for non-authenticated requests + try { + await fetchCSRFToken(); + } catch (csrfError) { + console.error("Failed to fetch CSRF token:", csrfError); + } + } finally { + setLoading(false); + } + }; + + initializeAuth(); }, []); const login = async (email: string, password: string) => { const response = await authAPI.login({ email, password }); - localStorage.setItem("token", response.data.token); setUser(response.data.user); + // Fetch new CSRF token after login + await fetchCSRFToken(); }; const register = async (data: any) => { const response = await authAPI.register(data); - localStorage.setItem("token", response.data.token); setUser(response.data.user); + // Fetch new CSRF token after registration + await fetchCSRFToken(); }; const googleLogin = async (idToken: string) => { const response = await authAPI.googleLogin({ idToken }); - localStorage.setItem("token", response.data.token); setUser(response.data.user); + // Fetch new CSRF token after Google login + await fetchCSRFToken(); }; - const logout = () => { - localStorage.removeItem("token"); - setUser(null); + const logout = async () => { + try { + await authAPI.logout(); + setUser(null); + // Reset CSRF token on logout + resetCSRFToken(); + // Redirect to home page after logout + window.location.href = "/"; + } catch (error) { + console.error("Logout failed:", error); + // Even if logout fails, clear local state and CSRF token + setUser(null); + resetCSRFToken(); + } }; const updateUser = (user: User) => { @@ -84,7 +125,16 @@ export const AuthProvider: React.FC = ({ children }) => { return ( {children} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 19fd059..93a3dda 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1,37 +1,160 @@ -import axios from "axios"; +import axios, { AxiosError, AxiosRequestConfig } from "axios"; const API_BASE_URL = process.env.REACT_APP_API_URL; +// CSRF token management +let csrfToken: string | null = null; + +// Token refresh state +let isRefreshing = false; +let failedQueue: Array<{ + resolve: (value?: any) => void; + reject: (reason?: any) => void; + config: AxiosRequestConfig; +}> = []; + +const processQueue = (error: AxiosError | null, token: string | null = null) => { + failedQueue.forEach((prom) => { + if (error) { + prom.reject(error); + } else { + prom.resolve(prom.config); + } + }); + failedQueue = []; +}; + const api = axios.create({ baseURL: API_BASE_URL, headers: { "Content-Type": "application/json", }, + withCredentials: true, // Enable cookies }); -api.interceptors.request.use((config) => { - const token = localStorage.getItem("token"); - if (token) { - config.headers.Authorization = `Bearer ${token}`; +// Fetch CSRF token +export const fetchCSRFToken = async (): Promise => { + try { + const response = await api.get("/auth/csrf-token"); + csrfToken = response.data.csrfToken || ""; + return csrfToken || ""; + } catch (error) { + console.error("Failed to fetch CSRF token:", error); + return ""; + } +}; + +// Reset CSRF token (call on logout) +export const resetCSRFToken = () => { + csrfToken = null; +}; + +// Check if authentication cookie exists +export const hasAuthCookie = (): boolean => { + return document.cookie + .split('; ') + .some(cookie => cookie.startsWith('accessToken=')); +}; + +// Check if user has any auth indicators +export const hasAuthIndicators = (): boolean => { + return hasAuthCookie() || !!localStorage.getItem('token'); +}; + +api.interceptors.request.use(async (config) => { + // Add CSRF token to headers for state-changing requests + const method = config.method?.toUpperCase() || ""; + if (["POST", "PUT", "DELETE", "PATCH"].includes(method)) { + // If we don't have a CSRF token yet, try to fetch it + if (!csrfToken) { + // Skip fetching for auth endpoints to avoid circular dependency + const isAuthEndpoint = config.url?.includes("/auth/") && !config.url?.includes("/auth/refresh"); + if (!isAuthEndpoint) { + await fetchCSRFToken(); + } + } + + // Add the token if we have it + if (csrfToken) { + config.headers["X-CSRF-Token"] = csrfToken; + } } return config; }); api.interceptors.response.use( (response) => response, - (error) => { - if (error.response?.status === 401) { - // Only redirect to login if we have a token (user was logged in) - const token = localStorage.getItem("token"); + async (error: AxiosError) => { + const originalRequest = error.config as AxiosRequestConfig & { _retry?: boolean, _csrfRetry?: boolean }; - if (token) { - // User was logged in but token expired/invalid - localStorage.removeItem("token"); + // Handle CSRF token errors + if (error.response?.status === 403) { + const errorData = error.response?.data as any; + if (errorData?.code === "CSRF_TOKEN_MISMATCH" && !originalRequest._csrfRetry) { + originalRequest._csrfRetry = true; + + // Try to fetch a new CSRF token and retry + try { + await fetchCSRFToken(); + // Retry the original request with new token + return api(originalRequest); + } catch (csrfError) { + console.error("Failed to refresh CSRF token:", csrfError); + } + } + } + + // Handle token expiration and authentication errors + if (error.response?.status === 401) { + const errorData = error.response?.data as any; + + // Don't redirect for NO_TOKEN on public endpoints + if (errorData?.code === "NO_TOKEN") { + // Let the app handle this - user simply isn't logged in + return Promise.reject(error); + } + + // If token is expired, try to refresh + if (errorData?.code === "TOKEN_EXPIRED" && !originalRequest._retry) { + if (isRefreshing) { + // If already refreshing, queue the request + return new Promise((resolve, reject) => { + failedQueue.push({ resolve, reject, config: originalRequest }); + }); + } + + originalRequest._retry = true; + isRefreshing = true; + + try { + // Try to refresh the token + await api.post("/auth/refresh"); + isRefreshing = false; + processQueue(null); + + // Also refresh CSRF token after auth refresh + await fetchCSRFToken(); + + // Retry the original request + return api(originalRequest); + } catch (refreshError) { + isRefreshing = false; + processQueue(refreshError as AxiosError); + + // Refresh failed, redirect to login + window.location.href = "/login"; + return Promise.reject(refreshError); + } + } + + // For other 401 errors, check if we should redirect + // Only redirect if this is not a login/register request + const isAuthEndpoint = originalRequest.url?.includes("/auth/"); + if (!isAuthEndpoint && errorData?.error !== "Access token required") { window.location.href = "/login"; } - // For non-authenticated users, just reject the error without redirecting - // Let individual components handle 401 errors as needed } + return Promise.reject(error); } ); @@ -40,6 +163,9 @@ export const authAPI = { register: (data: any) => api.post("/auth/register", data), login: (data: any) => api.post("/auth/login", data), googleLogin: (data: any) => api.post("/auth/google", data), + logout: () => api.post("/auth/logout"), + refresh: () => api.post("/auth/refresh"), + getCSRFToken: () => api.get("/auth/csrf-token"), }; export const userAPI = {