diff --git a/client/src/services/api.js b/client/src/services/api.js index 0ec5301..9f8cf82 100644 --- a/client/src/services/api.js +++ b/client/src/services/api.js @@ -52,12 +52,24 @@ api.interceptors.response.use( if (error.response?.status === 401 || error.response?.status === 403) { // Check if it's a token-related error const errorMessage = error.response?.data?.message || ''; - if (errorMessage.includes('token') || errorMessage.includes('expired') || error.response?.status === 401) { - console.warn('🔐 Token expired or invalid - logging out'); - // Token expired or invalid - remove token and let ProtectedRoute handle navigation + if (errorMessage.includes('token') || errorMessage.includes('expired') || + errorMessage.includes('invalid') || errorMessage.includes('required') || + error.response?.status === 401) { + console.warn('🔐 Authentication failed:', errorMessage); + console.warn('🔐 Redirecting to login page'); + + // Clear authentication data localStorage.removeItem('token'); - // Force a state update by dispatching a custom event + localStorage.removeItem('user'); + + // Dispatch logout event for components that need to react window.dispatchEvent(new CustomEvent('auth-logout')); + + // Redirect to login page + const currentPath = window.location.pathname; + if (currentPath !== '/login' && currentPath !== '/') { + window.location.href = '/login'; + } } } return Promise.reject(error); diff --git a/management/src/services/api.js b/management/src/services/api.js index 58bf531..ea3658b 100644 --- a/management/src/services/api.js +++ b/management/src/services/api.js @@ -27,9 +27,17 @@ api.interceptors.response.use( (response) => response, (error) => { if (error.response?.status === 401) { + const errorData = error.response.data; + console.warn('🔐 Authentication failed:', errorData?.message || 'Unknown error'); + + // Clear authentication data localStorage.removeItem('management_token') localStorage.removeItem('management_user') - window.location.href = '/login' + + // Check if the backend indicates we should redirect to login + if (errorData?.redirectToLogin !== false) { + window.location.href = '/login' + } } return Promise.reject(error) } diff --git a/server/middleware/auth.js b/server/middleware/auth.js index 0406f9b..0911faf 100644 --- a/server/middleware/auth.js +++ b/server/middleware/auth.js @@ -104,22 +104,51 @@ async function authenticateToken(req, res, next) { next(); } catch (error) { - // Only log unexpected errors, not common JWT validation failures + // Log authentication errors for monitoring (but not in tests) if (process.env.NODE_ENV !== 'test' || error.name === 'TypeError') { - console.error('Token verification error:', error); - } - - // Handle specific JWT errors - if (error.name === 'TokenExpiredError') { - return res.status(401).json({ - success: false, - message: 'Token expired' + console.error('🔐 Authentication error:', { + error: error.name, + message: error.message, + userAgent: req.headers['user-agent'], + ip: req.ip || req.connection.remoteAddress, + path: req.path }); } + // Handle specific JWT errors with detailed responses + if (error.name === 'TokenExpiredError') { + return res.status(401).json({ + success: false, + error: 'TOKEN_EXPIRED', + message: 'Token expired', + redirectToLogin: true + }); + } + + if (error.name === 'JsonWebTokenError') { + return res.status(401).json({ + success: false, + error: 'INVALID_TOKEN', + message: 'Invalid token', + redirectToLogin: true + }); + } + + if (error.name === 'NotBeforeError') { + return res.status(401).json({ + success: false, + error: 'TOKEN_NOT_ACTIVE', + message: 'Token not active', + redirectToLogin: true + }); + } + + // Generic authentication error return res.status(401).json({ success: false, - message: 'Invalid token' + error: 'AUTHENTICATION_FAILED', + message: 'Authentication failed', + redirectToLogin: true }); } } diff --git a/server/tests/integration/auth-security.test.js b/server/tests/integration/auth-security.test.js new file mode 100644 index 0000000..3b7b3ae --- /dev/null +++ b/server/tests/integration/auth-security.test.js @@ -0,0 +1,285 @@ +// CRITICAL: Set environment variables FIRST +process.env.NODE_ENV = 'test'; +process.env.JWT_SECRET = 'test-jwt-secret-key-for-testing-only'; + +const { describe, it, beforeEach, afterEach, before, after } = require('mocha'); +const { expect } = require('chai'); +const request = require('supertest'); +const jwt = require('jsonwebtoken'); +const { setupTestEnvironment, teardownTestEnvironment, cleanDatabase, createTestUser, createTestTenant } = require('../setup'); + +describe('Authentication Integration Tests', () => { + let app, models, sequelize, user, tenant; + + before(async () => { + ({ app, models, sequelize } = await setupTestEnvironment()); + }); + + after(async () => { + await teardownTestEnvironment(); + }); + + beforeEach(async () => { + await cleanDatabase(); + tenant = await createTestTenant({ slug: 'test-tenant' }); + user = await createTestUser({ + username: 'testuser', + role: 'admin', + tenant_id: tenant.id + }); + }); + + describe('Protected Route Access', () => { + it('should deny access to protected route without token', async () => { + const response = await request(app) + .get('/api/users/profile') + .expect(401); + + expect(response.body).to.deep.equal({ + success: false, + message: 'Access token required' + }); + }); + + it('should deny access with malformed authorization header', async () => { + const response = await request(app) + .get('/api/users/profile') + .set('Authorization', 'InvalidToken') + .expect(401); + + expect(response.body).to.deep.equal({ + success: false, + message: 'Invalid token format' + }); + }); + + it('should deny access with expired token', async () => { + const expiredToken = jwt.sign( + { userId: user.id, username: user.username, role: user.role }, + process.env.JWT_SECRET, + { expiresIn: '-1h' } + ); + + const response = await request(app) + .get('/api/users/profile') + .set('Authorization', `Bearer ${expiredToken}`) + .expect(401); + + expect(response.body).to.deep.equal({ + success: false, + error: 'TOKEN_EXPIRED', + message: 'Token expired', + redirectToLogin: true + }); + }); + + it('should deny access with invalid token signature', async () => { + const invalidToken = jwt.sign( + { userId: user.id, username: user.username, role: user.role }, + 'wrong-secret', + { expiresIn: '1h' } + ); + + const response = await request(app) + .get('/api/users/profile') + .set('Authorization', `Bearer ${invalidToken}`) + .expect(401); + + expect(response.body).to.deep.equal({ + success: false, + error: 'INVALID_TOKEN', + message: 'Invalid token', + redirectToLogin: true + }); + }); + + it('should deny access for non-existent user', async () => { + const fakeToken = jwt.sign( + { userId: 'non-existent-id', username: 'fake', role: 'admin' }, + process.env.JWT_SECRET, + { expiresIn: '1h' } + ); + + const response = await request(app) + .get('/api/users/profile') + .set('Authorization', `Bearer ${fakeToken}`) + .expect(401); + + expect(response.body).to.deep.equal({ + success: false, + message: 'User not found' + }); + }); + + it('should deny access for inactive user', async () => { + // Deactivate the user + await user.update({ is_active: false }); + + const validToken = jwt.sign( + { userId: user.id, username: user.username, role: user.role }, + process.env.JWT_SECRET, + { expiresIn: '1h' } + ); + + const response = await request(app) + .get('/api/users/profile') + .set('Authorization', `Bearer ${validToken}`) + .expect(401); + + expect(response.body).to.deep.equal({ + success: false, + message: 'User account is inactive' + }); + }); + + it('should allow access with valid token', async () => { + const validToken = jwt.sign( + { userId: user.id, username: user.username, role: user.role, tenantId: tenant.slug }, + process.env.JWT_SECRET, + { expiresIn: '1h' } + ); + + const response = await request(app) + .get('/api/users/profile') + .set('Authorization', `Bearer ${validToken}`) + .expect(200); + + expect(response.body.success).to.be.true; + expect(response.body.data).to.have.property('username', user.username); + }); + }); + + describe('Role-Based Access Control', () => { + let adminUser, regularUser, adminToken, userToken; + + beforeEach(async () => { + adminUser = await createTestUser({ + username: 'admin', + role: 'admin', + tenant_id: tenant.id + }); + regularUser = await createTestUser({ + username: 'user', + role: 'user', + tenant_id: tenant.id + }); + + adminToken = jwt.sign( + { userId: adminUser.id, username: adminUser.username, role: adminUser.role, tenantId: tenant.slug }, + process.env.JWT_SECRET, + { expiresIn: '1h' } + ); + + userToken = jwt.sign( + { userId: regularUser.id, username: regularUser.username, role: regularUser.role, tenantId: tenant.slug }, + process.env.JWT_SECRET, + { expiresIn: '1h' } + ); + }); + + it('should allow admin access to admin-only endpoints', async () => { + const response = await request(app) + .get('/api/health/metrics') + .set('Authorization', `Bearer ${adminToken}`) + .expect(200); + + expect(response.body.success).to.be.true; + }); + + it('should deny regular user access to admin-only endpoints', async () => { + const response = await request(app) + .get('/api/health/metrics') + .set('Authorization', `Bearer ${userToken}`) + .expect(403); + + expect(response.body).to.deep.equal({ + success: false, + message: 'Insufficient permissions' + }); + }); + }); + + describe('Token Security Validation', () => { + it('should reject token with incorrect algorithm', async () => { + // Create token with different algorithm (if supported by library) + const maliciousToken = jwt.sign( + { userId: user.id, username: user.username, role: 'admin' }, + process.env.JWT_SECRET, + { algorithm: 'none' } + ); + + const response = await request(app) + .get('/api/users/profile') + .set('Authorization', `Bearer ${maliciousToken}`) + .expect(401); + + expect(response.body.success).to.be.false; + expect(response.body.error).to.equal('INVALID_TOKEN'); + }); + + it('should reject tampered token payload', async () => { + const validToken = jwt.sign( + { userId: user.id, username: user.username, role: 'user' }, + process.env.JWT_SECRET, + { expiresIn: '1h' } + ); + + // Tamper with the token by modifying the payload + const parts = validToken.split('.'); + const tamperedPayload = Buffer.from('{"userId":"' + user.id + '","username":"' + user.username + '","role":"admin"}').toString('base64'); + const tamperedToken = parts[0] + '.' + tamperedPayload + '.' + parts[2]; + + const response = await request(app) + .get('/api/users/profile') + .set('Authorization', `Bearer ${tamperedToken}`) + .expect(401); + + expect(response.body.success).to.be.false; + expect(response.body.error).to.equal('INVALID_TOKEN'); + }); + }); + + describe('Tenant Context Validation', () => { + let otherTenant, otherUser; + + beforeEach(async () => { + otherTenant = await createTestTenant({ slug: 'other-tenant' }); + otherUser = await createTestUser({ + username: 'otheruser', + role: 'admin', + tenant_id: otherTenant.id + }); + }); + + it('should set correct tenant context from JWT', async () => { + const validToken = jwt.sign( + { userId: user.id, username: user.username, role: user.role, tenantId: tenant.slug }, + process.env.JWT_SECRET, + { expiresIn: '1h' } + ); + + // Make a request that would show tenant context in logs + const response = await request(app) + .get('/api/devices') + .set('Authorization', `Bearer ${validToken}`) + .expect(200); + + expect(response.body.success).to.be.true; + }); + + it('should handle missing tenant context gracefully', async () => { + const tokenWithoutTenant = jwt.sign( + { userId: user.id, username: user.username, role: user.role }, + process.env.JWT_SECRET, + { expiresIn: '1h' } + ); + + const response = await request(app) + .get('/api/users/profile') + .set('Authorization', `Bearer ${tokenWithoutTenant}`) + .expect(200); + + expect(response.body.success).to.be.true; + }); + }); +}); \ No newline at end of file diff --git a/server/tests/middleware/auth.test.js b/server/tests/middleware/auth.test.js index 0e5e4a2..31210fa 100644 --- a/server/tests/middleware/auth.test.js +++ b/server/tests/middleware/auth.test.js @@ -188,5 +188,179 @@ describe('Authentication Middleware', () => { expect(res.data.success).to.be.false; expect(res.data.message).to.equal('User account is inactive'); }); + + it('should handle malformed JWT token', async () => { + const req = mockRequest({ + headers: { authorization: 'Bearer malformed.jwt' } + }); + const res = mockResponse(); + const next = mockNext(); + + await authenticateToken(req, res, next); + + expect(res.statusCode).to.equal(401); + expect(res.data.success).to.be.false; + expect(res.data.error).to.equal('INVALID_TOKEN'); + expect(res.data.redirectToLogin).to.be.true; + }); + + it('should handle JWT with invalid signature', async () => { + const invalidToken = jwt.sign( + { userId: 1, username: 'test' }, + 'wrong-secret', + { expiresIn: '1h' } + ); + + const req = mockRequest({ + headers: { authorization: `Bearer ${invalidToken}` } + }); + const res = mockResponse(); + const next = mockNext(); + + await authenticateToken(req, res, next); + + expect(res.statusCode).to.equal(401); + expect(res.data.success).to.be.false; + expect(res.data.error).to.equal('INVALID_TOKEN'); + expect(res.data.redirectToLogin).to.be.true; + }); + + it('should handle JWT not before error', async () => { + const futureToken = jwt.sign( + { userId: 1, username: 'test', nbf: Math.floor(Date.now() / 1000) + 3600 }, + process.env.JWT_SECRET || 'test-secret', + { expiresIn: '1h' } + ); + + const req = mockRequest({ + headers: { authorization: `Bearer ${futureToken}` } + }); + const res = mockResponse(); + const next = mockNext(); + + await authenticateToken(req, res, next); + + expect(res.statusCode).to.equal(401); + expect(res.data.success).to.be.false; + expect(res.data.error).to.equal('TOKEN_NOT_ACTIVE'); + expect(res.data.redirectToLogin).to.be.true; + }); + + it('should handle missing JWT_SECRET environment variable', async () => { + const originalSecret = process.env.JWT_SECRET; + delete process.env.JWT_SECRET; + + const token = jwt.sign( + { userId: 1, username: 'test' }, + 'any-secret', + { expiresIn: '1h' } + ); + + const req = mockRequest({ + headers: { authorization: `Bearer ${token}` } + }); + const res = mockResponse(); + const next = mockNext(); + + await authenticateToken(req, res, next); + + expect(res.statusCode).to.equal(401); + expect(res.data.success).to.be.false; + expect(res.data.error).to.equal('AUTHENTICATION_FAILED'); + expect(res.data.redirectToLogin).to.be.true; + + // Restore original secret + process.env.JWT_SECRET = originalSecret; + }); + + it('should include error details for monitoring', async () => { + const originalEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + + const consoleSpy = sinon.spy(console, 'error'); + + const req = mockRequest({ + headers: { + authorization: 'Bearer invalid.jwt.token', + 'user-agent': 'Test Agent', + }, + ip: '127.0.0.1', + path: '/api/test', + connection: { remoteAddress: '127.0.0.1' } + }); + const res = mockResponse(); + const next = mockNext(); + + await authenticateToken(req, res, next); + + expect(consoleSpy.calledOnce).to.be.true; + const logCall = consoleSpy.firstCall.args; + expect(logCall[0]).to.equal('🔐 Authentication error:'); + expect(logCall[1]).to.have.property('error'); + expect(logCall[1]).to.have.property('userAgent', 'Test Agent'); + expect(logCall[1]).to.have.property('ip', '127.0.0.1'); + expect(logCall[1]).to.have.property('path', '/api/test'); + + consoleSpy.restore(); + process.env.NODE_ENV = originalEnv; + }); + }); + + describe('Enhanced Error Response Format', () => { + it('should return consistent error format for expired tokens', async () => { + const expiredToken = jwt.sign( + { userId: 1, username: 'test' }, + process.env.JWT_SECRET || 'test-secret', + { expiresIn: '-1h' } + ); + + const req = mockRequest({ + headers: { authorization: `Bearer ${expiredToken}` } + }); + const res = mockResponse(); + const next = mockNext(); + + await authenticateToken(req, res, next); + + expect(res.statusCode).to.equal(401); + expect(res.data).to.deep.equal({ + success: false, + error: 'TOKEN_EXPIRED', + message: 'Token expired', + redirectToLogin: true + }); + }); + + it('should return consistent error format for invalid tokens', async () => { + const req = mockRequest({ + headers: { authorization: 'Bearer invalid.jwt.token' } + }); + const res = mockResponse(); + const next = mockNext(); + + await authenticateToken(req, res, next); + + expect(res.statusCode).to.equal(401); + expect(res.data).to.deep.equal({ + success: false, + error: 'INVALID_TOKEN', + message: 'Invalid token', + redirectToLogin: true + }); + }); + + it('should return consistent error format for missing tokens', async () => { + const req = mockRequest(); + const res = mockResponse(); + const next = mockNext(); + + await authenticateToken(req, res, next); + + expect(res.statusCode).to.equal(401); + expect(res.data).to.deep.equal({ + success: false, + message: 'Access token required' + }); + }); }); });