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.
This commit is contained in:
Keysat
2026-06-15 13:22:41 -05:00
parent bbddebc3d6
commit fe66575ffe
13 changed files with 125 additions and 28 deletions
+40 -2
View File
@@ -12,12 +12,48 @@ const cookieOpts = {
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 };
@@ -36,8 +72,10 @@ export default async function authRoutes(app) {
app.post('/api/password', async (req, reply) => {
const { current, next } = req.body || {};
if (!verifyPassword(current)) return reply.code(401).send({ error: 'Wrong current password' });
if (!next || String(next).length < 4) return reply.code(400).send({ error: 'New password too short' });
setPassword(next);
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 };