diff --git a/.github/workflows/backend-cicd.yml b/.github/workflows/backend-cicd.yml index 6395981..22c0a10 100644 --- a/.github/workflows/backend-cicd.yml +++ b/.github/workflows/backend-cicd.yml @@ -40,6 +40,9 @@ jobs: # Step 4: Run unit tests and generate a coverage report - name: Run Tests with Coverage run: npm run test:coverage + env: + JWT_SECRET: ci_test_jwt_secret_for_github_actions_min_32_chars + NODE_ENV: test # Step 5: Upload the coverage report as an artifact - name: Upload Coverage Report diff --git a/README.md b/README.md index 12ec641..3146244 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,10 @@ # 🌍 myWorld Travel API - Backend + +[![Build Status](https://github.com/fraidakis/software-engineering-2-backend/actions/workflows/backend-cicd.yml/badge.svg?branch=main)](https://github.com/fraidakis/software-engineering-2-backend/actions) [![Version](https://img.shields.io/badge/version-1.6.2-blue.svg)](https://github.com/fraidakis/software-engineering-2-backend) -[![Node.js](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](https://nodejs.org/) -[![Test Coverage](https://img.shields.io/badge/coverage-90%25+-brightgreen.svg)](./coverage/index.html) -[![Tests](https://img.shields.io/badge/tests-passing-success.svg)](./tests) +[![Node.js](https://img.shields.io/badge/node-%3E%3D18-turquoise.svg)](https://nodejs.org/) +[![Test Coverage](https://img.shields.io/badge/coverage-90%25+-04C38E.svg)](./coverage/index.html) [![API](https://img.shields.io/badge/API-REST%20Level%203-orange.svg)](https://github.com/fraidakis/software-engineering-2-backend) A production-ready RESTful API with HATEOAS support for personalized travel experiences. Discover attractions, get recommendations, navigate, and experience the world with **myWorld**. diff --git a/app.js b/app.js index 4f91411..0c6b197 100644 --- a/app.js +++ b/app.js @@ -5,15 +5,18 @@ import express from 'express'; import cors from 'cors'; +import helmet from 'helmet'; import swaggerUi from 'swagger-ui-express'; import yaml from 'js-yaml'; import { readFileSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; +import mongoose from 'mongoose'; // Import middleware import errorHandler from './middleware/errorHandler.js'; import { requestLogger } from './middleware/logger.js'; +import requestId from './middleware/requestId.js'; // Import centralized routes import routes from './routes/index.js'; @@ -42,10 +45,10 @@ if (process.env.NODE_ENV === 'production' && !process.env.CORS_ORIGIN) { const corsOptions = { origin: (origin, callback) => { - const allowedOrigins = process.env.CORS_ORIGIN + const allowedOrigins = process.env.CORS_ORIGIN ? process.env.CORS_ORIGIN.split(',') : ['*']; - + if (allowedOrigins.includes('*')) { return callback(null, true); } @@ -58,7 +61,9 @@ const corsOptions = { }; // Middleware +app.use(helmet()); // Security headers app.use(cors(corsOptions)); +app.use(requestId); // Request ID for tracing app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(requestLogger); @@ -113,10 +118,19 @@ app.get('/', (_req, res) => { // Health check endpoint app.get('/health', (_req, res) => { + // Determine database status + let dbStatus = 'in-memory'; + if (process.env.USE_MONGODB === 'true') { + const readyState = mongoose.connection.readyState; + dbStatus = readyState === 1 ? 'connected' : readyState === 2 ? 'connecting' : 'disconnected'; + } + res.json({ status: 'healthy', timestamp: new Date().toISOString(), - uptime: process.uptime() + uptime: process.uptime(), + database: dbStatus, + version: API_VERSION }); }); diff --git a/middleware/rateLimiter.js b/middleware/rateLimiter.js new file mode 100644 index 0000000..ac28f8a --- /dev/null +++ b/middleware/rateLimiter.js @@ -0,0 +1,47 @@ +/** + * Rate Limiting Middleware + * Protects auth endpoints from brute-force attacks + */ + +import rateLimit from 'express-rate-limit'; + +const isTest = process.env.NODE_ENV === 'test'; + +/** + * Strict rate limiter for authentication endpoints + * Allows 10 requests per 15 minutes per IP + * Disabled in test environment to avoid test failures + */ +export const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: isTest ? 0 : 10, // 0 = unlimited in test mode + message: { + success: false, + error: 'TOO_MANY_REQUESTS', + message: 'Too many authentication attempts, please try again later' + }, + standardHeaders: true, + legacyHeaders: false, + skipSuccessfulRequests: true, // Don't count successful logins against the limit + skip: () => isTest // Skip rate limiting entirely in test environment +}); + +/** + * General API rate limiter + * Allows 100 requests per minute per IP + * Disabled in test environment + */ +export const apiLimiter = rateLimit({ + windowMs: 60 * 1000, // 1 minute + max: isTest ? 0 : 100, // 0 = unlimited in test mode + message: { + success: false, + error: 'TOO_MANY_REQUESTS', + message: 'Too many requests, please slow down' + }, + standardHeaders: true, + legacyHeaders: false, + skip: () => isTest // Skip rate limiting entirely in test environment +}); + +export default { authLimiter, apiLimiter }; diff --git a/middleware/requestId.js b/middleware/requestId.js new file mode 100644 index 0000000..db262e2 --- /dev/null +++ b/middleware/requestId.js @@ -0,0 +1,20 @@ +/** + * Request ID Middleware + * Generates unique request IDs for tracing and log correlation + */ + +import { randomUUID } from 'crypto'; + +/** + * Attaches a unique request ID to each incoming request + * ID is available as req.id and returned in X-Request-ID response header + * If client provides X-Request-ID header, it will be reused for tracing + */ +export const requestId = (req, res, next) => { + const id = req.headers['x-request-id'] || randomUUID(); + req.id = id; + res.setHeader('X-Request-ID', id); + next(); +}; + +export default requestId; diff --git a/models/Place.js b/models/Place.js index 9026e74..32eab8f 100644 --- a/models/Place.js +++ b/models/Place.js @@ -7,22 +7,33 @@ import mongoose from 'mongoose'; const placeSchema = new mongoose.Schema({ placeId: { type: Number, required: true, unique: true }, - name: { type: String, required: true }, + name: { type: String, required: true, trim: true }, category: { type: String, required: true }, - description: String, - address: String, - city: String, - country: String, + description: { type: String, trim: true }, + address: { type: String, trim: true }, + city: { type: String, trim: true }, + country: { type: String, trim: true }, location: { latitude: Number, longitude: Number }, website: String, - rating: { type: Number, default: 0 }, - createdAt: { type: Date, default: Date.now } + rating: { type: Number, default: 0, min: 0, max: 5 } +}, { + timestamps: true }); // Index for better geospatial queries placeSchema.index({ 'location.latitude': 1, 'location.longitude': 1 }); +// Index for category-based filtering +placeSchema.index({ category: 1 }); + +// Index for city-based queries +placeSchema.index({ city: 1 }); + +// Text index for search functionality +placeSchema.index({ name: 'text', description: 'text', city: 'text' }); + export default mongoose.model('Place', placeSchema); + diff --git a/models/User.js b/models/User.js index 200cb51..6a96e67 100644 --- a/models/User.js +++ b/models/User.js @@ -7,9 +7,15 @@ import mongoose from 'mongoose'; const userSchema = new mongoose.Schema({ userId: { type: Number, required: true, unique: true }, - name: { type: String, required: true }, - email: { type: String, required: true, unique: true }, - phone: String, + name: { type: String, required: true, trim: true }, + email: { + type: String, + required: true, + unique: true, + lowercase: true, + trim: true + }, + phone: { type: String, trim: true }, dateOfBirth: String, password: { type: String, required: true }, role: { type: String, enum: ['user', 'admin'], default: 'user' }, @@ -21,8 +27,14 @@ const userSchema = new mongoose.Schema({ latitude: Number, longitude: Number }, - activeProfile: Number, - createdAt: { type: Date, default: Date.now } + activeProfile: Number +}, { + timestamps: true // Adds createdAt and updatedAt automatically }); +// Indexes for better query performance +// Note: email already has unique index from unique: true +userSchema.index({ role: 1 }); + export default mongoose.model('User', userSchema); + diff --git a/package-lock.json b/package-lock.json index 01980b7..98c6003 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,11 +10,12 @@ "license": "Apache-2.0", "dependencies": { "bcryptjs": "^2.4.3", - "body-parser": "^1.20.2", "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", + "express-rate-limit": "^7.1.5", "express-validator": "^7.0.1", + "helmet": "^8.0.0", "js-yaml": "^4.1.1", "jsonwebtoken": "^9.0.2", "mongoose": "^8.19.3", @@ -2963,6 +2964,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express-validator": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.0.tgz", @@ -3444,6 +3460,15 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", diff --git a/package.json b/package.json index fc07ece..a77c168 100644 --- a/package.json +++ b/package.json @@ -33,11 +33,12 @@ "homepage": "https://github.com/fraidakis/software-engineering-2-backend#readme", "dependencies": { "bcryptjs": "^2.4.3", - "body-parser": "^1.20.2", "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", + "express-rate-limit": "^7.1.5", "express-validator": "^7.0.1", + "helmet": "^8.0.0", "js-yaml": "^4.1.1", "jsonwebtoken": "^9.0.2", "mongoose": "^8.19.3", @@ -53,4 +54,4 @@ "nodemon": "^3.0.1", "supertest": "^6.3.3" } -} +} \ No newline at end of file diff --git a/routes/authRoutes.js b/routes/authRoutes.js index 14ce832..0333f03 100644 --- a/routes/authRoutes.js +++ b/routes/authRoutes.js @@ -1,11 +1,49 @@ +/** + * Authentication Routes + * Handles login and signup with rate limiting and validation + */ + import express from 'express'; -const router = express.Router(); +import { body } from 'express-validator'; +import { validate } from '../middleware/validation.js'; +import { authLimiter } from '../middleware/rateLimiter.js'; import authController from '../controllers/authController.js'; +const router = express.Router(); + +// Validation chains +const loginValidation = [ + body('email') + .trim() + .isEmail() + .withMessage('Valid email is required'), + body('password') + .notEmpty() + .withMessage('Password is required') +]; + +const signupValidation = [ + body('name') + .trim() + .notEmpty() + .withMessage('Name is required') + .isLength({ max: 100 }) + .withMessage('Name must be 100 characters or less'), + body('email') + .trim() + .isEmail() + .withMessage('Valid email is required') + .normalizeEmail(), + body('password') + .isLength({ min: 6 }) + .withMessage('Password must be at least 6 characters') +]; + // POST /auth/login - User login -router.post('/login', authController.login); +router.post('/login', authLimiter, loginValidation, validate, authController.login); -// POST /auth/signup - User signup -router.post('/signup', authController.signup); +// POST /auth/signup - User signup +router.post('/signup', authLimiter, signupValidation, validate, authController.signup); export default router; + diff --git a/server.js b/server.js index e02e3f3..3691eb0 100644 --- a/server.js +++ b/server.js @@ -20,11 +20,13 @@ const isProduction = process.env.NODE_ENV === 'production'; // Ensure a default JWT secret is available when not provided via .env const DEFAULT_JWT_SECRET = 'myworld_secret_key_2025_change_in_production'; if (!process.env.JWT_SECRET) { - process.env.JWT_SECRET = DEFAULT_JWT_SECRET; - if (!isProduction) { - console.warn('\n⚠️ JWT_SECRET not set in environment; using default secret.'); - console.warn(' Change this value in production by setting JWT_SECRET in .env\n'); + if (isProduction) { + console.error('\n❌ FATAL: JWT_SECRET must be set in production environment\n'); + process.exit(1); } + process.env.JWT_SECRET = DEFAULT_JWT_SECRET; + console.warn('\n⚠️ JWT_SECRET not set in environment; using default secret.'); + console.warn(' Change this value in production by setting JWT_SECRET in .env\n'); } // Database connection module (only loaded if using MongoDB) diff --git a/tests/helpers/testUtils.js b/tests/helpers/testUtils.js index 9518d74..9e050fb 100644 --- a/tests/helpers/testUtils.js +++ b/tests/helpers/testUtils.js @@ -67,15 +67,15 @@ export const defaultAdminUser = { */ export const createTestUser = async (overrides = {}) => { const userData = { ...defaultTestUser, ...overrides }; - + // Hash the password if provided if (userData.password) { userData.password = await bcrypt.hash(userData.password, 10); } - + // Create user via database const user = await db.createUser(userData); - + return user; }; @@ -111,6 +111,28 @@ export const generateTestJWT = (user) => { ); }; +/** + * Generate an expired JWT token for testing + * Useful for testing token expiry handling + * + * @param {Object} user - User object containing userId, email, and role + * @returns {string} Expired JWT token + * + * Example: + * const expiredToken = generateExpiredToken({ userId: 1, email: 'test@example.com', role: 'user' }); + */ +export const generateExpiredToken = (user) => { + return jwt.sign( + { + userId: user.userId, + email: user.email, + role: user.role + }, + process.env.JWT_SECRET, + { expiresIn: '-1h' } // Already expired + ); +}; + /** * Create an authenticated request with Authorization header * Returns a Supertest request object with the Authorization header set @@ -147,7 +169,7 @@ export const authRequest = (token) => { export const createAuthenticatedUser = async (overrides = {}) => { const user = await createTestUser(overrides); const token = generateTestJWT(user); - + return { user, token }; }; @@ -160,7 +182,7 @@ export const createAuthenticatedUser = async (overrides = {}) => { export const createAuthenticatedAdmin = async (overrides = {}) => { const admin = await createTestAdmin(overrides); const token = generateTestJWT(admin); - + return { user: admin, token }; }; @@ -185,7 +207,7 @@ export const createTestPlace = async (overrides = {}) => { address: '123 Test Street', ...overrides }; - + // Use your actual database method to create a place // Example: await db.createPlace(placeData); // For now, returning the data structure @@ -213,7 +235,7 @@ export const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms)); export const extractCookies = (response) => { const cookies = {}; const setCookieHeader = response.headers['set-cookie']; - + if (setCookieHeader) { setCookieHeader.forEach(cookie => { const [nameValue] = cookie.split(';'); @@ -221,7 +243,7 @@ export const extractCookies = (response) => { cookies[name.trim()] = value; }); } - + return cookies; }; @@ -235,20 +257,20 @@ export const fixtures = { email: 'newuser@example.com', password: 'ValidPassword123!' }, - + // Invalid user registration data invalidUserRegistration: { name: '', email: 'invalid-email', password: '123' // Too short }, - + // Valid login credentials (matches defaultTestUser) validLoginCredentials: { email: 'test@example.com', password: 'TestPassword123!' }, - + // Invalid login credentials invalidLoginCredentials: { email: 'wrong@example.com', @@ -267,7 +289,7 @@ export const verifyHATEOASLinks = (response, expectedLinks) => { if (!response.links) { return false; } - + return expectedLinks.every(linkName => { const link = response.links[linkName]; return link && link.href && link.method; @@ -283,6 +305,7 @@ export default { createAuthenticatedAdmin, createTestPlace, generateTestJWT, + generateExpiredToken, wait, extractCookies, fixtures, diff --git a/tests/integration/security.test.js b/tests/integration/security.test.js new file mode 100644 index 0000000..0e46225 --- /dev/null +++ b/tests/integration/security.test.js @@ -0,0 +1,408 @@ +/** + * Security Integration Tests + * Tests for JWT handling, authorization bypass prevention, and input validation + * + * Coverage: + * - JWT token expiry handling + * - Invalid token rejection + * - Authorization bypass prevention + * - Input validation and sanitization + */ + +import { + api, + createAuthenticatedUser, + createAuthenticatedAdmin, + generateExpiredToken, + authRequest +} from '../helpers/testUtils.js'; +import db from '../../config/db.js'; + +describe('Security Tests', () => { + describe('JWT Token Handling', () => { + describe('Token Expiry', () => { + it('should reject requests with expired tokens', async () => { + // Arrange - Create user and generate expired token + const { user } = await createAuthenticatedUser({ email: 'expired@test.com' }); + const expiredToken = generateExpiredToken(user); + + // Act + const response = await api + .get(`/users/${user.userId}/profile`) + .set('Authorization', `Bearer ${expiredToken}`); + + // Assert + expect(response.status).toBe(401); + expect(response.body.error).toBe('TOKEN_EXPIRED'); + expect(response.body.message).toMatch(/expired/i); + }); + }); + + describe('Invalid Tokens', () => { + it('should reject requests with malformed tokens', async () => { + // Act + const response = await api + .get('/users/1/profile') + .set('Authorization', 'Bearer invalid.token.here'); + + // Assert + expect(response.status).toBe(401); + expect(response.body.error).toBe('INVALID_TOKEN'); + }); + + it('should reject requests without Bearer prefix', async () => { + // Arrange + const { token } = await createAuthenticatedUser({ email: 'nobearer@test.com' }); + + // Act - Send token without 'Bearer ' prefix + const response = await api + .get('/users/1/profile') + .set('Authorization', token); + + // Assert + expect(response.status).toBe(401); + expect(response.body.error).toBe('AUTHENTICATION_REQUIRED'); + }); + + it('should reject requests with empty Authorization header', async () => { + // Act + const response = await api + .get('/users/1/profile') + .set('Authorization', ''); + + // Assert + expect(response.status).toBe(401); + }); + + it('should reject requests with only Bearer (no token)', async () => { + // Act + const response = await api + .get('/users/1/profile') + .set('Authorization', 'Bearer '); + + // Assert + expect(response.status).toBe(401); + }); + }); + }); + + describe('Authorization Bypass Prevention', () => { + describe('User Resource Access', () => { + it('should prevent user from accessing another user profile', async () => { + // Arrange - Create two users + const { user: user1, token: token1 } = await createAuthenticatedUser({ + email: 'user1profile@test.com' + }); + const { user: user2 } = await createAuthenticatedUser({ + email: 'user2profile@test.com' + }); + + // Act - User1 tries to access User2's profile + const response = await authRequest(token1).get(`/users/${user2.userId}/profile`); + + // Assert + expect(response.status).toBe(403); + expect(response.body.error).toBe('ACCESS_DENIED'); + expect(response.body.message).toMatch(/your own resources/i); + }); + + it('should prevent user from modifying another user favourites', async () => { + // Arrange - Create two users and a place + const { user: user1, token: token1 } = await createAuthenticatedUser({ + email: 'user1fav@test.com' + }); + const { user: user2 } = await createAuthenticatedUser({ + email: 'user2fav@test.com' + }); + + // Create a test place + const place = await db.createPlace({ + name: 'Fav Test Place', + category: 'RESTAURANT', + description: 'Test place for favourites', + location: { latitude: 40.64, longitude: 22.94 } + }); + + // Act - User1 tries to add favourite to User2's list + const response = await api + .post(`/users/${user2.userId}/favourite-places`) + .set('Authorization', `Bearer ${token1}`) + .send({ placeId: place.placeId }); + + // Assert + expect(response.status).toBe(403); + }); + + it('should prevent user from modifying another user settings', async () => { + // Arrange + const { user: user1, token: token1 } = await createAuthenticatedUser({ + email: 'user1settings@test.com' + }); + const { user: user2 } = await createAuthenticatedUser({ + email: 'user2settings@test.com' + }); + + // Act - User1 tries to update User2's settings + const response = await api + .put(`/users/${user2.userId}/settings`) + .set('Authorization', `Bearer ${token1}`) + .send({ emailNotifications: false }); + + // Assert + expect(response.status).toBe(403); + }); + + it('should prevent user from accessing another user preference profiles', async () => { + // Arrange + const { user: user1, token: token1 } = await createAuthenticatedUser({ + email: 'user1prefs@test.com' + }); + const { user: user2 } = await createAuthenticatedUser({ + email: 'user2prefs@test.com' + }); + + // Act - User1 tries to get User2's preference profiles + const response = await api + .get(`/users/${user2.userId}/preference-profiles`) + .set('Authorization', `Bearer ${token1}`); + + // Assert + expect(response.status).toBe(403); + }); + }); + + describe('Admin Endpoint Protection', () => { + it('should reject admin endpoints for regular users', async () => { + // Arrange - Create regular user + const { user, token } = await createAuthenticatedUser({ + email: 'regularuser@test.com' + }); + + // Create a place to have valid report target + await db.createPlace({ + name: 'Admin Test Place', + category: 'MUSEUM', + description: 'Test', + location: { latitude: 40.64, longitude: 22.94 } + }); + + // Act - Regular user tries to access admin endpoint + const response = await api + .get(`/admin/${user.userId}/places/1/reports`) + .set('Authorization', `Bearer ${token}`); + + // Assert + expect(response.status).toBe(403); + expect(response.body.error).toBe('ACCESS_DENIED'); + expect(response.body.message).toMatch(/admin|administrator/i); + }); + + it('should allow admin access to admin endpoints', async () => { + // Arrange - Create admin user + const { token } = await createAuthenticatedAdmin({ + email: 'adminaccess@test.com' + }); + + // Create a place + const place = await db.createPlace({ + name: 'Admin Access Test Place', + category: 'MUSEUM', + description: 'Test', + location: { latitude: 40.64, longitude: 22.94 } + }); + + // Act - Admin accesses admin endpoint + const response = await api + .get(`/admin/1/places/${place.placeId}/reports`) + .set('Authorization', `Bearer ${token}`); + + // Assert - Should succeed (200) or return empty reports + expect([200, 404]).toContain(response.status); + }); + }); + }); + + describe('Input Validation', () => { + describe('XSS Prevention', () => { + it('should sanitize XSS attempts in review comments', async () => { + // Arrange + const { user, token } = await createAuthenticatedUser({ + email: 'xsstest@test.com' + }); + + // Create a place for the review + const place = await db.createPlace({ + name: 'XSS Test Place', + category: 'RESTAURANT', + description: 'Test', + location: { latitude: 40.64, longitude: 22.94 } + }); + + // Act - Submit review with XSS payload + const response = await api + .post(`/places/${place.placeId}/reviews`) + .set('Authorization', `Bearer ${token}`) + .send({ + rating: 5, + comment: 'Great place!' + }); + + // Assert - Should succeed but sanitize the HTML + if (response.status === 201) { + expect(response.body.data.review.comment).not.toContain('