diff --git a/display/middleware/package-lock.json b/display/middleware/package-lock.json index 6d806be..d0c789f 100644 --- a/display/middleware/package-lock.json +++ b/display/middleware/package-lock.json @@ -8,7 +8,9 @@ "name": "display-middleware", "version": "1.0.0", "dependencies": { + "@types/bcryptjs": "^2.4.6", "bcrypt": "^5.1.1", + "bcryptjs": "^3.0.3", "better-sqlite3": "^9.6.0", "cors": "^2.8.6", "express": "^4.22.1", @@ -952,6 +954,12 @@ "@types/node": "*" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "license": "MIT" + }, "node_modules/@types/better-sqlite3": { "version": "7.6.13", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", @@ -1474,6 +1482,15 @@ "node": ">= 10.0.0" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/better-sqlite3": { "version": "9.6.0", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-9.6.0.tgz", diff --git a/display/middleware/package.json b/display/middleware/package.json index 7aa3605..75305f6 100644 --- a/display/middleware/package.json +++ b/display/middleware/package.json @@ -11,7 +11,7 @@ "test:watch": "vitest" }, "dependencies": { - "bcrypt": "^5.1.1", + "bcryptjs": "^3.0.3", "better-sqlite3": "^9.6.0", "cors": "^2.8.6", "express": "^4.22.1", @@ -21,7 +21,7 @@ "pg": "^8.20.0" }, "devDependencies": { - "@types/bcrypt": "^5.0.2", + "@types/bcryptjs": "^2.4.6", "@types/better-sqlite3": "^7.6.13", "@types/cors": "^2.8.19", "@types/express": "^4.17.25", diff --git a/display/middleware/src/api/middleware/auth.ts b/display/middleware/src/api/middleware/auth.ts new file mode 100644 index 0000000..e3ecfd2 --- /dev/null +++ b/display/middleware/src/api/middleware/auth.ts @@ -0,0 +1,45 @@ +import jwt from 'jsonwebtoken'; +import type { Request, Response, NextFunction } from 'express'; + +const JWT_SECRET = process.env.JWT_SECRET || 'display-middleware-dev-secret'; +const JWT_EXPIRY = '24h'; + +export interface JwtPayload { + userId: number; + username: string; +} + +export interface AuthRequest extends Request { + user?: JwtPayload; +} + +export function signToken(userId: number, username: string): string { + return jwt.sign({ userId, username }, JWT_SECRET, { expiresIn: JWT_EXPIRY }); +} + +export function verifyToken(token: string): JwtPayload { + return jwt.verify(token, JWT_SECRET) as JwtPayload; +} + +export function authMiddleware( + req: AuthRequest, + res: Response, + next: NextFunction +): void { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + res.status(401).json({ error: 'No token provided' }); + return; + } + + const token = authHeader.slice(7); + + try { + const payload = verifyToken(token); + req.user = payload; + next(); + } catch { + res.status(401).json({ error: 'Invalid or expired token' }); + } +} diff --git a/display/middleware/src/api/routes/auth.ts b/display/middleware/src/api/routes/auth.ts new file mode 100644 index 0000000..f7cd652 --- /dev/null +++ b/display/middleware/src/api/routes/auth.ts @@ -0,0 +1,45 @@ +import { Router } from 'express'; +import bcrypt from 'bcryptjs'; +import { getDb } from '../../db/index'; +import { signToken, authMiddleware, type AuthRequest } from '../middleware/auth'; + +const router = Router(); + +router.post('/login', async (req, res): Promise => { + const { username, password } = req.body; + + if (!username || !password) { + res.status(400).json({ error: 'Username and password are required' }); + return; + } + + try { + const db = getDb(); + const user = db + .prepare('SELECT id, username, password_hash FROM users WHERE username = ?') + .get(username) as { id: number; username: string; password_hash: string } | undefined; + + if (!user) { + res.status(401).json({ error: 'Invalid credentials' }); + return; + } + + const valid = await bcrypt.compare(password, user.password_hash); + + if (!valid) { + res.status(401).json({ error: 'Invalid credentials' }); + return; + } + + const token = signToken(user.id, user.username); + res.json({ token, username: user.username }); + } catch (err) { + res.status(500).json({ error: 'Internal server error' }); + } +}); + +router.get('/me', authMiddleware, (req: AuthRequest, res): void => { + res.json({ userId: req.user!.userId, username: req.user!.username }); +}); + +export default router; diff --git a/display/middleware/src/index.ts b/display/middleware/src/index.ts index 3acc8d9..764e607 100644 --- a/display/middleware/src/index.ts +++ b/display/middleware/src/index.ts @@ -1,5 +1,6 @@ import express from 'express'; import cors from 'cors'; +import authRouter from './api/routes/auth'; const app = express(); const PORT = process.env.PORT ? parseInt(process.env.PORT) : 4000; @@ -11,6 +12,8 @@ app.get('/health', (_req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); +app.use('/api/auth', authRouter); + if (require.main === module) { app.listen(PORT, () => { console.log(`Middleware server running on port ${PORT}`); diff --git a/display/middleware/tests/api/auth.test.ts b/display/middleware/tests/api/auth.test.ts new file mode 100644 index 0000000..01d4bef --- /dev/null +++ b/display/middleware/tests/api/auth.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import request from 'supertest'; +import bcrypt from 'bcryptjs'; +import app from '../../src/index'; +import { initDb, closeDb } from '../../src/db/index'; + +beforeAll(() => { + const db = initDb(':memory:'); + // Create a test user + const hash = bcrypt.hashSync('correctpassword', 10); + db.run( + `INSERT INTO users (username, password_hash) VALUES (?, ?)`, + ['testuser', hash] + ); +}); + +afterAll(() => { + closeDb(); +}); + +describe('POST /api/auth/login', () => { + it('should return JWT on successful login', async () => { + const res = await request(app) + .post('/api/auth/login') + .send({ username: 'testuser', password: 'correctpassword' }); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('token'); + expect(typeof res.body.token).toBe('string'); + expect(res.body.token.split('.').length).toBe(3); // valid JWT format + }); + + it('should reject wrong password with 401', async () => { + const res = await request(app) + .post('/api/auth/login') + .send({ username: 'testuser', password: 'wrongpassword' }); + + expect(res.status).toBe(401); + expect(res.body).toHaveProperty('error'); + }); + + it('should reject unknown user with 401', async () => { + const res = await request(app) + .post('/api/auth/login') + .send({ username: 'nouser', password: 'anything' }); + + expect(res.status).toBe(401); + }); + + it('should reject missing credentials with 400', async () => { + const res = await request(app) + .post('/api/auth/login') + .send({}); + + expect(res.status).toBe(400); + }); +}); + +describe('Auth middleware', () => { + it('should reject requests without token with 401', async () => { + const res = await request(app).get('/api/auth/me'); + expect(res.status).toBe(401); + }); + + it('should reject requests with invalid token with 401', async () => { + const res = await request(app) + .get('/api/auth/me') + .set('Authorization', 'Bearer invalid.token.here'); + + expect(res.status).toBe(401); + }); + + it('should allow access with valid token', async () => { + // First login to get a token + const loginRes = await request(app) + .post('/api/auth/login') + .send({ username: 'testuser', password: 'correctpassword' }); + + const token = loginRes.body.token; + + const res = await request(app) + .get('/api/auth/me') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('username', 'testuser'); + }); +});