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

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