feat(middleware): add JWT auth module with bcryptjs login and protected routes
- src/api/middleware/auth.ts: signToken, verifyToken, authMiddleware (Bearer JWT, 24h expiry) - src/api/routes/auth.ts: POST /api/auth/login (bcryptjs verify) and GET /api/auth/me (protected) - src/index.ts: mount auth router at /api/auth - tests/api/auth.test.ts: 7 tests covering login success, wrong password, no token, invalid token, valid access - Using bcryptjs (pure JS) instead of bcrypt (requires Visual Studio on Windows) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
34e0dcfe32
commit
f7d16c077e
17
display/middleware/package-lock.json
generated
17
display/middleware/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
45
display/middleware/src/api/middleware/auth.ts
Normal file
45
display/middleware/src/api/middleware/auth.ts
Normal file
@ -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' });
|
||||
}
|
||||
}
|
||||
45
display/middleware/src/api/routes/auth.ts
Normal file
45
display/middleware/src/api/routes/auth.ts
Normal file
@ -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<void> => {
|
||||
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;
|
||||
@ -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}`);
|
||||
|
||||
88
display/middleware/tests/api/auth.test.ts
Normal file
88
display/middleware/tests/api/auth.test.ts
Normal file
@ -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');
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user