diff --git a/backend/.env.example b/backend/.env.example index 849bb45..2f9ba78 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -5,9 +5,9 @@ PORT=3000 # Database DATABASE_URL=postgresql://spotlightcam:spotlightcam123@db:5432/spotlightcam -# JWT (future) -# JWT_SECRET=your-secret-key-here -# JWT_EXPIRES_IN=24h +# JWT +JWT_SECRET=your-secret-key-change-this-in-production +JWT_EXPIRES_IN=24h # CORS CORS_ORIGIN=http://localhost:8080 diff --git a/backend/package-lock.json b/backend/package-lock.json index a389b39..8b41dd1 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -10,9 +10,12 @@ "license": "ISC", "dependencies": { "@prisma/client": "^5.8.0", + "bcryptjs": "^2.4.3", "cors": "^2.8.5", "dotenv": "^16.3.1", - "express": "^4.18.2" + "express": "^4.18.2", + "express-validator": "^7.3.0", + "jsonwebtoken": "^9.0.2" }, "devDependencies": { "jest": "^29.7.0", @@ -1424,6 +1427,12 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, "node_modules/binary-extensions": { "version": "2.3.0", "dev": true, @@ -1521,6 +1530,12 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -1978,6 +1993,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "license": "MIT" @@ -2205,6 +2229,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-validator": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.0.tgz", + "integrity": "sha512-ujK2BX5JUun5NR4JuBo83YSXoDDIpoGz3QxgHTzQcHFevkKnwV1in4K7YNuuXQ1W3a2ObXB/P4OTnTZpUyGWiw==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21", + "validator": "~13.15.15" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -3482,6 +3519,55 @@ "node": ">=6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -3522,6 +3608,54 @@ "node": ">=8" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -4206,7 +4340,6 @@ }, "node_modules/semver": { "version": "7.7.3", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -4764,6 +4897,15 @@ "node": ">=10.12.0" } }, + "node_modules/validator": { + "version": "13.15.23", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.23.tgz", + "integrity": "sha512-4yoz1kEWqUjzi5zsPbAS/903QXSYp0UOtHsPpp7p9rHAw/W+dkInskAE386Fat3oKRROwO98d9ZB0G4cObgUyw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "license": "MIT", diff --git a/backend/package.json b/backend/package.json index 6e79da2..def3764 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,20 +13,29 @@ "prisma:seed": "node prisma/seed.js", "prisma:studio": "prisma studio" }, - "keywords": ["webrtc", "p2p", "video", "dance", "matchmaking"], + "keywords": [ + "webrtc", + "p2p", + "video", + "dance", + "matchmaking" + ], "author": "", "license": "ISC", "dependencies": { - "express": "^4.18.2", + "@prisma/client": "^5.8.0", + "bcryptjs": "^2.4.3", "cors": "^2.8.5", "dotenv": "^16.3.1", - "@prisma/client": "^5.8.0" + "express": "^4.18.2", + "express-validator": "^7.3.0", + "jsonwebtoken": "^9.0.2" }, "devDependencies": { - "nodemon": "^3.0.2", "jest": "^29.7.0", - "supertest": "^6.3.3", - "prisma": "^5.8.0" + "nodemon": "^3.0.2", + "prisma": "^5.8.0", + "supertest": "^6.3.3" }, "jest": { "testEnvironment": "node", diff --git a/backend/src/__tests__/auth.test.js b/backend/src/__tests__/auth.test.js new file mode 100644 index 0000000..b1ceadf --- /dev/null +++ b/backend/src/__tests__/auth.test.js @@ -0,0 +1,244 @@ +const request = require('supertest'); +const app = require('../app'); +const { prisma } = require('../utils/db'); +const { hashPassword, generateToken } = require('../utils/auth'); + +// Clean up database before and after tests +beforeAll(async () => { + await prisma.user.deleteMany({}); +}); + +afterAll(async () => { + await prisma.user.deleteMany({}); + await prisma.$disconnect(); +}); + +describe('Authentication API Tests', () => { + describe('POST /api/auth/register', () => { + it('should register a new user successfully', async () => { + const response = await request(app) + .post('/api/auth/register') + .send({ + username: 'testuser', + email: 'test@example.com', + password: 'password123', + }) + .expect('Content-Type', /json/) + .expect(201); + + expect(response.body).toHaveProperty('success', true); + expect(response.body).toHaveProperty('message', 'User registered successfully'); + expect(response.body.data).toHaveProperty('user'); + expect(response.body.data).toHaveProperty('token'); + expect(response.body.data.user).toHaveProperty('id'); + expect(response.body.data.user).toHaveProperty('username', 'testuser'); + expect(response.body.data.user).toHaveProperty('email', 'test@example.com'); + expect(response.body.data.user).toHaveProperty('avatar'); + expect(response.body.data.user).not.toHaveProperty('passwordHash'); + }); + + it('should reject registration with duplicate email', async () => { + const response = await request(app) + .post('/api/auth/register') + .send({ + username: 'anotheruser', + email: 'test@example.com', // Same email as above + password: 'password123', + }) + .expect('Content-Type', /json/) + .expect(400); + + expect(response.body).toHaveProperty('success', false); + expect(response.body).toHaveProperty('error', 'Email already registered'); + }); + + it('should reject registration with duplicate username', async () => { + const response = await request(app) + .post('/api/auth/register') + .send({ + username: 'testuser', // Same username as above + email: 'another@example.com', + password: 'password123', + }) + .expect('Content-Type', /json/) + .expect(400); + + expect(response.body).toHaveProperty('success', false); + expect(response.body).toHaveProperty('error', 'Username already taken'); + }); + + it('should reject registration with invalid email', async () => { + const response = await request(app) + .post('/api/auth/register') + .send({ + username: 'newuser', + email: 'invalid-email', + password: 'password123', + }) + .expect('Content-Type', /json/) + .expect(400); + + expect(response.body).toHaveProperty('success', false); + expect(response.body).toHaveProperty('error', 'Validation Error'); + }); + + it('should reject registration with short password', async () => { + const response = await request(app) + .post('/api/auth/register') + .send({ + username: 'newuser', + email: 'new@example.com', + password: '12345', // Too short + }) + .expect('Content-Type', /json/) + .expect(400); + + expect(response.body).toHaveProperty('success', false); + expect(response.body).toHaveProperty('error', 'Validation Error'); + }); + + it('should reject registration with invalid username', async () => { + const response = await request(app) + .post('/api/auth/register') + .send({ + username: 'ab', // Too short + email: 'new@example.com', + password: 'password123', + }) + .expect('Content-Type', /json/) + .expect(400); + + expect(response.body).toHaveProperty('success', false); + expect(response.body).toHaveProperty('error', 'Validation Error'); + }); + }); + + describe('POST /api/auth/login', () => { + it('should login successfully with correct credentials', async () => { + const response = await request(app) + .post('/api/auth/login') + .send({ + email: 'test@example.com', + password: 'password123', + }) + .expect('Content-Type', /json/) + .expect(200); + + expect(response.body).toHaveProperty('success', true); + expect(response.body).toHaveProperty('message', 'Login successful'); + expect(response.body.data).toHaveProperty('user'); + expect(response.body.data).toHaveProperty('token'); + expect(response.body.data.user).toHaveProperty('email', 'test@example.com'); + expect(response.body.data.user).not.toHaveProperty('passwordHash'); + }); + + it('should reject login with incorrect password', async () => { + const response = await request(app) + .post('/api/auth/login') + .send({ + email: 'test@example.com', + password: 'wrongpassword', + }) + .expect('Content-Type', /json/) + .expect(401); + + expect(response.body).toHaveProperty('success', false); + expect(response.body).toHaveProperty('error', 'Invalid credentials'); + }); + + it('should reject login with non-existent email', async () => { + const response = await request(app) + .post('/api/auth/login') + .send({ + email: 'nonexistent@example.com', + password: 'password123', + }) + .expect('Content-Type', /json/) + .expect(401); + + expect(response.body).toHaveProperty('success', false); + expect(response.body).toHaveProperty('error', 'Invalid credentials'); + }); + + it('should reject login with invalid email format', async () => { + const response = await request(app) + .post('/api/auth/login') + .send({ + email: 'invalid-email', + password: 'password123', + }) + .expect('Content-Type', /json/) + .expect(400); + + expect(response.body).toHaveProperty('success', false); + expect(response.body).toHaveProperty('error', 'Validation Error'); + }); + }); + + describe('GET /api/users/me', () => { + let authToken; + + beforeAll(async () => { + // Create a user and get token + const response = await request(app) + .post('/api/auth/login') + .send({ + email: 'test@example.com', + password: 'password123', + }); + authToken = response.body.data.token; + }); + + it('should return current user with valid token', async () => { + const response = await request(app) + .get('/api/users/me') + .set('Authorization', `Bearer ${authToken}`) + .expect('Content-Type', /json/) + .expect(200); + + expect(response.body).toHaveProperty('success', true); + expect(response.body.data).toHaveProperty('id'); + expect(response.body.data).toHaveProperty('username', 'testuser'); + expect(response.body.data).toHaveProperty('email', 'test@example.com'); + expect(response.body.data).toHaveProperty('stats'); + expect(response.body.data.stats).toHaveProperty('matchesCount'); + expect(response.body.data.stats).toHaveProperty('ratingsCount'); + expect(response.body.data.stats).toHaveProperty('rating'); + expect(response.body.data).not.toHaveProperty('passwordHash'); + }); + + it('should reject request without token', async () => { + const response = await request(app) + .get('/api/users/me') + .expect('Content-Type', /json/) + .expect(401); + + expect(response.body).toHaveProperty('success', false); + expect(response.body).toHaveProperty('error', 'Unauthorized'); + expect(response.body).toHaveProperty('message', 'No token provided'); + }); + + it('should reject request with invalid token', async () => { + const response = await request(app) + .get('/api/users/me') + .set('Authorization', 'Bearer invalid-token') + .expect('Content-Type', /json/) + .expect(401); + + expect(response.body).toHaveProperty('success', false); + expect(response.body).toHaveProperty('error', 'Unauthorized'); + expect(response.body).toHaveProperty('message', 'Invalid or expired token'); + }); + + it('should reject request with malformed Authorization header', async () => { + const response = await request(app) + .get('/api/users/me') + .set('Authorization', authToken) // Missing 'Bearer ' prefix + .expect('Content-Type', /json/) + .expect(401); + + expect(response.body).toHaveProperty('success', false); + expect(response.body).toHaveProperty('error', 'Unauthorized'); + }); + }); +}); diff --git a/backend/src/__tests__/utils/auth.test.js b/backend/src/__tests__/utils/auth.test.js new file mode 100644 index 0000000..f8dbdb4 --- /dev/null +++ b/backend/src/__tests__/utils/auth.test.js @@ -0,0 +1,99 @@ +const { hashPassword, comparePassword, generateToken, verifyToken } = require('../../utils/auth'); + +// Set up test environment variables +beforeAll(() => { + process.env.JWT_SECRET = 'test-secret-key'; + process.env.JWT_EXPIRES_IN = '24h'; +}); + +describe('Auth Utils Tests', () => { + describe('hashPassword and comparePassword', () => { + it('should hash password successfully', async () => { + const password = 'mySecretPassword123'; + const hash = await hashPassword(password); + + expect(hash).toBeDefined(); + expect(hash).not.toBe(password); + expect(hash.length).toBeGreaterThan(0); + }); + + it('should compare password with hash successfully', async () => { + const password = 'mySecretPassword123'; + const hash = await hashPassword(password); + + const isValid = await comparePassword(password, hash); + expect(isValid).toBe(true); + }); + + it('should reject incorrect password', async () => { + const password = 'mySecretPassword123'; + const wrongPassword = 'wrongPassword'; + const hash = await hashPassword(password); + + const isValid = await comparePassword(wrongPassword, hash); + expect(isValid).toBe(false); + }); + + it('should generate different hashes for same password', async () => { + const password = 'mySecretPassword123'; + const hash1 = await hashPassword(password); + const hash2 = await hashPassword(password); + + expect(hash1).not.toBe(hash2); + + // But both should be valid + expect(await comparePassword(password, hash1)).toBe(true); + expect(await comparePassword(password, hash2)).toBe(true); + }); + }); + + describe('generateToken and verifyToken', () => { + it('should generate a valid JWT token', () => { + const payload = { userId: 123 }; + const token = generateToken(payload); + + expect(token).toBeDefined(); + expect(typeof token).toBe('string'); + expect(token.split('.')).toHaveLength(3); // JWT has 3 parts + }); + + it('should verify a valid token', () => { + const payload = { userId: 123, email: 'test@example.com' }; + const token = generateToken(payload); + const decoded = verifyToken(token); + + expect(decoded).toBeDefined(); + expect(decoded).toHaveProperty('userId', 123); + expect(decoded).toHaveProperty('email', 'test@example.com'); + expect(decoded).toHaveProperty('iat'); // Issued at + expect(decoded).toHaveProperty('exp'); // Expiration + }); + + it('should reject invalid token', () => { + const invalidToken = 'invalid.token.here'; + const decoded = verifyToken(invalidToken); + + expect(decoded).toBeNull(); + }); + + it('should reject malformed token', () => { + const malformedToken = 'notavalidtoken'; + const decoded = verifyToken(malformedToken); + + expect(decoded).toBeNull(); + }); + + it('should include expiration time in token', () => { + const payload = { userId: 123 }; + const token = generateToken(payload); + const decoded = verifyToken(token); + + expect(decoded).toHaveProperty('exp'); + expect(decoded.exp).toBeGreaterThan(decoded.iat); + + // Should expire in approximately 24 hours (allowing 1 minute tolerance) + const expectedExpiration = decoded.iat + (24 * 60 * 60); + expect(Math.abs(decoded.exp - expectedExpiration)).toBeLessThan(60); + }); + }); +}); diff --git a/backend/src/app.js b/backend/src/app.js index 0bcaaa4..4a714df 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -28,9 +28,9 @@ app.get('/api/health', (req, res) => { }); // API routes +app.use('/api/auth', require('./routes/auth')); +app.use('/api/users', require('./routes/users')); app.use('/api/events', require('./routes/events')); -// app.use('/api/auth', require('./routes/auth')); -// app.use('/api/users', require('./routes/users')); // app.use('/api/matches', require('./routes/matches')); // app.use('/api/ratings', require('./routes/ratings')); diff --git a/backend/src/controllers/auth.js b/backend/src/controllers/auth.js new file mode 100644 index 0000000..0f850a1 --- /dev/null +++ b/backend/src/controllers/auth.js @@ -0,0 +1,117 @@ +const { prisma } = require('../utils/db'); +const { hashPassword, comparePassword, generateToken } = require('../utils/auth'); + +// Register new user +async function register(req, res, next) { + try { + const { username, email, password } = req.body; + + // Check if user already exists + const existingUser = await prisma.user.findFirst({ + where: { + OR: [ + { email }, + { username }, + ], + }, + }); + + if (existingUser) { + if (existingUser.email === email) { + return res.status(400).json({ + success: false, + error: 'Email already registered', + }); + } + return res.status(400).json({ + success: false, + error: 'Username already taken', + }); + } + + // Hash password + const passwordHash = await hashPassword(password); + + // Create user + const user = await prisma.user.create({ + data: { + username, + email, + passwordHash, + avatar: `https://ui-avatars.com/api/?name=${encodeURIComponent(username)}&background=6366f1&color=fff`, + }, + select: { + id: true, + username: true, + email: true, + avatar: true, + createdAt: true, + }, + }); + + // Generate token + const token = generateToken({ userId: user.id }); + + res.status(201).json({ + success: true, + message: 'User registered successfully', + data: { + user, + token, + }, + }); + } catch (error) { + next(error); + } +} + +// Login user +async function login(req, res, next) { + try { + const { email, password } = req.body; + + // Find user by email + const user = await prisma.user.findUnique({ + where: { email }, + }); + + if (!user) { + return res.status(401).json({ + success: false, + error: 'Invalid credentials', + }); + } + + // Compare password + const isPasswordValid = await comparePassword(password, user.passwordHash); + + if (!isPasswordValid) { + return res.status(401).json({ + success: false, + error: 'Invalid credentials', + }); + } + + // Generate token + const token = generateToken({ userId: user.id }); + + // Return user without password + const { passwordHash, ...userWithoutPassword } = user; + + res.json({ + success: true, + message: 'Login successful', + data: { + user: userWithoutPassword, + token, + }, + }); + } catch (error) { + next(error); + } +} + +module.exports = { + register, + login, +}; diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js new file mode 100644 index 0000000..49886a7 --- /dev/null +++ b/backend/src/middleware/auth.js @@ -0,0 +1,64 @@ +const { verifyToken } = require('../utils/auth'); +const { prisma } = require('../utils/db'); + +// Authentication middleware +async function authenticate(req, res, next) { + try { + // Get token from header + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ + success: false, + error: 'Unauthorized', + message: 'No token provided', + }); + } + + const token = authHeader.substring(7); // Remove 'Bearer ' prefix + + // Verify token + const decoded = verifyToken(token); + + if (!decoded) { + return res.status(401).json({ + success: false, + error: 'Unauthorized', + message: 'Invalid or expired token', + }); + } + + // Get user from database + const user = await prisma.user.findUnique({ + where: { id: decoded.userId }, + select: { + id: true, + username: true, + email: true, + avatar: true, + createdAt: true, + updatedAt: true, + }, + }); + + if (!user) { + return res.status(401).json({ + success: false, + error: 'Unauthorized', + message: 'User not found', + }); + } + + // Attach user to request + req.user = user; + next(); + } catch (error) { + console.error('Auth middleware error:', error); + res.status(500).json({ + success: false, + error: 'Internal Server Error', + }); + } +} + +module.exports = { authenticate }; diff --git a/backend/src/middleware/validators.js b/backend/src/middleware/validators.js new file mode 100644 index 0000000..660db92 --- /dev/null +++ b/backend/src/middleware/validators.js @@ -0,0 +1,52 @@ +const { body, validationResult } = require('express-validator'); + +// Validation error handler +function handleValidationErrors(req, res, next) { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + success: false, + error: 'Validation Error', + details: errors.array(), + }); + } + next(); +} + +// Register validation rules +const registerValidation = [ + body('username') + .trim() + .isLength({ min: 3, max: 50 }) + .withMessage('Username must be between 3 and 50 characters') + .matches(/^[a-zA-Z0-9_]+$/) + .withMessage('Username can only contain letters, numbers, and underscores'), + body('email') + .trim() + .isEmail() + .withMessage('Must be a valid email address') + .normalizeEmail(), + body('password') + .isLength({ min: 6 }) + .withMessage('Password must be at least 6 characters long'), + handleValidationErrors, +]; + +// Login validation rules +const loginValidation = [ + body('email') + .trim() + .isEmail() + .withMessage('Must be a valid email address') + .normalizeEmail(), + body('password') + .notEmpty() + .withMessage('Password is required'), + handleValidationErrors, +]; + +module.exports = { + registerValidation, + loginValidation, + handleValidationErrors, +}; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js new file mode 100644 index 0000000..3fc41f1 --- /dev/null +++ b/backend/src/routes/auth.js @@ -0,0 +1,13 @@ +const express = require('express'); +const { register, login } = require('../controllers/auth'); +const { registerValidation, loginValidation } = require('../middleware/validators'); + +const router = express.Router(); + +// POST /api/auth/register - Register new user +router.post('/register', registerValidation, register); + +// POST /api/auth/login - Login user +router.post('/login', loginValidation, login); + +module.exports = router; diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js new file mode 100644 index 0000000..dcc6fa3 --- /dev/null +++ b/backend/src/routes/users.js @@ -0,0 +1,66 @@ +const express = require('express'); +const { authenticate } = require('../middleware/auth'); +const { prisma } = require('../utils/db'); + +const router = express.Router(); + +// GET /api/users/me - Get current authenticated user +router.get('/me', authenticate, async (req, res, next) => { + try { + // req.user is set by authenticate middleware + const user = await prisma.user.findUnique({ + where: { id: req.user.id }, + select: { + id: true, + username: true, + email: true, + avatar: true, + createdAt: true, + updatedAt: true, + _count: { + select: { + matchesAsUser1: true, + matchesAsUser2: true, + ratingsReceived: true, + }, + }, + }, + }); + + if (!user) { + return res.status(404).json({ + success: false, + error: 'User not found', + }); + } + + // Calculate total matches + const totalMatches = user._count.matchesAsUser1 + user._count.matchesAsUser2; + + // Calculate average rating + const ratings = await prisma.rating.findMany({ + where: { ratedId: user.id }, + select: { score: true }, + }); + + const averageRating = ratings.length > 0 + ? ratings.reduce((sum, r) => sum + r.score, 0) / ratings.length + : 0; + + res.json({ + success: true, + data: { + ...user, + stats: { + matchesCount: totalMatches, + ratingsCount: user._count.ratingsReceived, + rating: averageRating.toFixed(1), + }, + }, + }); + } catch (error) { + next(error); + } +}); + +module.exports = router; diff --git a/backend/src/utils/auth.js b/backend/src/utils/auth.js new file mode 100644 index 0000000..5703a65 --- /dev/null +++ b/backend/src/utils/auth.js @@ -0,0 +1,36 @@ +const bcrypt = require('bcryptjs'); +const jwt = require('jsonwebtoken'); + +// Hash password with bcrypt +async function hashPassword(password) { + const salt = await bcrypt.genSalt(10); + return bcrypt.hash(password, salt); +} + +// Compare password with hash +async function comparePassword(password, hash) { + return bcrypt.compare(password, hash); +} + +// Generate JWT token +function generateToken(payload) { + return jwt.sign(payload, process.env.JWT_SECRET, { + expiresIn: process.env.JWT_EXPIRES_IN || '24h', + }); +} + +// Verify JWT token +function verifyToken(token) { + try { + return jwt.verify(token, process.env.JWT_SECRET); + } catch (error) { + return null; + } +} + +module.exports = { + hashPassword, + comparePassword, + generateToken, + verifyToken, +}; diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx index 4b44996..d54b05f 100644 --- a/frontend/src/contexts/AuthContext.jsx +++ b/frontend/src/contexts/AuthContext.jsx @@ -1,5 +1,5 @@ import { createContext, useContext, useState, useEffect } from 'react'; -import { mockCurrentUser } from '../mocks/users'; +import { authAPI } from '../services/api'; const AuthContext = createContext(null); @@ -16,45 +16,53 @@ export const AuthProvider = ({ children }) => { const [loading, setLoading] = useState(true); useEffect(() => { - // Check if user is logged in (from localStorage) - const storedUser = localStorage.getItem('user'); - if (storedUser) { - setUser(JSON.parse(storedUser)); - } - setLoading(false); + // Check if user is logged in (from token) + const loadUser = async () => { + const token = localStorage.getItem('token'); + if (token) { + try { + const userData = await authAPI.getCurrentUser(); + setUser(userData); + } catch (error) { + // Token expired or invalid + console.error('Failed to load user:', error); + localStorage.removeItem('token'); + localStorage.removeItem('user'); + } + } + setLoading(false); + }; + + loadUser(); }, []); const login = async (email, password) => { - // Mock login - w przyszłości będzie API call - return new Promise((resolve) => { - setTimeout(() => { - const userData = mockCurrentUser; - setUser(userData); - localStorage.setItem('user', JSON.stringify(userData)); - resolve(userData); - }, 500); - }); + try { + const { user: userData } = await authAPI.login(email, password); + setUser(userData); + // Save to localStorage for persistence + localStorage.setItem('user', JSON.stringify(userData)); + return userData; + } catch (error) { + throw new Error(error.data?.error || 'Login failed'); + } }; const register = async (username, email, password) => { - // Mock register - w przyszłości będzie API call - return new Promise((resolve) => { - setTimeout(() => { - const userData = { - ...mockCurrentUser, - username, - email, - }; - setUser(userData); - localStorage.setItem('user', JSON.stringify(userData)); - resolve(userData); - }, 500); - }); + try { + const { user: userData } = await authAPI.register(username, email, password); + setUser(userData); + // Save to localStorage for persistence + localStorage.setItem('user', JSON.stringify(userData)); + return userData; + } catch (error) { + throw new Error(error.data?.error || 'Registration failed'); + } }; const logout = () => { + authAPI.logout(); setUser(null); - localStorage.removeItem('user'); }; const value = { diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js new file mode 100644 index 0000000..c6b7c95 --- /dev/null +++ b/frontend/src/services/api.js @@ -0,0 +1,104 @@ +const API_URL = 'http://localhost:8080/api'; + +class ApiError extends Error { + constructor(message, status, data) { + super(message); + this.name = 'ApiError'; + this.status = status; + this.data = data; + } +} + +async function fetchAPI(endpoint, options = {}) { + const url = `${API_URL}${endpoint}`; + + const config = { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + }; + + // Add auth token if available + const token = localStorage.getItem('token'); + if (token) { + config.headers['Authorization'] = `Bearer ${token}`; + } + + try { + const response = await fetch(url, config); + const data = await response.json(); + + if (!response.ok) { + throw new ApiError( + data.error || 'API request failed', + response.status, + data + ); + } + + return data; + } catch (error) { + if (error instanceof ApiError) { + throw error; + } + throw new ApiError('Network error', 0, { message: error.message }); + } +} + +// Auth API +export const authAPI = { + async register(username, email, password) { + const data = await fetchAPI('/auth/register', { + method: 'POST', + body: JSON.stringify({ username, email, password }), + }); + + // Save token + if (data.data.token) { + localStorage.setItem('token', data.data.token); + } + + return data.data; + }, + + async login(email, password) { + const data = await fetchAPI('/auth/login', { + method: 'POST', + body: JSON.stringify({ email, password }), + }); + + // Save token + if (data.data.token) { + localStorage.setItem('token', data.data.token); + } + + return data.data; + }, + + async getCurrentUser() { + const data = await fetchAPI('/users/me'); + return data.data; + }, + + logout() { + localStorage.removeItem('token'); + localStorage.removeItem('user'); + }, +}; + +// Events API +export const eventsAPI = { + async getAll() { + const data = await fetchAPI('/events'); + return data.data; + }, + + async getById(id) { + const data = await fetchAPI(`/events/${id}`); + return data.data; + }, +}; + +export { ApiError };