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
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
132
services/sessionLimitService.js
Normal file
132
services/sessionLimitService.js
Normal file
@@ -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
|
||||
};
|
||||
@@ -43,7 +43,8 @@
|
||||
<a href="#features"><%= t('landing.nav.features','Keunggulan') %></a>
|
||||
<a href="/customer/check-billing"><%= t('landing.hero.check','Cek Tagihan') %></a>
|
||||
<% if (voucherOn) { %><a href="/customer/voucher"><%= t('login.voucher_nav','Voucher') %></a><% } %>
|
||||
<% if (typeof settings !== 'undefined' && settings && settings.status_url) { %><a href="<%= settings.status_url %>" target="_blank" rel="noopener">Status</a><% } %>
|
||||
<% var _statusUrl = (typeof settings !== 'undefined' && settings) ? (settings.uptime_url || settings.status_url || '') : ''; %>
|
||||
<% if (_statusUrl) { %><a href="<%= _statusUrl %>" target="_blank" rel="noopener">Status</a><% } %>
|
||||
</nav>
|
||||
<div class="header-actions">
|
||||
<button class="icon-btn" onclick="toggleTheme()" aria-label="Ganti tema"><i class="bi bi-moon-stars-fill" id="themeIcon"></i></button>
|
||||
@@ -59,7 +60,7 @@
|
||||
<a href="/customer/check-billing" onclick="toggleMenu()"><i class="bi bi-receipt"></i> <%= t('landing.hero.check','Cek Tagihan') %></a>
|
||||
<% if (voucherOn) { %><a href="/customer/voucher" onclick="toggleMenu()"><i class="bi bi-ticket-perforated"></i> <%= t('login.voucher_short','Beli Voucher') %></a><% } %>
|
||||
<a href="/customer/register" onclick="toggleMenu()"><i class="bi bi-person-plus"></i> <%= t('login.register_new','Daftar Baru') %></a>
|
||||
<% if (typeof settings !== 'undefined' && settings && settings.status_url) { %><a href="<%= settings.status_url %>" target="_blank" rel="noopener" onclick="toggleMenu()"><i class="bi bi-activity"></i> Status Server</a><% } %>
|
||||
<% if (_statusUrl) { %><a href="<%= _statusUrl %>" target="_blank" rel="noopener" onclick="toggleMenu()"><i class="bi bi-activity"></i> Status Server</a><% } %>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -223,7 +224,7 @@
|
||||
<a href="/customer/tos"><%= t('customer.footer.tos', 'Syarat & Ketentuan') %></a>
|
||||
<a href="/customer/privacy"><%= t('customer.footer.privacy', 'Kebijakan Privasi') %></a>
|
||||
<a href="/customer/tos#refund"><%= t('customer.footer.refund', 'Kebijakan Refund') %></a>
|
||||
<% if (typeof settings !== 'undefined' && settings && settings.status_url) { %><a href="<%= settings.status_url %>" target="_blank" rel="noopener"><%= t('login.status_full','Status Server & Uptime') %></a><% } %>
|
||||
<% if (_statusUrl) { %><a href="<%= _statusUrl %>" target="_blank" rel="noopener"><%= t('login.status_full','Status Server & Uptime') %></a><% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user