Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
38 changes: 35 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions config/di-web.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
],
],
];
1 change: 1 addition & 0 deletions config/params.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
],
],
];
133 changes: 121 additions & 12 deletions src/Login/Cookie/CookieLogin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand All @@ -39,7 +63,7 @@
*/
public function withCookieName(string $name): self
{
$new = clone $this;

Check warning on line 66 in src/Login/Cookie/CookieLogin.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.3-ubuntu-latest

Escaped Mutant for Mutator "CloneRemoval": @@ @@ */ public function withCookieName(string $name): self { - $new = clone $this; + $new = $this; $new->cookieName = $name; return $new; }
$new->cookieName = $name;
return $new;
}
Expand All @@ -65,19 +89,12 @@
): 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);
}

/**
Expand All @@ -103,4 +120,96 @@
{
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);

Check warning on line 143 in src/Login/Cookie/CookieLogin.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.3-ubuntu-latest

Escaped Mutant for Mutator "IncrementInteger": @@ @@ } try { - $data = json_decode($payload, true, 512, JSON_THROW_ON_ERROR); + $data = json_decode($payload, true, 513, JSON_THROW_ON_ERROR); } catch (Throwable) { return null; }

Check warning on line 143 in src/Login/Cookie/CookieLogin.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.3-ubuntu-latest

Escaped Mutant for Mutator "DecrementInteger": @@ @@ } try { - $data = json_decode($payload, true, 512, JSON_THROW_ON_ERROR); + $data = json_decode($payload, true, 511, JSON_THROW_ON_ERROR); } catch (Throwable) { return null; }
} 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,

Check warning on line 177 in src/Login/Cookie/CookieLogin.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.3-ubuntu-latest

Escaped Mutant for Mutator "BitwiseOr": @@ @@ { $payload = json_encode( [$id, $key, $expiresDate?->getTimestamp() ?? 0], - JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE, + JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES & JSON_UNESCAPED_UNICODE, ); return $this->signatureKey === null ? $payload : $this->sign($payload, $this->signatureKey);
);

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;

Check warning on line 207 in src/Login/Cookie/CookieLogin.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.3-ubuntu-latest

Escaped Mutant for Mutator "ReturnRemoval": @@ @@ 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);
}

$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;
}
}
37 changes: 14 additions & 23 deletions src/Login/Cookie/CookieLoginMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -37,11 +37,11 @@
* @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,

Check warning on line 44 in src/Login/Cookie/CookieLoginMiddleware.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.3-ubuntu-latest

Escaped Mutant for Mutator "FalseValue": @@ @@ private readonly IdentityRepositoryInterface $identityRepository, private readonly LoggerInterface $logger, private readonly CookieLogin $cookieLogin, - private readonly bool $forceAddCookie = false, + private readonly bool $forceAddCookie = true, ) {} /**
) {}

/**
Expand Down Expand Up @@ -93,23 +93,14 @@
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);

Expand All @@ -129,12 +120,12 @@

if (!$identity->validateCookieLoginKey($key)) {
$this->logger->warning('Unable to authenticate user by cookie. Invalid key.');
return;

Check warning on line 123 in src/Login/Cookie/CookieLoginMiddleware.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.3-ubuntu-latest

Escaped Mutant for Mutator "ReturnRemoval": @@ @@ if (!$identity->validateCookieLoginKey($key)) { $this->logger->warning('Unable to authenticate user by cookie. Invalid key.'); - return; + } if ($expires !== 0 && $expires < time()) {
}

if ($expires !== 0 && $expires < time()) {

Check warning on line 126 in src/Login/Cookie/CookieLoginMiddleware.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.3-ubuntu-latest

Escaped Mutant for Mutator "LessThan": @@ @@ return; } - if ($expires !== 0 && $expires < time()) { + if ($expires !== 0 && $expires <= time()) { $this->logger->warning('Unable to authenticate user by cookie. Lifetime has expired.'); return; }
$this->logger->warning('Unable to authenticate user by cookie. Lifetime has expired.');
return;

Check warning on line 128 in src/Login/Cookie/CookieLoginMiddleware.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.3-ubuntu-latest

Escaped Mutant for Mutator "ReturnRemoval": @@ @@ if ($expires !== 0 && $expires < time()) { $this->logger->warning('Unable to authenticate user by cookie. Lifetime has expired.'); - return; + } $this->currentUser->login($identity);
}

$this->currentUser->login($identity);
Expand Down
3 changes: 3 additions & 0 deletions tests/ConfigTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -66,6 +67,7 @@ public function testOverrideParams(): void
'cookieLogin' => [
'forceAddCookie' => true,
'duration' => 'P2D',
'signatureKey' => 'test-signature-key',
],
],
]);
Expand All @@ -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);

Expand Down
Loading
Loading