Skip to content

Commit afeec41

Browse files
committed
Harden authentication: rate limiting, session fixation, CORS, cookie flags
- Add express-rate-limit (10 req/15 min/IP) on /api/auth/login and /api/auth/signup - Regenerate session ID after successful login to prevent session fixation - Strip password hash from deserializeUser and login response - Unify auth error messages to prevent user enumeration - Replace wildcard CORS with explicit ALLOWED_ORIGINS allowlist - Add httpOnly, Secure, SameSite, maxAge flags to session cookie - Strip internal error details from 500 responses - Set NODE_ENV=production in production Dockerfile - Document ALLOWED_ORIGINS and NODE_ENV in .env.sample Closes #372, #373, #374, #375
1 parent 8d17610 commit afeec41

6 files changed

Lines changed: 77 additions & 20 deletions

File tree

backend/.env.sample

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
PORT=5000
22
MONGO_URI=mongodb://localhost:27017/githubTracker
3-
SESSION_SECRET=your-secret-key
3+
SESSION_SECRET=replace-with-a-long-random-string
4+
NODE_ENV=development
5+
# Comma-separated list of allowed frontend origins
6+
ALLOWED_ORIGINS=http://localhost:5173

backend/Dockerfile.prod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ RUN npm install --production
1313
# Copy the rest of the application files
1414
COPY . .
1515

16+
# Set production environment so session cookies are Secure + SameSite=Strict
17+
ENV NODE_ENV=production
18+
1619
# Expose the port for the application
1720
EXPOSE 5000
1821

