2026-05-06 20:35:34 +02:00
|
|
|
import type { NextRequest } from 'next/server';
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
import { toNextJsHandler } from 'better-auth/next-js';
|
|
|
|
|
|
2026-05-06 20:35:34 +02:00
|
|
|
import { auth } from '@/lib/auth';
|
|
|
|
|
import { createAuditLog } from '@/lib/audit';
|
|
|
|
|
import { logger } from '@/lib/logger';
|
|
|
|
|
|
|
|
|
|
const upstream = toNextJsHandler(auth);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Wrap better-auth's `[...all]` handler so we can stamp the audit log on
|
|
|
|
|
* authentication events. Better-auth itself doesn't fire any callback we
|
|
|
|
|
* can hook on sign-in / sign-out / failed-login — we inspect the route
|
|
|
|
|
* + response status after the upstream handler finishes.
|
|
|
|
|
*
|
|
|
|
|
* Successful sign-in → action 'login' (severity info)
|
|
|
|
|
* Failed sign-in → action 'login' (severity warning, ok=false)
|
|
|
|
|
* Sign-out → action 'logout' (userId resolved before cookie
|
|
|
|
|
* is cleared)
|
|
|
|
|
*
|
|
|
|
|
* Audit writes are fire-and-forget (createAuditLog never throws).
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
interface AuthBody {
|
|
|
|
|
user?: { id?: string; email?: string };
|
|
|
|
|
id?: string;
|
|
|
|
|
email?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clientMeta(req: NextRequest): { ipAddress: string; userAgent: string } {
|
|
|
|
|
const ip =
|
|
|
|
|
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? req.headers.get('x-real-ip') ?? '';
|
|
|
|
|
return { ipAddress: ip, userAgent: req.headers.get('user-agent') ?? '' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function logSignIn(args: {
|
|
|
|
|
req: NextRequest;
|
|
|
|
|
responseBody: string;
|
|
|
|
|
status: number;
|
|
|
|
|
attemptedEmail: string | null;
|
|
|
|
|
}) {
|
|
|
|
|
const meta = clientMeta(args.req);
|
|
|
|
|
let parsed: AuthBody | null = null;
|
|
|
|
|
try {
|
|
|
|
|
parsed = JSON.parse(args.responseBody) as AuthBody;
|
|
|
|
|
} catch {
|
|
|
|
|
/* upstream returned non-JSON */
|
|
|
|
|
}
|
|
|
|
|
const userId = parsed?.user?.id ?? parsed?.id ?? null;
|
|
|
|
|
const email = parsed?.user?.email ?? parsed?.email ?? args.attemptedEmail ?? null;
|
|
|
|
|
const ok = args.status >= 200 && args.status < 300;
|
|
|
|
|
|
2026-05-06 22:40:35 +02:00
|
|
|
// entityId is text/unbounded but indexed; truncate the attempted-
|
|
|
|
|
// email fallback to keep the row predictably sized when the form
|
|
|
|
|
// sends a giant value. The audit metadata still carries the full
|
|
|
|
|
// original attempted email for forensic context.
|
|
|
|
|
const safeAttempted = (args.attemptedEmail ?? '').slice(0, 256);
|
2026-05-06 20:35:34 +02:00
|
|
|
void createAuditLog({
|
|
|
|
|
userId,
|
|
|
|
|
portId: null,
|
|
|
|
|
action: 'login',
|
|
|
|
|
entityType: 'session',
|
2026-05-06 22:40:35 +02:00
|
|
|
entityId: userId ?? safeAttempted ?? 'unknown',
|
2026-05-06 20:35:34 +02:00
|
|
|
metadata: {
|
|
|
|
|
ok,
|
|
|
|
|
status: args.status,
|
|
|
|
|
attemptedEmail: args.attemptedEmail ?? email ?? null,
|
|
|
|
|
},
|
|
|
|
|
ipAddress: meta.ipAddress,
|
|
|
|
|
userAgent: meta.userAgent,
|
|
|
|
|
severity: ok ? 'info' : 'warning',
|
|
|
|
|
source: 'auth',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function logSignOut(req: NextRequest) {
|
|
|
|
|
const meta = clientMeta(req);
|
|
|
|
|
let userId: string | null = null;
|
|
|
|
|
try {
|
|
|
|
|
const session = await auth.api.getSession({ headers: req.headers });
|
|
|
|
|
userId = session?.user?.id ?? null;
|
|
|
|
|
} catch {
|
|
|
|
|
/* unauthenticated or expired */
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void createAuditLog({
|
|
|
|
|
userId,
|
|
|
|
|
portId: null,
|
|
|
|
|
action: 'logout',
|
|
|
|
|
entityType: 'session',
|
|
|
|
|
entityId: userId ?? 'unknown',
|
|
|
|
|
metadata: {},
|
|
|
|
|
ipAddress: meta.ipAddress,
|
|
|
|
|
userAgent: meta.userAgent,
|
|
|
|
|
severity: 'info',
|
|
|
|
|
source: 'auth',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function withAuthAudit(req: NextRequest): Promise<Response> {
|
|
|
|
|
const url = new URL(req.url);
|
|
|
|
|
const path = url.pathname;
|
|
|
|
|
const isSignIn = path.endsWith('/sign-in/email') || path.endsWith('/sign-in');
|
|
|
|
|
const isSignOut = path.endsWith('/sign-out');
|
|
|
|
|
|
|
|
|
|
// Read the request body BEFORE forwarding so we can extract the
|
|
|
|
|
// attempted email even when the credentials are wrong (the upstream
|
|
|
|
|
// handler will consume the body stream and we can't read it twice).
|
|
|
|
|
let attemptedEmail: string | null = null;
|
|
|
|
|
let forwardReq: NextRequest = req;
|
|
|
|
|
if (isSignIn && req.method === 'POST') {
|
|
|
|
|
try {
|
|
|
|
|
const raw = await req.text();
|
|
|
|
|
try {
|
|
|
|
|
attemptedEmail = (JSON.parse(raw) as { email?: string }).email ?? null;
|
|
|
|
|
} catch {
|
|
|
|
|
/* form-encoded or non-JSON */
|
|
|
|
|
}
|
|
|
|
|
// Reconstruct a fresh Request so the upstream handler can read it.
|
|
|
|
|
forwardReq = new Request(req.url, {
|
|
|
|
|
method: req.method,
|
|
|
|
|
headers: req.headers,
|
|
|
|
|
body: raw,
|
|
|
|
|
}) as unknown as NextRequest;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
logger.warn({ err }, 'Failed to read sign-in body for audit');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Capture sign-out userId BEFORE the upstream handler clears the cookie.
|
|
|
|
|
const signOutPromise = isSignOut ? logSignOut(req) : null;
|
|
|
|
|
|
|
|
|
|
const res = await upstream.POST(forwardReq);
|
|
|
|
|
|
|
|
|
|
if (isSignIn) {
|
|
|
|
|
try {
|
|
|
|
|
const body = await res.clone().text();
|
|
|
|
|
logSignIn({ req, responseBody: body, status: res.status, attemptedEmail });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
logger.warn({ err }, 'Failed to capture sign-in response for audit');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (signOutPromise) void signOutPromise;
|
|
|
|
|
|
|
|
|
|
return res;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const GET = upstream.GET;
|
|
|
|
|
export async function POST(req: NextRequest): Promise<Response> {
|
|
|
|
|
return withAuthAudit(req);
|
|
|
|
|
}
|