Skip to content

Commit

Permalink
Use PSR-20 for hashids.
Browse files Browse the repository at this point in the history
PSR-20 allows for a frozen clock.
This removes the dependency on carbon.
  • Loading branch information
jasny committed Aug 30, 2024
1 parent 459003a commit eafc779
Show file tree
Hide file tree
Showing 3 changed files with 80 additions and 52 deletions.
4 changes: 2 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
79 changes: 50 additions & 29 deletions src/Confirmation/HashidsConfirmation.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -37,6 +39,7 @@ class HashidsConfirmation implements ConfirmationInterface
protected Closure $decodeUid;

protected Logger $logger;
protected ClockInterface $clock;

/**
* HashidsConfirmation constructor.
Expand All @@ -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.
*/
Expand Down Expand Up @@ -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);
Expand All @@ -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)];
Expand All @@ -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
{
Expand All @@ -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;
}

Expand Down Expand Up @@ -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");
}


Expand All @@ -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(),
];
Expand All @@ -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'));
}
}
49 changes: 28 additions & 21 deletions tests/Confirmation/HashidsConfirmationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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);
}
Expand All @@ -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);
}
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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();

Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);

Expand All @@ -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);

Expand Down

0 comments on commit eafc779

Please sign in to comment.