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: 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 // 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));