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. 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 4b: Claim session — always replace existing token ──────────────────── // Password + OTP is full 2FA proof of identity, so we always grant the login. // Writing a new token here also invalidates any prior session: db_auth.php // compares session_token in the DB to the one stored in the PHP session, so the // old device is kicked out on its next request. This also fixes the case where // a PHP session expired without clearing the DB token, which would otherwise // permanently block re-login. $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));