我开发了一款API,最初仅通过浏览器使用,从未发现任何问题。然而,现在我正在尝试通过第三方Android库(OkHttpClient)连接到它,并使用REST API测试客户端(Insomnia.rest)测试了我所看到的内容。
我的问题在于,当我执行API的登录操作并启动会话时,我调用
然而,当我这样做时,如下所示的标头中返回了两个PHPSESSID cookie.
从上面的输出可以看到有两个带有PHPSESSID的Set-Cookie。如果我删除session_regenerate_id,那么只会得到一个PHPSESSID cookie,然后Android客户端就可以正常工作了。
我在Windows 10上的Wamp中展示了这个问题,并在CentOS 7构建的生产环境中使用了Apache。
所以问题是,如何生成新的PHP会话ID而不返回两个不同的PHPSESSID cookie?
以下是与登录过程相关的一些代码。我无法包括所有的代码,但它应该显示正在发生的概念。
调用API请求登录函数。
在上面的函数中,我调用了
如果我删除
我的问题在于,当我执行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中展示了这个问题,并在CentOS 7构建的生产环境中使用了Apache。
所以问题是,如何生成新的PHP会话ID而不返回两个不同的PHPSESSID cookie?
以下是与登录过程相关的一些代码。我无法包括所有的代码,但它应该显示正在发生的概念。
调用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));
此时会返回一个API响应,其中包含JSON对象。
在上面的代码中,如果电子邮件和密码匹配(这里没有使用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=/
session_regenerate_id
函数。每次调用该函数都会添加一个Set-Cookie
头信息。 - waterloomattsession_start()
的?在哪里调用的? - Woodrow