feat(CookieHelper): add get and clear all methods

This commit is contained in:
Jamie Barton 2024-10-19 10:51:24 +01:00
parent dd4b9153f6
commit 11cdba6253
3 changed files with 264 additions and 31 deletions

1
.gitignore vendored
View File

@ -7,3 +7,4 @@ composer.lock
.vscode .vscode
cache cache
file.db file.db
.phpunit.result.cache

View File

@ -13,6 +13,29 @@ class Cookie
public const PREFIX_SECURE = "secure"; public const PREFIX_SECURE = "secure";
public const PREFIX_HOST = "host"; public const PREFIX_HOST = "host";
/**
* Check if a cookie exists
*
* @param Context $context The context object containing request information
* @param string $name The name of the cookie to check
* @param string|null $value Optional value to check against
* @param string $prefix Optional prefix for the cookie name
* @return bool True if the cookie exists (and optionally matches the value), false otherwise
*/
public static function hasCookie(
Context $context,
string $name,
?string $value = null,
string $prefix = ""
): bool {
$cookies = self::parseCookies($context->req->header("Cookie") ?? "");
$fullName = self::getPrefixedName($name, $prefix);
$cookieValue = $cookies[$fullName] ?? null;
return $cookieValue !== null &&
($value === null || $cookieValue === $value);
}
/** /**
* Get all cookies or a specific cookie. * Get all cookies or a specific cookie.
* *
@ -26,19 +49,15 @@ class Cookie
?string $name = null, ?string $name = null,
?string $prefix = null ?string $prefix = null
): array|string|null { ): array|string|null {
$cookies = $context->req->header("Cookie"); $cookies = self::parseCookies($context->req->header("Cookie") ?? "");
if (!$cookies) {
return $name ? null : [];
}
$parsedCookies = self::parseCookies($cookies);
if ($name === null) { if ($name === null) {
return $parsedCookies; return $cookies;
} }
$fullName = self::getPrefixedName($name, $prefix); $fullName = self::getPrefixedName($name, $prefix);
return $parsedCookies[$fullName] ?? null;
return $cookies[$fullName] ?? null;
} }
/** /**
@ -72,9 +91,17 @@ class Cookie
string $name, string $name,
array $options = [] array $options = []
): ?string { ): ?string {
$value = self::getCookie($context, $name); $prefix = $options["prefix"] ?? "";
$value = self::getCookie($context, $name, $prefix);
if ($value === null) {
return null;
}
$options["expires"] = 1; $options["expires"] = 1;
$options["path"] = $options["path"] ?? "/";
self::setCookie($context, $name, "", $options); self::setCookie($context, $name, "", $options);
return $value; return $value;
} }
@ -93,25 +120,8 @@ class Cookie
?string $name = null, ?string $name = null,
?string $prefix = null ?string $prefix = null
): mixed { ): mixed {
if ($name === null) {
$allCookies = self::getCookie($context);
$signedCookies = [];
foreach ($allCookies as $cookieName => $cookieValue) {
$verifiedValue = self::verifySignedCookie(
$cookieValue,
$secret
);
if ($verifiedValue !== false) {
$signedCookies[$cookieName] = $verifiedValue;
}
}
return $signedCookies;
}
$value = self::getCookie($context, $name, $prefix); $value = self::getCookie($context, $name, $prefix);
if ($value === null) { if ($value === null) {
return null; return null;
} }
@ -137,9 +147,52 @@ class Cookie
): void { ): void {
$signature = self::sign($value, $secret); $signature = self::sign($value, $secret);
$signedValue = $value . "." . $signature; $signedValue = $value . "." . $signature;
self::setCookie($context, $name, $signedValue, $options); self::setCookie($context, $name, $signedValue, $options);
} }
/**
* Refresh a cookie's expiration time if it exists.
*
* @param Context $context The context object for setting the response header
* @param string $name The name of the cookie to refresh
* @param array $options Additional options for the cookie
* @return bool True if the cookie was refreshed, false if it doesn't exist
*/
public static function refreshCookie(
Context $context,
string $name,
array $options = []
): bool {
$prefix = $options["prefix"] ?? "";
$value = self::getCookie($context, $name, $prefix);
if ($value === null) {
return false;
}
self::setCookie($context, $name, $value, $options);
return true;
}
/**
* Clear all cookies.
*
* @param Context $context The context object for setting the response header
*/
public static function clearAllCookies(Context $context): void
{
$cookies = self::getCookie($context);
foreach ($cookies as $name => $value) {
$context->header(
"Set-Cookie",
$name . "=; Expires=Thu, 01 Jan 1970 00:00:01 GMT"
);
}
}
/** /**
* Parse a cookie string into an associative array. * Parse a cookie string into an associative array.
* *
@ -149,13 +202,19 @@ class Cookie
private static function parseCookies(string $cookieString): array private static function parseCookies(string $cookieString): array
{ {
$cookies = []; $cookies = [];
if (empty($cookieString)) {
return $cookies;
}
$pairs = explode("; ", $cookieString); $pairs = explode("; ", $cookieString);
foreach ($pairs as $pair) { foreach ($pairs as $pair) {
$parts = explode("=", $pair, 2); $parts = explode("=", $pair, 2);
if (count($parts) === 2) { if (count($parts) === 2) {
$cookies[urldecode($parts[0])] = urldecode($parts[1]); $cookies[urldecode($parts[0])] = urldecode($parts[1]);
} }
} }
return $cookies; return $cookies;
} }
@ -208,10 +267,6 @@ class Cookie
$parts[] = "SameSite=" . $options["sameSite"]; $parts[] = "SameSite=" . $options["sameSite"];
} }
if (isset($options["partitioned"]) && $options["partitioned"]) {
$parts[] = "Partitioned";
}
return implode("; ", $parts); return implode("; ", $parts);
} }
@ -258,11 +313,13 @@ class Cookie
string $secret string $secret
): string|false { ): string|false {
$parts = explode(".", $value, 2); $parts = explode(".", $value, 2);
if (count($parts) !== 2) { if (count($parts) !== 2) {
return false; return false;
} }
list($value, $signature) = $parts; list($value, $signature) = $parts;
$expectedSignature = self::sign($value, $secret); $expectedSignature = self::sign($value, $secret);
if (!hash_equals($signature, $expectedSignature)) { if (!hash_equals($signature, $expectedSignature)) {

View File

@ -0,0 +1,175 @@
<?php
namespace Dumbo\Tests\Helpers;
use PHPUnit\Framework\TestCase;
use Dumbo\Helpers\Cookie;
use Dumbo\Context;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use GuzzleHttp\Psr7\Response;
class CookieTest extends TestCase
{
private $context;
private $request;
private $response;
protected function setUp(): void
{
$this->request = $this->createMock(ServerRequestInterface::class);
$this->response = new Response();
$this->context = new TestContext($this->request, $this->response);
}
private function setRequestCookie(string $cookieString): void
{
$this->request
->method("getHeader")
->with("Cookie")
->willReturn([$cookieString]);
}
public function testHasCookie()
{
$this->setRequestCookie("test_cookie=value; other_cookie=other_value");
$this->assertTrue(Cookie::hasCookie($this->context, "test_cookie"));
$this->assertFalse(
Cookie::hasCookie($this->context, "non_existent_cookie")
);
}
public function testGetCookie()
{
$this->setRequestCookie("test_cookie=value; other_cookie=other_value");
$this->assertEquals(
"value",
Cookie::getCookie($this->context, "test_cookie")
);
$this->assertNull(
Cookie::getCookie($this->context, "non_existent_cookie")
);
}
public function testSetCookie()
{
Cookie::setCookie($this->context, "test_cookie", "new_value");
$setCookieHeaders = $this->context
->getResponse()
->getHeader("Set-Cookie");
$this->assertCount(1, $setCookieHeaders);
$this->assertStringContainsString(
"test_cookie=new_value",
$setCookieHeaders[0]
);
}
public function testDeleteCookie()
{
$this->setRequestCookie("test_cookie=value; other_cookie=other_value");
$deletedValue = Cookie::deleteCookie($this->context, "test_cookie");
$this->assertEquals("value", $deletedValue);
$setCookieHeaders = $this->context
->getResponse()
->getHeader("Set-Cookie");
$this->assertCount(1, $setCookieHeaders);
$this->assertStringContainsString("test_cookie=", $setCookieHeaders[0]);
$this->assertStringContainsString("Expires=", $setCookieHeaders[0]);
}
public function testGetSignedCookie()
{
$secret = "test_secret";
$value = "test_value";
$signature = hash_hmac("sha256", $value, $secret);
$signedValue = $value . "." . $signature;
$this->setRequestCookie("signed_cookie=$signedValue");
$this->assertEquals(
$value,
Cookie::getSignedCookie($this->context, $secret, "signed_cookie")
);
}
public function testSetSignedCookie()
{
$secret = "test_secret";
$name = "signed_cookie";
$value = "test_value";
Cookie::setSignedCookie($this->context, $name, $value, $secret);
$setCookieHeaders = $this->context
->getResponse()
->getHeader("Set-Cookie");
$this->assertCount(1, $setCookieHeaders);
$this->assertStringContainsString("$name=", $setCookieHeaders[0]);
}
public function testRefreshCookie()
{
$this->setRequestCookie("test_cookie=old_value");
$refreshed = Cookie::refreshCookie($this->context, "test_cookie");
$this->assertTrue($refreshed);
$setCookieHeaders = $this->context
->getResponse()
->getHeader("Set-Cookie");
$this->assertCount(1, $setCookieHeaders);
$this->assertStringContainsString(
"test_cookie=old_value",
$setCookieHeaders[0]
);
}
public function testClearAllCookies()
{
$this->setRequestCookie("cookie1=value1; cookie2=value2");
Cookie::clearAllCookies($this->context);
$setCookieHeaders = $this->context
->getResponse()
->getHeader("Set-Cookie");
$this->assertCount(2, $setCookieHeaders);
$this->assertStringContainsString(
"cookie1=; Expires=",
$setCookieHeaders[0]
);
$this->assertStringContainsString(
"cookie2=; Expires=",
$setCookieHeaders[1]
);
}
}
class TestContext extends Context
{
private $response;
public function __construct(
ServerRequestInterface $request,
ResponseInterface $response
) {
parent::__construct($request, [], "");
$this->response = $response;
}
public function getResponse(): ResponseInterface
{
return $this->response;
}
public function header(string $name, string $value): self
{
$this->response = $this->response->withAddedHeader($name, $value);
return $this;
}
}