Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
],
],
];
131 changes: 119 additions & 12 deletions src/Login/Cookie/CookieLogin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand All @@ -39,7 +62,7 @@
*/
public function withCookieName(string $name): self
{
$new = clone $this;

Check warning on line 65 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 +88,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 +119,95 @@
{
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 142 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 142 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) || count($data) !== 3) {
return null;
}
Comment thread
vjik marked this conversation as resolved.
Outdated

[$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 175 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 205 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