import express from "express"; import cors from "cors"; import cookieParser from "cookie-parser"; import dotenv from "dotenv"; import path from "path"; import fs from "fs"; import crypto from "crypto"; import http from "http"; import axios from "axios"; import { fileURLToPath } from "url"; import { scanLogs, getBruteForceLocks, unblockIp, getBackupArchives, } from "./logParser.js"; dotenv.config(); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const app = express(); const server = http.createServer(app); const PORT = process.env.PORT || 5880; const LOGS_DIR = path.resolve(process.env.LOGS_DIR || "./logs"); const isDev = process.env.NODE_ENV !== "production" || process.env.VITE_DEV === "true"; app.use(cors({ origin: true, credentials: true })); app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(cookieParser()); // Default Keycloak Env Config const defaultKcServer = process.env.KEYCLOAK_URL || "https://auth.rssa.top/"; const defaultKcRealm = process.env.KEYCLOAK_REALM || "rssa"; const defaultKcClient = process.env.KEYCLOAK_CLIENT_ID || "satu"; const defaultKcSecret = process.env.KEYCLOAK_CLIENT_SECRET || "ZhkK45MHB0a0eAZX5ecNTnlfnWlZXfBE"; const SESSION_COOKIE = "forensic_session"; const PKCE_STATE_COOKIE = "pkce_auth_state"; // Helper for Session cookie function getSessionUser(req) { const sessionStr = req.cookies?.[SESSION_COOKIE]; if (!sessionStr) return null; try { return JSON.parse(Buffer.from(sessionStr, "base64").toString("utf-8")); } catch (e) { return null; } } function setSessionUser(res, user) { const encoded = Buffer.from(JSON.stringify(user)).toString("base64"); res.cookie(SESSION_COOKIE, encoded, { httpOnly: true, secure: false, sameSite: "lax", maxAge: 86400 * 1000, }); } // Cryptographic Helpers for PKCE function base64UrlEncode(buffer) { return buffer .toString("base64") .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=/g, ""); } function generateCodeVerifier() { return base64UrlEncode(crypto.randomBytes(32)); } function generateCodeChallenge(verifier) { const hash = crypto.createHash("sha256").update(verifier).digest(); return base64UrlEncode(hash); } // Keycloak Group Verification Helper (Restricted to "Instalasi STIM" / "STIM") async function extractAndVerifyUserGroups(payload, accessToken, serverUrl, realm) { const groups = new Set(); const checkClaim = (val) => { if (Array.isArray(val)) { val.forEach((item) => { if (typeof item === "string") groups.add(item); else if (item && item.name) groups.add(item.name); }); } else if (typeof val === "string") { groups.add(val); } }; checkClaim(payload.groups); checkClaim(payload.group); checkClaim(payload.realm_access?.roles); if (payload.resource_access) { Object.values(payload.resource_access).forEach((resAcc) => { checkClaim(resAcc?.roles); }); } // If no groups found in token payload, query Keycloak UserInfo Endpoint if (accessToken && serverUrl && realm) { try { const cleanServer = serverUrl.replace(/\/$/, ""); const userinfoUrl = `${cleanServer}/realms/${encodeURIComponent(realm)}/protocol/openid-connect/userinfo`; const res = await axios.get(userinfoUrl, { headers: { Authorization: `Bearer ${accessToken}` }, timeout: 5000, }); if (res.data) { checkClaim(res.data.groups); checkClaim(res.data.group); checkClaim(res.data.realm_access?.roles); } } catch (e) { console.warn("Keycloak UserInfo fetch warning:", e.message); } } const groupList = Array.from(groups); console.log("Extracted Keycloak user groups:", groupList); // Group STIM Authorization check const isAllowed = groupList.some((g) => { const lower = String(g).toLowerCase(); return lower.includes("stim") || lower.includes("instalasi stim"); }); return { isAllowed, groupList }; } if (!fs.existsSync(LOGS_DIR)) { fs.mkdirSync(LOGS_DIR, { recursive: true }); } // ------------------------------------------------------------- // REST API ENDPOINTS // ------------------------------------------------------------- // 1. PKCE Keycloak SSO Initiate Route app.get("/api/auth/pkce/login", (req, res) => { const serverUrl = (req.query.kc_server || defaultKcServer) .toString() .replace(/\/$/, ""); const realm = (req.query.kc_realm || defaultKcRealm).toString(); const clientId = (req.query.kc_client_id || defaultKcClient).toString(); const clientSecret = ( req.query.kc_client_secret || defaultKcSecret ).toString(); const codeVerifier = generateCodeVerifier(); const codeChallenge = generateCodeChallenge(codeVerifier); const state = base64UrlEncode(crypto.randomBytes(16)); const proto = req.headers["x-forwarded-proto"] || req.protocol || "http"; const host = req.headers["x-forwarded-host"] || req.headers.host; let redirectUri = ( req.query.kc_redirect_uri || process.env.KEYCLOAK_REDIRECT_URI || "" ).toString(); if (!redirectUri) { redirectUri = `${proto}://${host}/api/auth/pkce/callback`; } const pkceState = { codeVerifier, state, redirectUri, serverUrl, realm, clientId, clientSecret, }; res.cookie( PKCE_STATE_COOKIE, Buffer.from(JSON.stringify(pkceState)).toString("base64"), { httpOnly: true, secure: false, sameSite: "lax", maxAge: 600 * 1000, }, ); const authEndpoint = `${serverUrl}/realms/${encodeURIComponent(realm)}/protocol/openid-connect/auth`; const authUrl = `${authEndpoint}?` + new URLSearchParams({ response_type: "code", client_id: clientId, redirect_uri: redirectUri, scope: "openid profile email", state: state, code_challenge: codeChallenge, code_challenge_method: "S256", }).toString(); return res.redirect(authUrl); }); // 2. PKCE Keycloak SSO Callback Route app.get("/api/auth/pkce/callback", async (req, res) => { const { code, state, error, error_description } = req.query; if (error) { res.clearCookie(PKCE_STATE_COOKIE); return res.redirect( `/?error=${encodeURIComponent(error_description || error)}`, ); } const pkceCookie = req.cookies?.[PKCE_STATE_COOKIE]; if (!pkceCookie || !code || !state) { res.clearCookie(PKCE_STATE_COOKIE); return res.redirect("/?error=Sesi+PKCE+tidak+valid+atau+kadaluarsa."); } let pkceState = null; try { pkceState = JSON.parse(Buffer.from(pkceCookie, "base64").toString("utf-8")); } catch (e) { res.clearCookie(PKCE_STATE_COOKIE); return res.redirect("/?error=Gagal+membaca+state+PKCE."); } if (pkceState.state !== state) { res.clearCookie(PKCE_STATE_COOKIE); return res.redirect("/?error=Validasi+CSRF+State+PKCE+Gagal."); } const tokenEndpoint = `${pkceState.serverUrl}/realms/${encodeURIComponent(pkceState.realm)}/protocol/openid-connect/token`; const params = new URLSearchParams(); params.append("grant_type", "authorization_code"); params.append("client_id", pkceState.clientId); params.append("code", code.toString()); params.append("redirect_uri", pkceState.redirectUri); params.append("code_verifier", pkceState.codeVerifier); if (pkceState.clientSecret) { params.append("client_secret", pkceState.clientSecret); } try { const tokenRes = await axios.post(tokenEndpoint, params.toString(), { headers: { "Content-Type": "application/x-www-form-urlencoded" }, timeout: 10000, }); if (tokenRes.data && tokenRes.data.access_token) { let payload = {}; const parts = tokenRes.data.access_token.split("."); if (parts[1]) { try { payload = JSON.parse( Buffer.from(parts[1], "base64").toString("utf-8"), ); } catch (e) {} } const username = payload.preferred_username || payload.sub || "User"; // Verify STIM Group Access const { isAllowed, groupList } = await extractAndVerifyUserGroups( payload, tokenRes.data.access_token, pkceState.serverUrl, pkceState.realm, ); if (!isAllowed) { res.clearCookie(PKCE_STATE_COOKIE); const groupInfo = groupList.length > 0 ? groupList.join(", ") : "Tanpa Grup"; return res.redirect( `/?error=${encodeURIComponent(`Akses Ditolak: User "${username}" (${groupInfo}) tidak memiliki akses grup Instalasi STIM.`)}`, ); } const user = { username: username, name: payload.name || username, email: payload.email || "", login_time: new Date().toISOString().replace("T", " ").substring(0, 19), access_token: tokenRes.data.access_token, auth_type: "keycloak_sso_pkce", kc_server: pkceState.serverUrl, kc_realm: pkceState.realm, groups: groupList, }; setSessionUser(res, user); res.clearCookie(PKCE_STATE_COOKIE); return res.redirect("/"); } else { res.clearCookie(PKCE_STATE_COOKIE); return res.redirect("/?error=Penukaran+Token+PKCE+Gagal."); } } catch (err) { const errMsg = err.response?.data?.error_description || err.response?.data?.error || err.message; res.clearCookie(PKCE_STATE_COOKIE); return res.redirect( `/?error=Penukaran+Token+PKCE+Error:+${encodeURIComponent(errMsg)}`, ); } }); // 3. Direct Login Endpoint (Fallback / Temporary Local) app.post("/api/auth/login", async (req, res) => { const { username, password, kc_server, kc_realm, kc_client_id, kc_client_secret, } = req.body; if (!username || !password) { return res .status(400) .json({ error: "Username dan Password wajib diisi." }); } // A. Temporary Local Login if ( (username === "stim@rssa.id" || username === "stim") && password === "RSSAjaya2026" ) { const user = { username: "stim@rssa.id", name: "STIM RSSA Admin (Pengembangan SSO)", email: "stim@rssa.id", login_time: new Date().toISOString().replace("T", " ").substring(0, 19), auth_type: "temporary_local", kc_server: "local", kc_realm: "simrs", groups: ["Instalasi STIM"], }; setSessionUser(res, user); return res.json({ success: true, user }); } // B. Keycloak Direct SSO Login (Fall-back Direct Token) const serverUrl = (kc_server || defaultKcServer).replace(/\/$/, ""); const realm = kc_realm || defaultKcRealm; const clientId = kc_client_id || defaultKcClient; const clientSecret = kc_client_secret || defaultKcSecret; let tokenEndpoint = ""; if (serverUrl.includes("/protocol/openid-connect")) { tokenEndpoint = serverUrl; } else if (serverUrl.includes("/realms/")) { tokenEndpoint = `${serverUrl}/protocol/openid-connect/token`; } else { tokenEndpoint = `${serverUrl}/realms/${encodeURIComponent(realm)}/protocol/openid-connect/token`; } const params = new URLSearchParams(); params.append("grant_type", "password"); params.append("client_id", clientId); params.append("username", username); params.append("password", password); params.append("scope", "openid profile email"); if (clientSecret) { params.append("client_secret", clientSecret); } try { const response = await axios.post(tokenEndpoint, params.toString(), { headers: { "Content-Type": "application/x-www-form-urlencoded" }, timeout: 10000, }); if (response.data && response.data.access_token) { let payload = {}; const parts = response.data.access_token.split("."); if (parts[1]) { try { payload = JSON.parse( Buffer.from(parts[1], "base64").toString("utf-8"), ); } catch (e) {} } const userUsername = payload.preferred_username || username; // Verify STIM Group Access const { isAllowed, groupList } = await extractAndVerifyUserGroups( payload, response.data.access_token, serverUrl, realm, ); if (!isAllowed) { const groupInfo = groupList.length > 0 ? groupList.join(", ") : "Tanpa Grup"; return res.status(403).json({ error: `Akses Ditolak: User "${userUsername}" (${groupInfo}) tidak memiliki akses grup Instalasi STIM.`, }); } const user = { username: userUsername, name: payload.name || payload.preferred_username || username, email: payload.email || "", login_time: new Date().toISOString().replace("T", " ").substring(0, 19), access_token: response.data.access_token, auth_type: "keycloak_sso", kc_server: serverUrl, kc_realm: realm, groups: groupList, }; setSessionUser(res, user); return res.json({ success: true, user }); } else { return res .status(401) .json({ error: "Keycloak SSO: Token tidak valid." }); } } catch (err) { const errMsg = err.response?.data?.error_description || err.response?.data?.error || err.message; return res.status(401).json({ error: `Login Keycloak Gagal: ${errMsg}` }); } }); app.post("/api/auth/logout", (req, res) => { res.clearCookie(SESSION_COOKIE); res.clearCookie(PKCE_STATE_COOKIE); res.json({ success: true }); }); app.get("/api/auth/me", (req, res) => { const envKcServer = process.env.KEYCLOAK_URL || process.env.SSO_URL; const envKcRealm = process.env.KEYCLOAK_REALM; const envKcClient = process.env.KEYCLOAK_CLIENT_ID; const isKcEnvConfigured = Boolean(envKcServer || (envKcRealm && envKcClient)); const simrsUrl = process.env.SIMRS_URL || "/"; const user = getSessionUser(req); const config = { isKcEnvConfigured, defaultKcServer, defaultKcRealm, defaultKcClient, defaultKcSecret, simrsUrl, }; if (!user) { return res.json({ authenticated: false, config }); } return res.json({ authenticated: true, user, config }); }); // 4. Scan Logs API app.get("/api/logs", (req, res) => { const { source = "auto", date = new Date().toISOString().substring(0, 10), channel = "all", level = "all", time_start = "", time_end = "", search = "", page = "1", limit = "100", } = req.query; const filterOpts = { source: String(source), date: String(date), channel: String(channel), level: String(level), timeStart: String(time_start), timeEnd: String(time_end), search: String(search), }; const { logs, stats } = scanLogs(LOGS_DIR, filterOpts); const lockedIPs = getBruteForceLocks(LOGS_DIR); const backups = getBackupArchives(LOGS_DIR); const totalLogs = stats.total; const perPage = parseInt(String(limit), 10) || 100; const totalPages = Math.max(1, Math.ceil(logs.length / perPage)); const currentPage = Math.min( Math.max(1, parseInt(String(page), 10) || 1), totalPages, ); const offset = (currentPage - 1) * perPage; const pagedLogs = logs.slice(offset, offset + perPage); res.json({ logs: pagedLogs, stats: { ...stats, brute_force_locked: lockedIPs.length, }, pagination: { totalLogs, perPage, currentPage, totalPages, hasNext: currentPage < totalPages, offset, }, backups, lockedIPs, }); }); // 5. Brute Force API app.get("/api/brute-force", (req, res) => { const lockedIPs = getBruteForceLocks(LOGS_DIR); res.json({ lockedIPs }); }); app.post("/api/brute-force/unblock", (req, res) => { const { target_ip } = req.body; if (!target_ip) { return res.status(400).json({ error: "target_ip wajib diisi." }); } const success = unblockIp(LOGS_DIR, target_ip); if (success) { res.json({ success: true, message: `Lockout IP ${target_ip} berhasil dibersihkan.`, }); } else { res .status(404) .json({ error: `IP ${target_ip} tidak ditemukan dalam cache lockout.` }); } }); // 6. Manual Retention Trigger API app.post("/api/retention/run", (req, res) => { const retentionPath = path.join(LOGS_DIR, "retention.log"); const nowStr = new Date().toISOString().replace("T", " ").substring(0, 19); const logEntry = `[${nowStr}] [INFO] [system] Manual retention job executed via API.\n`; try { fs.appendFileSync(retentionPath, logEntry); res.json({ success: true, message: "Job retention & backup berhasil dijalankan (Manual Retention Triggered).", }); } catch (err) { res .status(500) .json({ error: `Gagal menjalankan retention job: ${err.message}` }); } }); // 7. Backups Listing API app.get("/api/backups", (req, res) => { const backups = getBackupArchives(LOGS_DIR); res.json({ backups }); }); // 8. Export API app.get("/api/export", (req, res) => { const { type = "json", source = "auto", date = new Date().toISOString().substring(0, 10), channel = "all", level = "all", search = "", } = req.query; const filterOpts = { source: String(source), date: String(date), channel: String(channel), level: String(level), search: String(search), }; const { logs } = scanLogs(LOGS_DIR, filterOpts); if (type === "csv") { res.setHeader("Content-Type", "text/csv"); res.setHeader( "Content-Disposition", `attachment; filename="forensic_log_${date}.csv"`, ); let csv = "Timestamp,Correlation ID,Channel,Level,Message,IP,NIP,URI\n"; for (const l of logs) { const ts = `"${(l.timestamp || "").replace(/"/g, '""')}"`; const cid = `"${(l.correlation_id || "").replace(/"/g, '""')}"`; const ch = `"${(l.channel || "").replace(/"/g, '""')}"`; const lvl = `"${(l.level || "").replace(/"/g, '""')}"`; const msg = `"${(l.message || "").replace(/"/g, '""')}"`; const ip = `"${(l.context?.ip_address || "").replace(/"/g, '""')}"`; const nip = `"${(l.context?.user_nip || "").replace(/"/g, '""')}"`; const uri = `"${(l.context?.request_uri || "").replace(/"/g, '""')}"`; csv += `${ts},${cid},${ch},${lvl},${msg},${ip},${nip},${uri}\n`; } return res.send(csv); } else { res.setHeader("Content-Type", "application/json"); res.setHeader( "Content-Disposition", `attachment; filename="forensic_log_${date}.json"`, ); return res.json(logs); } }); // ------------------------------------------------------------- // STATIC DIST SERVING (PRIMARY) VS VITE HMR DEV MIDDLEWARE // ------------------------------------------------------------- const distDir = path.resolve(__dirname, "../dist"); if (fs.existsSync(distDir) && process.env.VITE_DEV !== "true") { app.use(express.static(distDir)); app.get("*", (req, res, next) => { if (req.path.startsWith("/api")) return next(); res.sendFile(path.join(distDir, "index.html")); }); } else if (isDev) { try { const { createServer: createViteServer } = await import("vite"); const vite = await createViteServer({ server: { middlewareMode: true, allowedHosts: true, hmr: { server: server, }, watch: { usePolling: true, interval: 500, ignored: ["**/logs/**", "**/node_modules/**", "**/dist/**", "**/.git/**"], }, }, appType: "custom", }); app.use(vite.middlewares); app.get("*", async (req, res, next) => { if (req.path.startsWith("/api")) return next(); try { const url = req.originalUrl; const indexPath = path.resolve(__dirname, "../index.html"); let template = fs.readFileSync(indexPath, "utf-8"); template = await vite.transformIndexHtml(url, template); res.status(200).set({ "Content-Type": "text/html" }).end(template); } catch (e) { vite.ssrFixStacktrace(e); next(e); } }); } catch (err) { console.error( "Failed to start Vite Dev Middleware, falling back to static:", err.message, ); } } server.listen(PORT, "0.0.0.0", () => { console.log( `⚡ SIMRS Forensic Inspector Server running on http://0.0.0.0:${PORT} [ENV: ${process.env.NODE_ENV || "development"}]`, ); });