feat(security): implement comprehensive security hardening

- Add CSRF protection with cookie-based tokens
  - Add cookie-parser and csurf middleware
  - Create GET /api/csrf-token endpoint
  - Frontend automatically includes CSRF token in POST/PUT/DELETE requests
  - Add retry logic for expired CSRF tokens

- Implement account lockout mechanism
  - Add database fields: failedLoginAttempts, lockedUntil
  - Track failed login attempts and lock accounts after max attempts (configurable)
  - Auto-unlock after lockout duration expires
  - Return helpful error messages with remaining time

- Add comprehensive security environment variables
  - Rate limiting configuration (API, auth, email endpoints)
  - CSRF protection toggle
  - Password policy requirements
  - Account lockout settings
  - Logging levels

- Add comprehensive test coverage
  - 6 new tests for account lockout functionality
  - 11 new tests for CSRF protection
  - All tests handle enabled/disabled states gracefully

- Update documentation
  - Add Phase 3 security hardening to SESSION_CONTEXT.md
  - Document new database fields and migration
  - Update progress to 85%

Files changed:
- Backend: app.js, auth controller, security config, new migration
- Frontend: api.js with CSRF token handling
- Tests: auth.test.js (extended), csrf.test.js (new)
- Config: .env examples with security variables
- Docs: SESSION_CONTEXT.md updated
This commit is contained in:
Radosław Gierwiało
2025-11-19 20:16:05 +01:00
parent cbc970f60b
commit 44df50362a
10 changed files with 687 additions and 12 deletions

View File

@@ -241,4 +241,215 @@ describe('Authentication API Tests', () => {
expect(response.body).toHaveProperty('error', 'Unauthorized');
});
});
describe('Account Lockout Tests (Phase 3 - Security Hardening)', () => {
const lockoutTestUser = {
username: 'lockouttest',
email: 'lockout@example.com',
password: 'TestPassword123!',
};
beforeAll(async () => {
// Create a test user for lockout tests
const passwordHash = await hashPassword(lockoutTestUser.password);
await prisma.user.create({
data: {
username: lockoutTestUser.username,
email: lockoutTestUser.email,
passwordHash,
emailVerified: true,
avatar: 'https://ui-avatars.com/api/?name=lockouttest',
},
});
});
afterAll(async () => {
// Clean up lockout test user
await prisma.user.deleteMany({
where: { email: lockoutTestUser.email },
});
});
beforeEach(async () => {
// Reset failed attempts before each test
await prisma.user.update({
where: { email: lockoutTestUser.email },
data: {
failedLoginAttempts: 0,
lockedUntil: null,
},
});
});
it('should increment failed login attempts on wrong password', async () => {
// Skip if account lockout is disabled
const securityConfig = require('../config/security');
if (!securityConfig.accountLockout.enabled) {
console.log('Skipping test: Account lockout is disabled');
return;
}
const response = await request(app)
.post('/api/auth/login')
.send({
email: lockoutTestUser.email,
password: 'wrongpassword',
})
.expect(401);
expect(response.body).toHaveProperty('success', false);
expect(response.body).toHaveProperty('error', 'Invalid credentials');
expect(response.body).toHaveProperty('attemptsLeft');
// Check database
const user = await prisma.user.findUnique({
where: { email: lockoutTestUser.email },
});
expect(user.failedLoginAttempts).toBe(1);
});
it('should lock account after max failed attempts', async () => {
const securityConfig = require('../config/security');
if (!securityConfig.accountLockout.enabled) {
console.log('Skipping test: Account lockout is disabled');
return;
}
const maxAttempts = securityConfig.accountLockout.maxAttempts;
// Make max-1 failed attempts
for (let i = 0; i < maxAttempts - 1; i++) {
await request(app)
.post('/api/auth/login')
.send({
email: lockoutTestUser.email,
password: 'wrongpassword',
});
}
// Final attempt should lock the account
const response = await request(app)
.post('/api/auth/login')
.send({
email: lockoutTestUser.email,
password: 'wrongpassword',
})
.expect(423);
expect(response.body).toHaveProperty('success', false);
expect(response.body).toHaveProperty('error');
expect(response.body.error).toContain('locked');
// Check database
const user = await prisma.user.findUnique({
where: { email: lockoutTestUser.email },
});
expect(user.failedLoginAttempts).toBe(maxAttempts);
expect(user.lockedUntil).not.toBeNull();
expect(new Date(user.lockedUntil)).toBeInstanceOf(Date);
});
it('should reject login attempts while account is locked', async () => {
const securityConfig = require('../config/security');
if (!securityConfig.accountLockout.enabled) {
console.log('Skipping test: Account lockout is disabled');
return;
}
// Manually lock the account
const lockoutDuration = 15 * 60 * 1000; // 15 minutes
await prisma.user.update({
where: { email: lockoutTestUser.email },
data: {
lockedUntil: new Date(Date.now() + lockoutDuration),
failedLoginAttempts: 5,
},
});
// Try to login with correct password
const response = await request(app)
.post('/api/auth/login')
.send({
email: lockoutTestUser.email,
password: lockoutTestUser.password,
})
.expect(423);
expect(response.body).toHaveProperty('success', false);
expect(response.body).toHaveProperty('error');
expect(response.body.error).toContain('locked');
expect(response.body).toHaveProperty('lockedUntil');
});
it('should reset failed attempts on successful login', async () => {
const securityConfig = require('../config/security');
if (!securityConfig.accountLockout.enabled) {
console.log('Skipping test: Account lockout is disabled');
return;
}
// Set some failed attempts
await prisma.user.update({
where: { email: lockoutTestUser.email },
data: {
failedLoginAttempts: 3,
},
});
// Successful login
const response = await request(app)
.post('/api/auth/login')
.send({
email: lockoutTestUser.email,
password: lockoutTestUser.password,
})
.expect(200);
expect(response.body).toHaveProperty('success', true);
expect(response.body.data).toHaveProperty('token');
// Check database - failed attempts should be reset
const user = await prisma.user.findUnique({
where: { email: lockoutTestUser.email },
});
expect(user.failedLoginAttempts).toBe(0);
expect(user.lockedUntil).toBeNull();
});
it('should allow login after lockout period expires', async () => {
const securityConfig = require('../config/security');
if (!securityConfig.accountLockout.enabled) {
console.log('Skipping test: Account lockout is disabled');
return;
}
// Lock account with expired lockout time
await prisma.user.update({
where: { email: lockoutTestUser.email },
data: {
lockedUntil: new Date(Date.now() - 1000), // 1 second ago
failedLoginAttempts: 5,
},
});
// Should be able to login now
const response = await request(app)
.post('/api/auth/login')
.send({
email: lockoutTestUser.email,
password: lockoutTestUser.password,
})
.expect(200);
expect(response.body).toHaveProperty('success', true);
expect(response.body.data).toHaveProperty('token');
// Failed attempts should be reset
const user = await prisma.user.findUnique({
where: { email: lockoutTestUser.email },
});
expect(user.failedLoginAttempts).toBe(0);
expect(user.lockedUntil).toBeNull();
});
});
});

