Files
cvfs/apps/webapp/src/app/api/auth/login/route.ts
Claude 52eab5b6e4 feat: robust login with rate limiting + diff propagation across tree
Login hardening:
- API: in-memory rate limiter (10 attempts / IP / 15 min), timing-safe credential comparison, strict body validation
- Middleware: validate session token timestamp expiry (7-day window) not just HMAC signature
- Logout: also clears oidc_token_pub cookie
- Login page: try/catch around fetch to surface network errors, parse OIDC error query params, disable inputs during loading, wrap useSearchParams in Suspense

Diff propagation:
- DiffViewer: shows inherited ancestor patches in a collapsible section above own patches
- CVTree: annotates each branch with ↑N ancestor patch count badge; propagate (↓) button on hover for versions with own patches and child branches
- Dashboard: computeInheritedPatches/getDescendants helpers; Propagate ↓ action button in version header; handlePropagate sends current version's patches to all descendants via appendPatches

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Stdyi4YmG1BUSurArZPe8w
2026-06-19 11:00:41 +00:00

67 lines
2.5 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { createHmac, timingSafeEqual } from 'crypto';
const SECRET = process.env.SESSION_SECRET ?? 'dev-secret-change-in-production';
const LOGIN_USER = process.env.LOGIN_USER ?? 'admin';
const LOGIN_PASS = process.env.LOGIN_PASS ?? 'admin';
// in-memory rate limiter: max 10 attempts per IP per 15 min
const attempts = new Map<string, { n: number; resetAt: number }>();
function checkRate(ip: string): boolean {
const now = Date.now();
const e = attempts.get(ip);
if (!e || now > e.resetAt) { attempts.set(ip, { n: 1, resetAt: now + 15 * 60_000 }); return true; }
if (e.n >= 10) return false;
e.n++;
return true;
}
function safeEq(a: string, b: string): boolean {
try {
const ab = Buffer.from(a), bb = Buffer.from(b);
if (ab.length !== bb.length) {
// still run comparison on equal-length buffers to avoid timing leak
timingSafeEqual(ab, ab);
return false;
}
return timingSafeEqual(ab, bb);
} catch { return false; }
}
function sign(value: string) {
return createHmac('sha256', SECRET).update(value).digest('hex');
}
export async function POST(req: NextRequest) {
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? req.headers.get('x-real-ip') ?? 'unknown';
if (!checkRate(ip)) {
return NextResponse.json({ error: 'Too many attempts. Try again later.' }, { status: 429 });
}
const body = await req.json().catch(() => null);
if (!body || typeof body !== 'object') {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
}
const { username, password } = body as Record<string, unknown>;
if (typeof username !== 'string' || typeof password !== 'string' || !username || !password) {
return NextResponse.json({ error: 'Username and password required' }, { status: 400 });
}
const validUser = safeEq(username, LOGIN_USER);
const validPass = safeEq(password, LOGIN_PASS);
if (!validUser || !validPass) {
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
}
const payload = `${username}:${Date.now()}`;
const token = `${payload}.${sign(payload)}`;
const res = NextResponse.json({ ok: true });
res.cookies.set('session', token, {
httpOnly: true, sameSite: 'lax', path: '/',
maxAge: 60 * 60 * 24 * 7,
secure: process.env.NODE_ENV === 'production',
});
return res;
}