session_regenerate_id导致返回两个PHPSESSID cookie

时间:2019-04-18 22:54:57

标签: php cookies

我已经开发了一个API,该API最初仅通过浏览器使用,但从未发现问题,但是,我现在尝试通过第三方Android库(OkHttpClient)连接到它,并且已经测试了我所看到的使用REST API测试客户端(Insomnia.rest)。

我遇到的问题是,当我执行API的登录操作时,我开始一个会话并调用session_regenerate_id(true);以避免粘性会话攻击(我不确定这是否是正确的名称)。

但是,当我这样做时,我将返回两个PHPSESSID cookie,如下面的标题所示:

< HTTP/1.1 200 OK
< Date: Thu, 18 Apr 2019 22:51:43 GMT
< Server: Apache/2.4.27 (Win64) PHP/7.1.9
< X-Powered-By: PHP/7.1.9
* cookie size: name/val 8 + 6 bytes
* cookie size: name/val 4 + 1 bytes
< Set-Cookie: ClientID=413059; path=/
* cookie size: name/val 9 + 26 bytes
* cookie size: name/val 4 + 1 bytes
< Set-Cookie: PHPSESSID=15u9j1p2oinfl5a8slh518ee9r; path=/
< Expires: Thu, 19 Nov 1981 08:52:00 GMT
< Cache-Control: no-store, no-cache, must-revalidate
< Pragma: no-cache
* cookie size: name/val 9 + 26 bytes
* cookie size: name/val 4 + 1 bytes
* Replaced cookie PHPSESSID="hkkffpj8ta9onsn92pp70r257v" for domain localhost, path /, expire 0
< Set-Cookie: PHPSESSID=hkkffpj8ta9onsn92pp70r257v; path=/
* cookie size: name/val 17 + 1 bytes
* cookie size: name/val 4 + 1 bytes
< Set-Cookie: UsingGoogleSignIn=0; path=/
* cookie size: name/val 6 + 1 bytes
* cookie size: name/val 4 + 1 bytes
< Set-Cookie: UserID=7; path=/
< Access-Control-Allow-Credentials: true
< Content-Length: 47
< Content-Type: application/json

从上面的输出中可以看到,有两个带有PHPSESSID的Set-Cookie。如果我删除session_regenerate_id,那么我只会得到一个PHPSESSID cookie,然后Android客户端就可以成功工作了。

我已经在Windows 10上的Wamp下的Apache上展示了该产品,并在CentOS 7上的生产环境中的Apache上展示了该产品。

问题是,如何在不发送回两个不同的PHPSESSID cookie的情况下生成新的PHP会话ID?

UDPATE

下面是一些与登录过程有关的代码。我不能包含所有代码,但是应该显示正在发生的事情的概念。

向登录功能发出了API请求

