From f003fe600b564e754ff4c4080a7a9f895743aa03 Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Sun, 16 Aug 2026 09:19:04 +0300 Subject: [PATCH 1/7] Add note about cookie protection --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 4ec205f..880ac0b 100644 --- a/README.md +++ b/README.md @@ -308,6 +308,12 @@ final class CookieLoginIdentityRepository implements IdentityRepositoryInterface The `CookieLoginMiddleware` will check for the existence of a cookie in the request, validate it and login the user automatically. +> [!note] +> The auto-login cookie value isn't protected against tampering by this package. This isn't only about +> the end user editing their own cookie, anyone who steals the cookie value can modify it too, e.g. to +> remove its expiration. Use `CookieMiddleware` from [`yiisoft/cookies`](https://github.com/yiisoft/cookies) +> to sign or encrypt it if you want to prevent that. + #### Creating a cookie By default, you should set cookie for auto login manually in your application after logging user in: From a932011d69d959878d2291d88236afaf69421b27 Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Sun, 16 Aug 2026 09:21:04 +0300 Subject: [PATCH 2/7] improve --- README.md | 2 +- src/Login/Cookie/CookieLoginMiddleware.php | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 880ac0b..7dd138b 100644 --- a/README.md +++ b/README.md @@ -310,7 +310,7 @@ validate it and login the user automatically. > [!note] > The auto-login cookie value isn't protected against tampering by this package. This isn't only about -> the end user editing their own cookie, anyone who steals the cookie value can modify it too, e.g. to +> the end user editing their own cookie — anyone who steals the cookie value can modify it too, e.g. to > remove its expiration. Use `CookieMiddleware` from [`yiisoft/cookies`](https://github.com/yiisoft/cookies) > to sign or encrypt it if you want to prevent that. diff --git a/src/Login/Cookie/CookieLoginMiddleware.php b/src/Login/Cookie/CookieLoginMiddleware.php index c3b45c9..1d617aa 100644 --- a/src/Login/Cookie/CookieLoginMiddleware.php +++ b/src/Login/Cookie/CookieLoginMiddleware.php @@ -26,6 +26,11 @@ /** * `CookieLoginMiddleware` automatically logs user in based on cookie. + * + * The auto-login cookie value isn't protected against tampering by this package. This isn't only about + * the end user editing their own cookie — anyone who steals the cookie value can modify it too, e.g. to + * remove its expiration. Use `CookieMiddleware` from {@link https://github.com/yiisoft/cookies} to sign + * or encrypt it if you want to prevent that. */ final class CookieLoginMiddleware implements MiddlewareInterface { From 299c250f7c55627127355fec5b4aa057fc9e379f Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Sun, 16 Aug 2026 09:22:27 +0300 Subject: [PATCH 3/7] improve --- src/Login/Cookie/CookieLoginMiddleware.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Login/Cookie/CookieLoginMiddleware.php b/src/Login/Cookie/CookieLoginMiddleware.php index 1d617aa..90f1902 100644 --- a/src/Login/Cookie/CookieLoginMiddleware.php +++ b/src/Login/Cookie/CookieLoginMiddleware.php @@ -13,6 +13,7 @@ use RuntimeException; use Throwable; use Yiisoft\Auth\IdentityRepositoryInterface; +use Yiisoft\Cookies\CookieMiddleware; use Yiisoft\User\CurrentUser; use function array_key_exists; @@ -29,8 +30,7 @@ * * The auto-login cookie value isn't protected against tampering by this package. This isn't only about * the end user editing their own cookie — anyone who steals the cookie value can modify it too, e.g. to - * remove its expiration. Use `CookieMiddleware` from {@link https://github.com/yiisoft/cookies} to sign - * or encrypt it if you want to prevent that. + * remove its expiration. Use {@see CookieMiddleware} to sign or encrypt it if you want to prevent that. */ final class CookieLoginMiddleware implements MiddlewareInterface { From d26f001b180de2270d1d39ac10defc10efc87717 Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Tue, 1 Sep 2026 11:26:23 +0300 Subject: [PATCH 4/7] Add optional HMAC signing of the auto-login cookie value via `CookieLogin` signature key --- CHANGELOG.md | 1 + README.md | 42 ++++-- config/di-web.php | 1 + config/params.php | 1 + src/Login/Cookie/CookieLogin.php | 131 ++++++++++++++++-- src/Login/Cookie/CookieLoginMiddleware.php | 38 ++--- tests/ConfigTest.php | 3 + .../Cookie/CookieLoginMiddlewareTest.php | 91 +++++++++++- tests/Login/Cookie/CookieLoginTest.php | 121 ++++++++++++++++ 9 files changed, 381 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b17c40..986f8c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - Enh #120: Explicitly import classes and constants in "use" section (@vjik) - Enh #127: Bump `yiisoft/auth` version to `^3.3.0`, and fix deprecated classes usage (@klsoft-web, @vjik) - Enh #132: Bump `yiisoft/session` version to `^3.0.2` (@vjik) +- New #126: Add optional HMAC signing of the auto-login cookie value via `CookieLogin` signature key (@vjik) ## 2.3.2 December 23, 2025 diff --git a/README.md b/README.md index b626108..5fbb185 100644 --- a/README.md +++ b/README.md @@ -308,11 +308,9 @@ final class CookieLoginIdentityRepository implements IdentityRepositoryInterface The `CookieLoginMiddleware` will check for the existence of a cookie in the request, validate it and login the user automatically. -> [!note] -> The auto-login cookie value isn't protected against tampering by this package. This isn't only about -> the end user editing their own cookie — anyone who steals the cookie value can modify it too, e.g. to -> remove its expiration. Use `CookieMiddleware` from [`yiisoft/cookies`](https://github.com/yiisoft/cookies) -> to sign or encrypt it if you want to prevent that. +> [!warning] +> By default the auto-login cookie value isn't protected against tampering. See +> [Protecting the cookie value](#protecting-the-cookie-value) below. #### Creating a cookie @@ -383,15 +381,43 @@ public function logout( } ``` -#### Preventing the substitution of cookies +#### Protecting the cookie value + +By default the auto-login cookie value is stored raw: `[id, key, expires]` as JSON, with no integrity check. +Anyone able to edit the cookie value — the end user, or an attacker who obtained the cookie — can change the +identity or the expiration timestamp. + +You must protect the cookie value against tampering in one of the following ways: -The login cookie value is stored raw. To prevent the substitution of the cookie value, -you can use a `Yiisoft\Cookies\CookieMiddleware`. For more information, see +**Option 1 (recommended). Set a `signatureKey` in `params.php`:** + +```php +return [ + 'yiisoft/user' => [ + 'cookieLogin' => [ + 'signatureKey' => 'your-secret-random-string', + ], + ], +]; +``` + +When it is set, `CookieLogin` signs the cookie value with HMAC-SHA256, and `CookieLoginMiddleware` rejects any +auto-login cookie whose signature is missing or invalid. Use a long random string, keep it secret, and don't +reuse it for other purposes. Changing it invalidates all existing auto-login cookies. + +In this case, don't additionally sign or encrypt the auto-login cookie through `Yiisoft\Cookies\CookieMiddleware`. + +**Option 2. Sign or encrypt the cookie separately**, for example with `Yiisoft\Cookies\CookieMiddleware` from +[`yiisoft/cookies`](https://github.com/yiisoft/cookies). Leave `signatureKey` as `null` and make sure the +middleware processes the auto-login cookie on every response, including logout. For more information, see the [Yii guide to cookies](https://github.com/yiisoft/docs/blob/master/guide/en/runtime/cookies.md). > Please note that `Yiisoft\Cookies\CookieMiddleware` should be located before > `Yiisoft\User\Login\Cookie\CookieLoginMiddleware` in the middleware stack. +> [!note] +> `signatureKey` will become required in the next major version. + You can find examples of the above features in the [yiisoft/demo](https://github.com/yiisoft/demo). ## Documentation diff --git a/config/di-web.php b/config/di-web.php index 50f1370..2b666f8 100644 --- a/config/di-web.php +++ b/config/di-web.php @@ -43,6 +43,7 @@ 'duration' => $params['yiisoft/user']['cookieLogin']['duration'] !== null ? new DateInterval($params['yiisoft/user']['cookieLogin']['duration']) : null, + 'signatureKey' => $params['yiisoft/user']['cookieLogin']['signatureKey'], ], ], ]; diff --git a/config/params.php b/config/params.php index 142de47..8d8b9c9 100644 --- a/config/params.php +++ b/config/params.php @@ -8,6 +8,7 @@ 'cookieLogin' => [ 'forceAddCookie' => false, 'duration' => 'P5D', // 5 days, see format on https://www.php.net/manual/dateinterval.construct.php + 'signatureKey' => null, // secret key to sign the auto-login cookie value; keep `null` to store it unsigned ], ], ]; diff --git a/src/Login/Cookie/CookieLogin.php b/src/Login/Cookie/CookieLogin.php index 946678b..61650b5 100644 --- a/src/Login/Cookie/CookieLogin.php +++ b/src/Login/Cookie/CookieLogin.php @@ -8,9 +8,17 @@ use DateTimeImmutable; use JsonException; use Psr\Http\Message\ResponseInterface; +use Throwable; use Yiisoft\Cookies\Cookie; +use function count; +use function hash_equals; +use function hash_hmac; +use function is_array; +use function json_decode; use function json_encode; +use function strlen; +use function substr; use const JSON_THROW_ON_ERROR; use const JSON_UNESCAPED_SLASHES; @@ -19,18 +27,33 @@ /** * The service is used to send or remove auto-login cookie. * + * The auto-login cookie value must be protected against tampering: either set a signature key here, or sign/encrypt + * the cookie separately (for example with `Yiisoft\Cookies\CookieMiddleware`). When a signature key is set, the + * value is signed with HMAC-SHA256, so anyone able to edit the cookie can no longer change the identity or the + * expiration timestamp without invalidating the signature. + * * @see CookieLoginIdentityInterface * @see CookieLoginMiddleware */ final class CookieLogin { + /** + * Length of a hexadecimal HMAC-SHA256 signature that prefixes a signed cookie value. + */ + private const SIGNATURE_LENGTH = 64; + private string $cookieName = 'autoLogin'; /** * @param DateInterval|null $duration Interval until the auto-login cookie expires. If it isn't set it means * the auto-login cookie is session cookie that expires when browser is closed. + * @param string|null $signatureKey Secret key used to sign the auto-login cookie value with HMAC-SHA256. If it + * isn't set, the cookie value is stored without a signature and isn't protected against tampering. */ - public function __construct(private ?DateInterval $duration = null) {} + public function __construct( + private readonly ?DateInterval $duration = null, + private readonly ?string $signatureKey = null, + ) {} /** * Returns a new instance with the specified auto-login cookie name. @@ -65,19 +88,12 @@ public function addCookie( ): ResponseInterface { $duration = $duration === false ? $this->duration : $duration; - $data = [$identity->getId(), $identity->getCookieLoginKey()]; + $expires = $duration === null ? null : (new DateTimeImmutable())->add($duration); - if ($duration !== null) { - $expires = (new DateTimeImmutable())->add($duration); - $data[] = $expires->getTimestamp(); - } else { - $expires = null; - $data[] = 0; - } - - $cookieValue = json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $cookieValue = $this->createValue((string) $identity->getId(), $identity->getCookieLoginKey(), $expires); - return (new Cookie(name: $this->cookieName, value: $cookieValue, expires: $expires))->addToResponse($response); + return (new Cookie(name: $this->cookieName, value: $cookieValue, expires: $expires)) + ->addToResponse($response); } /** @@ -103,4 +119,95 @@ public function getCookieName(): string { return $this->cookieName; } + + /** + * Parses the auto-login cookie value produced by {@see createValue()} back into the identity data. + * + * When a signature key is set, a value without a valid signature is rejected. + * + * @param string $value The auto-login cookie value. + * + * @return array|null The identity data, or `null` if the value is malformed or has an invalid signature. + * + * @psalm-return array{id: string, key: string, expires: int}|null + */ + public function parseValue(string $value): ?array + { + $payload = $this->signatureKey === null ? $value : $this->getVerifiedPayload($value, $this->signatureKey); + if ($payload === null) { + return null; + } + + try { + $data = json_decode($payload, true, 512, JSON_THROW_ON_ERROR); + } catch (Throwable) { + return null; + } + + if (!is_array($data) || count($data) !== 3) { + return null; + } + + [$id, $key, $expires] = $data; + + return [ + 'id' => (string) $id, + 'key' => (string) $key, + 'expires' => (int) $expires, + ]; + } + + /** + * Builds the auto-login cookie value from the identity data, optionally prefixing it with an HMAC signature. + * + * @param string $id The identity ID. + * @param string $key The cookie login key. + * @param DateTimeImmutable|null $expiresDate Expiration date, or `null` for a session cookie. + * + * @throws JsonException If an error occurs during JSON encoding of the cookie value. + * + * @return string The auto-login cookie value. + */ + private function createValue(string $id, string $key, ?DateTimeImmutable $expiresDate): string + { + $payload = json_encode( + [$id, $key, $expiresDate?->getTimestamp() ?? 0], + JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE, + ); + + return $this->signatureKey === null ? $payload : $this->sign($payload, $this->signatureKey); + } + + /** + * Prefixes the payload with its HMAC-SHA256 signature. + * + * @param string $payload The cookie payload to sign. + * @param string $signatureKey The secret key used to sign the payload. + * + * @return string The signed cookie value. + */ + private function sign(string $payload, string $signatureKey): string + { + return hash_hmac('sha256', $payload, $signatureKey) . '.' . $payload; + } + + /** + * Verifies the signature of a cookie value and returns its payload. + * + * @param string $value The cookie value to verify. + * @param string $signatureKey The secret key the value is expected to be signed with. + * + * @return string|null The cookie payload, or `null` if the signature is missing or invalid. + */ + private function getVerifiedPayload(string $value, string $signatureKey): ?string + { + if (strlen($value) <= self::SIGNATURE_LENGTH || $value[self::SIGNATURE_LENGTH] !== '.') { + return null; + } + + $signature = substr($value, 0, self::SIGNATURE_LENGTH); + $payload = substr($value, self::SIGNATURE_LENGTH + 1); + + return hash_equals(hash_hmac('sha256', $payload, $signatureKey), $signature) ? $payload : null; + } } diff --git a/src/Login/Cookie/CookieLoginMiddleware.php b/src/Login/Cookie/CookieLoginMiddleware.php index 90f1902..2b50eb0 100644 --- a/src/Login/Cookie/CookieLoginMiddleware.php +++ b/src/Login/Cookie/CookieLoginMiddleware.php @@ -11,26 +11,21 @@ use Psr\Http\Server\RequestHandlerInterface; use Psr\Log\LoggerInterface; use RuntimeException; -use Throwable; use Yiisoft\Auth\IdentityRepositoryInterface; use Yiisoft\Cookies\CookieMiddleware; use Yiisoft\User\CurrentUser; use function array_key_exists; -use function count; -use function is_array; -use function json_decode; use function sprintf; use function time; -use const JSON_THROW_ON_ERROR; - /** * `CookieLoginMiddleware` automatically logs user in based on cookie. * - * The auto-login cookie value isn't protected against tampering by this package. This isn't only about - * the end user editing their own cookie — anyone who steals the cookie value can modify it too, e.g. to - * remove its expiration. Use {@see CookieMiddleware} to sign or encrypt it if you want to prevent that. + * The auto-login cookie value must be protected against tampering: either configure a signature key for + * {@see CookieLogin}, or sign/encrypt the cookie separately (for example with {@see CookieMiddleware}). + * Otherwise anyone able to edit the cookie (the end user, or an attacker who obtained it) can change the identity + * or the expiration timestamp. */ final class CookieLoginMiddleware implements MiddlewareInterface { @@ -42,11 +37,11 @@ final class CookieLoginMiddleware implements MiddlewareInterface * @param bool $forceAddCookie Whether to force add a cookie. */ public function __construct( - private CurrentUser $currentUser, - private IdentityRepositoryInterface $identityRepository, - private LoggerInterface $logger, - private CookieLogin $cookieLogin, - private bool $forceAddCookie = false, + private readonly CurrentUser $currentUser, + private readonly IdentityRepositoryInterface $identityRepository, + private readonly LoggerInterface $logger, + private readonly CookieLogin $cookieLogin, + private readonly bool $forceAddCookie = false, ) {} /** @@ -98,23 +93,14 @@ private function authenticateUserByCookieFromRequest(ServerRequestInterface $req return; } - try { - $data = json_decode((string) $cookies[$cookieName], true, 512, JSON_THROW_ON_ERROR); - } catch (Throwable) { - $this->logger->warning('Unable to authenticate user by cookie. Invalid cookie.'); - return; - } + $data = $this->cookieLogin->parseValue((string) $cookies[$cookieName]); - if (!is_array($data) || count($data) !== 3) { + if ($data === null) { $this->logger->warning('Unable to authenticate user by cookie. Invalid cookie.'); return; } - [$id, $key, $expires] = $data; - - $id = (string) $id; - $key = (string) $key; - $expires = (int) $expires; + ['id' => $id, 'key' => $key, 'expires' => $expires] = $data; $identity = $this->identityRepository->findIdentity($id); diff --git a/tests/ConfigTest.php b/tests/ConfigTest.php index 2e6eb5a..5f926ff 100644 --- a/tests/ConfigTest.php +++ b/tests/ConfigTest.php @@ -51,6 +51,7 @@ public function testBase(): void $this->assertSame(5, $this ->getInaccessibleProperty($cookieLogin, 'duration') ->d); + $this->assertNull($this->getInaccessibleProperty($cookieLogin, 'signatureKey')); $cookieLoginMiddleware = $container->get(CookieLoginMiddleware::class); @@ -66,6 +67,7 @@ public function testOverrideParams(): void 'cookieLogin' => [ 'forceAddCookie' => true, 'duration' => 'P2D', + 'signatureKey' => 'test-signature-key', ], ], ]); @@ -83,6 +85,7 @@ public function testOverrideParams(): void $this->assertSame(2, $this ->getInaccessibleProperty($cookieLogin, 'duration') ->d); + $this->assertSame('test-signature-key', $this->getInaccessibleProperty($cookieLogin, 'signatureKey')); $cookieLoginMiddleware = $container->get(CookieLoginMiddleware::class); diff --git a/tests/Login/Cookie/CookieLoginMiddlewareTest.php b/tests/Login/Cookie/CookieLoginMiddlewareTest.php index b306a9f..22dc8da 100644 --- a/tests/Login/Cookie/CookieLoginMiddlewareTest.php +++ b/tests/Login/Cookie/CookieLoginMiddlewareTest.php @@ -24,10 +24,14 @@ use Yiisoft\User\Tests\Support\LastMessageLogger; use Yiisoft\User\CurrentUser; +use function hash_hmac; use function json_encode; +use function str_repeat; use function time; use const JSON_THROW_ON_ERROR; +use const JSON_UNESCAPED_SLASHES; +use const JSON_UNESCAPED_UNICODE; final class CookieLoginMiddlewareTest extends TestCase { @@ -205,6 +209,73 @@ public function testInvalidCookie(): void $this->assertSame('Unable to authenticate user by cookie. Invalid cookie.', $this->getLastLogMessage()); } + public function testCorrectLoginWithSignedCookie(): void + { + $currentUser = $this->createCurrentUser(); + + $middleware = new CookieLoginMiddleware( + $currentUser, + $this->getCookieLoginIdentityRepository(), + $this->logger, + $this->createCookieLogin('secret-key'), + ); + + $middleware->process( + $this->getRequestWithCookies([ + 'autoLogin' => $this->createCookieValue( + CookieLoginIdentity::ID, + CookieLoginIdentity::KEY_CORRECT, + 0, + 'secret-key', + ), + ]), + $this->getRequestHandler(), + ); + + $this->assertNull($this->getLastLogMessage()); + $this->assertSame(CookieLoginIdentity::ID, $currentUser->getIdentity()->getId()); + } + + public function testUnsignedCookieIsRejectedWhenSignatureKeyIsSet(): void + { + $middleware = new CookieLoginMiddleware( + $this->createCurrentUser(), + $this->getCookieLoginIdentityRepository(), + $this->logger, + $this->createCookieLogin('secret-key'), + ); + + $response = $middleware->process($this->getRequestWithAutoLoginCookie(), $this->getRequestHandler()); + + $this->assertEmpty($response->getHeaderLine('Set-Cookie')); + $this->assertSame('Unable to authenticate user by cookie. Invalid cookie.', $this->getLastLogMessage()); + } + + public function testSignedCookieWithInvalidSignatureIsRejected(): void + { + $middleware = new CookieLoginMiddleware( + $this->createCurrentUser(), + $this->getCookieLoginIdentityRepository(), + $this->logger, + $this->createCookieLogin('secret-key'), + ); + + $cookieValue = $this->createCookieValue( + CookieLoginIdentity::ID, + CookieLoginIdentity::KEY_CORRECT, + 0, + 'other-key' + ); + + $response = $middleware->process( + $this->getRequestWithCookies(['autoLogin' => $cookieValue]), + $this->getRequestHandler(), + ); + + $this->assertEmpty($response->getHeaderLine('Set-Cookie')); + $this->assertSame('Unable to authenticate user by cookie. Invalid cookie.', $this->getLastLogMessage()); + } + public function testIncorrectIdentity(): void { $middleware = new CookieLoginMiddleware( @@ -450,9 +521,25 @@ private function getRequestWithCookies(array $cookies): ServerRequestInterface return $request; } - private function createCookieLogin(): CookieLogin + private function createCookieLogin(?string $signatureKey = null): CookieLogin { - return new CookieLogin(new DateInterval('P1W')); + return new CookieLogin(new DateInterval('P1W'), $signatureKey); + } + + private function createCookieValue( + string $id, + string $key, + int $expires, + ?string $signatureKey = null, + ): string { + $payload = json_encode( + [$id, $key, $expires], + JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE, + ); + + return $signatureKey === null + ? $payload + : hash_hmac('sha256', $payload, $signatureKey) . '.' . $payload; } private function createCurrentUser(): CurrentUser diff --git a/tests/Login/Cookie/CookieLoginTest.php b/tests/Login/Cookie/CookieLoginTest.php index ecb4f69..8bc1290 100644 --- a/tests/Login/Cookie/CookieLoginTest.php +++ b/tests/Login/Cookie/CookieLoginTest.php @@ -11,6 +11,19 @@ use Yiisoft\User\Login\Cookie\CookieLogin; use Yiisoft\User\Tests\Support\CookieLoginIdentity; +use function explode; +use function hash_hmac; +use function json_encode; +use function rawurldecode; +use function str_ends_with; +use function str_repeat; +use function str_starts_with; +use function substr; + +use const JSON_THROW_ON_ERROR; +use const JSON_UNESCAPED_SLASHES; +use const JSON_UNESCAPED_UNICODE; + final class CookieLoginTest extends TestCase { public function testAddCookie(): void @@ -122,4 +135,112 @@ public function testAddCookieWithCustomDuration(string $expectedRegExp, DateInte $response->getHeaderLine('Set-Cookie'), ); } + + public function testAddSignedCookie(): void + { + $cookieLogin = new CookieLogin(signatureKey: 'secret-key'); + + $response = $cookieLogin->addCookie(new CookieLoginIdentity(), new Response()); + + $this->assertMatchesRegularExpression( + '#autoLogin=[0-9a-f]{64}\.%5B%2242%22%2C%22auto-login-key-correct%22%2C0%5D;' + . ' Path=/; Secure; HttpOnly; SameSite=Lax#', + $response->getHeaderLine('Set-Cookie'), + ); + + $value = rawurldecode($this->extractCookieValue($response->getHeaderLine('Set-Cookie'))); + $payload = json_encode( + [CookieLoginIdentity::ID, CookieLoginIdentity::KEY_CORRECT, 0], + JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE, + ); + $this->assertSame(hash_hmac('sha256', $payload, 'secret-key') . '.' . $payload, $value); + } + + public function testParseValueRoundTrip(): void + { + $cookieLogin = new CookieLogin(signatureKey: 'secret-key'); + + $value = rawurldecode( + $this->extractCookieValue( + $cookieLogin + ->addCookie(new CookieLoginIdentity(), new Response()) + ->getHeaderLine('Set-Cookie'), + ), + ); + + $this->assertSame( + ['id' => CookieLoginIdentity::ID, 'key' => CookieLoginIdentity::KEY_CORRECT, 'expires' => 0], + $cookieLogin->parseValue($value), + ); + } + + public static function dataParseValueInvalid(): array + { + return [ + 'not a string prefixed with signature' => ['["42","auto-login-key-correct",0]'], + 'invalid signature' => [str_repeat('0', 64) . '.["42","auto-login-key-correct",0]'], + 'malformed payload' => [hash_hmac('sha256', 'not-json', 'secret-key') . '.not-json'], + 'empty' => [''], + 'no separator' => [str_repeat('0', 64)], + ]; + } + + #[DataProvider('dataParseValueInvalid')] + public function testParseValueInvalidSigned(string $value): void + { + $cookieLogin = new CookieLogin(signatureKey: 'secret-key'); + + $this->assertNull($cookieLogin->parseValue($value)); + } + + public function testParseValueTamperedExpires(): void + { + $cookieLogin = new CookieLogin(signatureKey: 'secret-key'); + $payload = '["42","auto-login-key-correct",1000000000]'; + $signature = hash_hmac('sha256', $payload, 'secret-key'); + + $tampered = $signature . '.["42","auto-login-key-correct",0]'; + + $this->assertNull($cookieLogin->parseValue($tampered)); + } + + public function testParseValueUnsigned(): void + { + $cookieLogin = new CookieLogin(); + + $this->assertSame( + ['id' => '42', 'key' => 'auto-login-key-correct', 'expires' => 0], + $cookieLogin->parseValue('["42","auto-login-key-correct",0]'), + ); + } + + public static function dataParseValueInvalidUnsigned(): array + { + return [ + 'empty' => [''], + 'not json' => ['weird stuff'], + 'not an array' => ['"string"'], + 'wrong element count' => ['["42","auto-login-key-correct",0,"extra"]'], + ]; + } + + #[DataProvider('dataParseValueInvalidUnsigned')] + public function testParseValueInvalidUnsigned(string $value): void + { + $cookieLogin = new CookieLogin(); + + $this->assertNull($cookieLogin->parseValue($value)); + } + + private function extractCookieValue(string $setCookieHeader): string + { + $pair = explode(';', $setCookieHeader, 2)[0]; + [, $value] = explode('=', $pair, 2); + + if (str_starts_with($value, '"') && str_ends_with($value, '"')) { + $value = substr($value, 1, -1); + } + + return $value; + } } From bc6560614a87a6b2fe566177d558181777aacb11 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 1 Sep 2026 08:32:13 +0000 Subject: [PATCH 5/7] Apply PHP CS Fixer and Rector changes (CI) --- tests/Login/Cookie/CookieLoginMiddlewareTest.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/Login/Cookie/CookieLoginMiddlewareTest.php b/tests/Login/Cookie/CookieLoginMiddlewareTest.php index 22dc8da..8a30b87 100644 --- a/tests/Login/Cookie/CookieLoginMiddlewareTest.php +++ b/tests/Login/Cookie/CookieLoginMiddlewareTest.php @@ -26,7 +26,6 @@ use function hash_hmac; use function json_encode; -use function str_repeat; use function time; use const JSON_THROW_ON_ERROR; @@ -264,7 +263,7 @@ public function testSignedCookieWithInvalidSignatureIsRejected(): void CookieLoginIdentity::ID, CookieLoginIdentity::KEY_CORRECT, 0, - 'other-key' + 'other-key', ); $response = $middleware->process( From 7d3e69ce372897f16cb21e740d4ac62f658b99a9 Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Tue, 1 Sep 2026 11:35:00 +0300 Subject: [PATCH 6/7] fix --- src/Login/Cookie/CookieLogin.php | 3 ++- tests/Login/Cookie/CookieLoginTest.php | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Login/Cookie/CookieLogin.php b/src/Login/Cookie/CookieLogin.php index 61650b5..2174ebf 100644 --- a/src/Login/Cookie/CookieLogin.php +++ b/src/Login/Cookie/CookieLogin.php @@ -11,6 +11,7 @@ use Throwable; use Yiisoft\Cookies\Cookie; +use function array_is_list; use function count; use function hash_equals; use function hash_hmac; @@ -144,7 +145,7 @@ public function parseValue(string $value): ?array return null; } - if (!is_array($data) || count($data) !== 3) { + if (!is_array($data) || !array_is_list($data) || count($data) !== 3) { return null; } diff --git a/tests/Login/Cookie/CookieLoginTest.php b/tests/Login/Cookie/CookieLoginTest.php index 8bc1290..abc5c86 100644 --- a/tests/Login/Cookie/CookieLoginTest.php +++ b/tests/Login/Cookie/CookieLoginTest.php @@ -180,6 +180,10 @@ public static function dataParseValueInvalid(): array 'not a string prefixed with signature' => ['["42","auto-login-key-correct",0]'], 'invalid signature' => [str_repeat('0', 64) . '.["42","auto-login-key-correct",0]'], 'malformed payload' => [hash_hmac('sha256', 'not-json', 'secret-key') . '.not-json'], + 'associative array payload' => [ + hash_hmac('sha256', '{"id":"42","key":"k","expires":0}', 'secret-key') + . '.{"id":"42","key":"k","expires":0}', + ], 'empty' => [''], 'no separator' => [str_repeat('0', 64)], ]; @@ -221,6 +225,8 @@ public static function dataParseValueInvalidUnsigned(): array 'not json' => ['weird stuff'], 'not an array' => ['"string"'], 'wrong element count' => ['["42","auto-login-key-correct",0,"extra"]'], + 'associative array' => ['{"id":"42","key":"auto-login-key-correct","expires":0}'], + 'list with string keys mixed' => ['{"0":"42","1":"auto-login-key-correct","3":0}'], ]; } From b3b3b77d7e51997027ea540ba77586fd624ae6fc Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Tue, 1 Sep 2026 11:40:53 +0300 Subject: [PATCH 7/7] fix --- src/Login/Cookie/CookieLogin.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Login/Cookie/CookieLogin.php b/src/Login/Cookie/CookieLogin.php index 2174ebf..87abc55 100644 --- a/src/Login/Cookie/CookieLogin.php +++ b/src/Login/Cookie/CookieLogin.php @@ -148,6 +148,7 @@ public function parseValue(string $value): ?array if (!is_array($data) || !array_is_list($data) || count($data) !== 3) { return null; } + /** @psalm-var array{0: scalar, 1: scalar, 2: scalar} $data */ [$id, $key, $expires] = $data;