From eafc779688d46aeabfe27e4d5267e56aa445e69c Mon Sep 17 00:00:00 2001 From: arnold Date: Fri, 30 Aug 2024 15:03:53 +0200 Subject: [PATCH] Use PSR-20 for hashids. PSR-20 allows for a frozen clock. This removes the dependency on carbon. --- composer.json | 4 +- src/Confirmation/HashidsConfirmation.php | 79 ++++++++++++------- .../Confirmation/HashidsConfirmationTest.php | 49 +++++++----- 3 files changed, 80 insertions(+), 52 deletions(-) diff --git a/composer.json b/composer.json index 8051d7b..a783142 100644 --- a/composer.json +++ b/composer.json @@ -21,10 +21,10 @@ "php": ">=8.2.0", "improved/iterable": "^0.1.4", "jasny/immutable": "^2.1", - "nesbot/carbon": "^2.27", + "psr/clock": "^1.0", "psr/event-dispatcher": "^1.0", "psr/http-factory": "^1.0", - "psr/http-message": "^1.0", + "psr/http-message": "^1.1", "psr/http-server-middleware": "^1.0", "psr/log": "^1.1" }, diff --git a/src/Confirmation/HashidsConfirmation.php b/src/Confirmation/HashidsConfirmation.php index 68d8f7c..be71efd 100644 --- a/src/Confirmation/HashidsConfirmation.php +++ b/src/Confirmation/HashidsConfirmation.php @@ -4,14 +4,16 @@ namespace Jasny\Auth\Confirmation; -use Carbon\CarbonImmutable; use Closure; +use DateTimeImmutable; use DateTimeInterface; +use DateTimeZone; use Exception; use Hashids\Hashids; use Jasny\Auth\UserInterface as User; use Jasny\Auth\StorageInterface as Storage; use Jasny\Immutable; +use Psr\Clock\ClockInterface; use Psr\Log\LoggerInterface as Logger; use Psr\Log\NullLogger; use RuntimeException; @@ -37,6 +39,7 @@ class HashidsConfirmation implements ConfirmationInterface protected Closure $decodeUid; protected Logger $logger; + protected ClockInterface $clock; /** * HashidsConfirmation constructor. @@ -59,19 +62,32 @@ public function __construct(string $secret, ?callable $createHashids = null) $this->decodeUid = fn(string $hex) => pack('H*', $hex); $this->logger = new NullLogger(); + + $this->clock = new class () implements ClockInterface + { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('now', new DateTimeZone('UTC')); + } + }; } /** * Get copy with storage service. - * - * @param Storage $storage - * @return static */ - public function withStorage(Storage $storage): self + public function withStorage(Storage $storage): static { return $this->withProperty('storage', $storage); } + /** + * Get copy with clock service. Mainly used for testing. + */ + public function withClock(ClockInterface $clock): static + { + return $this->withProperty('clock', $clock); + } + /** * Get a copy with custom methods to encode/decode the uid. */ @@ -105,7 +121,7 @@ public function withSubject(string $subject): static public function getToken(User $user, DateTimeInterface $expire): string { $uidHex = $this->encodeUid($user->getAuthId()); - $expireHex = CarbonImmutable::instance($expire)->utc()->format('YmdHis'); + $expireHex = self::utc($expire)->format('YmdHis'); $checksum = $this->calcChecksum($user, $expire); return $this->createHashids()->encodeHex($checksum . $expireHex . $uidHex); @@ -122,6 +138,7 @@ public function getToken(User $user, DateTimeInterface $expire): string public function from(string $token): User { $hex = $this->createHashids()->decodeHex($token); + /** @var null|array{checksum:string,expire:DateTimeImmutable,uid:string} $info */ $info = $this->extractHex($hex); $context = ['subject' => $this->subject, 'token' => self::partialToken($token)]; @@ -148,7 +165,7 @@ public function from(string $token): User * Extract uid, expire date and checksum from hex. * * @param string $hex - * @return null|array{checksum:string,expire:CarbonImmutable,uid:string} + * @return null|array{checksum:string,expire:DateTimeImmutable,uid:string} */ protected function extractHex(string $hex): ?array { @@ -162,14 +179,12 @@ protected function extractHex(string $hex): ?array try { $uid = $this->decodeUid($uidHex); - - /** @var CarbonImmutable $expire */ - $expire = CarbonImmutable::createFromFormat('YmdHis', $expireHex, '+00:00'); + $expire = DateTimeImmutable::createFromFormat('YmdHis', $expireHex, new DateTimeZone('UTC')); } catch (Exception $exception) { return null; } - if ($expire->format('YmdHis') !== $expireHex) { + if ($expire === false || $expire->format('YmdHis') !== $expireHex) { return null; } @@ -227,39 +242,35 @@ protected function fetchUserFromStorage(string $uid, array $context): User /** * Check that the checksum from the token matches the expected checksum. * - * @param string $checksum - * @param User $user - * @param CarbonImmutable $expire - * @param string[] $context + * @param string $checksum + * @param User $user + * @param DateTimeInterface $expire + * @param string[] $context * @throws InvalidTokenException */ - protected function verifyChecksum(string $checksum, User $user, CarbonImmutable $expire, array $context): void + protected function verifyChecksum(string $checksum, User $user, DateTimeInterface $expire, array $context): void { $expected = $this->calcChecksum($user, $expire); - if ($checksum === $expected) { - return; + if ($checksum !== $expected) { + $this->logger->debug('Invalid confirmation token: bad checksum', $context); + throw new InvalidTokenException("Token has been revoked"); } - - $this->logger->debug('Invalid confirmation token: bad checksum', $context); - throw new InvalidTokenException("Token has been revoked"); } /** * Check that the token isn't expired. * - * @param CarbonImmutable $expire + * @param DateTimeInterface $expire * @param string[] $context * @throws InvalidTokenException */ - protected function verifyNotExpired(CarbonImmutable $expire, array $context): void + protected function verifyNotExpired(DateTimeInterface $expire, array $context): void { - if (!$expire->isPast()) { - return; + if ($expire < $this->clock->now()) { + $this->logger->debug('Expired confirmation token', $context); + throw new InvalidTokenException("Token is expired"); } - - $this->logger->debug('Expired confirmation token', $context); - throw new InvalidTokenException("Token is expired"); } @@ -269,7 +280,7 @@ protected function verifyNotExpired(CarbonImmutable $expire, array $context): vo protected function calcChecksum(User $user, DateTimeInterface $expire): string { $parts = [ - CarbonImmutable::instance($expire)->utc()->format('YmdHis'), + self::utc($expire)->format('YmdHis'), $user->getAuthId(), $user->getAuthChecksum(), ]; @@ -294,4 +305,14 @@ protected static function partialToken(string $token): string { return substr($token, 0, 8) . '...'; } + + /** + * Create a UTC date from a date. + */ + protected static function utc(DateTimeInterface $date): DateTimeImmutable + { + return (new DateTimeImmutable()) + ->setTimestamp($date->getTimestamp()) + ->setTimezone(new DateTimeZone('UTC')); + } } diff --git a/tests/Confirmation/HashidsConfirmationTest.php b/tests/Confirmation/HashidsConfirmationTest.php index 960742a..d4d2568 100644 --- a/tests/Confirmation/HashidsConfirmationTest.php +++ b/tests/Confirmation/HashidsConfirmationTest.php @@ -2,20 +2,24 @@ namespace Jasny\Auth\Tests\Confirmation; -use Carbon\CarbonImmutable; +use DateTime; +use DateTimeImmutable; use Hashids\Hashids; use Jasny\Auth\Confirmation\HashidsConfirmation; use Jasny\Auth\Confirmation\InvalidTokenException; use Jasny\Auth\StorageInterface as Storage; use Jasny\Auth\UserInterface as User; use Jasny\PHPUnit\CallbackMockTrait; +use Lcobucci\Clock\FrozenClock; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; use PHPUnit\Framework\MockObject\MockObject; +use Psr\Clock\ClockInterface; use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface as Logger; +use RuntimeException; #[CoversClass(HashidsConfirmation::class)] class HashidsConfirmationTest extends TestCase @@ -28,18 +32,13 @@ class HashidsConfirmationTest extends TestCase protected User & MockObject $user; protected Logger & MockObject $logger; + protected ClockInterface $clock; public function setUp(): void { - CarbonImmutable::setTestNow('2019-12-01T00:00:00+00:00'); - $this->user = $this->createConfiguredMock(User::class, ['getAuthId' => '42', 'getAuthChecksum' => 'xyz']); $this->logger = $this->createMock(LoggerInterface::class); - } - - public function tearDown(): void - { - CarbonImmutable::setTestNow(null); + $this->clock = new FrozenClock(new DateTimeImmutable('2019-12-01T00:00:00+00:00')); } public function expectedContext(?string $uid = null, ?string $expire = null): array @@ -63,7 +62,7 @@ public function testGetToken(): void ->withStorage($storage) ->withSubject('test'); - $token = $confirm->getToken($this->user, new \DateTime('2020-01-01T12:00:00+00:00')); + $token = $confirm->getToken($this->user, new DateTime('2020-01-01T12:00:00+00:00')); $this->assertEquals(self::TOKEN, $token); } @@ -86,9 +85,10 @@ public function testGetTokenWithCustomUidEncoding(): void $confirm = (new HashidsConfirmation('secret', fn() => $hashids)) ->withUidEncoded($encode, $decode) ->withStorage($storage) - ->withSubject('test'); + ->withSubject('test') + ->withClock($this->clock); - $token = $confirm->getToken($this->user, new \DateTime('2020-01-01T12:00:00+00:00')); + $token = $confirm->getToken($this->user, new DateTime('2020-01-01T12:00:00+00:00')); $this->assertEquals(self::TOKEN, $token); } @@ -107,11 +107,12 @@ public function testGetTokenWithInvalidUid(): void $confirm = (new HashidsConfirmation('secret', fn() => $hashids)) ->withUidEncoded($encode, $decode) ->withStorage($storage) - ->withSubject('test'); + ->withSubject('test') + ->withClock($this->clock); - $this->expectExceptionObject(new \RuntimeException("Failed to encode uid")); + $this->expectExceptionObject(new RuntimeException("Failed to encode uid")); - $confirm->getToken($this->user, new \DateTime('2020-01-01T12:00:00+00:00')); + $confirm->getToken($this->user, new DateTime('2020-01-01T12:00:00+00:00')); } protected function createService(string $hex, ?User $user = null): HashidsConfirmation @@ -135,7 +136,8 @@ protected function createService(string $hex, ?User $user = null): HashidsConfir return (new HashidsConfirmation('secret', fn() => $hashids)) ->withStorage($storage) ->withLogger($this->logger) - ->withSubject('test'); + ->withSubject('test') + ->withClock($this->clock); } public function testFrom(): void @@ -265,7 +267,8 @@ public function testCreateHashIdsWithCallback(): void $service = (new HashidsConfirmation('secret', $callback)) ->withStorage($storage) - ->withSubject('test'); + ->withSubject('test') + ->withClock($this->clock); $result = $service->createHashids(); @@ -281,7 +284,8 @@ public function testCreateHashIds(): void $service = (new HashidsConfirmation('secret')) ->withStorage($storage) - ->withSubject('test'); + ->withSubject('test') + ->withClock($this->clock); $hashids = $service->createHashids(); $this->assertInstanceOf(Hashids::class, $hashids); @@ -302,9 +306,10 @@ public function testGetTokenWithRealHashids(): void $confirm = (new HashidsConfirmation('secret')) ->withStorage($storage) - ->withSubject('test'); + ->withSubject('test') + ->withClock($this->clock); - $token = $confirm->getToken($this->user, new \DateTime('2020-01-01T12:00:00+00:00')); + $token = $confirm->getToken($this->user, new DateTime('2020-01-01T12:00:00+00:00')); $expectedToken = self::TOKEN; $this->assertEquals($expectedToken, $token); @@ -321,7 +326,8 @@ public function testFromWithRealHashids(): void $confirm = (new HashidsConfirmation('secret')) ->withStorage($storage) - ->withSubject('test'); + ->withSubject('test') + ->withClock($this->clock); $user = $confirm->from(self::TOKEN); @@ -337,7 +343,8 @@ public function testFromOtherSubjectWithRealHashids(): void $confirm = (new HashidsConfirmation('secret')) ->withStorage($storage) - ->withSubject('foo-bar'); + ->withSubject('foo-bar') + ->withClock($this->clock); $this->expectException(InvalidTokenException::class);