mirror of
https://github.com/velocitatem/cvfs.git
synced 2026-07-15 19:03:38 +00:00
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
67 lines
2.5 KiB
TypeScript
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;
|
|
}
|