Harden the Redis layer for production.

- Locks no longer fail open: an unreachable backend throws
  LockUnavailableError and withLock skips the run (or opts into running
  unlocked, as template seeding does). The lnbits payment poller keeps
  polling through an outage, made safe by only sending confirmation
  emails when a row actually transitioned.
- Rate limiter INCR+PEXPIRE now runs as one Lua script, self-healing
  counters stranded without a TTL.
- Per-email login lockout moves to a Redis-backed store shared across
  replicas (in-memory fallback preserved), case-insensitive on email.
- Graceful shutdown on SIGTERM/SIGINT: stop periodic jobs, close the
  server, force-close lingering SSE sockets, close Redis.
- Active PING probe backs the health flag; /health reports last ping.
  SSE payment streams also poll the DB as a pub/sub-gap fallback.
- Scale compose gains requirepass, maxmemory 256mb with noeviction, and
  an authenticated healthcheck; .env.example documents passwords, TLS
  (rediss://), and DB-index selection.
- First tests in the repo: vitest + ioredis-mock covering the lock,
  rate limiter, and login lockout stores (27 tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Michilis
2026-07-26 04:58:52 +00:00
co-authored by Claude Fable 5
parent 71c277045b
commit e38d14970d
16 changed files with 785 additions and 106 deletions
+9 -44
View File
@@ -21,6 +21,7 @@ import {
import { generateId, getNow, toDbBool } from '../lib/utils.js';
import { sendEmail } from '../lib/email.js';
import { rateLimitMiddleware } from '../lib/rateLimit.js';
import { getLoginLockout } from '../lib/stores/loginLockout.js';
// Per-IP rate limit for sensitive auth endpoints (registration, login, and all
// email-dispatching flows) to curb credential stuffing and email flooding.
@@ -36,42 +37,6 @@ type AuthUser = User & {
const auth = new Hono();
// Rate limiting store (in production, use Redis)
const loginAttempts = new Map<string, { count: number; resetAt: number }>();
const MAX_LOGIN_ATTEMPTS = 5;
const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 minutes
function checkRateLimit(email: string): { allowed: boolean; retryAfter?: number } {
const now = Date.now();
const attempts = loginAttempts.get(email);
if (!attempts) {
return { allowed: true };
}
if (now > attempts.resetAt) {
loginAttempts.delete(email);
return { allowed: true };
}
if (attempts.count >= MAX_LOGIN_ATTEMPTS) {
return { allowed: false, retryAfter: Math.ceil((attempts.resetAt - now) / 1000) };
}
return { allowed: true };
}
function recordFailedAttempt(email: string): void {
const now = Date.now();
const attempts = loginAttempts.get(email) || { count: 0, resetAt: now + LOCKOUT_DURATION };
attempts.count++;
loginAttempts.set(email, attempts);
}
function clearFailedAttempts(email: string): void {
loginAttempts.delete(email);
}
const registerSchema = z.object({
email: z.string().email(),
password: z.string().min(10, 'Password must be at least 10 characters'),
@@ -188,12 +153,12 @@ auth.post('/register', authRateLimit, zValidator('json', registerSchema), async
auth.post('/login', authRateLimit, zValidator('json', loginSchema), async (c) => {
const data = c.req.valid('json');
// Check rate limit
const rateLimit = checkRateLimit(data.email);
if (!rateLimit.allowed) {
return c.json({
// Per-email lockout (shared across instances when Redis is configured).
const lockout = await getLoginLockout().isLocked(data.email);
if (lockout.locked) {
return c.json({
error: 'Too many login attempts. Please try again later.',
retryAfter: rateLimit.retryAfter
retryAfter: lockout.retryAfter
}, 429);
}
@@ -201,7 +166,7 @@ auth.post('/login', authRateLimit, zValidator('json', loginSchema), async (c) =>
(db as any).select().from(users).where(eq((users as any).email, data.email))
);
if (!user) {
recordFailedAttempt(data.email);
await getLoginLockout().recordFailure(data.email);
return c.json({ error: 'Invalid credentials' }, 401);
}
@@ -223,12 +188,12 @@ auth.post('/login', authRateLimit, zValidator('json', loginSchema), async (c) =>
const validPassword = await verifyPassword(data.password, user.password);
if (!validPassword) {
recordFailedAttempt(data.email);
await getLoginLockout().recordFailure(data.email);
return c.json({ error: 'Invalid credentials' }, 401);
}
// Clear failed attempts on successful login
clearFailedAttempts(data.email);
await getLoginLockout().clear(data.email);
// Transparently upgrade legacy bcrypt hashes to argon2 now that we have the
// plaintext and have verified it. Best-effort: a failure here must not block