Files
files/nextjs/src/lib/security.js
2026-03-27 19:50:53 +01:00

204 lines
5.0 KiB
JavaScript

import crypto from 'node:crypto';
import jwt from 'jsonwebtoken';
import { cookies, headers } from 'next/headers';
import { redirect } from 'next/navigation';
import { cookieSecure } from './config.js';
const state = globalThis.__filesLehnertSecurityState || (globalThis.__filesLehnertSecurityState = {});
if (!state.jwtSecret) {
state.jwtSecret = crypto.randomBytes(32).toString('hex');
}
if (!state.loginAttempts) {
state.loginAttempts = new Map();
}
const authCookieName = 'auth';
const csrfCookieName = 'csrf';
const jwtMaxAgeSeconds = 2 * 60 * 60;
const loginWindowMs = 15 * 60 * 1000;
const loginMaxAttempts = 10;
function firstForwardedPart(value) {
if (!value) {
return '';
}
return value.split(',')[0].trim();
}
async function expectedOrigin() {
const headerStore = await headers();
const host = headerStore.get('x-forwarded-host') || headerStore.get('host') || '';
const forwardedProto = firstForwardedPart(headerStore.get('x-forwarded-proto'));
const protocol = forwardedProto || (process.env.NODE_ENV === 'production' ? 'https' : 'http');
return host ? `${protocol}://${host}` : '';
}
async function verifySameOrigin() {
const headerStore = await headers();
const source = headerStore.get('origin') || headerStore.get('referer');
if (!source) {
return;
}
let parsed;
try {
parsed = new URL(source);
} catch {
throw new Error('origin-check-failed');
}
const expected = await expectedOrigin();
if (!expected || parsed.origin !== expected) {
throw new Error('origin-check-failed');
}
}
export async function ensureCsrfToken() {
const cookieStore = await cookies();
const cookieToken = cookieStore.get(csrfCookieName)?.value;
if (cookieToken) {
return cookieToken;
}
const headerStore = await headers();
const headerToken = headerStore.get('x-csrf-token');
if (headerToken) {
return headerToken;
}
return crypto.randomBytes(32).toString('hex');
}
export async function verifyCsrf(formData) {
await verifySameOrigin();
const cookieStore = await cookies();
const expectedToken = cookieStore.get(csrfCookieName)?.value || '';
let providedToken = '';
if (formData && typeof formData.get === 'function') {
providedToken = String(formData.get('csrfToken') || '');
}
if (!providedToken) {
const headerStore = await headers();
providedToken = String(headerStore.get('x-csrf-token') || '');
}
if (!expectedToken || !providedToken || expectedToken !== providedToken) {
throw new Error('csrf-token-mismatch');
}
}
export async function getRequestMeta() {
const headerStore = await headers();
const forwardedFor = firstForwardedPart(headerStore.get('x-forwarded-for'));
const realIp = headerStore.get('x-real-ip') || '';
const ip = forwardedFor || realIp || 'unknown';
const userAgent = headerStore.get('user-agent') || '';
return { ip, userAgent };
}
export async function getAuthenticatedUser() {
const cookieStore = await cookies();
const token = cookieStore.get(authCookieName)?.value;
if (!token) {
return null;
}
try {
const payload = jwt.verify(token, state.jwtSecret);
if (!payload || typeof payload !== 'object' || !payload.sub) {
return null;
}
return {
username: String(payload.sub),
admin: Boolean(payload.admin),
};
} catch {
return null;
}
}
export async function setAuthCookie(payload) {
const token = jwt.sign(payload, state.jwtSecret, { expiresIn: jwtMaxAgeSeconds });
const cookieStore = await cookies();
cookieStore.set(authCookieName, token, {
httpOnly: true,
sameSite: 'lax',
maxAge: jwtMaxAgeSeconds,
secure: cookieSecure,
path: '/',
});
}
export async function clearAuthCookie() {
const cookieStore = await cookies();
cookieStore.set(authCookieName, '', {
httpOnly: true,
sameSite: 'lax',
maxAge: 0,
secure: cookieSecure,
path: '/',
});
}
export async function requireAuthenticatedUser() {
const user = await getAuthenticatedUser();
if (!user) {
await clearAuthCookie();
redirect('/manage/login');
}
return user;
}
export async function requireAdminUser() {
const user = await getAuthenticatedUser();
if (!user || !user.admin) {
await clearAuthCookie();
redirect('/manage/admin');
}
return user;
}
async function loginAttemptKey(type) {
const meta = await getRequestMeta();
return `${type}:${meta.ip || 'unknown'}`;
}
export async function checkLoginRateLimit(type) {
const key = await loginAttemptKey(type);
const now = Date.now();
const entry = state.loginAttempts.get(key) || {
count: 0,
resetAt: now + loginWindowMs,
};
if (now > entry.resetAt) {
entry.count = 0;
entry.resetAt = now + loginWindowMs;
}
entry.count += 1;
state.loginAttempts.set(key, entry);
if (entry.count > loginMaxAttempts) {
return Math.ceil((entry.resetAt - now) / 60_000);
}
return 0;
}
export async function clearLoginRateLimit(type) {
const key = await loginAttemptKey(type);
state.loginAttempts.delete(key);
}