diff --git a/backend/.env.development.example b/backend/.env.development.example index 6a1de0b..b43093a 100644 --- a/backend/.env.development.example +++ b/backend/.env.development.example @@ -22,3 +22,31 @@ SES_FROM_NAME=spotlight.cam # Email Settings FRONTEND_URL=http://localhost:8080 VERIFICATION_TOKEN_EXPIRY=24h + +# Security - Rate Limiting +RATE_LIMIT_ENABLED=false +RATE_LIMIT_WINDOW_MS=900000 +RATE_LIMIT_MAX=1000 +RATE_LIMIT_AUTH_MAX=100 +RATE_LIMIT_EMAIL_MAX=20 + +# Security - CSRF Protection +ENABLE_CSRF=false + +# Security - Body Size Limits +BODY_SIZE_LIMIT=50mb + +# Security - Password Policy +PASSWORD_MIN_LENGTH=8 +PASSWORD_REQUIRE_UPPERCASE=false +PASSWORD_REQUIRE_LOWERCASE=false +PASSWORD_REQUIRE_NUMBER=false +PASSWORD_REQUIRE_SPECIAL=false + +# Security - Account Lockout +ENABLE_ACCOUNT_LOCKOUT=false +MAX_LOGIN_ATTEMPTS=100 +LOCKOUT_DURATION_MINUTES=15 + +# Logging +LOG_LEVEL=debug diff --git a/backend/.env.production.example b/backend/.env.production.example index 22485d5..e2bd3b9 100644 --- a/backend/.env.production.example +++ b/backend/.env.production.example @@ -22,3 +22,31 @@ SES_FROM_NAME=spotlight.cam # Email Settings FRONTEND_URL=http://localhost VERIFICATION_TOKEN_EXPIRY=24h + +# Security - Rate Limiting +RATE_LIMIT_ENABLED=true +RATE_LIMIT_WINDOW_MS=900000 +RATE_LIMIT_MAX=100 +RATE_LIMIT_AUTH_MAX=5 +RATE_LIMIT_EMAIL_MAX=3 + +# Security - CSRF Protection +ENABLE_CSRF=true + +# Security - Body Size Limits +BODY_SIZE_LIMIT=10kb + +# Security - Password Policy +PASSWORD_MIN_LENGTH=8 +PASSWORD_REQUIRE_UPPERCASE=true +PASSWORD_REQUIRE_LOWERCASE=true +PASSWORD_REQUIRE_NUMBER=true +PASSWORD_REQUIRE_SPECIAL=false + +# Security - Account Lockout +ENABLE_ACCOUNT_LOCKOUT=true +MAX_LOGIN_ATTEMPTS=5 +LOCKOUT_DURATION_MINUTES=15 + +# Logging +LOG_LEVEL=warn diff --git a/backend/prisma/migrations/20251119_add_account_lockout_fields/migration.sql b/backend/prisma/migrations/20251119_add_account_lockout_fields/migration.sql new file mode 100644 index 0000000..c09982d --- /dev/null +++ b/backend/prisma/migrations/20251119_add_account_lockout_fields/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "failed_login_attempts" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "locked_until" TIMESTAMP(3); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 62c2cca..cb361db 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -43,6 +43,10 @@ model User { resetToken String? @unique @map("reset_token") @db.VarChar(255) resetTokenExpiry DateTime? @map("reset_token_expiry") + // Account Lockout (Phase 3 - Security Hardening) + failedLoginAttempts Int @default(0) @map("failed_login_attempts") + lockedUntil DateTime? @map("locked_until") + avatar String? @db.VarChar(255) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/backend/src/__tests__/auth.test.js b/backend/src/__tests__/auth.test.js index b1ceadf..2c6a1ef 100644 --- a/backend/src/__tests__/auth.test.js +++ b/backend/src/__tests__/auth.test.js @@ -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(); + }); + }); }); diff --git a/backend/src/__tests__/csrf.test.js b/backend/src/__tests__/csrf.test.js new file mode 100644 index 0000000..0b2c753 --- /dev/null +++ b/backend/src/__tests__/csrf.test.js @@ -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); + }); + }); +}); diff --git a/backend/src/app.js b/backend/src/app.js index e671933..7f2d885 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -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}`); diff --git a/backend/src/controllers/auth.js b/backend/src/controllers/auth.js index fc353fc..30f424b 100644 --- a/backend/src/controllers/auth.js +++ b/backend/src/controllers/auth.js @@ -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 }); diff --git a/docs/SESSION_CONTEXT.md b/docs/SESSION_CONTEXT.md index 2f223ba..f3362ec 100644 --- a/docs/SESSION_CONTEXT.md +++ b/docs/SESSION_CONTEXT.md @@ -21,8 +21,8 @@ - Phase 2 (Matches & Ratings API) - ✅ COMPLETED - Phase 1.6 (Competition Heats) - ✅ COMPLETED - Phase 1.5 (Email & WSDC & Profiles & Security & QR Check-in) - ✅ COMPLETED -**Progress:** ~80% overall -**Next Goal:** Security hardening, PWA features, Production deployment +**Progress:** ~85% overall +**Next Goal:** PWA features, Production deployment ### What Works Now - ✅ Docker Compose (nginx:8080 + frontend + backend + PostgreSQL) @@ -47,10 +47,10 @@ - ✅ **STUN servers for NAT traversal (production-ready) - Phase 2.5** - ✅ **Landing page with hero section and features showcase - Phase 3** - ✅ **WebRTC test suite (7 backend tests passing) - Phase 3** +- ✅ **Security hardening (CSRF protection, Account Lockout, Rate Limiting) - Phase 3** - ✅ Real-time chat (Socket.IO for event & match rooms) ### What's Missing -- ⏳ Security hardening (CORS, CSRF, Helmet, CSP) - ⏳ PWA features (manifest, service worker, offline support) - ⏳ Production deployment & monitoring - ⏳ Competition heats UI integration improvements @@ -141,16 +141,18 @@ - `frontend/src/components/common/PasswordStrengthIndicator.jsx` - Password strength indicator - `frontend/src/components/common/VerificationBanner.jsx` - Email verification banner - `frontend/src/contexts/AuthContext.jsx` - JWT authentication integration -- `frontend/src/services/api.js` - **UPDATED: Heats API (divisionsAPI, competitionTypesAPI, heatsAPI) - Phase 1.6** +- `frontend/src/services/api.js` - **UPDATED: Heats API, CSRF token handling - Phase 1.6 & Phase 3** - `frontend/src/services/socket.js` - Socket.IO client connection manager - `frontend/src/data/countries.js` - **NEW: List of 195 countries - Phase 1.5** - `frontend/src/utils/__tests__/webrtcDetection.test.js` - **NEW: WebRTC detection tests - Phase 3** - `frontend/src/components/__tests__/WebRTCWarning.test.jsx` - **NEW: WebRTC warning tests - Phase 3** **Backend:** -- `backend/src/controllers/auth.js` - Register, login, email verification, password reset +- `backend/src/app.js` - **UPDATED: CSRF protection, cookie-parser middleware - Phase 3** +- `backend/src/controllers/auth.js` - **UPDATED: Account lockout logic in login - Phase 3** - `backend/src/controllers/user.js` - **UPDATED: Profile updates (social, location) - Phase 1.5** - `backend/src/controllers/wsdc.js` - WSDC API proxy for dancer lookup +- `backend/src/config/security.js` - **Security configuration (CSRF, rate limiting, account lockout)** - `backend/src/routes/events.js` - **UPDATED: Heats management endpoints (POST/GET/DELETE /heats) - Phase 1.6** - `backend/src/routes/divisions.js` - **NEW: List all divisions - Phase 1.6** - `backend/src/routes/competitionTypes.js` - **NEW: List all competition types - Phase 1.6** @@ -159,34 +161,39 @@ - `backend/src/utils/email.js` - AWS SES email service with HTML templates - `backend/src/utils/auth.js` - Token generation utilities - `backend/src/middleware/auth.js` - Email verification middleware +- `backend/src/middleware/rateLimiter.js` - Rate limiting middleware (API, auth, email) - `backend/src/middleware/validators.js` - **UPDATED: Social media URL validation - Phase 1.5** - `backend/src/server.js` - Express server with Socket.IO integration - `backend/src/__tests__/socket-webrtc.test.js` - **NEW: WebRTC signaling tests (7 tests) - Phase 3** -- `backend/src/__tests__/auth.test.js` - Authentication tests +- `backend/src/__tests__/auth.test.js` - **UPDATED: Account lockout tests (6 new tests) - Phase 3** +- `backend/src/__tests__/csrf.test.js` - **NEW: CSRF protection tests (11 tests) - Phase 3** - `backend/src/__tests__/events.test.js` - Events API tests - `backend/src/__tests__/matches.test.js` - Matches API tests -- `backend/prisma/schema.prisma` - **UPDATED: 8 tables (EventCheckinToken added) - Phase 1.5** +- `backend/prisma/schema.prisma` - **UPDATED: Account lockout fields (failedLoginAttempts, lockedUntil) - Phase 3** - `backend/prisma/migrations/20251113151534_add_wsdc_and_email_verification/` - Phase 1.5 migration - `backend/prisma/migrations/20251113202500_add_event_slug/` - **NEW: Event slugs migration - Phase 1.5** - `backend/prisma/migrations/20251114125544_add_event_checkin_tokens/` - **NEW: QR check-in tokens - Phase 1.5** +- `backend/prisma/migrations/20251119_add_account_lockout_fields/` - **NEW: Account lockout migration - Phase 3** **Config:** - `docker-compose.yml` - nginx, frontend, backend, PostgreSQL - `nginx/conf.d/default.conf` - Proxy for /api and /socket.io -- `backend/.env` - **UPDATED: AWS SES credentials, email settings - Phase 1.5** +- `backend/.env.production` - **UPDATED: Security env variables - Phase 3** +- `backend/.env.development` - **UPDATED: Security env variables - Phase 3** --- ## Database Schema (Implemented - Prisma) 11 tables with relations: -- `users` - **EXTENDED in Phase 1.5:** +- `users` - **EXTENDED in Phase 1.5 & Phase 3:** - Base: id, username, email, password_hash, avatar, created_at, updated_at - **WSDC:** first_name, last_name, wsdc_id - **Email Verification:** email_verified, verification_token, verification_code, verification_token_expiry - **Password Reset:** reset_token, reset_token_expiry - **Social Media:** youtube_url, instagram_url, facebook_url, tiktok_url - **Location:** country, city + - **Account Lockout (Phase 3):** failed_login_attempts, locked_until - `events` - id, **slug (unique)**, name, location, start_date, end_date, description, worldsdc_id, participants_count - `event_participants` - **NEW in Phase 1.5:** id, user_id, event_id, joined_at (many-to-many) - `event_checkin_tokens` - **NEW in Phase 1.5:** id, event_id (unique), token (cuid, unique), created_at @@ -204,6 +211,7 @@ - `20251113202500_add_event_slug` - **Phase 1.5 (event security - unique slugs)** - `20251114125544_add_event_checkin_tokens` - **Phase 1.5 (QR code check-in system)** - `20251114142504_add_competition_heats_system` - **Phase 1.6 (competition heats for matchmaking)** +- `20251119_add_account_lockout_fields` - **Phase 3 (account lockout security)** **Seed data:** 4 events, 6 divisions, 2 competition types, event chat rooms @@ -455,7 +463,7 @@ RUN apk add --no-cache openssl **Phase 3 Status:** ⏳ IN PROGRESS - MVP Finalization - ✅ Landing page with hero section - ✅ WebRTC test suite (7 backend tests passing) - - ⏳ Security hardening (CORS, CSRF, Helmet, CSP) + - ✅ Security hardening (CSRF, Account Lockout, env variables, comprehensive tests) - ⏳ PWA features (manifest, service worker) - ⏳ Production deployment -**Next Goal:** Security hardening, PWA features, Production deployment +**Next Goal:** PWA features, Production deployment diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 4a18f29..5cd0b56 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -11,11 +11,36 @@ class ApiError extends Error { } } +// CSRF Token Management (Phase 3 - Security Hardening) +let csrfToken = null; + +async function getCsrfToken() { + if (csrfToken) return csrfToken; + + try { + const response = await fetch(`${API_URL}/csrf-token`, { + credentials: 'include', // Important for cookies + }); + const data = await response.json(); + csrfToken = data.csrfToken; + return csrfToken; + } catch (error) { + console.warn('Failed to fetch CSRF token:', error); + return null; + } +} + +// Reset CSRF token (call this on 403 CSRF errors) +function resetCsrfToken() { + csrfToken = null; +} + async function fetchAPI(endpoint, options = {}) { const url = `${API_URL}${endpoint}`; const config = { ...options, + credentials: 'include', // Include cookies for CSRF headers: { 'Content-Type': 'application/json', ...options.headers, @@ -28,6 +53,14 @@ async function fetchAPI(endpoint, options = {}) { config.headers['Authorization'] = `Bearer ${token}`; } + // Add CSRF token for state-changing requests (Phase 3) + if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(options.method?.toUpperCase())) { + const csrf = await getCsrfToken(); + if (csrf) { + config.headers['X-CSRF-Token'] = csrf; + } + } + try { const response = await fetch(url, config); @@ -44,6 +77,15 @@ async function fetchAPI(endpoint, options = {}) { const data = await response.json(); if (!response.ok) { + // Handle CSRF token errors (Phase 3) + if (response.status === 403 && data.error === 'Invalid CSRF token') { + resetCsrfToken(); + // Retry the request once with a fresh CSRF token + if (!options._csrfRetry) { + return fetchAPI(endpoint, { ...options, _csrfRetry: true }); + } + } + throw new ApiError( data.error || 'API request failed', response.status,