Files
webgis_pemetaan_kemiskinan/api/auth.ts
T

173 lines
5.9 KiB
TypeScript

import crypto from "node:crypto";
import * as cookie from "cookie";
import { sql } from "drizzle-orm";
import { Errors } from "@contracts/errors";
import { LocalAuth, Session } from "@contracts/constants";
import { getSessionCookieOptions } from "./lib/cookies";
import { signSessionToken, verifySessionToken } from "./session";
import { findUserByLoginIdentifier, findUserByUnionId, touchUserLastSignIn } from "./queries/users";
import { getDb } from "./queries/connection";
import { users } from "@db/schema";
const SCRYPT_KEY_LENGTH = 64;
export function hashPassword(password: string) {
const salt = crypto.randomBytes(16).toString("hex");
const derived = crypto.scryptSync(password, salt, SCRYPT_KEY_LENGTH) as Buffer;
return `${salt}:${derived.toString("hex")}`;
}
export function verifyPassword(password: string, storedHash: string) {
const [salt, hash] = storedHash.split(":");
if (!salt || !hash) {
return false;
}
const derived = crypto.scryptSync(password, salt, SCRYPT_KEY_LENGTH) as Buffer;
const stored = Buffer.from(hash, "hex");
if (stored.length !== derived.length) {
return false;
}
return crypto.timingSafeEqual(stored, derived);
}
export async function authenticateRequest(headers: Headers) {
const cookies = cookie.parse(headers.get("cookie") || "");
const token = cookies[Session.cookieName];
if (!token) {
console.warn("[auth] No session cookie found in request.");
throw Errors.forbidden("Invalid authentication token.");
}
const claim = await verifySessionToken(token);
if (!claim) {
throw Errors.forbidden("Invalid authentication token.");
}
const user = await findUserByUnionId(claim.unionId);
if (!user) {
throw Errors.forbidden("User not found. Please re-login.");
}
return user;
}
export async function loginWithCredentials(
identifier: string,
password: string,
) {
const user = await findUserByLoginIdentifier(identifier);
if (!user || !user.passwordHash) {
throw Errors.forbidden("Invalid username or password.");
}
if (!verifyPassword(password, user.passwordHash)) {
throw Errors.forbidden("Invalid username or password.");
}
await touchUserLastSignIn(user.unionId);
const token = await signSessionToken({
unionId: user.unionId,
clientId: "local",
});
return { user, token };
}
export function setSessionCookie(headers: Headers, token: string) {
const cookieOpts = getSessionCookieOptions(headers);
headers.append(
"set-cookie",
cookie.serialize(Session.cookieName, token, {
httpOnly: cookieOpts.httpOnly,
path: cookieOpts.path,
sameSite: cookieOpts.sameSite?.toLowerCase() as "lax" | "none",
secure: cookieOpts.secure,
maxAge: Session.maxAgeMs / 1000,
}),
);
}
export async function seedLocalUserIfMissing() {
const db = getDb();
// Ensure `password_hash` column exists (older DBs may lack it)
try {
const [colCheck] = await db.execute(sql`
SELECT COUNT(*) as cnt FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'users' AND column_name = 'password_hash'
`);
// colCheck can be an array of rows depending on driver
let count = 0;
if (Array.isArray(colCheck) && colCheck.length > 0) {
const first = colCheck[0];
count = Number(Object.values(first)[0]);
}
if (count === 0) {
await db.execute(sql`ALTER TABLE users ADD COLUMN password_hash TEXT`);
}
} catch (err) {
// If checking or altering fails, log and continue — seed will still try insert without password if necessary
console.warn('[seed] failed to ensure password_hash column exists', err);
}
// Check existence without selecting `password_hash` to avoid failures on older schemas
try {
await db.execute(sql`SELECT id FROM users WHERE unionId = ${LocalAuth.defaultAdminEmail} LIMIT 1`);
} catch (err) {
console.warn('[seed] existence check failed, will attempt to insert anyway', err);
}
// 1. Ensure admin account
try {
const [existingAdmin] = await db.execute(sql`SELECT id FROM users WHERE unionId = ${LocalAuth.defaultAdminEmail} LIMIT 1`);
if (!existingAdmin || (Array.isArray(existingAdmin) && existingAdmin.length === 0)) {
await db.insert(users).values({
unionId: LocalAuth.defaultAdminEmail,
name: "Admin Lokal",
email: LocalAuth.defaultAdminEmail,
passwordHash: hashPassword(LocalAuth.defaultAdminPassword),
role: "admin",
lastSignInAt: new Date(),
});
console.log(`[seed] Created admin account: ${LocalAuth.defaultAdminEmail}`);
}
} catch (err) {
console.warn('[seed] Admin seeding failed, attempting fallback', err);
try {
await db.execute(sql`INSERT IGNORE INTO users (unionId, name, email, role, lastSignInAt) VALUES (
${LocalAuth.defaultAdminEmail}, ${'Admin Lokal'}, ${LocalAuth.defaultAdminEmail}, ${'admin'}, ${new Date()}
)`);
} catch (err2) {
console.error('[seed] Admin fallback failed', err2);
}
}
// 2. Seed default roles for testing
const defaultAccounts = [
{ email: "officer@example.com", name: "Petugas Lapangan", role: "officer" as const },
{ email: "manager@example.com", name: "Pengelola Wilayah", role: "manager" as const },
];
for (const acc of defaultAccounts) {
try {
const res = await db.execute(sql`SELECT id FROM users WHERE unionId = ${acc.email} LIMIT 1`);
const [existing] = res as any;
if (existing && (!Array.isArray(existing) || existing.length > 0)) {
continue;
}
await db.insert(users).values({
unionId: acc.email,
name: acc.name,
email: acc.email,
passwordHash: hashPassword("password123"),
role: acc.role,
lastSignInAt: new Date(),
});
console.log(`[seed] Created ${acc.role} account: ${acc.email}`);
} catch (err) {
console.warn(`[seed] Failed to create ${acc.role} (${acc.email})`, err);
}
}
}