Files
premier-gunner/src/routes/auth.js
T
Keysat fe66575ffe Harden login and make personal-best records self-correct
Login: add an in-memory per-IP throttle (8 failed attempts -> 15-min lockout, 429 + Retry-After), raise the change-password minimum to 8 with a 72-char cap, and apply the same minimum on the StartOS Set Login Password action.

Records: add a record_floor column for manually-pinned bests plus recomputeRecord(); the live record is now the direction-aware best of the best logged value and the floor, recomputed on entry edit/delete so it can drop again (never below the floor). Settings exposes the floor as an override and shows the live best as a placeholder.

Bump package 0.1.6:0 -> 0.1.7:0 and the service-worker cache to v7.
2026-06-15 13:22:41 -05:00

84 lines
3.1 KiB
JavaScript

import {
COOKIE_NAME, verifyPassword, createSession, destroySession, setPassword,
} from '../auth.js';
import { config } from '../config.js';
const cookieOpts = {
path: '/',
httpOnly: true,
sameSite: 'lax',
signed: true,
secure: process.env.NODE_ENV === 'production',
maxAge: config.sessionDays * 86400,
};
const MIN_PASSWORD = 8;
const MAX_PASSWORD = 72; // bcrypt silently truncates beyond 72 bytes
// In-memory brute-force throttle for the single shared password. Keyed by client
// IP; behind the StartOS reverse proxy that collapses to one global lockout,
// which is acceptable for a single-user app. State is per process — fine here.
const MAX_FAILS = 8;
const LOCKOUT_MS = 15 * 60_000;
const loginAttempts = new Map(); // ip -> { fails, lockUntil }
function lockRemainingMs(ip, now) {
const rec = loginAttempts.get(ip);
if (!rec) return 0;
if (rec.lockUntil > now) return rec.lockUntil - now;
// Lockout elapsed — evict so the map can't grow unbounded. Only entries that
// actually locked (lockUntil set) are evictable; one still accumulating fails
// has lockUntil 0 and must be kept or the counter would reset every attempt.
if (rec.lockUntil) loginAttempts.delete(ip);
return 0;
}
function noteLoginFail(ip, now) {
const rec = loginAttempts.get(ip) || { fails: 0, lockUntil: 0 };
rec.fails += 1;
// On hitting the limit, lock and reset the counter so the next cycle starts fresh.
if (rec.fails >= MAX_FAILS) { rec.lockUntil = now + LOCKOUT_MS; rec.fails = 0; }
loginAttempts.set(ip, rec);
}
export default async function authRoutes(app) {
app.post('/api/login', async (req, reply) => {
const now = Date.now();
const wait = lockRemainingMs(req.ip, now);
if (wait > 0) {
reply.header('Retry-After', Math.ceil(wait / 1000));
return reply.code(429).send({ error: 'Too many attempts. Try again later.' });
}
const { password } = req.body || {};
if (!verifyPassword(password)) {
noteLoginFail(req.ip, now);
return reply.code(401).send({ error: 'Wrong password' });
}
loginAttempts.delete(req.ip);
const token = createSession();
reply.setCookie(COOKIE_NAME, token, cookieOpts);
return { ok: true };
});
app.post('/api/logout', async (req, reply) => {
const raw = req.cookies[COOKIE_NAME];
const unsigned = raw ? reply.unsignCookie(raw) : null;
if (unsigned && unsigned.valid) destroySession(unsigned.value);
reply.clearCookie(COOKIE_NAME, { path: '/' });
return { ok: true };
});
app.get('/api/me', async () => ({ ok: true }));
app.post('/api/password', async (req, reply) => {
const { current, next } = req.body || {};
if (!verifyPassword(current)) return reply.code(401).send({ error: 'Wrong current password' });
const pw = String(next || '');
if (pw.length < MIN_PASSWORD) return reply.code(400).send({ error: `New password must be at least ${MIN_PASSWORD} characters` });
if (pw.length > MAX_PASSWORD) return reply.code(400).send({ error: `New password must be at most ${MAX_PASSWORD} characters` });
setPassword(pw);
const token = createSession();
reply.setCookie(COOKIE_NAME, token, cookieOpts);
return { ok: true };
});
}