backend/config/passportConfig.js

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,21 @@ passport.use(
77
{ usernameField: "email" },
88
async (email, password, done) => {
99
try {
10-
const user = await User.findOne( {email} );
10+
const user = await User.findOne({ email });
1111
if (!user) {
12-
return done(null, false, { message: 'Email is invalid '});
12+
// Use a generic message to prevent user enumeration
13+
return done(null, false, { message: 'Invalid credentials' });
1314
}
1415

1516
const isMatch = await user.comparePassword(password);
1617
if (!isMatch) {
17-
return done(null, false, { message: 'Invalid password' });
18+
return done(null, false, { message: 'Invalid credentials' });
1819
}
1920

2021
return done(null, {
21-
id : user._id.toString(),
22+
id: user._id.toString(),
2223
username: user.username,
23-
email: user.email
24+
email: user.email,
2425
});
2526
} catch (err) {
2627
return done(err);
@@ -29,15 +30,15 @@ passport.use(
2930
)
3031
);
3132

32-
// Serialize user (store user info in session)
33+
// Serialize user store only the user id in the session
3334
passport.serializeUser((user, done) => {
3435
done(null, user.id);
3536
});
3637

37-
// Deserialize user (retrieve user from session)
38+
// Deserialize user — never load the password hash into req.user
3839
passport.deserializeUser(async (id, done) => {
3940
try {
40-
const user = await User.findById(id);
41+
const user = await User.findById(id).select('-password');
4142
done(null, user);
4243
} catch (err) {
4344
done(err, null);

backend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
"dev": "nodemon server.js",
77
"start": "node server.js",
88
"test": "jasmine spec/**/*.spec.cjs"
9-
109
},
1110
"keywords": [],
1211
"author": "",
@@ -18,6 +17,7 @@
1817
"cors": "^2.8.5",
1918
"dotenv": "^16.4.5",
2019
"express": "^4.21.1",
20+
"express-rate-limit": "^7.5.1",
2121
"express-session": "^1.18.1",
2222
"mongoose": "^8.8.2",
2323
"passport": "^0.7.0",

backend/routes/auth.js

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ const router = express.Router();
88
// Signup route
99
router.post("/signup", validateRequest(signupSchema), async (req, res) => {
1010

11-
const { username, email, password } = req.body;
11+
const { username, email, password } = req.body;
1212

1313
try {
1414
const existingUser = await User.findOne({
@@ -25,23 +25,37 @@ router.post("/signup", validateRequest(signupSchema), async (req, res) => {
2525
if (err && err.code === 11000) {
2626
return res.status(400).json({ message: 'User already exists' });
2727
}
28-
29-
res.status(500).json({ message: 'Error creating user', error: err.message });
28+
res.status(500).json({ message: 'Error creating user' });
3029
}
3130
});
3231

33-
// Login route
34-
router.post("/login", validateRequest(loginSchema), passport.authenticate('local'), (req, res) => {
35-
res.status(200).json( { message: 'Login successful', user: req.user } );
32+
// Login route — session is regenerated after successful authentication
33+
// to prevent session fixation; only safe fields returned in the response
34+
router.post("/login", validateRequest(loginSchema), (req, res, next) => {
35+
passport.authenticate('local', (err, user, info) => {
36+
if (err) return next(err);
37+
if (!user) return res.status(401).json({ message: info?.message || 'Invalid credentials' });
38+
39+
req.session.regenerate((regenerateErr) => {
40+
if (regenerateErr) return next(regenerateErr);
41+
42+
req.logIn(user, (loginErr) => {
43+
if (loginErr) return next(loginErr);
44+
res.status(200).json({
45+
message: 'Login successful',
46+
user: { id: user.id, username: user.username, email: user.email },
47+
});
48+
});
49+
});
50+
})(req, res, next);
3651
});
3752

3853
// Logout route
3954
router.get("/logout", (req, res) => {
4055

4156
req.logout((err) => {
42-
4357
if (err)
44-
return res.status(500).json({ message: 'Logout failed', error: err.message });
58+
return res.status(500).json({ message: 'Logout failed' });
4559
else
4660
res.status(200).json({ message: 'Logged out successfully' });
4761
});

backend/server.js

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ const mongoose = require('mongoose');
33
const session = require('express-session');
44
const passport = require('passport');
55
const bodyParser = require('body-parser');
6+
const rateLimit = require('express-rate-limit');
67
require('dotenv').config();
78
const cors = require('cors');
89

@@ -11,15 +12,50 @@ require('./config/passportConfig');
1112

1213
const app = express();
1314

14-
// CORS configuration
15-
app.use(cors('*'));
15+
// CORS — restrict to known frontend origins only
16+
const allowedOrigins = (process.env.ALLOWED_ORIGINS || 'http://localhost:5173')
17+
.split(',')
18+
.map(o => o.trim());
19+
20+
app.use(cors({
21+
origin: (origin, callback) => {
22+
// Allow server-to-server requests (no Origin header) and explicit allowlist
23+
if (!origin || allowedOrigins.includes(origin)) {
24+
callback(null, true);
25+
} else {
26+
callback(new Error('Not allowed by CORS'));
27+
}
28+
},
29+
credentials: true,
30+
methods: ['GET', 'POST'],
31+
allowedHeaders: ['Content-Type'],
32+
}));
33+
34+
// Rate limiting on auth endpoints — 10 attempts per 15-minute window per IP
35+
const authLimiter = rateLimit({
36+
windowMs: 15 * 60 * 1000,
37+
max: 10,
38+
standardHeaders: true,
39+
legacyHeaders: false,
40+
message: { message: 'Too many attempts, please try again after 15 minutes.' },
41+
skipSuccessfulRequests: true,
42+
});
43+
44+
app.use('/api/auth/login', authLimiter);
45+
app.use('/api/auth/signup', authLimiter);
1646

1747
// Middleware
1848
app.use(bodyParser.json());
1949
app.use(session({
2050
secret: process.env.SESSION_SECRET,
2151
resave: false,
2252
saveUninitialized: false,
53+
cookie: {
54+
httpOnly: true,
55+
secure: process.env.NODE_ENV === 'production',
56+
sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax',
57+
maxAge: 24 * 60 * 60 * 1000,
58+
},
2359
}));
2460
app.use(passport.initialize());
2561
app.use(passport.session());

0 commit comments

Comments
 (0)