From 74ffbcb65194346a00a2e8811ff8c0dcc8af43c3 Mon Sep 17 00:00:00 2001 From: Sky Johnson Date: Thu, 11 Sep 2025 10:35:42 -0500 Subject: [PATCH] add cookie manager --- Context.php | 6 +- Cookies.php | 240 ++++++++++++++++++++++++++++++++++++++++++++++++++ Response.php | 49 +++++++---- Web.php | 7 +- auth/Auth.php | 42 +++------ 5 files changed, 296 insertions(+), 48 deletions(-) create mode 100644 Cookies.php diff --git a/Context.php b/Context.php index b74f620..b1bd4b4 100644 --- a/Context.php +++ b/Context.php @@ -2,6 +2,7 @@ require_once __DIR__ . '/Session.php'; require_once __DIR__ . '/Validator.php'; +require_once __DIR__ . '/Cookies.php'; /** * Context holds the request, response, and shared state for a request @@ -11,17 +12,20 @@ class Context public Request $request; public Response $response; public Session $session; + public Cookies $cookie; public array $state = []; /** * __construct creates a new Context with request and response */ - public function __construct() + public function __construct(?Cookie $cookie = null) { $this->request = new Request(); $this->response = new Response(); $this->session = new Session(); $this->session->start(); + $this->cookie = $cookie ?: new Cookies(); + $this->response->setCookieManager($this->cookie); } /** diff --git a/Cookies.php b/Cookies.php new file mode 100644 index 0000000..f3fd307 --- /dev/null +++ b/Cookies.php @@ -0,0 +1,240 @@ +defaults = array_merge([ + 'expires' => 0, + 'path' => '/', + 'domain' => '', + 'secure' => false, + 'httponly' => true, + 'samesite' => 'Lax' + ], $defaults); + } + + /** + * Set a cookie + */ + public function set(string $name, string $value, array $options = []): bool + { + $options = array_merge($this->defaults, $options); + + // Convert lifetime to expires timestamp if provided + if (isset($options['lifetime'])) { + $options['expires'] = time() + $options['lifetime']; + unset($options['lifetime']); + } + + return setcookie($name, $value, $options); + } + + /** + * Get a cookie value + */ + public function get(string $name, ?string $default = null): ?string + { + return $_COOKIE[$name] ?? $default; + } + + /** + * Check if a cookie exists + */ + public function has(string $name): bool + { + return isset($_COOKIE[$name]); + } + + /** + * Delete a cookie + */ + public function delete(string $name, array $options = []): bool + { + $options = array_merge($this->defaults, $options); + $options['expires'] = time() - 3600; + + // Remove from $_COOKIE superglobal + unset($_COOKIE[$name]); + + return setcookie($name, '', $options); + } + + /** + * Set a cookie that expires when browser closes + */ + public function setSession(string $name, string $value, array $options = []): bool + { + $options['expires'] = 0; + return $this->set($name, $value, $options); + } + + /** + * Set a cookie with specific lifetime in seconds + */ + public function setWithLifetime(string $name, string $value, int $lifetime, array $options = []): bool + { + $options['lifetime'] = $lifetime; + return $this->set($name, $value, $options); + } + + /** + * Set a cookie that expires in specified days + */ + public function setForDays(string $name, string $value, int $days, array $options = []): bool + { + return $this->setWithLifetime($name, $value, $days * 86400, $options); + } + + /** + * Set a cookie that expires in specified hours + */ + public function setForHours(string $name, string $value, int $hours, array $options = []): bool + { + return $this->setWithLifetime($name, $value, $hours * 3600, $options); + } + + /** + * Set a cookie that expires in specified minutes + */ + public function setForMinutes(string $name, string $value, int $minutes, array $options = []): bool + { + return $this->setWithLifetime($name, $value, $minutes * 60, $options); + } + + /** + * Set a forever cookie (5 years) + */ + public function forever(string $name, string $value, array $options = []): bool + { + return $this->setWithLifetime($name, $value, 157680000, $options); // 5 years + } + + /** + * Get all cookies + */ + public function all(): array + { + return $_COOKIE; + } + + /** + * Clear all cookies + */ + public function clear(): void + { + foreach ($_COOKIE as $name => $value) $this->delete($name); + } + + /** + * Set default options + */ + public function setDefaults(array $defaults): void + { + $this->defaults = array_merge($this->defaults, $defaults); + } + + /** + * Get default options + */ + public function getDefaults(): array + { + return $this->defaults; + } + + /** + * Create a signed cookie value + */ + public function sign(string $value, string $secret): string + { + $signature = hash_hmac('sha256', $value, $secret); + return base64_encode($value . '|' . $signature); + } + + /** + * Verify and extract signed cookie value + */ + public function verify(string $signedValue, string $secret): ?string + { + $decoded = base64_decode($signedValue); + if (!$decoded || !str_contains($decoded, '|')) return null; + + [$value, $signature] = explode('|', $decoded, 2); + $expectedSignature = hash_hmac('sha256', $value, $secret); + + if (!hash_equals($expectedSignature, $signature)) return null; + + return $value; + } + + /** + * Set a signed cookie + */ + public function setSigned(string $name, string $value, string $secret, array $options = []): bool + { + $signedValue = $this->sign($value, $secret); + return $this->set($name, $signedValue, $options); + } + + /** + * Get and verify a signed cookie + */ + public function getSigned(string $name, string $secret, ?string $default = null): ?string + { + $signedValue = $this->get($name); + if ($signedValue === null) return $default; + + $value = $this->verify($signedValue, $secret); + return $value !== null ? $value : $default; + } + + /** + * Encrypt a cookie value + */ + public function encrypt(string $value, string $key): string + { + $iv = random_bytes(16); + $encrypted = openssl_encrypt($value, 'AES-256-CBC', $key, 0, $iv); + return base64_encode($iv . '|' . $encrypted); + } + + /** + * Decrypt a cookie value + */ + public function decrypt(string $encryptedValue, string $key): ?string + { + $decoded = base64_decode($encryptedValue); + if (!$decoded || !str_contains($decoded, '|')) return null; + + [$iv, $encrypted] = explode('|', $decoded, 2); + $decrypted = openssl_decrypt($encrypted, 'AES-256-CBC', $key, 0, $iv); + + return $decrypted !== false ? $decrypted : null; + } + + /** + * Set an encrypted cookie + */ + public function setEncrypted(string $name, string $value, string $key, array $options = []): bool + { + $encryptedValue = $this->encrypt($value, $key); + return $this->set($name, $encryptedValue, $options); + } + + /** + * Get and decrypt a cookie + */ + public function getEncrypted(string $name, string $key, ?string $default = null): ?string + { + $encryptedValue = $this->get($name); + if ($encryptedValue === null) return $default; + + $value = $this->decrypt($encryptedValue, $key); + return $value !== null ? $value : $default; + } +} \ No newline at end of file diff --git a/Response.php b/Response.php index ee2c706..d23c35f 100644 --- a/Response.php +++ b/Response.php @@ -1,5 +1,7 @@ cookie = $cookie; + return $this; + } /** * Set the HTTP status code for the response @@ -76,23 +88,28 @@ class Response */ public function cookie(string $name, string $value, array $options = []): Response { - $options = array_merge([ - 'expires' => 0, - 'path' => '/', - 'domain' => '', - 'secure' => false, - 'httponly' => true, - 'samesite' => 'Lax' - ], $options); + if ($this->cookie) { + $this->cookie->set($name, $value, $options); + } else { + // Fallback to direct setcookie if no Cookie manager is set + $options = array_merge([ + 'expires' => 0, + 'path' => '/', + 'domain' => '', + 'secure' => false, + 'httponly' => true, + 'samesite' => 'Lax' + ], $options); - setcookie($name, $value, [ - 'expires' => $options['expires'], - 'path' => $options['path'], - 'domain' => $options['domain'], - 'secure' => $options['secure'], - 'httponly' => $options['httponly'], - 'samesite' => $options['samesite'] - ]); + setcookie($name, $value, [ + 'expires' => $options['expires'], + 'path' => $options['path'], + 'domain' => $options['domain'], + 'secure' => $options['secure'], + 'httponly' => $options['httponly'], + 'samesite' => $options['samesite'] + ]); + } return $this; } diff --git a/Web.php b/Web.php index 162f024..608ec9c 100644 --- a/Web.php +++ b/Web.php @@ -6,6 +6,7 @@ require_once __DIR__ . '/Response.php'; require_once __DIR__ . '/Context.php'; require_once __DIR__ . '/Router.php'; require_once __DIR__ . '/ErrorHandler.php'; +require_once __DIR__ . '/Cookies.php'; /** * Web is the application controller itself @@ -16,11 +17,13 @@ class Web private array $middleware = []; private Context $context; private ErrorHandler $errorHandler; + private Cookies $cookie; - public function __construct(bool $debug = false) + public function __construct(bool $debug = false, array $cookieDefaults = []) { $this->router = new Router(); $this->errorHandler = new ErrorHandler($debug); + $this->cookie = new Cookies($cookieDefaults); } public function use(callable $middleware): self @@ -118,7 +121,7 @@ class Web public function run(): void { - $this->context = new Context(); + $this->context = new Context($this->cookie); try { $next = function() { diff --git a/auth/Auth.php b/auth/Auth.php index 326f30b..ff0a724 100644 --- a/auth/Auth.php +++ b/auth/Auth.php @@ -2,6 +2,7 @@ require_once __DIR__ . '/User.php'; require_once __DIR__ . '/../Session.php'; +require_once __DIR__ . '/../Cookies.php'; /** * Simplified Auth handles user authentication with external verification @@ -9,6 +10,7 @@ require_once __DIR__ . '/../Session.php'; class Auth { private Session $session; + private Cookies $cookie; private ?User $user = null; private array $config; @@ -16,17 +18,17 @@ class Auth const REMEMBER_COOKIE = 'remember_token'; const REMEMBER_DURATION = 2592000; // 30 days in seconds - public function __construct(Session $session, array $config = []) + public function __construct(Session $session, ?Cookie $cookie = null, array $config = []) { $this->session = $session; + $this->cookie = $cookie ?: new Cookies([ + 'path' => '/', + 'httponly' => true, + 'samesite' => 'Lax' + ]); $this->config = array_merge([ 'cookie_name' => self::REMEMBER_COOKIE, - 'cookie_lifetime' => self::REMEMBER_DURATION, - 'cookie_path' => '/', - 'cookie_domain' => '', - 'cookie_secure' => false, - 'cookie_httponly' => true, - 'cookie_samesite' => 'Lax' + 'cookie_lifetime' => self::REMEMBER_DURATION ], $config); $this->initializeFromSession(); @@ -176,17 +178,10 @@ class Auth */ private function setRememberCookie(string $value): void { - setcookie( + $this->cookie->setWithLifetime( $this->config['cookie_name'], $value, - [ - 'expires' => time() + $this->config['cookie_lifetime'], - 'path' => $this->config['cookie_path'], - 'domain' => $this->config['cookie_domain'], - 'secure' => $this->config['cookie_secure'], - 'httponly' => $this->config['cookie_httponly'], - 'samesite' => $this->config['cookie_samesite'] - ] + $this->config['cookie_lifetime'] ); } @@ -195,18 +190,7 @@ class Auth */ private function clearRememberCookie(): void { - setcookie( - $this->config['cookie_name'], - '', - [ - 'expires' => time() - 3600, - 'path' => $this->config['cookie_path'], - 'domain' => $this->config['cookie_domain'], - 'secure' => $this->config['cookie_secure'], - 'httponly' => $this->config['cookie_httponly'], - 'samesite' => $this->config['cookie_samesite'] - ] - ); + $this->cookie->delete($this->config['cookie_name']); } /** @@ -214,7 +198,7 @@ class Auth */ private function getUserDataFromRememberCookie(): ?array { - $cookie = $_COOKIE[$this->config['cookie_name']] ?? null; + $cookie = $this->cookie->get($this->config['cookie_name']); if (!$cookie || !str_contains($cookie, '|')) return null;