View File

@@ -0,0 +1,253 @@
const request = require('supertest');
const app = require('../app');
const { prisma } = require('../utils/db');
const { hashPassword } = require('../utils/auth');
const securityConfig = require('../config/security');
// Clean up database before and after tests
beforeAll(async () => {
await prisma.user.deleteMany({});
});
afterAll(async () => {
await prisma.user.deleteMany({});
await prisma.$disconnect();
});
describe('CSRF Protection Tests (Phase 3 - Security Hardening)', () => {
// Skip all tests if CSRF is disabled
if (!securityConfig.csrf.enabled) {
it('CSRF protection is disabled - skipping all tests', () => {
console.log('Skipping CSRF tests: CSRF protection is disabled in configuration');
expect(securityConfig.csrf.enabled).toBe(false);
});
return;
}
const testUser = {
username: 'csrftest',
email: 'csrf@example.com',
password: 'TestPassword123!',
};
let authToken;
let csrfToken;
beforeAll(async () => {
// Create test user
const passwordHash = await hashPassword(testUser.password);
await prisma.user.create({
data: {
username: testUser.username,
email: testUser.email,
passwordHash,
emailVerified: true,
avatar: 'https://ui-avatars.com/api/?name=csrftest',
},
});
// Login to get auth token
const loginResponse = await request(app)
.post('/api/auth/login')
.send({
email: testUser.email,
password: testUser.password,
});
authToken = loginResponse.body.data.token;
});
describe('GET /api/csrf-token', () => {
it('should return CSRF token', async () => {
const agent = request.agent(app);
const response = await agent
.get('/api/csrf-token')
.expect('Content-Type', /json/)
.expect(200);
expect(response.body).toHaveProperty('csrfToken');
expect(typeof response.body.csrfToken).toBe('string');
expect(response.body.csrfToken).not.toBe('');
csrfToken = response.body.csrfToken;
});
});
describe('POST requests with CSRF protection', () => {
it('should reject POST request without CSRF token', async () => {
const response = await request(app)
.post('/api/auth/resend-verification')
.send({
email: testUser.email,
})
.expect(403);
expect(response.body).toHaveProperty('success', false);
expect(response.body).toHaveProperty('error', 'Invalid CSRF token');
});
it('should accept POST request with valid CSRF token', async () => {
const agent = request.agent(app);
// First, get CSRF token
const csrfResponse = await agent.get('/api/csrf-token');
const token = csrfResponse.body.csrfToken;
// Then make POST request with CSRF token
const response = await agent
.post('/api/auth/resend-verification')
.set('X-CSRF-Token', token)
.send({
email: testUser.email,
})
.expect(200);
expect(response.body).toHaveProperty('success', true);
});
it('should reject POST request with invalid CSRF token', async () => {
const response = await request(app)
.post('/api/auth/resend-verification')
.set('X-CSRF-Token', 'invalid-token-12345')
.send({
email: testUser.email,
})
.expect(403);
expect(response.body).toHaveProperty('success', false);
expect(response.body).toHaveProperty('error', 'Invalid CSRF token');
});
});
describe('PUT requests with CSRF protection', () => {
it('should reject PUT request without CSRF token', async () => {
const response = await request(app)
.put('/api/users/me')
.set('Authorization', `Bearer ${authToken}`)
.send({
city: 'Test City',
})
.expect(403);
expect(response.body).toHaveProperty('success', false);
expect(response.body).toHaveProperty('error', 'Invalid CSRF token');
});
it('should accept PUT request with valid CSRF token', async () => {
const agent = request.agent(app);
// Get CSRF token
const csrfResponse = await agent.get('/api/csrf-token');
const token = csrfResponse.body.csrfToken;
// Make PUT request with CSRF token
const response = await agent
.put('/api/users/me')
.set('Authorization', `Bearer ${authToken}`)
.set('X-CSRF-Token', token)
.send({
city: 'Test City',
})
.expect(200);
expect(response.body).toHaveProperty('success', true);
});
});
describe('DELETE requests with CSRF protection', () => {
let eventSlug;
beforeAll(async () => {
// Create a test event
const event = await prisma.event.create({
data: {
name: 'CSRF Test Event',
location: 'Test Location',
startDate: new Date('2025-12-01'),
endDate: new Date('2025-12-03'),
},
});
eventSlug = event.slug;
// Join the event
await prisma.eventParticipant.create({
data: {
userId: (await prisma.user.findUnique({ where: { email: testUser.email } })).id,
eventId: event.id,
},
});
});
it('should reject DELETE request without CSRF token', async () => {
const response = await request(app)
.delete(`/api/events/${eventSlug}/leave`)
.set('Authorization', `Bearer ${authToken}`)
.expect(403);
expect(response.body).toHaveProperty('success', false);
expect(response.body).toHaveProperty('error', 'Invalid CSRF token');
});
it('should accept DELETE request with valid CSRF token', async () => {
const agent = request.agent(app);
// Get CSRF token
const csrfResponse = await agent.get('/api/csrf-token');
const token = csrfResponse.body.csrfToken;
// Make DELETE request with CSRF token
const response = await agent
.delete(`/api/events/${eventSlug}/leave`)
.set('Authorization', `Bearer ${authToken}`)
.set('X-CSRF-Token', token)
.expect(200);
expect(response.body).toHaveProperty('success', true);
});
});
describe('GET requests (should not require CSRF token)', () => {
it('should allow GET requests without CSRF token', async () => {
const response = await request(app)
.get('/api/health')
.expect(200);
expect(response.body).toHaveProperty('status', 'ok');
});
it('should allow authenticated GET requests without CSRF token', async () => {
const response = await request(app)
.get('/api/users/me')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body).toHaveProperty('success', true);
});
});
describe('CSRF token persistence across requests', () => {
it('should maintain CSRF token across multiple requests with same agent', async () => {
const agent = request.agent(app);
// Get CSRF token
const csrfResponse = await agent.get('/api/csrf-token');
const token = csrfResponse.body.csrfToken;
// Make multiple requests with same token
const response1 = await agent
.post('/api/auth/resend-verification')
.set('X-CSRF-Token', token)
.send({ email: testUser.email })
.expect(200);
const response2 = await agent
.post('/api/auth/resend-verification')
.set('X-CSRF-Token', token)
.send({ email: testUser.email })
.expect(200);
expect(response1.body).toHaveProperty('success', true);
expect(response2.body).toHaveProperty('success', true);
});
});
});

