From adc3437bd57e841ce305161ce00915adb3ee18ab Mon Sep 17 00:00:00 2001 From: oxpoe Date: Thu, 5 Nov 2026 01:58:29 +0700 Subject: [PATCH] fix(login): link status ikut setting uptime_url + batas sesi per akun - login.ejs: pakai settings.uptime_url (fallback status_url lama), sehingga tab Status tidak lagi hardcode status.skyfiber.qzz.io - sessionLimitService: rotasi session id saat login (anti fixation), catat sesi di tabel login_sessions, batas admin/kasir/teknisi/kolektor 2 per akun dan pelanggan 3 per akun; sesi tertua diputus dari store saat melebihi batas - hook login admin/kasir/teknisi/kolektor/pelanggan (direct & OTP) dan logout --- routes/adminPortal.js | 17 +++- routes/collectorPortal.js | 7 ++ routes/customerPortal.js | 21 ++++- routes/techPortal.js | 7 ++ services/sessionLimitService.js | 132 ++++++++++++++++++++++++++++++++ views/login.ejs | 7 +- 6 files changed, 186 insertions(+), 5 deletions(-) create mode 100644 services/sessionLimitService.js diff --git a/routes/adminPortal.js b/routes/adminPortal.js index 10b09c2..210bbbe 100644 --- a/routes/adminPortal.js +++ b/routes/adminPortal.js @@ -57,6 +57,7 @@ const backupSvc = require('../services/backupService'); const monitoringSvc = require('../services/monitoringService'); const inventorySvc = require('../services/inventoryService'); const securityWatchdog = require('../services/securityWatchdogService'); +const sessionLimit = require('../services/sessionLimitService'); const auditSvc = require('../services/auditTrailService'); const whatsappService = require('../services/whatsappService'); const { parseMikhmonOnLogin } = require('../utils/mikhmonParser'); @@ -568,8 +569,13 @@ router.post('/login', loginRateLimiter, express.urlencoded({ extended: true }), const { username, password } = req.body; if (username === getSetting('admin_username', 'admin') && password === getSetting('admin_password', 'demo-isp123')) { await securityWatchdog.recordAppLoginAttempt({ type: 'admin', username, ip, success: true }); + await sessionLimit.rotateSession(req); req.session.isAdmin = true; req.session.adminUser = username; + sessionLimit.registerLoginSession(req, { + role: 'admin', accountId: username, limit: 2, + ip, userAgent: req.get('user-agent') + }); return res.redirect('/admin'); } @@ -577,6 +583,11 @@ router.post('/login', loginRateLimiter, express.urlencoded({ extended: true }), const cashier = adminSvc.authenticateCashier(username, password); if (cashier) { await securityWatchdog.recordAppLoginAttempt({ type: 'cashier', username, ip, success: true }); + await sessionLimit.rotateSession(req); + sessionLimit.registerLoginSession(req, { + role: 'cashier', accountId: cashier.id, limit: 2, + ip, userAgent: req.get('user-agent') + }); req.session.isCashier = true; req.session.cashierId = cashier.id; req.session.cashierName = cashier.name; @@ -594,7 +605,11 @@ router.post('/login', loginRateLimiter, express.urlencoded({ extended: true }), res.render('admin/login', { title: 'Admin Login', company: company(), error: 'Username atau password salah' }); }); -router.get('/logout', (req, res) => { req.session.destroy(); res.redirect('/admin/login'); }); +router.get('/logout', (req, res) => { + sessionLimit.removeLoginSession(req); + req.session.destroy(); + res.redirect('/admin/login'); +}); // ─── OLT MANAGEMENT ──────────────────────────────────────────────────────── router.get('/olts', requireAdminSession, async (req, res) => { diff --git a/routes/collectorPortal.js b/routes/collectorPortal.js index 34241c7..f2b60b6 100644 --- a/routes/collectorPortal.js +++ b/routes/collectorPortal.js @@ -6,6 +6,7 @@ const db = require('../config/database'); const billingSvc = require('../services/billingService'); const customerSvc = require('../services/customerService'); const securityWatchdog = require('../services/securityWatchdogService'); +const sessionLimit = require('../services/sessionLimitService'); const adminSvc = require('../services/adminService'); const attendanceSvc = require('../services/attendanceService'); const pdfSvc = require('../services/pdfInvoiceService'); @@ -65,7 +66,12 @@ router.post('/login', loginRateLimiter, express.urlencoded({ extended: true }), const collector = adminSvc.authenticateCollector(username, password); if (collector) { await securityWatchdog.recordAppLoginAttempt({ type: 'collector', username, ip, success: true }); + await sessionLimit.rotateSession(req); req.session.isCollector = true; + sessionLimit.registerLoginSession(req, { + role: 'collector', accountId: collector.id, limit: 2, + ip, userAgent: req.get('user-agent') + }); req.session.collectorId = collector.id; req.session.collectorName = collector.name; req.session.collectorUsername = collector.username; @@ -83,6 +89,7 @@ router.post('/login', loginRateLimiter, express.urlencoded({ extended: true }), }); router.get('/logout', (req, res) => { + sessionLimit.removeLoginSession(req); req.session.destroy(); res.redirect('/collector/login'); }); diff --git a/routes/customerPortal.js b/routes/customerPortal.js index b988515..7438435 100644 --- a/routes/customerPortal.js +++ b/routes/customerPortal.js @@ -1,4 +1,5 @@ const express = require('express'); +const sessionLimit = require('../services/sessionLimitService'); const router = express.Router(); const customerDevice = require('../services/customerDeviceService'); const { getSettingsWithCache, getNowLocal, getCurrentTimeInfo, getNowLocalISO, formatDateLocal, formatTimeLocal } = require('../config/settingsManager'); @@ -1791,6 +1792,14 @@ router.post('/login', loginRateLimiter, async (req, res) => { // --- DIRECT LOGIN --- logger.info('[Login] Login direct berhasil.'); + await sessionLimit.rotateSession(req); + sessionLimit.registerLoginSession(req, { + role: 'customer', + accountId: customer ? customer.id : String(customerPhone || '').replace(/\D/g, ''), + limit: 3, + ip: req.ip || req.connection?.remoteAddress || '', + userAgent: req.get('user-agent') + }); req.session.phone = customerPhone; // Nomor telepon untuk findCustomerByAny() req.session.pppoe_username = pppoeUsername; // PPPoE username untuk GenieACS & MikroTik if (customer && customer.status === 'suspended') { @@ -1805,7 +1814,7 @@ router.get('/login-otp', (req, res) => { res.render('login_otp', { error: null, settings, phone: req.session.pending_login.phone }); }); -router.post('/login-otp', loginRateLimiter, (req, res) => { +router.post('/login-otp', loginRateLimiter, async (req, res) => { const { otp } = req.body; const settings = getSettingsWithCache(); const pending = req.session.pending_login; @@ -1824,6 +1833,15 @@ router.post('/login-otp', loginRateLimiter, (req, res) => { if (otp === pending.otp) { logger.info('[Login] OTP berhasil diverifikasi.'); + await sessionLimit.rotateSession(req); + const custForLimit = customerSvc.findCustomerByAny(pending.phone); + sessionLimit.registerLoginSession(req, { + role: 'customer', + accountId: custForLimit ? custForLimit.id : String(pending.phone || '').replace(/\D/g, ''), + limit: 3, + ip: req.ip || req.connection?.remoteAddress || '', + userAgent: req.get('user-agent') + }); req.session.phone = pending.phone; // Nomor telepon customer req.session.pppoe_username = pending.pppoeUsername; // PPPoE username untuk GenieACS & MikroTik delete req.session.pending_login; @@ -2591,6 +2609,7 @@ router.post('/change-tag', async (req, res) => { }); router.post('/logout', (req, res) => { + sessionLimit.removeLoginSession(req); req.session.destroy(() => { res.redirect('/customer/login'); }); diff --git a/routes/techPortal.js b/routes/techPortal.js index 3b14e4c..4e77e01 100644 --- a/routes/techPortal.js +++ b/routes/techPortal.js @@ -2,6 +2,7 @@ const express = require('express'); const router = express.Router(); const techSvc = require('../services/techService'); const securityWatchdog = require('../services/securityWatchdogService'); +const sessionLimit = require('../services/sessionLimitService'); const customerSvc = require('../services/customerService'); const odpSvc = require('../services/odpService'); const { getSetting, getNowLocal, getCurrentDateInTimezone, getNowLocalISO, formatDateLocal, getSettings, formatTimeLocal, parseDateInTimezone } = require('../config/settingsManager'); @@ -155,7 +156,12 @@ router.post('/login', loginRateLimiter, express.urlencoded({ extended: true }), const tech = techSvc.authenticate(username, password); if (tech) { await securityWatchdog.recordAppLoginAttempt({ type: 'technician', username, ip, success: true }); + await sessionLimit.rotateSession(req); req.session.isTechnician = true; + sessionLimit.registerLoginSession(req, { + role: 'technician', accountId: tech.id, limit: 2, + ip, userAgent: req.get('user-agent') + }); req.session.techId = tech.id; req.session.techName = tech.name; return res.redirect('/tech'); @@ -171,6 +177,7 @@ router.post('/login', loginRateLimiter, express.urlencoded({ extended: true }), }); router.get('/logout', (req, res) => { + sessionLimit.removeLoginSession(req); req.session.destroy(); res.redirect('/tech/login'); }); diff --git a/services/sessionLimitService.js b/services/sessionLimitService.js new file mode 100644 index 0000000..a544ca9 --- /dev/null +++ b/services/sessionLimitService.js @@ -0,0 +1,132 @@ +/** + * Service: batas jumlah sesi login per akun. + * + * - admin / kasir / teknisi / kolektor : maksimal 2 sesi aktif per akun + * - pelanggan : maksimal 3 sesi aktif per akun + * + * Sesi yang melebihi batas akan dihapus dari tabel `sessions` (cookie lama + * otomatis tidak berlaku), bukan sekadar diabaikan. Rotasi session id saat + * login mencegah session fixation. + */ +const db = require('../config/database'); +const { logger } = require('../config/logger'); + +const DEFAULT_LIMITS = { + admin: 2, + cashier: 2, + technician: 2, + collector: 2, + customer: 3 +}; + +try { + db.exec(` + CREATE TABLE IF NOT EXISTS login_sessions ( + sid TEXT PRIMARY KEY, + role TEXT NOT NULL, + account_id TEXT NOT NULL, + ip TEXT DEFAULT '', + user_agent TEXT DEFAULT '', + created_at INTEGER NOT NULL, + last_seen INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_login_sessions_account ON login_sessions(role, account_id); + `); +} catch (e) { + logger.error('[SessionLimit] Gagal membuat tabel login_sessions: ' + e.message); +} + +function now() { return Date.now(); } + +/** Hapus baris yang sesi aslinya sudah tidak ada / kedaluwarsa. */ +function purgeDead() { + try { + db.prepare(` + DELETE FROM login_sessions + WHERE sid NOT IN (SELECT sid FROM sessions WHERE expires_at IS NULL OR expires_at > ?) + `).run(now()); + } catch (e) { /* tabel sessions belum ada saat init awal */ } +} + +function rotateSession(req) { + return new Promise((resolve, reject) => { + const oldSid = req.sessionID; + req.session.regenerate((err) => { + if (err) return reject(err); + try { + if (oldSid && oldSid !== req.sessionID) { + db.prepare('DELETE FROM login_sessions WHERE sid = ?').run(oldSid); + db.prepare('DELETE FROM sessions WHERE sid = ?').run(oldSid); + } + } catch (e) { /* abaikan */ } + resolve(); + }); + }); +} + +/** + * Catat sesi login dan terapkan batas maksimal. + * @returns {{ allowed: boolean, active: number, limit: number }} + */ +function registerLoginSession(req, { role, accountId, limit, ip = '', userAgent = '' }) { + const r = String(role || '').toLowerCase(); + const acc = String(accountId == null ? '' : accountId); + const max = Number(limit) > 0 ? Number(limit) : (DEFAULT_LIMITS[r] || 2); + const sid = req.sessionID; + + if (!sid || !r || !acc) return { allowed: false, active: 0, limit: max }; + + purgeDead(); + + const existing = db.prepare('SELECT sid FROM login_sessions WHERE sid = ?').get(sid); + if (existing) { + db.prepare('UPDATE login_sessions SET last_seen = ? WHERE sid = ?').run(now(), sid); + return { allowed: true, active: countActive(r, acc), limit: max }; + } + + const rows = db.prepare( + 'SELECT sid FROM login_sessions WHERE role = ? AND account_id = ? ORDER BY created_at ASC' + ).all(r, acc); + + const excess = rows.length - max + 1; + if (excess > 0) { + for (const row of rows.slice(0, excess)) { + try { + db.prepare('DELETE FROM login_sessions WHERE sid = ?').run(row.sid); + db.prepare('DELETE FROM sessions WHERE sid = ?').run(row.sid); + logger.info(`[SessionLimit] Sesi lama ${r}:${acc} diputus (${row.sid.slice(0, 8)}…) karena melebihi batas ${max}.`); + } catch (e) { /* abaikan */ } + } + } + + db.prepare(` + INSERT INTO login_sessions (sid, role, account_id, ip, user_agent, created_at, last_seen) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run(sid, r, acc, String(ip || '').slice(0, 64), String(userAgent || '').slice(0, 255), now(), now()); + + return { allowed: true, active: countActive(r, acc), limit: max }; +} + +function countActive(role, accountId) { + try { + const row = db.prepare( + 'SELECT COUNT(*) as c FROM login_sessions WHERE role = ? AND account_id = ?' + ).get(String(role), String(accountId)); + return Number(row?.c || 0); + } catch (e) { return 0; } +} + +function removeLoginSession(req) { + try { + if (req.sessionID) db.prepare('DELETE FROM login_sessions WHERE sid = ?').run(req.sessionID); + } catch (e) { /* abaikan */ } +} + +module.exports = { + DEFAULT_LIMITS, + rotateSession, + registerLoginSession, + removeLoginSession, + countActive, + purgeDead +}; diff --git a/views/login.ejs b/views/login.ejs index 5355da6..f618811 100644 --- a/views/login.ejs +++ b/views/login.ejs @@ -43,7 +43,8 @@ <%= t('landing.nav.features','Keunggulan') %> <%= t('landing.hero.check','Cek Tagihan') %> <% if (voucherOn) { %><%= t('login.voucher_nav','Voucher') %><% } %> - <% if (typeof settings !== 'undefined' && settings && settings.status_url) { %>Status<% } %> + <% var _statusUrl = (typeof settings !== 'undefined' && settings) ? (settings.uptime_url || settings.status_url || '') : ''; %> + <% if (_statusUrl) { %>Status<% } %>
@@ -59,7 +60,7 @@ <%= t('landing.hero.check','Cek Tagihan') %> <% if (voucherOn) { %> <%= t('login.voucher_short','Beli Voucher') %><% } %> <%= t('login.register_new','Daftar Baru') %> - <% if (typeof settings !== 'undefined' && settings && settings.status_url) { %> Status Server<% } %> + <% if (_statusUrl) { %> Status Server<% } %>
@@ -223,7 +224,7 @@ <%= t('customer.footer.tos', 'Syarat & Ketentuan') %> <%= t('customer.footer.privacy', 'Kebijakan Privasi') %> <%= t('customer.footer.refund', 'Kebijakan Refund') %> - <% if (typeof settings !== 'undefined' && settings && settings.status_url) { %><%= t('login.status_full','Status Server & Uptime') %><% } %> + <% if (_statusUrl) { %><%= t('login.status_full','Status Server & Uptime') %><% } %>