prepare("select * from user where user_id = :user_id limit 1;"); $sth->execute([":user_id" => $user_id]); $temp = $sth->fetch(PDO::FETCH_ASSOC); // ── Step 3: Re-derive expected OTP ──────────────────────────────────────────── // Uses $_SESSION['otpTime'] (set when the OTP was generated) as the TOTP // counter base. This is the same algorithm used in login_otp.php and // request_new_otp.php — any change to one must be reflected in all three. function generateOTP($sercet_key, $time_step = 180, $length = 6) { $counter = floor($_SESSION["otpTime"] / $time_step); $data = pack("NN", 0, $counter); $hash = hash_hmac('sha1', $data, $sercet_key, true); $offset = ord(substr($hash, -1)) & 0x0F; $value = unpack("N", substr($hash, $offset, 4)); $otp = ($value[1] & 0x7FFFFFFF) % pow(10, $length); return str_pad(strval($otp), $length, '0', STR_PAD_LEFT); } $otp = generateOTP($temp["password"]); // ── Step 3b: Calculate elapsed time since OTP was issued ────────────────────── // otpTime is the Unix timestamp stored by login_otp.php when the OTP was sent. // The diff is computed in minutes for the 5-minute validity window check. $otp_time = isset($_SESSION['otpTime']) ? (int)$_SESSION['otpTime'] : 0; $now = time(); $otp_diff_seconds = max(0, $now - $otp_time); $otp_diff_minutes = $otp_diff_seconds / 60.0; // Store for debug convenience — visible in $_SESSION on the session inspect page $_SESSION["now"] = $now; $_SESSION["diff"] = $otp_diff_minutes; // ── Step 4: 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, // unless OTP_REQUIRED=false in config.php: that also covers a user who was already // on the OTP screen when the switch was turned off. if (empty($_SESSION['skip_otp'])) { if (!otp_required()) { if (!empty($user_id)) { otp_log_bypass($user_id, 'login_confirm'); } } elseif ($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 4b: Concurrent session check ──────────────────────────────────────── // Block the login if this account already has an active session. // "Active" = session_token is set AND session_last_seen is within the last // SESSION_ACTIVE_GRACE_SECONDS. db_auth.php refreshes session_last_seen on every // authenticated request (throttled to once per 60s), so a session that is truly // still in use on another device keeps re-touching this timestamp well within // the grace window below. A session that has actually ended — browser/tab closed, // cookie lost, PHP session GC'd — stops refreshing it and goes stale quickly. // // This window must stay well above the 60s throttle in db_auth.php (otherwise a // live second session could go stale between its own refreshes and let a login // through) but short enough that a real re-login isn't blocked for long after the // previous session actually ended. It intentionally does NOT match PHP's // session.gc_maxlifetime (3600s) — that timeout is about when PHP reclaims the // session file on disk, not about how quickly this check should stop treating a // dead session as "still active". // An explicit logout clears session_token to NULL, so back.php bypasses this. // // The staleness comparison is done entirely in SQL (session_last_seen vs MySQL's // own NOW()), not in PHP, because session_last_seen is written with MySQL's // NOW() and so is best compared against it. // // This originally worked around a timezone mismatch: config.php's $time_zone was // never applied via date_default_timezone_set(), so PHP ran on UTC while the // MySQL server ran on Asia/Bangkok. Pulling the timestamp into PHP and comparing // with strtotime()/time() misread that Bangkok wall-clock string as UTC — 7 hours // in the future — which made idle_seconds permanently negative and blocked every // login. assets/utils/timezone.php now applies $time_zone to PHP and pins both // PDO connections to the same offset, so the mismatch is gone; comparing in SQL // is kept because it is still the most direct way to read a NOW()-written column. define('SESSION_ACTIVE_GRACE_SECONDS', 120); $sth_active = $pdo1->prepare( "SELECT session_token, (session_last_seen IS NOT NULL AND session_last_seen > (NOW() - INTERVAL " . SESSION_ACTIVE_GRACE_SECONDS . " SECOND)) AS is_active FROM user WHERE user_id = :uid LIMIT 1" ); $sth_active->execute([':uid' => $user_id]); $active_row = $sth_active->fetch(PDO::FETCH_ASSOC); if (!empty($active_row['session_token']) && !empty($active_row['is_active'])) { $answer['message'] = 'This account is currently signed in on another device. Please sign out from that session first.'; exit(json_encode($answer)); } // ── Step 4c: Claim session ──────────────────────────────────────────────────── // No active session found (or it has gone stale) — write a new token. // db_auth.php compares session_token in the DB to the one in the PHP session, // so any tab that still holds the old token is invalidated on its next request. $session_token = bin2hex(random_bytes(32)); $sth_claim = $pdo1->prepare( "UPDATE user SET session_token = :token, session_token_at = NOW(), session_last_seen = NOW() WHERE user_id = :uid" ); $sth_claim->execute([ ':token' => $session_token, ':uid' => $user_id, ]); if ($sth_claim->rowCount() !== 1) { $answer["message"] = "Login failed: user record not found."; exit(json_encode($answer)); } // ── 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 // a session ID before the user logs in. session_regenerate_id(true); // ── Step 5b: Issue CSRF token ───────────────────────────────────────────────── // A fresh 256-bit token is generated here and stored in session. All subsequent // POST requests from the authenticated app must include this token in the // X-CSRF-Token header (validated by individual engine endpoints). $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); // ── Step 5c: Write authenticated login session ──────────────────────────────── // 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"]; $_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'; $role_sth = $pdo1->prepare( "SELECT role, app_access FROM company_map_user WHERE company_id = :company_id AND user_id = :user_id LIMIT 1" ); $role_sth->execute([ ':company_id' => $_SESSION["login_company_id"], ':user_id' => $_SESSION["login_user_id"], ]); $map_row = $role_sth->fetch(PDO::FETCH_ASSOC); $_SESSION["login_role"] = $map_row['role'] ?? 'viewer'; if (($temp['license'] ?? 'owner') !== 'owner') { $_SESSION["login_app_access"] = $map_row['app_access'] ?? 'wms'; } // ── Step 6: Respond ─────────────────────────────────────────────────────────── $answer["success"] = 1; $answer["message"] = "Login Complete!"; exit(json_encode($answer));