Files
log-viewer/server/index.js
T
2026-08-05 01:30:50 +00:00

688 lines
20 KiB
JavaScript

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 === "[email protected]" || username === "stim") &&
password === "RSSAjaya2026"
) {
const user = {
username: "[email protected]",
name: "STIM RSSA Admin (Pengembangan SSO)",
email: "[email protected]",
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"}]`,
);
});