204 lines
5.0 KiB
JavaScript
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);
|
|
}
|