fe66575ffe
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.
84 lines
3.1 KiB
JavaScript
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 };
|
|
});
|
|
}
|