← 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
M app/api/auth/login/route.tsM lib/rate-limit.ts
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 →