$email = mysqli_escape_string($this->getDBConn(), $encryption->encrypt($postArray["email"]));
        $password = mysqli_escape_string($this->getDBConn(), $encryption->encrypt($postArray["password"]));
        $externalDevice = isset($postArray["external_device"]) ? abs($postArray["external_device"]) : 0;

        $query = "SELECT * FROM users WHERE Email='$email'";
        $result = $this->getDBConn()->query($query);
        if ($result)
        {
            if (mysqli_num_rows($result) > 0 )
            {
                $myrow = $result->fetch_array();
                if ($myrow["UsingGoogleSignIn"] === '1')
                {
                    //We're trying to login as a normal user, but the account was registered using Google Sign In
                    //so tell the user to login via google instead
                    return new APIResponse(API_RESULT::SUCCESS, "AccountSigninViaGoogle");
                }
                else
                {
                    //Check the password matches
                    if ($myrow["Password"] === $password)
                    {
                        $this->getLogger()->writeToLog("Organisation ID: " . $myrow["Organisation"]);
                        $organisationDetails = $this->getOrganisationDetails(abs($myrow["Organisation"]), false);
                        $this->getLogger()->writeToLog(print_r($organisationDetails, true));

                        $this->createLoginSession($myrow, $organisationDetails, false, $paymentRequired, $passwordChangeRequired);
                        $data = null;
                        if ($externalDevice === 1)
                        {
                            $data = new stdClass();
                            $data->AuthToken = $_SESSION["AuthToken"];
                            $data->ClientID = $_SESSION["ClientID"];
                            $data->UserID = abs($_SESSION["UserID"]);
                        }

                        $this->getLogger()->writeToLog("Login Response Headers");
                        $headers = apache_response_headers();
                        $this->getLogger()->writeToLog(print_r($headers, true));

此时,将返回一个包含JSON对象的API响应

在上面的代码中,如果电子邮件和密码匹配(不使用Google此处登录),它将调用createLoginSession,如下所示:

private function createLoginSession($myrow, $organisationDetails, $usingGoogleSignIn, &$paymentRequired, &$passwordChangeRequired)
    {
        require_once 'CommonTasks.php';
        require_once 'IPLookup.php';
        require_once 'Encryption.php';
        try
        {
            $this->getLogger()->writeToLog("Creating login session");
            $paymentRequired = false;
            if ($organisationDetails === null)
            {
                $organisationDetails = $this->getOrganisationDetails($myrow["Organisation"]);
            }
            $encryption = new Encryption();
            $userID = mysqli_escape_string($this->getDBConn(), $myrow["UserID"]);
            $organisationID = intval(abs($myrow["Organisation"]));

            $commonTasks = new CommonTasks();
            $browserDetails = $commonTasks->getBrowserName();
            $this->getLogger()->writeToLog("Browser Details");
            $this->getLogger()->writeToLog(print_r($browserDetails, true));
            $clientName = $browserDetails["name"];

            $iplookup = new IPLookup(null, $this->getLogger());
            $ipDetails = json_decode($iplookup->getAllIPDetails($commonTasks->getIP()));

            if ($ipDetails !== null)
            {
                $ip = $ipDetails->ip;
                $country = $ipDetails->country_name;
                $city = $ipDetails->city;
            }
            else
            {
                $ip = "";
                $country = "";
                $city = "";
            }

            //Create a random client ID and store this as a cookie
            if (isset($_COOKIE["ClientID"]))
            {
                $clientID = $_COOKIE["ClientID"];
            }
            else
            {
                $clientID = $commonTasks->generateRandomString(6, "0123456789");
                setcookie("ClientID", $clientID, 0, "/");
            }

            //Create an auth token
            $authToken = $commonTasks->generateRandomString(25);
            $encryptedAuthToken = $encryption->encrypt($authToken);

            $query = "REPLACE INTO client (ClientID, UserID, AuthToken, ClientName, Country, City, IPAddress) " .
                "VALUES ('$clientID', '$userID', '$encryptedAuthToken', '$clientName', '$country', '$city', '$ip')";
            $result = $this->getDBConn()->query($query);
            if ($result)
            {

                session_start();
                $this->getLogger()->writeToLog("Logging in and regnerating session id");
                session_regenerate_id(true);

                $_SESSION["AuthToken"] = $authToken;
                $_SESSION["ClientID"] = $clientID;
                $_SESSION["UserID"] = $userID;
                $_SESSION["FirstName"] = $this->getEncryption()->decrypt($myrow["FirstName"]);
                $_SESSION["LastName"] = $this->getEncryption()->decrypt($myrow["LastName"]);

                $passwordChangeRequired = $myrow["PasswordChangeRequired"] === "1" ? true : false;
                //Check if the last payment failure reason is set, if so, set a cookie with the message but only
                //if the organisation is not on the free plan
                //Logger::log("Current Plan: " . $this->getOrganisationDetails(->getPlan()));
                if ($organisationDetails->getPlan() !== "Free")
                {
                    if (!empty($organisationDetails->getLastPaymentFailureReason()))
                    {
                        $this->getLogger()->writeToLog("Detected last payment as a failure. Setting cookies for organisation id: " . $organisationDetails->getId());
                        setcookie("HavePaymentFailure", true, 0, "/");
                        setcookie("PaymentFailureReason", $organisationDetails->getLastPaymentFailureReason(), 0, "/");

                    }
                    //Check if the current SubscriptionPeriodEnd is in the past
                    $subscriptionPeriodEnd = $organisationDetails->getSubscriptionOfPeriod();
                    $currentTime = DateTimeManager::getEpochFromCurrentTime();
                    if ($currentTime > $subscriptionPeriodEnd)
                    {
                        $this->getLogger()->writeToLog("Detected payment overdue for organisation: " . $organisationDetails->getId());
                        //The payment was overdue, determine the number of days grace period (there's a 7 day grace period) that's left
                        $subscriptionPeriodEndGracePeriod = $subscriptionPeriodEnd + (86400 * 7);
                        $numberOfDaysRemaining = floor((($subscriptionPeriodEndGracePeriod - $currentTime) / 86400));

                        setcookie("PaymentOverdue", true, 0, "/");
                        setcookie("DaysGraceRemaining", $numberOfDaysRemaining, 0, "/");
                        if ($numberOfDaysRemaining <= 0)
                        {
                            $paymentRequired = true;
                        }
                    }
                }
                setcookie("UsingGoogleSignIn", $usingGoogleSignIn ? "1" : "0", 0, "/");
                if ($organisationDetails->getId() !== 0)
                {

                    $_SESSION["OrganisationDetails"] = array();
                    $_SESSION["OrganisationDetails"]["id"] = $organisationDetails->getId();
                    $_SESSION["OrganisationDetails"]["Name"] = $organisationDetails->getName();

                }
                setcookie("UserID", $userID, 0, "/");
                $this->getLogger()->writeToLog("Successfully created login session. User ID '$userID' and Organisation ID '$organisationID'");
                return true;
            }
            else
            {
                $error = mysqli_error($this->getDBConn());
                $this->getLogger()->writeToLog("Failed to create login session. DB Error: $error");
                $this->getAlarms()->setAlarm(AlarmLevel::CRITICAL, "AccountManagement", "Failed to create login session. DB Error");
                throw new DBException($error);
            }
        }
        catch (DBException $ex)
        {
            throw $ex;
        }
    }

在上面的函数中,我先依次调用session_start()regenerate_session_id(),然后在响应中获得两个PHPSESSID cookie,尽管日志行仅输出一次,因此绝对不会多次调用它。

如果我删除了regenerate_session_id,那么问题就消失了。为安全起见,我尝试过交换session_start(),所以它紧跟regenerate_session_id之后,但是看起来会话ID并未按预期重新创建。

更新2

根据@waterloomatt的评论,我创建了一个PHP脚本,其内容如下:

<?php
session_start();
session_regenerate_id(true);
phpinfo();

和从phpinfo输出的HTTP标头信息如下

**HTTP Request Headers**
GET /api/session_test.php HTTP/1.1
Host    localhost
Connection  keep-alive Upgrade-Insecure-Requests    1
User-Agent  Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.103 Safari/537.36
Accept  text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3
Accept-Encoding gzip, deflate, br
Accept-Language en-GB,en-US;q=0.9,en;q=0.8
Cookie  _ga=GA1.1.1568991346.1553017442

**HTTP Response Headers**
X-Powered-By    PHP/7.2.10
Set-Cookie  PHPSESSID=i19irid70cqbvpkrh0ufffi0jk; path=/
Expires Thu, 19 Nov 1981 08:52:00 GMT 
Cache-Control   no-store, no-cache,> must-revalidate Pragma no-cache
Set-Cookie  PHPSESSID=48qvia5e6bpmmk251qfrqs8urd; path=/

3 个答案:

答案 0 :(得分:2)

此标头实际上将删除cookie。这是删除仅HTTP Cookie的唯一方法:使其过期日期过期。

Set-Cookie: PHPSESSID=15u9j1p2oinfl5a8slh518ee9r; path=/
< Expires: Thu, 19 Nov 1981 08:52:00 GMT

答案 1 :(得分:0)

这是一个已知且已记录的问题。 只需调用session_regenerate_id()而不传递true。 该手册明确指出,如果要避免出现竞争状况,则不应删除旧的会话数据,并且同时访问可能会导致状态不一致。 参见https://www.php.net/manual/en/function.session-regenerate-id.php 了解更多信息

答案 2 :(得分:0)

我想我已经知道发生了什么事。我以为Cookie会过期,这将全部在同一行上,但看起来-至少我传递Cookie字符串的方式表明Cookie的有效期是不同的行。

因此,我更改了解析cookie字符串的方式,因此,如果它在下一行中到期,则不包含似乎有效的cookie。以下是我传递cookie的方式,如果cookie过期,则不包括它们:

for (int i = 0; i < headers.length; i++)
                {
                    Log.d("BaseAPI", "Header: " + headers[i]);
                    if (headers[i].trim().toLowerCase().startsWith("set-cookie:"))
                    {
                        if (headers[i+1].toLowerCase().startsWith("expires"))
                        {
                            Log.d("BaseAPI", "Found expired header. The cookie is: " + headers[i+1]);
                            //Thu, 19 Nov 1981 08:52:00 GMT
                            long epoch = new SimpleDateFormat("EEE, dd MMM YYYY HH:mm:ss").parse(headers[i+1].replace("Expires: ", "").replace("GMT", "").trim()).getTime();
                            Log.d("BaseAPI", "Cookie Epoch: " + epoch);
                            long currentEpoch = new Date().getTime();
                            Log.d("BaseAPI", "Current Epoch: " + currentEpoch);
                            if (epoch < currentEpoch)
                            {
                                continue;
                            }
                        }
                        cookieBuilder.append(headers[i].trim().replace("Set-Cookie:", "").replace("path=/", ""));
                        cookieBuilder.append(" ");

                    }
                }