diff --git a/lib/api/api.js b/lib/api/api.js index f299ff8..2ba75c5 100644 --- a/lib/api/api.js +++ b/lib/api/api.js @@ -20,7 +20,7 @@ import { getDirName } from '../utils.js'; import { demoRouter } from './routes/demoRouter.js'; import logger from '../services/logger.js'; import { listingsRouter } from './routes/listingsRouter.js'; -import { getSettings } from '../services/storage/settingsStorage.js'; +import { getSettings, getOrCreateSessionSecret } from '../services/storage/settingsStorage.js'; import { dashboardRouter } from './routes/dashboardRouter.js'; import { backupRouter } from './routes/backupRouter.js'; import { trackingRouter } from './routes/trackingRoute.js'; @@ -28,9 +28,10 @@ import { registerMcpRoutes } from '../mcp/mcpHttpRoute.js'; const service = restana(); const staticService = files(path.join(getDirName(), '../ui/public')); const PORT = (await getSettings()).port || 9998; +const sessionSecret = await getOrCreateSessionSecret(); service.use(bodyParser.json()); -service.use(cookieSession()); +service.use(cookieSession(sessionSecret)); service.use(staticService); service.use('/api/admin', authInterceptor()); service.use('/api/jobs', authInterceptor()); diff --git a/lib/api/routes/loginRoute.js b/lib/api/routes/loginRoute.js index 228a43a..15f4aac 100644 --- a/lib/api/routes/loginRoute.js +++ b/lib/api/routes/loginRoute.js @@ -9,6 +9,27 @@ import * as hasher from '../../services/security/hash.js'; import { trackDemoAccessed } from '../../services/tracking/Tracker.js'; import logger from '../../services/logger.js'; import { getSettings } from '../../services/storage/settingsStorage.js'; + +const MAX_LOGIN_ATTEMPTS = 10; +const LOGIN_WINDOW_MS = 15 * 60 * 1000; // 15 minutes +const loginAttempts = new Map(); // ip -> { count, firstAttempt } + +function getClientIp(req) { + const forwarded = req.headers['x-forwarded-for']; + return (forwarded ? forwarded.split(',')[0] : req.socket?.remoteAddress) || 'unknown'; +} + +function isRateLimited(ip) { + const now = Date.now(); + const record = loginAttempts.get(ip); + if (!record || now - record.firstAttempt > LOGIN_WINDOW_MS) { + loginAttempts.set(ip, { count: 1, firstAttempt: now }); + return false; + } + record.count++; + return record.count > MAX_LOGIN_ATTEMPTS; +} + const service = restana(); const loginRouter = service.newRouter(); loginRouter.get('/user', async (req, res) => { @@ -25,6 +46,12 @@ loginRouter.get('/user', async (req, res) => { res.send(); }); loginRouter.post('/', async (req, res) => { + const ip = getClientIp(req); + if (isRateLimited(ip)) { + logger.error(`Login rate limit exceeded for IP ${ip}`); + res.send(429); + return; + } const settings = await getSettings(); const { username, password } = req.body; const user = userStorage.getUsers(true).find((user) => user.username === username); @@ -38,6 +65,8 @@ loginRouter.post('/', async (req, res) => { } req.session.currentUser = user.id; + req.session.createdAt = Date.now(); + loginAttempts.delete(ip); userStorage.setLastLoginToNow({ userId: user.id }); res.send(200); return; diff --git a/lib/api/security.js b/lib/api/security.js index 73a1610..6edf986 100644 --- a/lib/api/security.js +++ b/lib/api/security.js @@ -5,12 +5,17 @@ import * as userStorage from '../services/storage/userStorage.js'; import cookieSession from 'cookie-session'; -import { nanoid } from 'nanoid'; +const SESSION_MAX_AGE = 2 * 60 * 60 * 1000; // 2 hours const unauthorized = (res) => { return res.send(401); }; const isUnauthorized = (req) => { - return req.session.currentUser == null; + if (req.session.currentUser == null) return true; + if (Date.now() - req.session.createdAt > SESSION_MAX_AGE) { + req.session = null; + return true; + } + return false; }; const isAdmin = (req) => { if (!isUnauthorized(req)) { @@ -37,12 +42,11 @@ const adminInterceptor = () => { } }; }; -const cookieSession$0 = (userId) => { +const cookieSession$0 = (secret) => { return cookieSession({ name: 'fredy-admin-session', - keys: ['fredy', 'super', 'fancy', 'key', nanoid()], - userId, - maxAge: 2 * 60 * 60 * 1000, // 2 hours + keys: [secret], + maxAge: SESSION_MAX_AGE, }); }; export { cookieSession$0 as cookieSession }; diff --git a/lib/services/storage/settingsStorage.js b/lib/services/storage/settingsStorage.js index 5f09fbc..a75e8a3 100644 --- a/lib/services/storage/settingsStorage.js +++ b/lib/services/storage/settingsStorage.js @@ -67,6 +67,19 @@ export async function getSettings() { return cachedSettingsConfig; } +/** + * Get or create a persistent session signing secret. + * Generated once and stored in the settings table under the key 'session_secret'. + * @returns {Promise} + */ +export async function getOrCreateSessionSecret() { + const settings = await getSettings(); + if (settings.session_secret) return settings.session_secret; + const secret = nanoid(64); + upsertSettings({ session_secret: secret }); + return secret; +} + /** * Upsert settings rows. * - Accepts an object map of name -> value, or an entry {name, value}. diff --git a/package.json b/package.json index d33da2d..2bd1a64 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fredy", - "version": "20.1.1", + "version": "20.1.2", "description": "[F]ind [R]eal [E]states [d]amn eas[y].", "scripts": { "prepare": "husky",