From 45a78a3fed5831b46abadd598f5d021cd8ab9da6 Mon Sep 17 00:00:00 2001 From: Thanakorn S Date: Sun, 24 May 2026 11:37:53 +0700 Subject: [PATCH] login: block concurrent login, single-factor auth for staff/viewer --- app/assets/js/custom.js | 8 ++++ app/assets/utils/classes/ReportManager.php | 1 + app/assets/utils/classes/StockManager.php | 22 ++++----- app/assets/utils/db_auth.php | 13 ++++++ app/login/api/engine/back.php | 6 +++ app/login/api/engine/login_confirm.php | 54 ++++++++++++++++++---- app/login/api/engine/login_otp.php | 25 ++++++++++ app/login/index.php | 11 +++++ setup.php | 6 ++- 9 files changed, 124 insertions(+), 22 deletions(-) diff --git a/app/assets/js/custom.js b/app/assets/js/custom.js index 29a71ba..1ae5dfa 100644 --- a/app/assets/js/custom.js +++ b/app/assets/js/custom.js @@ -821,6 +821,14 @@ function ajax_request(options) { throw xhr; } + // Session displaced — another login took over this account + if (xhr?.status === 401 && xhr?.responseJSON?.code === 'signed_elsewhere') { + bootbox.alert('Signed in from another device.
Your session has been ended because this account was signed in elsewhere.', function() { + window.location.href = server_url + 'index.php'; + }); + return; + } + // File / payload too large (nginx 413) if (xhr?.status === 413) { bootbox.alert('The file you selected is too large. Please use an image under 2 MB and try again.'); diff --git a/app/assets/utils/classes/ReportManager.php b/app/assets/utils/classes/ReportManager.php index 9018adb..3ddef2a 100644 --- a/app/assets/utils/classes/ReportManager.php +++ b/app/assets/utils/classes/ReportManager.php @@ -44,6 +44,7 @@ class ReportManager return 'td_stock_' . $warehouse_id; } + private function resolveWarehouseTable(int $warehouse_id): ?string { $sth = $this->pdo->prepare( diff --git a/app/assets/utils/classes/StockManager.php b/app/assets/utils/classes/StockManager.php index 6a32aa2..82b3997 100644 --- a/app/assets/utils/classes/StockManager.php +++ b/app/assets/utils/classes/StockManager.php @@ -292,10 +292,10 @@ class StockManager { $this->pdo->prepare( "INSERT INTO `$table` (uuid, company_id, `date`, product_sku, `in`, price, zone, aisle, rack, - contact_id, `description`, `log`, `type`, lot_number, serial_number, status) + contact_id, `description`, `log`, `type`, lot_number, serial_number, status, updated_at) VALUES (:uuid, :company_id, :date, :product_sku, :quantity, :price, :zone, :aisle, :rack, - :contact_id, :description, :log, 'in', :lot_number, :serial_number, 0)" + :contact_id, :description, :log, 'in', :lot_number, :serial_number, 0, NOW())" )->execute([ ':uuid' => $uuid, ':company_id' => $this->company_id, @@ -427,10 +427,10 @@ class StockManager { $this->pdo->prepare( "INSERT INTO `$table` (uuid, company_id, `date`, product_sku, `out`, zone, aisle, rack, - contact_id, `description`, `log`, `type`, ref_id, lot_number, serial_number, status) + contact_id, `description`, `log`, `type`, ref_id, lot_number, serial_number, status, updated_at) VALUES (:uuid, :company_id, :date, :product_sku, :quantity, :zone, :aisle, :rack, - :contact_id, :description, :log, 'out', :ref_id, :lot_number, :serial_number, 0)" + :contact_id, :description, :log, 'out', :ref_id, :lot_number, :serial_number, 0, NOW())" )->execute([ ':uuid' => $uuid, ':company_id' => $this->company_id, @@ -604,11 +604,11 @@ class StockManager { "INSERT INTO `$from_table` (uuid, company_id, `date`, product_sku, `out`, ref_warehouse, zone, aisle, rack, - contact_id, `description`, `log`, `type`, lot_number, serial_number, status) + contact_id, `description`, `log`, `type`, lot_number, serial_number, status, updated_at) VALUES (:uuid, :company_id, :date, :product_sku, :quantity, :ref_warehouse, :zone, :aisle, :rack, - :contact_id, :description, :log, 'transfer', :lot_number, :serial_number, 0)" + :contact_id, :description, :log, 'transfer', :lot_number, :serial_number, 0, NOW())" )->execute([ ':uuid' => $uuid, ':company_id' => $this->company_id, @@ -632,11 +632,11 @@ class StockManager { "INSERT INTO `$to_table` (uuid, company_id, `date`, product_sku, `in`, ref_warehouse, ref_id, zone, aisle, rack, - contact_id, `description`, `log`, `type`, lot_number, serial_number, status) + contact_id, `description`, `log`, `type`, lot_number, serial_number, status, updated_at) VALUES (:uuid, :company_id, :date, :product_sku, :quantity, :ref_warehouse, :ref_id, :zone, :aisle, :rack, - :contact_id, :description, :log, 'transfer', :lot_number, :serial_number, 0)" + :contact_id, :description, :log, 'transfer', :lot_number, :serial_number, 0, NOW())" )->execute([ ':uuid' => $uuid, ':company_id' => $this->company_id, @@ -715,7 +715,7 @@ class StockManager { // ── Approve this row ────────────────────────────────────────────── $this->pdo->prepare( - "UPDATE `{$table}` SET status = 1 WHERE id = :id AND company_id = :company_id" + "UPDATE `{$table}` SET status = 1, updated_at = NOW() WHERE id = :id AND company_id = :company_id" )->execute([':id' => $id, ':company_id' => $this->company_id]); // ── Rack state + balance ────────────────────────────────────────── @@ -823,7 +823,7 @@ class StockManager { // Approve outbound row if ($from_row && (int)$from_row['status'] === 0) { $this->pdo->prepare( - "UPDATE `{$from_table}` SET status = 1 + "UPDATE `{$from_table}` SET status = 1, updated_at = NOW() WHERE id = :id AND company_id = :company_id" )->execute([':id' => $from_row['id'], ':company_id' => $this->company_id]); } @@ -831,7 +831,7 @@ class StockManager { // Approve inbound row if ($inbound_row && (int)$inbound_row['status'] === 0) { $this->pdo->prepare( - "UPDATE `{$paired_table}` SET status = 1 + "UPDATE `{$paired_table}` SET status = 1, updated_at = NOW() WHERE id = :id AND company_id = :company_id" )->execute([':id' => $inbound_row['id'], ':company_id' => $this->company_id]); } diff --git a/app/assets/utils/db_auth.php b/app/assets/utils/db_auth.php index b4c89b9..210c7a0 100644 --- a/app/assets/utils/db_auth.php +++ b/app/assets/utils/db_auth.php @@ -77,6 +77,19 @@ if(!empty($_SESSION["login_company_id"])){ $user_role = $map[0]['role'] ?? 'viewer'; $_SESSION['login_role'] = $user_role; + // Single-session enforcement: if a session_token was issued at login, verify + // it still matches the DB. A mismatch means a newer login has taken over. + if (!empty($_SESSION['session_token'])) { + $sth = $pdo1->prepare("SELECT session_token FROM user WHERE user_id = :uid LIMIT 1"); + $sth->execute([':uid' => $_SESSION['login_user_id']]); + $db_token = $sth->fetchColumn(); + if ($db_token !== $_SESSION['session_token']) { + session_destroy(); + http_response_code(401); + exit(json_encode(['success' => 0, 'message' => 'You have been signed in from another device.', 'code' => 'signed_elsewhere'])); + } + } + } // Fail closed — reject any request that arrives without an authenticated session diff --git a/app/login/api/engine/back.php b/app/login/api/engine/back.php index 7ada439..a812d54 100644 --- a/app/login/api/engine/back.php +++ b/app/login/api/engine/back.php @@ -25,6 +25,12 @@ require_once '../../../session.php'; define('UNAUTHENTICATED_ROUTE', true); require_once '../../../assets/utils/db_auth.php'; +// Clear session token so the account is free to log in elsewhere immediately +if (!empty($_SESSION['login_user_id'])) { + $pdo1->prepare("UPDATE user SET session_token = NULL, session_token_at = NULL WHERE user_id = :uid") + ->execute([':uid' => (int)$_SESSION['login_user_id']]); +} + // Intentional 1-second delay — prevents timing attacks on session enumeration sleep(1); diff --git a/app/login/api/engine/login_confirm.php b/app/login/api/engine/login_confirm.php index 2317c50..95d2cf9 100644 --- a/app/login/api/engine/login_confirm.php +++ b/app/login/api/engine/login_confirm.php @@ -23,10 +23,12 @@ * b. The elapsed time since otpTime is ≤ 5 minutes. * Fail either → return "Wrong OTP! Please try again." * 5. On success: - * a. session_regenerate_id(true) — prevents session fixation attack by + * a. Check session_token in DB — if non-NULL, another session is active; + * reject login with "account already logged in" message. + * b. session_regenerate_id(true) — prevents session fixation attack by * issuing a new session ID and deleting the old one. - * b. Generate a fresh CSRF token and store in session. - * c. Write the authenticated login session keys: + * c. Generate a fresh CSRF token and store in session. + * d. Write the authenticated login session keys: * login_status=1, login_username, login_name, login_surname, * login_company_id (from user's default_company). * 6. Return { success: 1, message: "Login Complete!" }. @@ -93,15 +95,43 @@ $otp_diff_minutes = $otp_diff_seconds / 60.0; $_SESSION["now"] = $now; $_SESSION["diff"] = $otp_diff_minutes; -// ── Step 4: Validate OTP value and expiry ───────────────────────────────────── -// Fails if either the code doesn't match OR more than 5 minutes have elapsed -// since the OTP was issued. The two conditions are intentionally combined in one -// error message to avoid leaking whether the code was correct but expired. -if ($data["otp"] != $otp || $otp_diff_minutes > 5) { - $answer["message"] = "Wrong OTP! Please try again. (Our OTP is valid for 5 minute)"; - exit(json_encode($answer)); +// ── Step 4: Block login if another session is already active ───────────────── +// If session_token is non-NULL AND was set within the last 8 hours, another +// session is active — reject. Tokens older than 8 hours are treated as +// abandoned (browser crash, PHP GC expiry, etc.) and cleared automatically. +$sth_token = $pdo1->prepare("SELECT session_token, session_token_at FROM user WHERE user_id = :uid LIMIT 1"); +$sth_token->execute([':uid' => $user_id]); +$token_row = $sth_token->fetch(PDO::FETCH_ASSOC); +$existing_token = $token_row['session_token'] ?? null; +if (!empty($existing_token)) { + $token_age_hours = PHP_INT_MAX; + if (!empty($token_row['session_token_at'])) { + $token_age_hours = (time() - strtotime($token_row['session_token_at'])) / 3600; + } + if ($token_age_hours < 8) { + $answer["message"] = "This account is currently logged in on another device. Please wait for the other session to end."; + exit(json_encode($answer)); + } + // Stale token — clear it and proceed with login + $pdo1->prepare("UPDATE user SET session_token = NULL, session_token_at = NULL WHERE user_id = :uid") + ->execute([':uid' => $user_id]); } +// ── Step 4b: Validate OTP value and expiry ──────────────────────────────────── +// Skipped for staff/viewer roles — login_otp.php sets skip_otp=true in session +// so they never receive or enter an OTP. Admin/owner always go through this check. +if (empty($_SESSION['skip_otp'])) { + if ($data["otp"] != $otp || $otp_diff_minutes > 5) { + $answer["message"] = "Wrong OTP! Please try again. (Our OTP is valid for 5 minute)"; + exit(json_encode($answer)); + } +} + +// ── Step 4c: Claim session — write token so no one else can log in ──────────── +$session_token = bin2hex(random_bytes(32)); +$pdo1->prepare("UPDATE user SET session_token = :token, session_token_at = NOW() WHERE user_id = :uid") + ->execute([':token' => $session_token, ':uid' => $user_id]); + // ── Step 5a: Regenerate session ID ──────────────────────────────────────────── // session_regenerate_id(true) issues a brand-new session ID and deletes the old // session file, preventing session fixation attacks where an attacker pre-sets @@ -118,6 +148,7 @@ $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); // These keys are read by db_auth.php on every subsequent request to gate access. // login_company_id is the user's default_company — used to scope all DB queries. $_SESSION["login_status"] = 1; +$_SESSION['session_token'] = $session_token; $_SESSION["login_user_id"] = (int)$temp["user_id"]; $_SESSION["login_username"] = $temp["username"]; $_SESSION["login_name"] = $temp["name"]; @@ -125,6 +156,9 @@ $_SESSION["login_surname"] = $temp["surname"]; $_SESSION["login_company_id"] = $temp["default_company"]; $_SESSION["login_profile_picture"] = $temp["profile_picture"] ?? ''; $_SESSION["login_license"] = $temp["license"] ?? 'user'; +// Required by db_auth.php's per-request OTP integrity check. For skip_otp users +// (staff/viewer) this was never written by login_otp.php, so we set it here. +$_SESSION["otp"] = $otp; // license='owner' means the user holds their own subscription — use user.app_access. // license='user' means they were invited — use company_map_user.app_access instead. $_SESSION["login_app_access"] = $temp["app_access"] ?? 'wms'; diff --git a/app/login/api/engine/login_otp.php b/app/login/api/engine/login_otp.php index 6b29429..9bee7ad 100644 --- a/app/login/api/engine/login_otp.php +++ b/app/login/api/engine/login_otp.php @@ -253,6 +253,31 @@ if (password_verify(trim($data["password"]), $temp["password"])) { exit(json_encode($answer)); } + // ── Step 5g: Role check — staff/viewer skip OTP entirely ───────────────── + // Owners always require 2FA. Invited users (license='user') require 2FA only + // if their role in this company is admin or owner; staff/viewer go straight in. + $requires_otp = true; + if (($r['license'] ?? 'owner') !== 'owner') { + $sth_role = $pdo1->prepare( + "SELECT role FROM company_map_user WHERE company_id = :cid AND user_id = :uid LIMIT 1" + ); + $sth_role->execute([':cid' => (int)($r['default_company'] ?? 0), ':uid' => (int)$r['user_id']]); + $role_for_otp = ($sth_role->fetch(PDO::FETCH_ASSOC))['role'] ?? 'viewer'; + $requires_otp = in_array($role_for_otp, ['admin', 'owner'], true); + } + + if (!$requires_otp) { + $_SESSION = []; + $_SESSION['login_data'] = $data; + $_SESSION['login_user_id'] = $user_id; + $_SESSION['otpTime'] = time(); + $_SESSION['skip_otp'] = true; + $answer['success'] = 1; + $answer['skip_otp'] = true; + $answer['message'] = 'Login Complete!'; + exit(json_encode($answer)); + } + // ── Step 6: Generate 6-digit TOTP ──────────────────────────────────────── // The secret key is the user's current password hash, so the OTP is unique // per user and automatically invalidated if the password changes. diff --git a/app/login/index.php b/app/login/index.php index d48fca7..747c7c1 100644 --- a/app/login/index.php +++ b/app/login/index.php @@ -108,6 +108,17 @@ action: 'read', onSuccess: function(res) { + if (res.skip_otp) { + // Staff/viewer — no OTP required, confirm session directly + return ajax_request({ + url: "login/api/engine/login_confirm.php", + data: { json: JSON.stringify({ otp: '', action: 'read' }) }, + onSuccess: function() { + window.location.href = "index.php"; + } + }); + } + window.location.href = "index.php"; } diff --git a/setup.php b/setup.php index 6f29134..a7ad602 100644 --- a/setup.php +++ b/setup.php @@ -103,6 +103,8 @@ CREATE TABLE IF NOT EXISTS `user` ( `verify_expires_at` datetime DEFAULT NULL, `login_attempts` int(11) NOT NULL DEFAULT 0, `locked_until` datetime DEFAULT NULL, + `session_token` varchar(64) DEFAULT NULL, + `session_token_at` datetime DEFAULT NULL, PRIMARY KEY (`user_id`), UNIQUE KEY `username` (`username`), UNIQUE KEY `email` (`email`), @@ -522,9 +524,11 @@ CREATE TABLE IF NOT EXISTS `td_stock` ( `description` text NOT NULL DEFAULT '', `status` int(3) NOT NULL DEFAULT 0, `log` mediumtext NOT NULL DEFAULT '{}', + `updated_at` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `company_id` (`company_id`), - KEY `product_id` (`product_sku`) + KEY `product_id` (`product_sku`), + KEY `updated_at` (`updated_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; ", 'td_stock (base template)');