我已经开发了一个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=/
答案 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(" ");
}
}