'login_restart']); } $user_id = (int)$_SESSION["login_user_id"]; // ── Step 2: Fetch user record — need password hash to re-derive the OTP ─────── $sth = $pdo1->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. $otp = login_generate_otp((string)($temp["password"] ?? ''), (int)($_SESSION["otpTime"] ?? 0)); // ── 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; // ── 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 (!hash_equals($otp, trim((string)($data["otp"] ?? ''))) || $otp_diff_minutes > 5) { // Count wrong codes per issued OTP; the 6-digit code must not be // guessable by brute force within its 5-minute window. $_SESSION['otp_attempts'] = (int)($_SESSION['otp_attempts'] ?? 0) + 1; if ($_SESSION['otp_attempts'] >= LOGIN_OTP_MAX_ATTEMPTS) { $_SESSION = []; login_fail(401, 'Too many incorrect OTP attempts. Please sign in again.', ['code' => 'login_restart']); } login_fail(401, "Wrong OTP! Please try again. (Our OTP is valid for 5 minute)"); } } // ── 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'])) { login_fail(409, 'This account is currently signed in on another device. Please sign out from that session first.'); } // ── 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) { $_SESSION = []; login_fail(401, "Login failed: user record not found.", ['code' => 'login_restart']); } // ── 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); // The pending-login keys are done with once the user is signed in. unset($_SESSION['login_data'], $_SESSION['password_verified_at'], $_SESSION['otp_attempts'], $_SESSION['otp_resends'], $_SESSION['reference'], $_SESSION['skip_otp']); // ── 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));