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:
+40
-2
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user