View File

@@ -1,6 +1,8 @@
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const cookieParser = require('cookie-parser');
const csrf = require('csurf');
const securityConfig = require('./config/security');
const { apiLimiter } = require('./middleware/rateLimiter');
@@ -53,6 +55,40 @@ app.use(cors({
app.use(express.json({ limit: securityConfig.bodyLimit }));
app.use(express.urlencoded({ extended: true, limit: securityConfig.bodyLimit }));
// Cookie parser (required for CSRF protection)
app.use(cookieParser());
// CSRF Protection (Phase 3 - Security Hardening)
if (securityConfig.csrf.enabled) {
const csrfProtection = csrf({
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production', // HTTPS only in production
sameSite: 'strict',
}
});
// Apply CSRF protection to all routes
app.use(csrfProtection);
// CSRF token endpoint - provides token to clients
app.get('/api/csrf-token', (req, res) => {
res.json({ csrfToken: req.csrfToken() });
});
// CSRF error handler
app.use((err, req, res, next) => {
if (err.code === 'EBADCSRFTOKEN') {
return res.status(403).json({
success: false,
error: 'Invalid CSRF token',
message: 'Form submission failed. Please refresh the page and try again.',
});
}
next(err);
});
}
// Request logging middleware
app.use((req, res, next) => {
console.log(`${new Date().toISOString()} - ${req.method} ${req.path}`);

View File

@@ -9,6 +9,7 @@ const {
} = require('../utils/auth');
const { sendVerificationEmail, sendWelcomeEmail, sendPasswordResetEmail } = require('../utils/email');
const { sanitizeForEmail, timingSafeEqual } = require('../utils/sanitize');
const securityConfig = require('../config/security');
// Register new user (Phase 1.5 - with WSDC support and email verification)
async function register(req, res, next) {
@@ -104,7 +105,7 @@ async function register(req, res, next) {
}
}
// Login user
// Login user (with account lockout protection - Phase 3)
async function login(req, res, next) {
try {
const { email, password } = req.body;
@@ -121,16 +122,77 @@ async function login(req, res, next) {
});
}
// Check if account is locked (Phase 3 - Account Lockout)
if (securityConfig.accountLockout.enabled && user.lockedUntil && user.lockedUntil > new Date()) {
const remainingMinutes = Math.ceil((user.lockedUntil - new Date()) / 60000);
return res.status(423).json({
success: false,
error: 'Account is temporarily locked due to too many failed login attempts',
message: `Account is locked. Please try again in ${remainingMinutes} minute(s).`,
lockedUntil: user.lockedUntil,
});
}
// Compare password
const isPasswordValid = await comparePassword(password, user.passwordHash);
if (!isPasswordValid) {
// Increment failed login attempts (Phase 3 - Account Lockout)
if (securityConfig.accountLockout.enabled) {
const newFailedAttempts = user.failedLoginAttempts + 1;
const updateData = {
failedLoginAttempts: newFailedAttempts,
};
// Lock account if max attempts reached
if (newFailedAttempts >= securityConfig.accountLockout.maxAttempts) {
const lockoutDuration = securityConfig.accountLockout.lockoutDuration * 60 * 1000; // minutes to ms
updateData.lockedUntil = new Date(Date.now() + lockoutDuration);
await prisma.user.update({
where: { id: user.id },
data: updateData,
});
return res.status(423).json({
success: false,
error: 'Account has been locked due to too many failed login attempts',
message: `Too many failed login attempts. Account is locked for ${securityConfig.accountLockout.lockoutDuration} minutes.`,
});
}
// Update failed attempts count
await prisma.user.update({
where: { id: user.id },
data: updateData,
});
const attemptsLeft = securityConfig.accountLockout.maxAttempts - newFailedAttempts;
return res.status(401).json({
success: false,
error: 'Invalid credentials',
attemptsLeft: attemptsLeft > 0 ? attemptsLeft : 0,
});
}
// Account lockout disabled - return generic error
return res.status(401).json({
success: false,
error: 'Invalid credentials',
});
}
// Successful login - reset failed attempts and unlock account (Phase 3)
if (securityConfig.accountLockout.enabled && (user.failedLoginAttempts > 0 || user.lockedUntil)) {
await prisma.user.update({
where: { id: user.id },
data: {
failedLoginAttempts: 0,
lockedUntil: null,
},
});
}
// Generate token
const token = generateToken({ userId: user.id });