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:
2026-04-06 12:46:50 +02:00
co-authored by Claude Sonnet 4.6
parent 34e0dcfe32
commit f7d16c077e
6 changed files with 200 additions and 2 deletions
@@ -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
View 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;
+3
View File
@@ -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}`);