[object Object]

← back to Norma

feat(auth): brute-force-resistant login rate-limit (10 fails/15min per IP, 30min lockout)

2c9def02a94f437b9cbad71c8a004648b2f6c242 · 2026-05-20 15:48:57 -0700 · Steve Abrams

- Add checkLoginAttempt / recordLoginFailure / clearLoginCounter helpers to
  lib/rate-limit.ts, sharing the existing in-memory Map under a 'login:<ip>'
  namespace (per learnings.md 2026-04-13 part 2).
- Only HTTP 401 'Invalid credentials' responses increment the counter;
  successful login clears it; 400/403/500 leave it untouched.
- Once 10 failures land inside a 15-minute window, the IP is locked for 30
  minutes — extended reset on threshold-crossing, not just window expiry.
- 429 responses now carry Retry-After header + {retryAfter} JSON field.
- Client IP extracted x-forwarded-for first (nginx in front) → x-real-ip →
  'unknown'. NextRequest.ip intentionally not used (unstable across Next 16
  runtimes).
- No new deps; no Redis; the existing /api/v1/* API-key limiter is untouched.

Files touched

Diff

commit 2c9def02a94f437b9cbad71c8a004648b2f6c242
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed May 20 15:48:57 2026 -0700

    feat(auth): brute-force-resistant login rate-limit (10 fails/15min per IP, 30min lockout)
    
    - Add checkLoginAttempt / recordLoginFailure / clearLoginCounter helpers to
      lib/rate-limit.ts, sharing the existing in-memory Map under a 'login:<ip>'
      namespace (per learnings.md 2026-04-13 part 2).
    - Only HTTP 401 'Invalid credentials' responses increment the counter;
      successful login clears it; 400/403/500 leave it untouched.
    - Once 10 failures land inside a 15-minute window, the IP is locked for 30
      minutes — extended reset on threshold-crossing, not just window expiry.
    - 429 responses now carry Retry-After header + {retryAfter} JSON field.
    - Client IP extracted x-forwarded-for first (nginx in front) → x-real-ip →
      'unknown'. NextRequest.ip intentionally not used (unstable across Next 16
      runtimes).
    - No new deps; no Redis; the existing /api/v1/* API-key limiter is untouched.
---
 app/api/auth/login/route.ts |  48 ++++++++++++++------
 lib/rate-limit.ts           | 107 ++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 141 insertions(+), 14 deletions(-)

diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts
index 5c5bc3f..dfdf60a 100644
--- a/app/api/auth/login/route.ts
+++ b/app/api/auth/login/route.ts
@@ -1,15 +1,38 @@
 import { NextRequest, NextResponse } from 'next/server';
 import { createSession, buildAuthCookie, verifyPassword, hashPassword } from '@/lib/auth';
-import { checkRateLimit } from '@/lib/rate-limit';
+import {
+  checkLoginAttempt,
+  recordLoginFailure,
+  clearLoginCounter,
+  loginClientIp,
+} from '@/lib/rate-limit';
 import { query } from '@/lib/db';
 
+/**
+ * Standard 401 for invalid credentials.
+ * Records the failure against the IP's brute-force counter.
+ */
+function invalidCredentials(ip: string): NextResponse {
+  recordLoginFailure(ip);
+  return NextResponse.json(
+    { error: 'Invalid credentials' },
+    { status: 401 },
+  );
+}
+
 export async function POST(request: NextRequest) {
-  // Rate limit: 5 login attempts per minute per IP
-  const rl = checkRateLimit(request, 5, 60_000, 'login');
-  if (rl.limited) {
+  const ip = loginClientIp(request);
+
+  // Brute-force gate: 10 failed attempts / 15 min per IP, then 30-min lock.
+  // Only failures bump the counter — see recordLoginFailure() calls below.
+  const gate = checkLoginAttempt(ip);
+  if (gate.locked) {
     return NextResponse.json(
-      { error: 'Too many login attempts. Try again later.' },
-      { status: 429 },
+      { error: 'too many attempts', retryAfter: gate.retryAfter },
+      {
+        status: 429,
+        headers: { 'Retry-After': String(gate.retryAfter) },
+      },
     );
   }
 
@@ -36,10 +59,7 @@ export async function POST(request: NextRequest) {
     );
 
     if (result.rows.length === 0) {
-      return NextResponse.json(
-        { error: 'Invalid credentials' },
-        { status: 401 },
-      );
+      return invalidCredentials(ip);
     }
 
     const row = result.rows[0];
@@ -47,10 +67,7 @@ export async function POST(request: NextRequest) {
     // Verify password (supports both bcrypt and legacy SHA-256 hashes)
     const { match, needsRehash } = await verifyPassword(password, row.password_hash);
     if (!match) {
-      return NextResponse.json(
-        { error: 'Invalid credentials' },
-        { status: 401 },
-      );
+      return invalidCredentials(ip);
     }
 
     // Block deactivated accounts AFTER password check so we don't leak whether
@@ -85,6 +102,9 @@ export async function POST(request: NextRequest) {
       ).catch(() => { /* non-fatal */ });
     }
 
+    // Successful login — clear this IP's brute-force counter
+    clearLoginCounter(ip);
+
     const token = createSession(row.username, row.role, row.org_id);
     const cookieValue = buildAuthCookie(token);
 
diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts
index f00f41f..19e8532 100644
--- a/lib/rate-limit.ts
+++ b/lib/rate-limit.ts
@@ -84,6 +84,113 @@ export function checkRateLimit(
   };
 }
 
+/* ─── Login Brute-Force Protection ────────────────────────────────────────── */
+
+/**
+ * Login attempt counter for brute-force protection.
+ *
+ * Policy:
+ *   - 10 FAILED attempts per 15-minute fixed window per IP.
+ *   - Once threshold crossed, lock the IP for 30 minutes (reset extended).
+ *   - Successful login clears the IP's counter immediately.
+ *
+ * Counter is incremented ONLY by recordLoginFailure() — checkLoginAttempt() is
+ * pure-read so a successful login pass doesn't accidentally bump the failure
+ * count. Reuses the same shared `store` Map as IP and API-key limiters under
+ * the `login:<ip>` namespace.
+ */
+
+const LOGIN_WINDOW_MS = 15 * 60 * 1000;     // 15 min — failure-count window
+const LOGIN_LOCKOUT_MS = 30 * 60 * 1000;    // 30 min — lock duration once tripped
+const LOGIN_MAX_ATTEMPTS = 10;
+const LOGIN_PREFIX = 'login:';
+
+export interface LoginAttemptResult {
+  /** True if the IP is currently locked out. */
+  locked: boolean;
+  /** Seconds remaining until the counter resets / lock expires. */
+  retryAfter: number;
+  /** Failed attempts remaining before lockout. 0 once locked. */
+  remaining: number;
+}
+
+/**
+ * Read-only: extract client IP from forwarding headers.
+ * x-forwarded-for first (nginx in front of Norma), then x-real-ip, then 'unknown'.
+ * NextRequest.ip is not stable across Next.js 16 runtimes, so we don't rely on it.
+ */
+export function loginClientIp(request: NextRequest): string {
+  return (
+    request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
+    request.headers.get('x-real-ip') ||
+    'unknown'
+  );
+}
+
+/**
+ * Pure read: should this login request be blocked right now?
+ * Does NOT increment the counter. Call BEFORE attempting to verify the password.
+ */
+export function checkLoginAttempt(ip: string): LoginAttemptResult {
+  cleanup();
+  const now = Date.now();
+  const entry = store.get(LOGIN_PREFIX + ip);
+
+  if (!entry || entry.resetTime <= now) {
+    return { locked: false, retryAfter: 0, remaining: LOGIN_MAX_ATTEMPTS };
+  }
+
+  if (entry.count >= LOGIN_MAX_ATTEMPTS) {
+    return {
+      locked: true,
+      retryAfter: Math.ceil((entry.resetTime - now) / 1000),
+      remaining: 0,
+    };
+  }
+
+  return {
+    locked: false,
+    retryAfter: Math.ceil((entry.resetTime - now) / 1000),
+    remaining: LOGIN_MAX_ATTEMPTS - entry.count,
+  };
+}
+
+/**
+ * Increment the IP's failure count. Call AFTER a 401 Invalid credentials response.
+ * On the attempt that crosses the threshold, extends the reset to +30 min.
+ */
+export function recordLoginFailure(ip: string): LoginAttemptResult {
+  cleanup();
+  const now = Date.now();
+  const key = LOGIN_PREFIX + ip;
+  let entry = store.get(key);
+
+  if (!entry || entry.resetTime <= now) {
+    entry = { count: 1, resetTime: now + LOGIN_WINDOW_MS };
+    store.set(key, entry);
+  } else {
+    entry.count += 1;
+    // Threshold just crossed → extend reset to the longer lockout window
+    if (entry.count >= LOGIN_MAX_ATTEMPTS) {
+      entry.resetTime = now + LOGIN_LOCKOUT_MS;
+    }
+  }
+
+  const locked = entry.count >= LOGIN_MAX_ATTEMPTS;
+  return {
+    locked,
+    retryAfter: Math.ceil((entry.resetTime - now) / 1000),
+    remaining: Math.max(0, LOGIN_MAX_ATTEMPTS - entry.count),
+  };
+}
+
+/**
+ * Clear the IP's failure counter. Call on successful login (HTTP 200).
+ */
+export function clearLoginCounter(ip: string): void {
+  store.delete(LOGIN_PREFIX + ip);
+}
+
 /* ─── API-Key Rate Limiting (per-key, hourly) ─────────────────────────────── */
 
 const API_KEY_WINDOW_MS = 60 * 60 * 1000; // 1 hour — matches norma_api_keys.rate_limit semantics

← adb6d64 tasks: mark #52 (admin impersonation UI) DONE  ·  back to Norma  ·  docs(sessions): log brute-force-protection learnings (commit 59324fb →