2026-03-23 16:28:35 -05:00
|
|
|
import type Database from "better-sqlite3";
|
2026-03-23 16:16:45 -05:00
|
|
|
import crypto from "node:crypto";
|
|
|
|
|
import { cookies } from "next/headers";
|
|
|
|
|
import { redirect } from "next/navigation";
|
|
|
|
|
|
|
|
|
|
const SESSION_COOKIE = "inven_session";
|
|
|
|
|
const SESSION_TTL_SECONDS = 60 * 60 * 24 * 14;
|
|
|
|
|
|
|
|
|
|
type SessionPayload = {
|
|
|
|
|
userId: number;
|
|
|
|
|
email: string;
|
|
|
|
|
role: string;
|
|
|
|
|
expiresAt: number;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function getAuthSecret() {
|
|
|
|
|
return process.env.AUTH_SECRET || "dev-insecure-auth-secret";
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-23 16:41:25 -05:00
|
|
|
function useSecureCookies() {
|
|
|
|
|
return process.env.AUTH_SECURE_COOKIES === "true";
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-23 16:16:45 -05:00
|
|
|
function hashPassword(password: string) {
|
|
|
|
|
const salt = crypto.randomBytes(16).toString("hex");
|
|
|
|
|
const hash = crypto.scryptSync(password, salt, 64).toString("hex");
|
|
|
|
|
return `${salt}:${hash}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function verifyPassword(password: string, storedHash: string) {
|
|
|
|
|
const [salt, expectedHash] = storedHash.split(":");
|
|
|
|
|
if (!salt || !expectedHash) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const hash = crypto.scryptSync(password, salt, 64).toString("hex");
|
|
|
|
|
return crypto.timingSafeEqual(Buffer.from(hash, "hex"), Buffer.from(expectedHash, "hex"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sign(value: string) {
|
|
|
|
|
return crypto.createHmac("sha256", getAuthSecret()).update(value).digest("hex");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function encodeSession(payload: SessionPayload) {
|
|
|
|
|
const base = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
|
|
|
return `${base}.${sign(base)}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function decodeSession(value: string | undefined): SessionPayload | null {
|
|
|
|
|
if (!value) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const [base, signature] = value.split(".");
|
|
|
|
|
if (!base || !signature || sign(base) !== signature) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const payload = JSON.parse(Buffer.from(base, "base64url").toString("utf8")) as SessionPayload;
|
|
|
|
|
if (payload.expiresAt < Date.now()) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return payload;
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-23 16:28:35 -05:00
|
|
|
export function bootstrapAdminUser(db: Database.Database) {
|
2026-03-23 16:16:45 -05:00
|
|
|
const countRow = db.prepare(`SELECT COUNT(*) AS count FROM users`).get() as { count: number };
|
|
|
|
|
|
|
|
|
|
if ((countRow.count ?? 0) > 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const email = (process.env.ADMIN_EMAIL || "").trim();
|
|
|
|
|
const password = process.env.ADMIN_PASSWORD || "";
|
|
|
|
|
|
|
|
|
|
if (!email || !password) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
db.prepare(`INSERT INTO users (email, password_hash, role) VALUES (?, ?, 'admin')`).run(email, hashPassword(password));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getSession() {
|
|
|
|
|
const cookieStore = await cookies();
|
|
|
|
|
return decodeSession(cookieStore.get(SESSION_COOKIE)?.value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function requireSession() {
|
|
|
|
|
const session = await getSession();
|
|
|
|
|
if (!session) {
|
|
|
|
|
redirect("/login");
|
|
|
|
|
}
|
|
|
|
|
return session;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function createSession(user: { id: number; email: string; role: string }) {
|
|
|
|
|
const cookieStore = await cookies();
|
|
|
|
|
const payload: SessionPayload = {
|
|
|
|
|
userId: user.id,
|
|
|
|
|
email: user.email,
|
|
|
|
|
role: user.role,
|
|
|
|
|
expiresAt: Date.now() + SESSION_TTL_SECONDS * 1000
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
cookieStore.set(SESSION_COOKIE, encodeSession(payload), {
|
|
|
|
|
httpOnly: true,
|
|
|
|
|
sameSite: "lax",
|
2026-03-23 16:41:25 -05:00
|
|
|
secure: useSecureCookies(),
|
2026-03-23 16:16:45 -05:00
|
|
|
path: "/",
|
|
|
|
|
maxAge: SESSION_TTL_SECONDS
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function destroySession() {
|
|
|
|
|
const cookieStore = await cookies();
|
|
|
|
|
cookieStore.delete(SESSION_COOKIE);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function authenticateUser(
|
2026-03-23 16:28:35 -05:00
|
|
|
db: Database.Database,
|
2026-03-23 16:16:45 -05:00
|
|
|
email: string,
|
|
|
|
|
password: string
|
|
|
|
|
) {
|
|
|
|
|
const user = db
|
|
|
|
|
.prepare(`SELECT id, email, password_hash AS passwordHash, role FROM users WHERE email = ?`)
|
|
|
|
|
.get(email) as { id: number; email: string; passwordHash: string; role: string } | undefined;
|
|
|
|
|
|
|
|
|
|
if (!user || !verifyPassword(password, user.passwordHash)) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
id: user.id,
|
|
|
|
|
email: user.email,
|
|
|
|
|
role: user.role
|
|
|
|
|
};
|
|
|
|
|
}
|