expressjs -> nextjs
This commit is contained in:
203
nextjs/src/lib/security.js
Normal file
203
nextjs/src/lib/security.js
Normal file
@@ -0,0 +1,203 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user