- Use timing-safe comparisons for HMAC verification and password checks - Add login rate limiting (5 attempts/minute per IP) - Lock down CORS to Vite dev origin only (not needed in production) - Derive signing key from APP_PASSWORD instead of using it directly - Replace hand-rolled cookie parsing with cookie-parser middleware - Wrap all order mutations in SQLite transactions - Fix TOCTOU race on stock with atomic UPDATE...WHERE quantity >= ? - Fix APP_SECERT typo in .env (gitignored, local fix only) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
89 lines
2.3 KiB
JavaScript
89 lines
2.3 KiB
JavaScript
const express = require('express');
|
|
const crypto = require('crypto');
|
|
const router = express.Router();
|
|
const {
|
|
COOKIE_NAME,
|
|
verify,
|
|
createSessionCookie,
|
|
clearSessionCookie,
|
|
} = require('../middleware/auth');
|
|
|
|
// In-memory rate limiter for login attempts
|
|
const loginAttempts = new Map();
|
|
const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute
|
|
const RATE_LIMIT_MAX = 5;
|
|
|
|
function isRateLimited(ip) {
|
|
const now = Date.now();
|
|
const attempts = loginAttempts.get(ip);
|
|
if (!attempts) return false;
|
|
// Remove expired entries
|
|
const recent = attempts.filter(t => now - t < RATE_LIMIT_WINDOW_MS);
|
|
if (recent.length === 0) {
|
|
loginAttempts.delete(ip);
|
|
return false;
|
|
}
|
|
loginAttempts.set(ip, recent);
|
|
return recent.length >= RATE_LIMIT_MAX;
|
|
}
|
|
|
|
function recordAttempt(ip) {
|
|
const now = Date.now();
|
|
const attempts = loginAttempts.get(ip) || [];
|
|
attempts.push(now);
|
|
loginAttempts.set(ip, attempts);
|
|
}
|
|
|
|
router.post('/login', (req, res) => {
|
|
const password = process.env.APP_PASSWORD;
|
|
if (!password) {
|
|
return res.status(200).json({ ok: true });
|
|
}
|
|
|
|
const ip = req.ip;
|
|
if (isRateLimited(ip)) {
|
|
return res.status(429).json({ error: 'Too many login attempts. Try again later.' });
|
|
}
|
|
|
|
const submitted = req.body?.password || '';
|
|
const submittedBuf = Buffer.from(String(submitted));
|
|
const passwordBuf = Buffer.from(password);
|
|
|
|
let match = false;
|
|
if (submittedBuf.length === passwordBuf.length) {
|
|
match = crypto.timingSafeEqual(submittedBuf, passwordBuf);
|
|
}
|
|
|
|
if (!match) {
|
|
recordAttempt(ip);
|
|
return res.status(401).json({ error: 'Invalid password' });
|
|
}
|
|
|
|
const session = createSessionCookie();
|
|
if (!session) {
|
|
return res.status(500).json({ error: 'Auth not configured' });
|
|
}
|
|
const cookieOpts = { ...session.opts };
|
|
if (cookieOpts.secure && !req.secure) cookieOpts.secure = false;
|
|
res.cookie(COOKIE_NAME, session.token, cookieOpts);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
router.post('/logout', (req, res) => {
|
|
res.cookie(COOKIE_NAME, '', clearSessionCookie());
|
|
res.status(204).send();
|
|
});
|
|
|
|
router.get('/me', (req, res) => {
|
|
if (!process.env.APP_PASSWORD) {
|
|
return res.status(200).json({ ok: true });
|
|
}
|
|
const token = req.cookies?.[COOKIE_NAME] || null;
|
|
if (!token || !verify(token)) {
|
|
return res.status(401).json({ error: 'Unauthorized' });
|
|
}
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
module.exports = router;
|