2939bfa608
CSRF: upgrade session cookie sameSite from 'lax' to 'strict'. Rate limiting: login endpoint now blocks an IP after 10 failed attempts in a 15-minute window; resets on success. In-memory, no new dependency. SESSION_SECRET: server exits at startup when NODE_ENV=production and SESSION_SECRET is unset. docker-compose.yml updated to pass it via env; .env.example added with generation instructions. Security headers: add X-Content-Type-Options, X-Frame-Options, and Referrer-Policy to all responses. Sensitive data: routing_number and account_number are now omitted from GET /api/account/:id responses for non-admin users. Image size: logo upload capped at 512 KB in the account PUT handler. Amount validation: checks (POST/PUT) and deposit items (POST/PUT) now reject non-finite and non-positive amounts. QBO import: uploaded file is rejected if its MIME type is not text or a known CSV variant.
138 lines
5.0 KiB
JavaScript
138 lines
5.0 KiB
JavaScript
'use strict';
|
|
|
|
const express = require('express');
|
|
const router = express.Router();
|
|
const bcrypt = require('bcryptjs');
|
|
const db = require('../db/database');
|
|
|
|
// ── Login rate limiter ────────────────────────────────────────────────────────
|
|
// Tracks failed login attempts per IP. After 10 failures within 15 minutes,
|
|
// further attempts are blocked until the window resets.
|
|
const loginAttempts = new Map(); // ip -> { count, resetAt }
|
|
const RATE_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
|
|
const RATE_MAX_FAILS = 10;
|
|
|
|
function checkLoginRate(ip) {
|
|
const now = Date.now();
|
|
const entry = loginAttempts.get(ip);
|
|
if (!entry || now > entry.resetAt) {
|
|
loginAttempts.set(ip, { count: 0, resetAt: now + RATE_WINDOW_MS });
|
|
return true; // allow
|
|
}
|
|
return entry.count < RATE_MAX_FAILS;
|
|
}
|
|
|
|
function recordLoginFailure(ip) {
|
|
const now = Date.now();
|
|
const entry = loginAttempts.get(ip);
|
|
if (!entry || now > entry.resetAt) {
|
|
loginAttempts.set(ip, { count: 1, resetAt: now + RATE_WINDOW_MS });
|
|
} else {
|
|
entry.count++;
|
|
}
|
|
}
|
|
|
|
function clearLoginFailures(ip) {
|
|
loginAttempts.delete(ip);
|
|
}
|
|
|
|
// Purge stale entries every 30 minutes to prevent unbounded memory growth
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [ip, entry] of loginAttempts) {
|
|
if (now > entry.resetAt) loginAttempts.delete(ip);
|
|
}
|
|
}, 30 * 60 * 1000).unref();
|
|
|
|
// GET /api/auth/setup-needed — true when no users exist (first-run)
|
|
router.get('/setup-needed', (req, res) => {
|
|
const { n } = db.prepare('SELECT COUNT(*) AS n FROM users').get();
|
|
res.json({ setupNeeded: n === 0 });
|
|
});
|
|
|
|
// POST /api/auth/setup — create the first admin (only works when no users exist)
|
|
router.post('/setup', async (req, res) => {
|
|
const { n } = db.prepare('SELECT COUNT(*) AS n FROM users').get();
|
|
if (n > 0) return res.status(409).json({ error: 'Setup already complete.' });
|
|
|
|
const { username, password } = req.body;
|
|
if (!username || !password) return res.status(400).json({ error: 'Username and password required.' });
|
|
if (password.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters.' });
|
|
|
|
const hash = await bcrypt.hash(password, 12);
|
|
const result = db.prepare(
|
|
"INSERT INTO users (username, password_hash, role) VALUES (?, ?, 'admin')"
|
|
).run(username.trim(), hash);
|
|
|
|
req.session.userId = result.lastInsertRowid;
|
|
req.session.username = username.trim();
|
|
req.session.role = 'admin';
|
|
|
|
res.status(201).json({ id: result.lastInsertRowid, username: username.trim(), role: 'admin' });
|
|
});
|
|
|
|
// POST /api/auth/login
|
|
router.post('/login', async (req, res) => {
|
|
const ip = req.ip || req.socket.remoteAddress || 'unknown';
|
|
|
|
if (!checkLoginRate(ip)) {
|
|
return res.status(429).json({ error: 'Too many failed login attempts. Please try again later.' });
|
|
}
|
|
|
|
const { username, password } = req.body;
|
|
if (!username || !password) return res.status(400).json({ error: 'Username and password required.' });
|
|
|
|
const user = db.prepare('SELECT * FROM users WHERE username = ? COLLATE NOCASE').get(username.trim());
|
|
if (!user) {
|
|
recordLoginFailure(ip);
|
|
return res.status(401).json({ error: 'Invalid username or password.' });
|
|
}
|
|
|
|
const match = await bcrypt.compare(password, user.password_hash);
|
|
if (!match) {
|
|
recordLoginFailure(ip);
|
|
return res.status(401).json({ error: 'Invalid username or password.' });
|
|
}
|
|
|
|
clearLoginFailures(ip);
|
|
req.session.userId = user.id;
|
|
req.session.username = user.username;
|
|
req.session.role = user.role;
|
|
|
|
res.json({ id: user.id, username: user.username, role: user.role });
|
|
});
|
|
|
|
// POST /api/auth/logout
|
|
router.post('/logout', (req, res) => {
|
|
req.session.destroy(() => res.status(204).end());
|
|
});
|
|
|
|
// GET /api/auth/me
|
|
router.get('/me', (req, res) => {
|
|
if (!req.session || !req.session.userId) {
|
|
return res.status(401).json({ error: 'Not authenticated.' });
|
|
}
|
|
res.json({ id: req.session.userId, username: req.session.username, role: req.session.role });
|
|
});
|
|
|
|
// POST /api/auth/change-password — any logged-in user can change their own password
|
|
router.post('/change-password', async (req, res) => {
|
|
if (!req.session || !req.session.userId) return res.status(401).json({ error: 'Not authenticated.' });
|
|
|
|
const { current_password, new_password } = req.body;
|
|
if (!current_password || !new_password) return res.status(400).json({ error: 'Both fields required.' });
|
|
if (new_password.length < 8) return res.status(400).json({ error: 'New password must be at least 8 characters.' });
|
|
|
|
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.session.userId);
|
|
const match = await bcrypt.compare(current_password, user.password_hash);
|
|
if (!match) return res.status(401).json({ error: 'Current password is incorrect.' });
|
|
|
|
const hash = await bcrypt.hash(new_password, 12);
|
|
db.prepare("UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?")
|
|
.run(hash, req.session.userId);
|
|
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
module.exports = router;
|