more secure token handling

This commit is contained in:
jackiettran
2025-09-17 18:37:07 -04:00
parent a9fa579b6d
commit cf6dd9be90
10 changed files with 807 additions and 231 deletions

View File

@@ -2,11 +2,14 @@ const jwt = require("jsonwebtoken");
const { User } = require("../models"); // Import from models/index.js to get models with associations const { User } = require("../models"); // Import from models/index.js to get models with associations
const authenticateToken = async (req, res, next) => { const authenticateToken = async (req, res, next) => {
const authHeader = req.headers["authorization"]; // First try to get token from cookie
const token = authHeader && authHeader.split(" ")[1]; let token = req.cookies?.accessToken;
if (!token) { if (!token) {
return res.status(401).json({ error: "Access token required" }); return res.status(401).json({
error: "Access token required",
code: "NO_TOKEN",
});
} }
try { try {
@@ -14,20 +17,37 @@ const authenticateToken = async (req, res, next) => {
const userId = decoded.id; const userId = decoded.id;
if (!userId) { 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); const user = await User.findByPk(userId);
if (!user) { 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; req.user = user;
next(); next();
} catch (error) { } 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); 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",
});
} }
}; };

View File

@@ -104,12 +104,70 @@ const burstProtection = createUserBasedRateLimiter(
"Too many requests in a short period. Please slow down." "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 = { module.exports = {
// Individual rate limiters // Individual rate limiters
placesAutocomplete: rateLimiters.placesAutocomplete, placesAutocomplete: rateLimiters.placesAutocomplete,
placeDetails: rateLimiters.placeDetails, placeDetails: rateLimiters.placeDetails,
geocoding: rateLimiters.geocoding, geocoding: rateLimiters.geocoding,
// Auth rate limiters
loginLimiter: authRateLimiters.login,
registerLimiter: authRateLimiters.register,
passwordResetLimiter: authRateLimiters.passwordReset,
generalLimiter: authRateLimiters.general,
// Burst protection // Burst protection
burstProtection, burstProtection,

View File

@@ -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,
};

View File

@@ -40,7 +40,7 @@ const User = sequelize.define(
allowNull: true, allowNull: true,
}, },
authProvider: { authProvider: {
type: DataTypes.ENUM("local", "google", "apple"), type: DataTypes.ENUM("local", "google"),
defaultValue: "local", defaultValue: "local",
}, },
providerId: { providerId: {
@@ -141,35 +141,35 @@ const MAX_LOGIN_ATTEMPTS = 5;
const LOCK_TIME = 2 * 60 * 60 * 1000; // 2 hours const LOCK_TIME = 2 * 60 * 60 * 1000; // 2 hours
// Check if account is locked // Check if account is locked
User.prototype.isLocked = function() { User.prototype.isLocked = function () {
return !!(this.lockUntil && this.lockUntil > Date.now()); return !!(this.lockUntil && this.lockUntil > Date.now());
}; };
// Increment login attempts and lock account if necessary // 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 we have a previous lock that has expired, restart at 1
if (this.lockUntil && this.lockUntil < Date.now()) { if (this.lockUntil && this.lockUntil < Date.now()) {
return this.update({ return this.update({
loginAttempts: 1, loginAttempts: 1,
lockUntil: null lockUntil: null,
}); });
} }
const updates = { loginAttempts: this.loginAttempts + 1 }; const updates = { loginAttempts: this.loginAttempts + 1 };
// Lock account after max attempts // Lock account after max attempts
if (this.loginAttempts + 1 >= MAX_LOGIN_ATTEMPTS && !this.isLocked()) { if (this.loginAttempts + 1 >= MAX_LOGIN_ATTEMPTS && !this.isLocked()) {
updates.lockUntil = Date.now() + LOCK_TIME; updates.lockUntil = Date.now() + LOCK_TIME;
} }
return this.update(updates); return this.update(updates);
}; };
// Reset login attempts after successful login // Reset login attempts after successful login
User.prototype.resetLoginAttempts = async function() { User.prototype.resetLoginAttempts = async function () {
return this.update({ return this.update({
loginAttempts: 0, loginAttempts: 0,
lockUntil: null lockUntil: null,
}); });
}; };

View File

