diff --git a/backend/.env.sample b/backend/.env.sample index 98f96881..9d229d18 100644 --- a/backend/.env.sample +++ b/backend/.env.sample @@ -1,3 +1,6 @@ PORT=5000 MONGO_URI=mongodb://localhost:27017/githubTracker -SESSION_SECRET=your-secret-key +SESSION_SECRET=replace-with-a-long-random-string +NODE_ENV=development +# Comma-separated list of allowed frontend origins +ALLOWED_ORIGINS=http://localhost:5173 diff --git a/backend/Dockerfile.prod b/backend/Dockerfile.prod index 9f35a107..241f0f6a 100644 --- a/backend/Dockerfile.prod +++ b/backend/Dockerfile.prod @@ -13,6 +13,9 @@ RUN npm install --production # Copy the rest of the application files COPY . . +# Set production environment so session cookies are Secure + SameSite=Strict +ENV NODE_ENV=production + # Expose the port for the application EXPOSE 5000 diff --git a/backend/config/passportConfig.js b/backend/config/passportConfig.js index 842f50ca..82a30e5b 100644 --- a/backend/config/passportConfig.js +++ b/backend/config/passportConfig.js @@ -7,20 +7,21 @@ passport.use( { usernameField: "email" }, async (email, password, done) => { try { - const user = await User.findOne( {email} ); + const user = await User.findOne({ email }); if (!user) { - return done(null, false, { message: 'Email is invalid '}); + // Use a generic message to prevent user enumeration + return done(null, false, { message: 'Invalid credentials' }); } const isMatch = await user.comparePassword(password); if (!isMatch) { - return done(null, false, { message: 'Invalid password' }); + return done(null, false, { message: 'Invalid credentials' }); } return done(null, { - id : user._id.toString(), + id: user._id.toString(), username: user.username, - email: user.email + email: user.email, }); } catch (err) { return done(err); @@ -29,15 +30,15 @@ passport.use( ) ); -// Serialize user (store user info in session) +// Serialize user — store only the user id in the session passport.serializeUser((user, done) => { done(null, user.id); }); -// Deserialize user (retrieve user from session) +// Deserialize user — never load the password hash into req.user passport.deserializeUser(async (id, done) => { try { - const user = await User.findById(id); + const user = await User.findById(id).select('-password'); done(null, user); } catch (err) { done(err, null); diff --git a/backend/package.json b/backend/package.json index 38e15b8b..ebe423da 100644 --- a/backend/package.json +++ b/backend/package.json @@ -6,7 +6,6 @@ "dev": "nodemon server.js", "start": "node server.js", "test": "jasmine spec/**/*.spec.cjs" - }, "keywords": [], "author": "", @@ -18,6 +17,7 @@ "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.21.1", + "express-rate-limit": "^7.5.1", "express-session": "^1.18.1", "mongoose": "^8.8.2", "passport": "^0.7.0", diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 7c2cda78..1b2211f9 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -8,7 +8,7 @@ const router = express.Router(); // Signup route router.post("/signup", validateRequest(signupSchema), async (req, res) => { - const { username, email, password } = req.body; + const { username, email, password } = req.body; try { const existingUser = await User.findOne({ @@ -25,23 +25,37 @@ router.post("/signup", validateRequest(signupSchema), async (req, res) => { if (err && err.code === 11000) { return res.status(400).json({ message: 'User already exists' }); } - - res.status(500).json({ message: 'Error creating user', error: err.message }); + res.status(500).json({ message: 'Error creating user' }); } }); -// Login route -router.post("/login", validateRequest(loginSchema), passport.authenticate('local'), (req, res) => { - res.status(200).json( { message: 'Login successful', user: req.user } ); +// Login route — session is regenerated after successful authentication +// to prevent session fixation; only safe fields returned in the response +router.post("/login", validateRequest(loginSchema), (req, res, next) => { + passport.authenticate('local', (err, user, info) => { + if (err) return next(err); + if (!user) return res.status(401).json({ message: info?.message || 'Invalid credentials' }); + + req.session.regenerate((regenerateErr) => { + if (regenerateErr) return next(regenerateErr); + + req.logIn(user, (loginErr) => { + if (loginErr) return next(loginErr); + res.status(200).json({ + message: 'Login successful', + user: { id: user.id, username: user.username, email: user.email }, + }); + }); + }); + })(req, res, next); }); // Logout route router.get("/logout", (req, res) => { req.logout((err) => { - if (err) - return res.status(500).json({ message: 'Logout failed', error: err.message }); + return res.status(500).json({ message: 'Logout failed' }); else res.status(200).json({ message: 'Logged out successfully' }); }); diff --git a/backend/server.js b/backend/server.js index 3f19f00b..ba881d54 100644 --- a/backend/server.js +++ b/backend/server.js @@ -3,6 +3,7 @@ const mongoose = require('mongoose'); const session = require('express-session'); const passport = require('passport'); const bodyParser = require('body-parser'); +const rateLimit = require('express-rate-limit'); require('dotenv').config(); const cors = require('cors'); @@ -11,8 +12,37 @@ require('./config/passportConfig'); const app = express(); -// CORS configuration -app.use(cors('*')); +// CORS — restrict to known frontend origins only +const allowedOrigins = (process.env.ALLOWED_ORIGINS || 'http://localhost:5173') + .split(',') + .map(o => o.trim()); + +app.use(cors({ + origin: (origin, callback) => { + // Allow server-to-server requests (no Origin header) and explicit allowlist + if (!origin || allowedOrigins.includes(origin)) { + callback(null, true); + } else { + callback(new Error('Not allowed by CORS')); + } + }, + credentials: true, + methods: ['GET', 'POST'], + allowedHeaders: ['Content-Type'], +})); + +// Rate limiting on auth endpoints — 10 attempts per 15-minute window per IP +const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 10, + standardHeaders: true, + legacyHeaders: false, + message: { message: 'Too many attempts, please try again after 15 minutes.' }, + skipSuccessfulRequests: true, +}); + +app.use('/api/auth/login', authLimiter); +app.use('/api/auth/signup', authLimiter); // Middleware app.use(bodyParser.json()); @@ -20,6 +50,12 @@ app.use(session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false, + cookie: { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax', + maxAge: 24 * 60 * 60 * 1000, + }, })); app.use(passport.initialize()); app.use(passport.session());