96 lines
3.1 KiB
TypeScript
96 lines
3.1 KiB
TypeScript
import { NextResponse, type NextRequest } from 'next/server';
|
|
|
|
const COOKIE_NAME = process.env.WEB_AUTH_COOKIE_NAME?.trim() || 'ai_toy_session';
|
|
|
|
function isPublicPath(pathname: string) {
|
|
return (
|
|
pathname === '/login' ||
|
|
pathname.startsWith('/login/') ||
|
|
pathname.startsWith('/_next/') ||
|
|
pathname.startsWith('/api/auth/') ||
|
|
pathname.startsWith('/api/img/') ||
|
|
pathname === '/favicon.ico' ||
|
|
pathname === '/robots.txt' ||
|
|
pathname === '/sitemap.xml'
|
|
);
|
|
}
|
|
|
|
function isBrowserHtml(pathname: string) {
|
|
return !pathname.startsWith('/api/') || pathname.startsWith('/api/gallery/') || pathname.startsWith('/api/audit/');
|
|
}
|
|
|
|
function authConfigured() {
|
|
return Boolean(process.env.WEB_AUTH_USERNAME && process.env.WEB_AUTH_SESSION_SECRET);
|
|
}
|
|
|
|
function bytesToHex(buffer: ArrayBuffer) {
|
|
return [...new Uint8Array(buffer)].map(byte => byte.toString(16).padStart(2, '0')).join('');
|
|
}
|
|
|
|
function safeEqual(a: string, b: string) {
|
|
if (a.length !== b.length) return false;
|
|
let diff = 0;
|
|
for (let i = 0; i < a.length; i += 1) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
return diff === 0;
|
|
}
|
|
|
|
async function sign(body: string) {
|
|
const secret = process.env.WEB_AUTH_SESSION_SECRET || '';
|
|
const key = await crypto.subtle.importKey(
|
|
'raw',
|
|
new TextEncoder().encode(secret),
|
|
{ name: 'HMAC', hash: 'SHA-256' },
|
|
false,
|
|
['sign'],
|
|
);
|
|
return bytesToHex(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(body)));
|
|
}
|
|
|
|
function decodePayload(body: string) {
|
|
const base64 = body.replace(/-/g, '+').replace(/_/g, '/');
|
|
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
|
|
return JSON.parse(atob(padded)) as { u?: unknown; exp?: unknown };
|
|
}
|
|
|
|
async function verifyToken(token?: string) {
|
|
if (!authConfigured() || !token || !token.includes('.')) return false;
|
|
const [body, suppliedSig] = token.split('.', 2);
|
|
if (!body || !suppliedSig || !safeEqual(await sign(body), suppliedSig)) return false;
|
|
try {
|
|
const payload = decodePayload(body);
|
|
const username = String(payload.u || '');
|
|
const expiresAt = Number(payload.exp || 0);
|
|
return username === process.env.WEB_AUTH_USERNAME && expiresAt >= Math.floor(Date.now() / 1000);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export async function middleware(req: NextRequest) {
|
|
const { pathname, search } = req.nextUrl;
|
|
const authed = await verifyToken(req.cookies.get(COOKIE_NAME)?.value);
|
|
|
|
if (pathname === '/login' || pathname.startsWith('/login/')) {
|
|
if (authed) {
|
|
const next = req.nextUrl.searchParams.get('next') || '/';
|
|
return NextResponse.redirect(new URL(next.startsWith('/') ? next : '/', req.url));
|
|
}
|
|
return NextResponse.next();
|
|
}
|
|
|
|
if (isPublicPath(pathname)) return NextResponse.next();
|
|
if (authed) return NextResponse.next();
|
|
|
|
if (isBrowserHtml(pathname)) {
|
|
const loginUrl = new URL('/login', req.url);
|
|
loginUrl.searchParams.set('next', `${pathname}${search}`);
|
|
return NextResponse.redirect(loginUrl);
|
|
}
|
|
|
|
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
|
|
}
|
|
|
|
export const config = {
|
|
matcher: ['/((?!.*\\.).*)', '/api/:path*'],
|
|
};
|