@@ -2,170 +2,343 @@ const express = require("express");
const jwt = require("jsonwebtoken"); const jwt = require("jsonwebtoken");
const { OAuth2Client } = require("google-auth-library"); const { OAuth2Client } = require("google-auth-library");
const { User } = require("../models"); // Import from models/index.js to get models with associations const { User } = require("../models"); // Import from models/index.js to get models with associations
const { const {
sanitizeInput, sanitizeInput,
validateRegistration, validateRegistration,
validateLogin, validateLogin,
validateGoogleAuth validateGoogleAuth,
} = require("../middleware/validation"); } = require("../middleware/validation");
const { csrfProtection, getCSRFToken } = require("../middleware/csrf");
const { loginLimiter, registerLimiter } = require("../middleware/rateLimiter");
const router = express.Router(); const router = express.Router();
const googleClient = new OAuth2Client(process.env.GOOGLE_CLIENT_ID); const googleClient = new OAuth2Client(process.env.GOOGLE_CLIENT_ID);
router.post("/register", sanitizeInput, validateRegistration, async (req, res) => { // Get CSRF token endpoint
try { router.get("/csrf-token", (req, res) => {
const { username, email, password, firstName, lastName, phone } = req.body; getCSRFToken(req, res);
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." });
}
}); });
router.post("/login", sanitizeInput, validateLogin, async (req, res) => { router.post(
try { "/register",
const { email, password } = req.body; registerLimiter,
csrfProtection,
sanitizeInput,
validateRegistration,
async (req, res) => {
try {
const { username, email, password, firstName, lastName, phone } =
req.body;
const user = await User.findOne({ where: { email } }); const existingUser = await User.findOne({
where: {
if (!user) { [require("sequelize").Op.or]: [{ email }, { username }],
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: "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) { if (existingUser) {
return res.status(409).json({ return res.status(400).json({
error: error: "Registration failed",
"An account with this email already exists. Please use email/password login.", details: [
{
field: "email",
message: "An account with this email already exists",
},
],
}); });
} }
// Create new user const user = await User.create({
user = await User.create({ username,
email, email,
password,
firstName, firstName,
lastName, lastName,
authProvider: "google", phone,
providerId: googleId,
profileImage: picture,
username: email.split("@")[0] + "_" + googleId.slice(-6), // Generate unique username
}); });
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 // Verify refresh token
const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET, { const decoded = jwt.verify(refreshToken, process.env.JWT_SECRET);
expiresIn: "7d",
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({ res.json({
@@ -175,29 +348,20 @@ router.post("/google", sanitizeInput, validateGoogleAuth, async (req, res) => {
email: user.email, email: user.email,
firstName: user.firstName, firstName: user.firstName,
lastName: user.lastName, lastName: user.lastName,
profileImage: user.profileImage,
}, },
token,
}); });
} catch (error) { } catch (error) {
if (error.message && error.message.includes("Token used too late")) { console.error("Token refresh error:", error);
return res res.status(401).json({ error: "Invalid or expired refresh token" });
.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." });
} }
}); });
// Logout endpoint
router.post("/logout", (req, res) => {
// Clear cookies
res.clearCookie("accessToken");
res.clearCookie("refreshToken");
res.json({ message: "Logged out successfully" });
});
module.exports = router; module.exports = router;

View File

@@ -27,7 +27,21 @@ const PayoutProcessor = require("./jobs/payoutProcessor");
const app = express(); 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( app.use(
helmet({ helmet({
contentSecurityPolicy: { contentSecurityPolicy: {
@@ -38,7 +52,7 @@ app.use(
scriptSrc: ["'self'", "https://accounts.google.com"], scriptSrc: ["'self'", "https://accounts.google.com"],
imgSrc: ["'self'"], imgSrc: ["'self'"],
connectSrc: ["'self'"], connectSrc: ["'self'"],
frameSrc: ["'self'"], frameSrc: ["'self'", "https://accounts.google.com"],
}, },
}, },
}) })
@@ -47,6 +61,9 @@ app.use(
// Cookie parser for CSRF // Cookie parser for CSRF
app.use(cookieParser); app.use(cookieParser);
// General rate limiting for all routes
app.use("/api/", generalLimiter);
// CORS with security settings // CORS with security settings
app.use( app.use(
cors({ cors({
@@ -93,6 +110,9 @@ app.get("/", (req, res) => {
res.json({ message: "CommunityRentals.App API is running!" }); res.json({ message: "CommunityRentals.App API is running!" });
}); });
// Error handling middleware (must be last)
app.use(sanitizeError);
const PORT = process.env.PORT || 5000; const PORT = process.env.PORT || 5000;
sequelize sequelize

View File

@@ -9,11 +9,16 @@
name="description" name="description"
content="CommunityRentals.App - Rent gym equipment, tools, and musical instruments from your neighbors" content="CommunityRentals.App - Rent gym equipment, tools, and musical instruments from your neighbors"
/> />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" /> <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>CommunityRentals.App - Equipment & Tool Rental Marketplace</title> <title>CommunityRentals.App - Equipment & Tool Rental Marketplace</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css"> href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
rel="stylesheet"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css"
/>
<script src="https://accounts.google.com/gsi/client" async defer></script> <script src="https://accounts.google.com/gsi/client" async defer></script>
</head> </head>
<body> <body>

View File

@@ -233,15 +233,6 @@ const AuthModal: React.FC<AuthModalProps> = ({
Sign in with Google Sign in with Google
</button> </button>
<button
className="btn btn-outline-dark w-100 mb-3 py-3 d-flex align-items-center justify-content-center"
onClick={() => handleSocialLogin("apple")}
disabled={loading}
>
<i className="bi bi-apple me-2"></i>
Sign in with Apple
</button>
<div className="text-center mt-3"> <div className="text-center mt-3">
<small className="text-muted"> <small className="text-muted">
{mode === "login" {mode === "login"

View File

@@ -6,7 +6,7 @@ import React, {
ReactNode, ReactNode,
} from "react"; } from "react";
import { User } from "../types"; import { User } from "../types";
import { authAPI, userAPI } from "../services/api"; import { authAPI, userAPI, fetchCSRFToken, resetCSRFToken, hasAuthIndicators } from "../services/api";
interface AuthContextType { interface AuthContextType {
user: User | null; user: User | null;
@@ -14,8 +14,9 @@ interface AuthContextType {
login: (email: string, password: string) => Promise<void>; login: (email: string, password: string) => Promise<void>;
register: (data: any) => Promise<void>; register: (data: any) => Promise<void>;
googleLogin: (idToken: string) => Promise<void>; googleLogin: (idToken: string) => Promise<void>;
logout: () => void; logout: () => Promise<void>;
updateUser: (user: User) => void; updateUser: (user: User) => void;
checkAuth: () => Promise<void>;
} }
const AuthContext = createContext<AuthContextType | undefined>(undefined); const AuthContext = createContext<AuthContextType | undefined>(undefined);
@@ -36,46 +37,86 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { const checkAuth = async () => {
const token = localStorage.getItem("token"); try {
if (token) { const response = await userAPI.getProfile();
userAPI setUser(response.data);
.getProfile() } catch (error: any) {
.then((response) => { // Only log actual errors, not "user not logged in"
setUser(response.data); if (error.response?.data?.code !== "NO_TOKEN") {
}) console.error("Auth check failed:", error);
.catch((error) => { }
localStorage.removeItem("token"); setUser(null);
})
.finally(() => {
setLoading(false);
});
} else {
setLoading(false);
} }
};
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 login = async (email: string, password: string) => {
const response = await authAPI.login({ email, password }); const response = await authAPI.login({ email, password });
localStorage.setItem("token", response.data.token);
setUser(response.data.user); setUser(response.data.user);
// Fetch new CSRF token after login
await fetchCSRFToken();
}; };
const register = async (data: any) => { const register = async (data: any) => {
const response = await authAPI.register(data); const response = await authAPI.register(data);
localStorage.setItem("token", response.data.token);
setUser(response.data.user); setUser(response.data.user);
// Fetch new CSRF token after registration
await fetchCSRFToken();
}; };
const googleLogin = async (idToken: string) => { const googleLogin = async (idToken: string) => {
const response = await authAPI.googleLogin({ idToken }); const response = await authAPI.googleLogin({ idToken });
localStorage.setItem("token", response.data.token);
setUser(response.data.user); setUser(response.data.user);
// Fetch new CSRF token after Google login
await fetchCSRFToken();
}; };
const logout = () => { const logout = async () => {
localStorage.removeItem("token"); try {
setUser(null); 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) => { const updateUser = (user: User) => {
@@ -84,7 +125,16 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
return ( return (
<AuthContext.Provider <AuthContext.Provider
value={{ user, loading, login, register, googleLogin, logout, updateUser }} value={{
user,
loading,
login,
register,
googleLogin,
logout,
updateUser,
checkAuth,
}}
> >
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>

View File

@@ -1,37 +1,160 @@
import axios from "axios"; import axios, { AxiosError, AxiosRequestConfig } from "axios";
const API_BASE_URL = process.env.REACT_APP_API_URL; 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({ const api = axios.create({
baseURL: API_BASE_URL, baseURL: API_BASE_URL,
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
withCredentials: true, // Enable cookies
}); });
api.interceptors.request.use((config) => { // Fetch CSRF token
const token = localStorage.getItem("token"); export const fetchCSRFToken = async (): Promise<string> => {
if (token) { try {
config.headers.Authorization = `Bearer ${token}`; 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; return config;
}); });
api.interceptors.response.use( api.interceptors.response.use(
(response) => response, (response) => response,
(error) => { async (error: AxiosError) => {
if (error.response?.status === 401) { const originalRequest = error.config as AxiosRequestConfig & { _retry?: boolean, _csrfRetry?: boolean };
// Only redirect to login if we have a token (user was logged in)
const token = localStorage.getItem("token");
if (token) { // Handle CSRF token errors
// User was logged in but token expired/invalid if (error.response?.status === 403) {
localStorage.removeItem("token"); 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"; 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); return Promise.reject(error);
} }
); );
@@ -40,6 +163,9 @@ export const authAPI = {
register: (data: any) => api.post("/auth/register", data), register: (data: any) => api.post("/auth/register", data),
login: (data: any) => api.post("/auth/login", data), login: (data: any) => api.post("/auth/login", data),
googleLogin: (data: any) => api.post("/auth/google", 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 = { export const userAPI = {