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 8607d0d..5fbb185 100644 --- a/README.md +++ b/README.md @@ -308,6 +308,10 @@ 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. +> [!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 By default, you should set cookie for auto login manually in your application after logging user in: @@ -377,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: + +**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. -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 +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..87abc55 100644 --- a/src/Login/Cookie/CookieLogin.php +++ b/src/Login/Cookie/CookieLogin.php @@ -8,9 +8,18 @@ use DateTimeImmutable; use JsonException; use Psr\Http\Message\ResponseInterface; +use Throwable; use Yiisoft\Cookies\Cookie; +use function array_is_list; +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 +28,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 +89,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 +120,96 @@ 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) || !array_is_list($data) || count($data) !== 3) { + return null; + } + /** @psalm-var array{0: scalar, 1: scalar, 2: scalar} $data */ + + [$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 c3b45c9..2b50eb0 100644 --- a/src/Login/Cookie/CookieLoginMiddleware.php +++ b/src/Login/Cookie/CookieLoginMiddleware.php @@ -11,21 +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 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 { @@ -37,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, ) {} /** @@ -93,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..8a30b87 100644 --- a/tests/Login/Cookie/CookieLoginMiddlewareTest.php +++ b/tests/Login/Cookie/CookieLoginMiddlewareTest.php @@ -24,10 +24,13 @@ use Yiisoft\User\Tests\Support\LastMessageLogger; use Yiisoft\User\CurrentUser; +use function hash_hmac; use function json_encode; 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 +208,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 +520,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..abc5c86 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,118 @@ 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'], + '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)], + ]; + } + + #[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"]'], + '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}'], + ]; + } + + #[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; + } }