diff --git a/src/Authenticator/CookieAuthenticator.php b/src/Authenticator/CookieAuthenticator.php index 580aec6b..9e9023d5 100644 --- a/src/Authenticator/CookieAuthenticator.php +++ b/src/Authenticator/CookieAuthenticator.php @@ -20,14 +20,18 @@ use Authentication\Identifier\AbstractIdentifier; use Authentication\Identifier\IdentifierCollection; use Authentication\Identifier\IdentifierInterface; +use Authentication\PasswordHasher\PasswordHasherFactory; +use Authentication\PasswordHasher\PasswordHasherInterface; use Authentication\PasswordHasher\PasswordHasherTrait; use Authentication\UrlChecker\UrlCheckerTrait; use Cake\Http\Cookie\Cookie; use Cake\Http\Cookie\CookieInterface; use Cake\Utility\Security; +use DateTimeInterface; use InvalidArgumentException; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; +use UnexpectedValueException; /** * Cookie Authenticator @@ -52,9 +56,30 @@ class CookieAuthenticator extends AbstractAuthenticator implements PersistenceIn ], 'cookie' => [ 'name' => 'CookieAuth', + 'expires' => '+7 days', ], + // Used only to verify legacy (v1) cookie tokens during the + // `legacyTokens` grace period. Deprecated: removed together with + // `legacyTokens` support. 'passwordHasher' => 'Authentication.Default', 'salt' => true, + // While true (default), legacy 2-part tokens issued before the + // HMAC token format are accepted (hardened) and holders are + // upgraded at their next login. Set to false to end the grace + // period and enforce the new token format only. + 'legacyTokens' => true, + // Upper bounds on the work factor of legacy token hashes. A forged + // legacy token could otherwise embed a valid bcrypt/argon2 hash with + // an arbitrarily large cost, turning password_verify() into a CPU or + // memory exhaustion vector. Hashes above these limits are rejected + // before verification. Defaults comfortably exceed the + // PASSWORD_DEFAULT parameters used to issue real cookies; raise them + // only if your application issued cookies with higher work factors. + 'legacyHashLimits' => [ + 'cost' => 15, + 'memory_cost' => 131072, + 'time_cost' => 10, + ], ]; /** @@ -90,28 +115,66 @@ public function authenticate(ServerRequestInterface $request): ResultInterface ]); } - if (is_array($cookies[$cookieName])) { - $token = $cookies[$cookieName]; - } else { - $token = json_decode($cookies[$cookieName], true); + $token = is_array($cookies[$cookieName]) + ? $cookies[$cookieName] + : json_decode((string)$cookies[$cookieName], true); + + if (!is_array($token) || !array_is_list($token)) { + return new Result(null, Result::FAILURE_CREDENTIALS_INVALID, [ + 'Cookie token is invalid.', + ]); + } + + if (count($token) === 3) { + return $this->_authenticateToken($token); + } + + if (count($token) === 2 && $this->getConfig('legacyTokens')) { + return $this->_authenticateLegacyToken($token); } - if ($token === null || count($token) !== 2) { + return new Result(null, Result::FAILURE_CREDENTIALS_INVALID, [ + 'Cookie token is invalid.', + ]); + } + + /** + * Validates a three-part `[username, expires, hmac]` token. + * + * Checks part types, then expiry (before touching the identifier), then + * locates the user and verifies the HMAC of + * `username + password hash + expires` in constant time. + * + * @param array $token The decoded token parts. + * @return \Authentication\Authenticator\ResultInterface + */ + protected function _authenticateToken(array $token): ResultInterface + { + [$username, $expires, $tokenHash] = $token; + if (!is_string($username) || !is_numeric($expires) || !is_string($tokenHash)) { return new Result(null, Result::FAILURE_CREDENTIALS_INVALID, [ 'Cookie token is invalid.', ]); } + $expires = (int)$expires; - [$username, $tokenHash] = $token; + if ($expires < time()) { + return new Result(null, Result::FAILURE_CREDENTIALS_INVALID, [ + 'Cookie token has expired.', + ]); + } $identifier = $this->getIdentifier(); $identity = $identifier->identify(compact('username')); - if (!$identity) { return new Result(null, Result::FAILURE_IDENTITY_NOT_FOUND, $identifier->getErrors()); } - if (!$this->_checkToken($identity, $tokenHash)) { + $usernameField = $this->getConfig('fields.username'); + $passwordField = $this->getConfig('fields.password'); + $plain = $identity[$usernameField] . $identity[$passwordField] . $expires; + + if (!hash_equals(hash_hmac('sha256', $plain, $this->_hmacKey()), $tokenHash)) { return new Result(null, Result::FAILURE_CREDENTIALS_INVALID, [ 'Cookie token does not match', ]); @@ -121,38 +184,87 @@ public function authenticate(ServerRequestInterface $request): ResultInterface } /** - * @inheritDoc + * Validates a legacy two-part `[username, passwordHashedToken]` token. + * + * Legitimate legacy tokens were created with `password_hash()`, so any + * hash that is not a known `password_hash()` algorithm is rejected + * before it can reach `password_verify()`. This blocks forged + * crypt()-format hashes (e.g. DES, which truncates its input to + * 8 bytes) while all real legacy cookies keep working. + * + * @param array $token The decoded token parts. + * @return \Authentication\Authenticator\ResultInterface */ - public function persistIdentity(ServerRequestInterface $request, ResponseInterface $response, $identity): array + protected function _authenticateLegacyToken(array $token): ResultInterface { - $field = $this->getConfig('rememberMeField'); - $bodyData = $request->getParsedBody(); + [$username, $tokenHash] = $token; + if (!is_string($username) || !is_string($tokenHash)) { + return new Result(null, Result::FAILURE_CREDENTIALS_INVALID, [ + 'Cookie token is invalid.', + ]); + } - if (!$this->_checkUrl($request) || !is_array($bodyData) || empty($bodyData[$field])) { - return [ - 'request' => $request, - 'response' => $response, - ]; + $info = password_get_info($tokenHash); + if ($info['algoName'] === 'unknown' || !$this->_legacyHashWithinLimits($info)) { + return new Result(null, Result::FAILURE_CREDENTIALS_INVALID, [ + 'Cookie token is invalid.', + ]); } - $value = $this->_createToken($identity); - $cookie = $this->_createCookie($value); + $identifier = $this->getIdentifier(); + $identity = $identifier->identify(compact('username')); + if (!$identity) { + return new Result(null, Result::FAILURE_IDENTITY_NOT_FOUND, $identifier->getErrors()); + } - return [ - 'request' => $request, - 'response' => $response->withAddedHeader('Set-Cookie', $cookie->toHeaderValue()), - ]; + $plain = $this->_createLegacyPlainToken($identity); + if (!$this->getPasswordHasher()->check($plain, $tokenHash)) { + return new Result(null, Result::FAILURE_CREDENTIALS_INVALID, [ + 'Cookie token does not match', + ]); + } + + return new Result($identity, Result::SUCCESS); } /** - * Creates a plain part of a cookie token. + * Checks a legacy token hash uses a bounded work factor. * - * Returns concatenated username, password hash, and HMAC signature. + * A forged legacy token could embed a valid bcrypt/argon2 hash with an + * arbitrarily large cost, turning password_verify() into a CPU or memory + * exhaustion vector. Legitimately issued cookies use the PASSWORD_DEFAULT + * parameters, so bounding the work factor rejects abusive hashes without + * affecting real tokens. Bounds come from the `legacyHashLimits` config. * - * @param \ArrayAccess|array $identity Identity data. + * @param array $info Result of password_get_info() for the token hash. + * @return bool + */ + protected function _legacyHashWithinLimits(array $info): bool + { + $limits = $this->getConfig('legacyHashLimits'); + $options = $info['options'] ?? []; + + if (isset($options['cost']) && $options['cost'] > $limits['cost']) { + return false; + } + if (isset($options['memory_cost']) && $options['memory_cost'] > $limits['memory_cost']) { + return false; + } + + return !(isset($options['time_cost']) && $options['time_cost'] > $limits['time_cost']); + } + + /** + * Recreates the plain part of a legacy cookie token. + * + * This must match the pre-HMAC token construction byte for byte, + * including the legacy `salt` config semantics, or existing cookies + * would not survive the grace period. + * + * @param \ArrayAccess|array $identity Identity data. * @return string */ - protected function _createPlainToken(ArrayAccess|array $identity): string + protected function _createLegacyPlainToken(ArrayAccess|array $identity): string { $usernameField = $this->getConfig('fields.username'); $passwordField = $this->getConfig('fields.password'); @@ -176,15 +288,55 @@ protected function _createPlainToken(ArrayAccess|array $identity): string } $hmac = hash_hmac('sha1', $value, $salt); - // Instead of appending the plain salt, we create a hash. This limits the chance of the salt being leaked. return $value . $hmac; } /** - * Creates a full cookie token serialized as a JSON sting. + * Return the password hasher built from the `passwordHasher` config. * - * Cookie token consists of a username and hashed username + password hash. + * Overrides the trait accessor so the config option is honored. + * + * @return \Authentication\PasswordHasher\PasswordHasherInterface + */ + public function getPasswordHasher(): PasswordHasherInterface + { + if (!$this->_passwordHasher instanceof PasswordHasherInterface) { + $this->_passwordHasher = PasswordHasherFactory::build($this->getConfig('passwordHasher')); + } + + return $this->_passwordHasher; + } + + /** + * @inheritDoc + */ + public function persistIdentity(ServerRequestInterface $request, ResponseInterface $response, $identity): array + { + $field = $this->getConfig('rememberMeField'); + $bodyData = $request->getParsedBody(); + + if (!$this->_checkUrl($request) || !is_array($bodyData) || empty($bodyData[$field])) { + return [ + 'request' => $request, + 'response' => $response, + ]; + } + + $value = $this->_createToken($identity); + $cookie = $this->_createCookie($value); + + return [ + 'request' => $request, + 'response' => $response->withAddedHeader('Set-Cookie', $cookie->toHeaderValue()), + ]; + } + + /** + * Creates a full cookie token serialized as a JSON string. + * + * Cookie token consists of the username, an expiry timestamp, and an + * HMAC-SHA256 of `username + password hash + expires`. * * @param \ArrayAccess|array $identity Identity data. * @return string @@ -192,26 +344,71 @@ protected function _createPlainToken(ArrayAccess|array $identity): string */ protected function _createToken(ArrayAccess|array $identity): string { - $plain = $this->_createPlainToken($identity); - $hash = $this->getPasswordHasher()->hash($plain); - $usernameField = $this->getConfig('fields.username'); + $passwordField = $this->getConfig('fields.password'); - return json_encode([$identity[$usernameField], $hash], JSON_THROW_ON_ERROR); + if ($identity[$usernameField] === null || $identity[$passwordField] === null) { + throw new InvalidArgumentException( + sprintf('Fields %s cannot be found in entity', '`' . $usernameField . '`/`' . $passwordField . '`'), + ); + } + + $expires = $this->_expiryTimestamp(); + $plain = $identity[$usernameField] . $identity[$passwordField] . $expires; + $hash = hash_hmac('sha256', $plain, $this->_hmacKey()); + + return json_encode([$identity[$usernameField], $expires, $hash], JSON_THROW_ON_ERROR); } /** - * Checks whether a token hash matches the identity data. + * Returns the HMAC key for token creation and verification. * - * @param \ArrayAccess|array $identity Identity data. - * @param string $tokenHash Hashed part of a cookie token. - * @return bool + * The `salt` config is used as the key when it is a string. Any other + * value falls back to the application salt — the HMAC key cannot be + * disabled. + * + * @return string + */ + protected function _hmacKey(): string + { + $salt = $this->getConfig('salt'); + if (is_string($salt)) { + if ($salt === '') { + throw new InvalidArgumentException('Salt must be a non-empty string.'); + } + + return $salt; + } + + return Security::getSalt(); + } + + /** + * Converts the `cookie.expires` config value to a timestamp. + * + * Supported values: `DateTimeInterface` instances, numeric UNIX + * timestamps (consistent with `Cake\Http\Cookie\Cookie`), and + * `strtotime()` compatible strings such as the `+7 days` default. + * + * @return int Timestamp the token will expire at. */ - protected function _checkToken(ArrayAccess|array $identity, string $tokenHash): bool + protected function _expiryTimestamp(): int { - $plain = $this->_createPlainToken($identity); + $expires = $this->getConfig('cookie.expires'); + if ($expires instanceof DateTimeInterface) { + return $expires->getTimestamp(); + } + if (is_numeric($expires)) { + return (int)$expires; + } + if (is_string($expires)) { + $time = strtotime($expires); + if ($time !== false) { + return $time; + } + } - return $this->getPasswordHasher()->check($plain, $tokenHash); + throw new UnexpectedValueException('Invalid `cookie.expires` value'); } /** diff --git a/tests/TestCase/Authenticator/CookieAuthenticatorTest.php b/tests/TestCase/Authenticator/CookieAuthenticatorTest.php index a6e468e8..07752640 100644 --- a/tests/TestCase/Authenticator/CookieAuthenticatorTest.php +++ b/tests/TestCase/Authenticator/CookieAuthenticatorTest.php @@ -23,10 +23,15 @@ use Cake\Http\Cookie\Cookie; use Cake\Http\Response; use Cake\Http\ServerRequestFactory; +use Cake\ORM\Entity; use Cake\TestSuite\TestCase; +use Cake\Utility\Security; +use DateTimeImmutable; use InvalidArgumentException; +use PHPUnit\Framework\Attributes\DataProvider; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; +use UnexpectedValueException; class CookieAuthenticatorTest extends TestCase { @@ -52,6 +57,47 @@ public function setUp(): void parent::setUp(); } + /** + * Fetch a user entity from the fixture data. + */ + protected function getUser(string $username): Entity + { + $users = $this->getTableLocator()->get('Users'); + + /** @var \Cake\ORM\Entity */ + return $users->findByUsername($username)->firstOrFail(); + } + + /** + * Build a valid v2 token from fixture data. + * + * @return array{0: string, 1: int, 2: string} + */ + protected function createToken(string $username, ?int $expires = null, ?string $key = null): array + { + $user = $this->getUser($username); + $expires ??= time() + 60 * 60 * 24; + $key ??= Security::getSalt(); + $hash = hash_hmac('sha256', $user->username . $user->password . $expires, $key); + + return [$user->username, $expires, $hash]; + } + + /** + * Build a legacy (v1) token exactly as the pre-fix code did: + * `[username, password_hash(username . password [. sha1 hmac])]`. + * + * @return array{0: string, 1: string} + */ + protected function createLegacyToken(string $username, bool $withSalt = true): array + { + $user = $this->getUser($username); + $value = $user->username . $user->password; + $plain = $withSalt ? $value . hash_hmac('sha1', $value, Security::getSalt()) : $value; + + return [$user->username, password_hash($plain, PASSWORD_DEFAULT)]; + } + /** * testAuthenticateInvalidTokenMissingUsername * @@ -95,8 +141,7 @@ public function testAuthenticateSuccess() null, null, [ - // hash(username . password . hmac(username . password, salt)) - 'CookieAuth' => '["mariano","$2y$10$RlCAFt3e/9l42f8SIaIbqejOg9/b/HklPo.fjXY.tFGuluafugssa"]', + 'CookieAuth' => json_encode($this->createToken('mariano')), ], ); @@ -108,7 +153,7 @@ public function testAuthenticateSuccess() } /** - * testAuthenticateSuccess + * An array-format (expanded) cookie is accepted. * * @return void */ @@ -123,7 +168,7 @@ public function testAuthenticateExpandedCookie() null, null, [ - 'CookieAuth' => ['mariano', '$2y$10$RlCAFt3e/9l42f8SIaIbqejOg9/b/HklPo.fjXY.tFGuluafugssa'], + 'CookieAuth' => $this->createToken('mariano'), ], ); @@ -135,7 +180,8 @@ public function testAuthenticateExpandedCookie() } /** - * testAuthenticateSuccessNoSalt + * `salt => false` cannot disable the HMAC key for v2 tokens; the + * application salt is used instead. * * @return void */ @@ -152,8 +198,7 @@ public function testAuthenticateNoSalt() null, null, [ - // hash(username . password) - 'CookieAuth' => '["mariano","$2y$10$yq91zLgrlF0TUzPjFj49DOL44svGrOYxaBfB6QYWEvxVKzNkvcVom"]', + 'CookieAuth' => json_encode($this->createToken('mariano')), ], ); @@ -165,7 +210,7 @@ public function testAuthenticateNoSalt() } /** - * testAuthenticateSuccessNoSalt + * An empty-string salt config throws. * * @return void */ @@ -180,7 +225,7 @@ public function testAuthenticateInvalidSalt() null, null, [ - 'CookieAuth' => '["mariano","some_hash"]', + 'CookieAuth' => json_encode($this->createToken('mariano')), ], ); @@ -206,7 +251,7 @@ public function testAuthenticateUnknownUser() null, null, [ - 'CookieAuth' => '["robert","$2y$10$1bE1SgasKoz9WmEvUfuZLeYa6pQgxUIJ5LAoS/KGmC1hNuWkUG7ES"]', + 'CookieAuth' => json_encode(['robert', time() + 60 * 60 * 24, str_repeat('a', 64)]), ], ); @@ -240,7 +285,7 @@ public function testCredentialsNotPresent() } /** - * testAuthenticateInvalidToken + * A well-formed token with a wrong HMAC is rejected. * * @return void */ @@ -250,12 +295,15 @@ public function testAuthenticateInvalidToken() 'Authentication.Password', ]); + $token = $this->createToken('mariano'); + $token[2] = str_repeat('0', 64); + $request = ServerRequestFactory::fromGlobals( ['REQUEST_URI' => '/testpath'], null, null, [ - 'CookieAuth' => '["mariano","$2y$10$1bE1SgasKoz9WmEvUfuZLeYa6pQgxUIJ5LAoS/asdasdsadasd"]', + 'CookieAuth' => json_encode($token), ], ); @@ -266,12 +314,456 @@ public function testAuthenticateInvalidToken() $this->assertSame(Result::FAILURE_CREDENTIALS_INVALID, $result->getStatus()); } + /** + * testDESBypassRejected + * + * The forged DES-crypt hash must be rejected in BOTH modes: during the + * grace period (killed by the password_get_info() algorithm check) and + * after it (killed by the format rejection). + * + * @return void + */ + public function testDESBypassRejected(): void + { + $identifiers = new IdentifierCollection([ + 'Authentication.Password', + ]); + + // Attacker forges a DES-crypt hash. DES truncates plaintext to 8 bytes. + // 'mariano' is 7 bytes; the first byte of any bcrypt/argon2 hash is '$', + // so the first 8 bytes of the server plaintext are always 'mariano$'. + $forgedDESHash = crypt('mariano$', 'xx'); + + $request = ServerRequestFactory::fromGlobals( + ['REQUEST_URI' => '/testpath'], + null, + null, + ['CookieAuth' => json_encode(['mariano', $forgedDESHash])], + ); + + // Grace period on (default) + $authenticator = new CookieAuthenticator($identifiers); + $result = $authenticator->authenticate($request); + $this->assertSame(Result::FAILURE_CREDENTIALS_INVALID, $result->getStatus()); + + // Grace period ended + $authenticator = new CookieAuthenticator($identifiers, ['legacyTokens' => false]); + $result = $authenticator->authenticate($request); + $this->assertSame(Result::FAILURE_CREDENTIALS_INVALID, $result->getStatus()); + } + + /** + * A legitimate legacy token authenticates during the grace period. + * + * @return void + */ + public function testAuthenticateLegacyTokenSuccess(): void + { + $identifiers = new IdentifierCollection([ + 'Authentication.Password', + ]); + + $request = ServerRequestFactory::fromGlobals( + ['REQUEST_URI' => '/testpath'], + null, + null, + [ + 'CookieAuth' => json_encode($this->createLegacyToken('mariano')), + ], + ); + + $authenticator = new CookieAuthenticator($identifiers); + $result = $authenticator->authenticate($request); + + $this->assertInstanceOf(Result::class, $result); + $this->assertSame(Result::SUCCESS, $result->getStatus()); + } + + /** + * A legacy token created with `salt => false` authenticates during + * the grace period. + * + * @return void + */ + public function testAuthenticateLegacyTokenNoSaltSuccess(): void + { + $identifiers = new IdentifierCollection([ + 'Authentication.Password', + ]); + + $request = ServerRequestFactory::fromGlobals( + ['REQUEST_URI' => '/testpath'], + null, + null, + [ + 'CookieAuth' => json_encode($this->createLegacyToken('mariano', false)), + ], + ); + + $authenticator = new CookieAuthenticator($identifiers, ['salt' => false]); + $result = $authenticator->authenticate($request); + + $this->assertInstanceOf(Result::class, $result); + $this->assertSame(Result::SUCCESS, $result->getStatus()); + } + + /** + * Legacy tokens are rejected once the grace period is ended. + * + * @return void + */ + public function testAuthenticateLegacyTokenRejectedWhenDisabled(): void + { + $identifiers = new IdentifierCollection([ + 'Authentication.Password', + ]); + + $request = ServerRequestFactory::fromGlobals( + ['REQUEST_URI' => '/testpath'], + null, + null, + [ + 'CookieAuth' => json_encode($this->createLegacyToken('mariano')), + ], + ); + + $authenticator = new CookieAuthenticator($identifiers, ['legacyTokens' => false]); + $result = $authenticator->authenticate($request); + + $this->assertInstanceOf(Result::class, $result); + $this->assertSame(Result::FAILURE_CREDENTIALS_INVALID, $result->getStatus()); + } + + /** + * A legacy token for an unknown user reports identity-not-found. + * + * @return void + */ + public function testAuthenticateLegacyTokenUnknownUser(): void + { + $identifiers = new IdentifierCollection([ + 'Authentication.Password', + ]); + + $request = ServerRequestFactory::fromGlobals( + ['REQUEST_URI' => '/testpath'], + null, + null, + [ + 'CookieAuth' => '["robert","$2y$10$1bE1SgasKoz9WmEvUfuZLeYa6pQgxUIJ5LAoS/KGmC1hNuWkUG7ES"]', + ], + ); + + $authenticator = new CookieAuthenticator($identifiers); + $result = $authenticator->authenticate($request); + + $this->assertInstanceOf(Result::class, $result); + $this->assertSame(Result::FAILURE_IDENTITY_NOT_FOUND, $result->getStatus()); + } + + /** + * A legacy token whose bcrypt hash uses a work factor above the ceiling + * is rejected before it can reach password_verify(). + * + * @return void + */ + public function testAuthenticateLegacyTokenExcessiveCostRejected(): void + { + $identifiers = new IdentifierCollection([ + 'Authentication.Password', + ]); + $user = $this->getUser('mariano'); + + // Well-formed bcrypt string at cost 20 (above the default ceiling of + // 15). Crafted directly so no expensive hash is ever computed. + $abusiveHash = '$2y$20$' . str_repeat('a', 53); + + $request = ServerRequestFactory::fromGlobals( + ['REQUEST_URI' => '/testpath'], + null, + null, + [ + 'CookieAuth' => json_encode([$user->username, $abusiveHash]), + ], + ); + + $authenticator = new CookieAuthenticator($identifiers); + $result = $authenticator->authenticate($request); + + $this->assertInstanceOf(Result::class, $result); + $this->assertSame(Result::FAILURE_CREDENTIALS_INVALID, $result->getStatus()); + // Must be rejected at the work-factor gate (this message), not by a + // slow password_verify() mismatch ('Cookie token does not match'). + // Without this the test would pass even if the gate were removed. + $this->assertContains('Cookie token is invalid.', $result->getErrors()); + } + + /** + * The legacy hash work-factor ceiling is configurable: lowering it below + * the cost used to issue a real token causes that token to be rejected. + * + * @return void + */ + public function testAuthenticateLegacyHashLimitsConfigurable(): void + { + $identifiers = new IdentifierCollection([ + 'Authentication.Password', + ]); + + $request = ServerRequestFactory::fromGlobals( + ['REQUEST_URI' => '/testpath'], + null, + null, + [ + 'CookieAuth' => json_encode($this->createLegacyToken('mariano')), + ], + ); + + // A real legacy token uses PASSWORD_DEFAULT cost (>= 10). A ceiling of + // 4 (bcrypt's minimum) is below that, so the token is now rejected. + $authenticator = new CookieAuthenticator($identifiers, [ + 'legacyHashLimits' => ['cost' => 4, 'memory_cost' => 131072, 'time_cost' => 10], + ]); + $result = $authenticator->authenticate($request); + + $this->assertInstanceOf(Result::class, $result); + $this->assertSame(Result::FAILURE_CREDENTIALS_INVALID, $result->getStatus()); + } + + /** + * A user authenticated via legacy token gets a v2 cookie at next login. + * + * @return void + */ + public function testLegacyUpgradeAtLogin(): void + { + $identifiers = new IdentifierCollection([ + 'Authentication.Password', + ]); + $user = $this->getUser('mariano'); + + $request = ServerRequestFactory::fromGlobals( + ['REQUEST_URI' => '/testpath'], + null, + null, + [ + 'CookieAuth' => json_encode($this->createLegacyToken('mariano')), + ], + ); + + $authenticator = new CookieAuthenticator($identifiers); + $result = $authenticator->authenticate($request); + $this->assertSame(Result::SUCCESS, $result->getStatus()); + + $request = $request->withParsedBody([ + 'remember_me' => 1, + ]); + $response = new Response(); + $identity = new ArrayObject([ + 'username' => $user->username, + 'password' => $user->password, + ]); + $persisted = $authenticator->persistIdentity($request, $response, $identity); + + $cookie = Cookie::createFromHeaderString($persisted['response']->getHeaderLine('Set-Cookie')); + $decoded = json_decode($cookie->getValue(), true); + $this->assertCount(3, $decoded); + } + + /** + * An expired token is rejected before the identifier is queried. + * + * @return void + */ + public function testAuthenticateExpiredToken(): void + { + $identifiers = new IdentifierCollection([ + 'Authentication.Password', + ]); + + $request = ServerRequestFactory::fromGlobals( + ['REQUEST_URI' => '/testpath'], + null, + null, + [ + 'CookieAuth' => json_encode($this->createToken('mariano', time() - 1)), + ], + ); + + $authenticator = new CookieAuthenticator($identifiers); + $result = $authenticator->authenticate($request); + + $this->assertInstanceOf(Result::class, $result); + $this->assertSame(Result::FAILURE_CREDENTIALS_INVALID, $result->getStatus()); + } + + /** + * Extending the expiry timestamp invalidates the HMAC. + * + * @return void + */ + public function testAuthenticateExpireModificationFailure(): void + { + $identifiers = new IdentifierCollection([ + 'Authentication.Password', + ]); + + $token = $this->createToken('mariano'); + $token[1] += 1; + + $request = ServerRequestFactory::fromGlobals( + ['REQUEST_URI' => '/testpath'], + null, + null, + [ + 'CookieAuth' => json_encode($token), + ], + ); + + $authenticator = new CookieAuthenticator($identifiers); + $result = $authenticator->authenticate($request); + + $this->assertInstanceOf(Result::class, $result); + $this->assertSame(Result::FAILURE_CREDENTIALS_INVALID, $result->getStatus()); + } + + /** + * Appending to the hash invalidates the token. + * + * @return void + */ + public function testAuthenticateHashModificationFailure(): void + { + $identifiers = new IdentifierCollection([ + 'Authentication.Password', + ]); + + $token = $this->createToken('mariano'); + $token[2] .= 'a'; + + $request = ServerRequestFactory::fromGlobals( + ['REQUEST_URI' => '/testpath'], + null, + null, + [ + 'CookieAuth' => json_encode($token), + ], + ); + + $authenticator = new CookieAuthenticator($identifiers); + $result = $authenticator->authenticate($request); + + $this->assertInstanceOf(Result::class, $result); + $this->assertSame(Result::FAILURE_CREDENTIALS_INVALID, $result->getStatus()); + } + + public static function malformedCookieProvider(): array + { + return [ + 'garbage json' => ['notjson'], + 'scalar string json' => ['"abc"'], + 'scalar int json' => ['123'], + 'assoc object' => ['{"a":"x","b":"y","c":"z"}'], + 'four parts' => ['["a","b","c","d"]'], + 'non-string hash' => ['["mariano",99999999999,123]'], + 'non-numeric expires' => ['["mariano","soon","abc"]'], + 'non-string username' => ['[1,99999999999,"abc"]'], + ]; + } + + /** + * Malformed cookies produce an invalid result, never a TypeError. + * + * @return void + */ + #[DataProvider('malformedCookieProvider')] + public function testAuthenticateMalformedCookie(string $cookieValue): void + { + $identifiers = new IdentifierCollection([ + 'Authentication.Password', + ]); + + $request = ServerRequestFactory::fromGlobals( + ['REQUEST_URI' => '/testpath'], + null, + null, + [ + 'CookieAuth' => $cookieValue, + ], + ); + + $authenticator = new CookieAuthenticator($identifiers); + $result = $authenticator->authenticate($request); + + $this->assertInstanceOf(Result::class, $result); + $this->assertSame(Result::FAILURE_CREDENTIALS_INVALID, $result->getStatus()); + } + + /** + * A cookie issued with untouched default config must round-trip. + * + * This is the regression test for the upstream defect where the default + * `cookie.expires` produced tokens that were already expired in 1970. + * + * @return void + */ + public function testAuthenticateDefaultConfigRoundtrip(): void + { + $identifiers = new IdentifierCollection([ + 'Authentication.Password', + ]); + $user = $this->getUser('mariano'); + + $request = ServerRequestFactory::fromGlobals( + ['REQUEST_URI' => '/testpath'], + ); + $request = $request->withParsedBody([ + 'remember_me' => 1, + ]); + $response = new Response(); + + $authenticator = new CookieAuthenticator($identifiers); + $identity = new ArrayObject([ + 'username' => $user->username, + 'password' => $user->password, + ]); + $result = $authenticator->persistIdentity($request, $response, $identity); + + $cookie = Cookie::createFromHeaderString($result['response']->getHeaderLine('Set-Cookie')); + + $request = ServerRequestFactory::fromGlobals( + ['REQUEST_URI' => '/testpath'], + null, + null, + [ + 'CookieAuth' => $cookie->getValue(), + ], + ); + $result = $authenticator->authenticate($request); + + $this->assertInstanceOf(Result::class, $result); + $this->assertSame(Result::SUCCESS, $result->getStatus()); + } + + public static function validExpiresProvider(): array + { + $string = '2030-01-01 00:00:00'; + $datetime = new DateTimeImmutable($string); + + return [ + 'strtotime string' => [$string], + 'datetime instance' => [$datetime], + 'unix timestamp' => [$datetime->getTimestamp()], + ]; + } + /** * testPersistIdentity * * @return void */ - public function testPersistIdentity() + #[DataProvider('validExpiresProvider')] + public function testPersistIdentity(DateTimeImmutable|string|int $expires) { $identifiers = new IdentifierCollection([ 'Authentication.Password', @@ -287,12 +779,13 @@ public function testPersistIdentity() Cookie::setDefaults(['samesite' => 'None']); $authenticator = new CookieAuthenticator($identifiers, [ - 'cookie' => ['expires' => '2030-01-01 00:00:00'], + 'cookie' => ['expires' => $expires], ]); + $password = '$2a$10$u05j8FjsvLBNdfhBhc21LOuVMpzpabVXQ9OpC2wO3pSO0q6t7HHMO'; $identity = new ArrayObject([ 'username' => 'mariano', - 'password' => '$2a$10$u05j8FjsvLBNdfhBhc21LOuVMpzpabVXQ9OpC2wO3pSO0q6t7HHMO', + 'password' => $password, ]); $result = $authenticator->persistIdentity($request, $response, $identity); @@ -301,45 +794,122 @@ public function testPersistIdentity() $this->assertArrayHasKey('response', $result); $this->assertInstanceOf(RequestInterface::class, $result['request']); $this->assertInstanceOf(ResponseInterface::class, $result['response']); - $hashCost = '10'; - if (PHP_VERSION_ID >= 80400) { - $hashCost = '12'; - } - $this->assertStringContainsString( - 'CookieAuth=%5B%22mariano%22%2C%22%242y%24' . $hashCost . '%24', // `CookieAuth=["mariano","$2y$10$` - $result['response']->getHeaderLine('Set-Cookie'), - ); - $this->assertStringContainsString( - 'expires=Tue, 01-Jan-2030 00:00:00 GMT;', - $result['response']->getHeaderLine('Set-Cookie'), - ); - $this->assertStringContainsString( - 'samesite=None', - $result['response']->getHeaderLine('Set-Cookie'), + + $header = $result['response']->getHeaderLine('Set-Cookie'); + $cookie = Cookie::createFromHeaderString($header); + $this->assertSame('CookieAuth', $cookie->getName()); + + $expectedExpires = strtotime('2030-01-01 00:00:00'); + $decoded = json_decode($cookie->getValue(), true); + $this->assertCount(3, $decoded); + $this->assertSame('mariano', $decoded[0]); + $this->assertSame($expectedExpires, $decoded[1]); + $this->assertSame( + hash_hmac('sha256', 'mariano' . $password . $expectedExpires, Security::getSalt()), + $decoded[2], ); + $this->assertStringContainsString('expires=Tue, 01-Jan-2030 00:00:00 GMT;', $header); + $this->assertStringContainsString('samesite=None', $header); Cookie::setDefaults(['samesite' => null]); + } + + /** + * The cookie is not written without the remember-me field. + * + * @return void + */ + public function testPersistIdentityNoField(): void + { + $identifiers = new IdentifierCollection([ + 'Authentication.Password', + ]); - // Testing that the field is not present + $request = ServerRequestFactory::fromGlobals( + ['REQUEST_URI' => '/testpath'], + ); $request = $request->withParsedBody([]); + + $response = new Response(); + + $authenticator = new CookieAuthenticator($identifiers); + $identity = new ArrayObject([ + 'username' => 'mariano', + 'password' => '$2a$10$u05j8FjsvLBNdfhBhc21LOuVMpzpabVXQ9OpC2wO3pSO0q6t7HHMO', + ]); $result = $authenticator->persistIdentity($request, $response, $identity); + $this->assertStringNotContainsString( 'CookieAuth', $result['response']->getHeaderLine('Set-Cookie'), ); + } + + /** + * A custom remember-me field name is honored. + * + * @return void + */ + public function testPersistIdentityOtherField(): void + { + $identifiers = new IdentifierCollection([ + 'Authentication.Password', + ]); - // Testing a different field name + $request = ServerRequestFactory::fromGlobals( + ['REQUEST_URI' => '/testpath'], + ); $request = $request->withParsedBody([ 'other_field' => 1, ]); + $response = new Response(); + $authenticator = new CookieAuthenticator($identifiers, [ 'rememberMeField' => 'other_field', ]); + $identity = new ArrayObject([ + 'username' => 'mariano', + 'password' => '$2a$10$u05j8FjsvLBNdfhBhc21LOuVMpzpabVXQ9OpC2wO3pSO0q6t7HHMO', + ]); $result = $authenticator->persistIdentity($request, $response, $identity); - $this->assertStringContainsString( - 'CookieAuth=%5B%22mariano%22%2C%22%242y%24' . $hashCost . '%24', - $result['response']->getHeaderLine('Set-Cookie'), + + $cookie = Cookie::createFromHeaderString($result['response']->getHeaderLine('Set-Cookie')); + $this->assertSame('CookieAuth', $cookie->getName()); + $decoded = json_decode($cookie->getValue(), true); + $this->assertCount(3, $decoded); + } + + /** + * An unparseable cookie.expires config value throws at persist time. + * + * @return void + */ + public function testPersistIdentityInvalidExpiryTime(): void + { + $identifiers = new IdentifierCollection([ + 'Authentication.Password', + ]); + + $request = ServerRequestFactory::fromGlobals( + ['REQUEST_URI' => '/testpath'], ); + $request = $request->withParsedBody([ + 'remember_me' => 1, + ]); + $response = new Response(); + + $authenticator = new CookieAuthenticator($identifiers, [ + 'cookie' => ['expires' => 'nope'], + ]); + + $identity = new ArrayObject([ + 'username' => 'mariano', + 'password' => '$2a$10$u05j8FjsvLBNdfhBhc21LOuVMpzpabVXQ9OpC2wO3pSO0q6t7HHMO', + ]); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Invalid `cookie.expires` value'); + $authenticator->persistIdentity($request, $response, $identity); } /** @@ -376,10 +946,7 @@ public function testPersistIdentityLoginUrlMismatch() $this->assertArrayHasKey('response', $result); $this->assertInstanceOf(RequestInterface::class, $result['request']); $this->assertInstanceOf(ResponseInterface::class, $result['response']); - $this->assertStringNotContainsString( - 'CookieAuth=%5B%22mariano%22%2C%22%242y%2410%24', - $result['response']->getHeaderLine('Set-Cookie'), - ); + $this->assertSame('', $result['response']->getHeaderLine('Set-Cookie')); } /**