feat: add JWT authentication with complete test coverage

Phase 1 - Step 3: Authentication API

**Backend Authentication:**
- bcryptjs for password hashing (salt rounds: 10)
- JWT tokens with 24h expiration
- Secure password storage (never expose passwordHash)

**API Endpoints:**
- POST /api/auth/register - User registration
  - Username validation (3-50 chars, alphanumeric + underscore)
  - Email validation and normalization
  - Password validation (min 6 chars)
  - Duplicate email/username detection
  - Auto-generated avatar (ui-avatars.com)

- POST /api/auth/login - User authentication
  - Email + password credentials
  - Returns JWT token + user data
  - Invalid credentials protection

- GET /api/users/me - Get current user (protected)
  - Requires valid JWT token
  - Returns user data + stats (matches, ratings)
  - Token validation via middleware

**Security Features:**
- express-validator for input sanitization
- Auth middleware for protected routes
- Token verification (Bearer token)
- Password never returned in responses
- Proper error messages (no information leakage)

**Frontend Integration:**
- API service layer (frontend/src/services/api.js)
- Updated AuthContext to use real API
- Token storage in localStorage
- Automatic token inclusion in requests
- Error handling for expired/invalid tokens

**Unit Tests (30 tests, 78.26% coverage):**

Auth Endpoints (14 tests):
-  Register: success, duplicate email, duplicate username
-  Register validation: invalid email, short password, short username
-  Login: success, wrong password, non-existent user, invalid format
-  Protected route: valid token, no token, invalid token, malformed header

Auth Utils (9 tests):
-  Password hashing and comparison
-  Different hashes for same password
-  JWT generation and verification
-  Token expiration validation
-  Invalid token handling

All tests passing 
Coverage: 78.26% 
This commit is contained in:
Radosław Gierwiało
2025-11-12 22:16:14 +01:00
parent 0e62b12f5e
commit 3788274f73
14 changed files with 997 additions and 43 deletions

View File

@@ -5,9 +5,9 @@ PORT=3000
# Database # Database
DATABASE_URL=postgresql://spotlightcam:spotlightcam123@db:5432/spotlightcam DATABASE_URL=postgresql://spotlightcam:spotlightcam123@db:5432/spotlightcam
# JWT (future) # JWT
# JWT_SECRET=your-secret-key-here JWT_SECRET=your-secret-key-change-this-in-production
# JWT_EXPIRES_IN=24h JWT_EXPIRES_IN=24h
# CORS # CORS
CORS_ORIGIN=http://localhost:8080 CORS_ORIGIN=http://localhost:8080

View File

@@ -10,9 +10,12 @@
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@prisma/client": "^5.8.0", "@prisma/client": "^5.8.0",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2" "express": "^4.18.2",
"express-validator": "^7.3.0",
"jsonwebtoken": "^9.0.2"
}, },
"devDependencies": { "devDependencies": {
"jest": "^29.7.0", "jest": "^29.7.0",
@@ -1424,6 +1427,12 @@
"baseline-browser-mapping": "dist/cli.js" "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": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
"dev": true, "dev": true,
@@ -1521,6 +1530,12 @@
"node-int64": "^0.4.0" "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": { "node_modules/buffer-from": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -1978,6 +1993,15 @@
"node": ">= 0.4" "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": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"license": "MIT" "license": "MIT"
@@ -2205,6 +2229,19 @@
"url": "https://opencollective.com/express" "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": { "node_modules/fast-json-stable-stringify": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -3482,6 +3519,55 @@
"node": ">=6" "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": { "node_modules/kleur": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
@@ -3522,6 +3608,54 @@
"node": ">=8" "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": { "node_modules/lru-cache": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -4206,7 +4340,6 @@
}, },
"node_modules/semver": { "node_modules/semver": {
"version": "7.7.3", "version": "7.7.3",
"dev": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
@@ -4764,6 +4897,15 @@
"node": ">=10.12.0" "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": { "node_modules/vary": {
"version": "1.1.2", "version": "1.1.2",
"license": "MIT", "license": "MIT",

View File

@@ -13,20 +13,29 @@
"prisma:seed": "node prisma/seed.js", "prisma:seed": "node prisma/seed.js",
"prisma:studio": "prisma studio" "prisma:studio": "prisma studio"
}, },
"keywords": ["webrtc", "p2p", "video", "dance", "matchmaking"], "keywords": [
"webrtc",
"p2p",
"video",
"dance",
"matchmaking"
],
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"express": "^4.18.2", "@prisma/client": "^5.8.0",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"@prisma/client": "^5.8.0" "express": "^4.18.2",
"express-validator": "^7.3.0",
"jsonwebtoken": "^9.0.2"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.0.2",
"jest": "^29.7.0", "jest": "^29.7.0",
"supertest": "^6.3.3", "nodemon": "^3.0.2",
"prisma": "^5.8.0" "prisma": "^5.8.0",
"supertest": "^6.3.3"
}, },
"jest": { "jest": {
"testEnvironment": "node", "testEnvironment": "node",

View File

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

View File

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

View File

@@ -28,9 +28,9 @@ app.get('/api/health', (req, res) => {
}); });
// API routes // 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/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/matches', require('./routes/matches'));
// app.use('/api/ratings', require('./routes/ratings')); // app.use('/api/ratings', require('./routes/ratings'));

View File

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

View File

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

View File

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

View File

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

View File

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

36
backend/src/utils/auth.js Normal file
View File

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

View File

@@ -1,5 +1,5 @@
import { createContext, useContext, useState, useEffect } from 'react'; import { createContext, useContext, useState, useEffect } from 'react';
import { mockCurrentUser } from '../mocks/users'; import { authAPI } from '../services/api';
const AuthContext = createContext(null); const AuthContext = createContext(null);
@@ -16,45 +16,53 @@ export const AuthProvider = ({ children }) => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
// Check if user is logged in (from localStorage) // Check if user is logged in (from token)
const storedUser = localStorage.getItem('user'); const loadUser = async () => {
if (storedUser) { const token = localStorage.getItem('token');
setUser(JSON.parse(storedUser)); if (token) {
} try {
setLoading(false); 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) => { const login = async (email, password) => {
// Mock login - w przyszłości będzie API call try {
return new Promise((resolve) => { const { user: userData } = await authAPI.login(email, password);
setTimeout(() => { setUser(userData);
const userData = mockCurrentUser; // Save to localStorage for persistence
setUser(userData); localStorage.setItem('user', JSON.stringify(userData));
localStorage.setItem('user', JSON.stringify(userData)); return userData;
resolve(userData); } catch (error) {
}, 500); throw new Error(error.data?.error || 'Login failed');
}); }
}; };
const register = async (username, email, password) => { const register = async (username, email, password) => {
// Mock register - w przyszłości będzie API call try {
return new Promise((resolve) => { const { user: userData } = await authAPI.register(username, email, password);
setTimeout(() => { setUser(userData);
const userData = { // Save to localStorage for persistence
...mockCurrentUser, localStorage.setItem('user', JSON.stringify(userData));
username, return userData;
email, } catch (error) {
}; throw new Error(error.data?.error || 'Registration failed');
setUser(userData); }
localStorage.setItem('user', JSON.stringify(userData));
resolve(userData);
}, 500);
});
}; };
const logout = () => { const logout = () => {
authAPI.logout();
setUser(null); setUser(null);
localStorage.removeItem('user');
}; };
const value = { const value = {

View File

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