diff --git a/src/Symfony/Component/HttpFoundation/CHANGELOG.md b/src/Symfony/Component/HttpFoundation/CHANGELOG.md index 1a3ef0e411ea1..7335ab39620ab 100644 --- a/src/Symfony/Component/HttpFoundation/CHANGELOG.md +++ b/src/Symfony/Component/HttpFoundation/CHANGELOG.md @@ -1,6 +1,12 @@ CHANGELOG ========= +7.1 +--- + + * Add optional `$expirationParameter` argument to `UriSigner::__construct()` + * Add optional `$expiration` argument to `UriSigner::sign()` + 7.0 --- diff --git a/src/Symfony/Component/HttpFoundation/Tests/UriSignerTest.php b/src/Symfony/Component/HttpFoundation/Tests/UriSignerTest.php index dfbe81e8827f9..dd265c58664d4 100644 --- a/src/Symfony/Component/HttpFoundation/Tests/UriSignerTest.php +++ b/src/Symfony/Component/HttpFoundation/Tests/UriSignerTest.php @@ -24,21 +24,38 @@ public function testSign() $this->assertStringContainsString('?_hash=', $signer->sign('http://example.com/foo')); $this->assertStringContainsString('?_hash=', $signer->sign('http://example.com/foo?foo=bar')); $this->assertStringContainsString('&foo=', $signer->sign('http://example.com/foo?foo=bar')); + + $this->assertStringContainsString('?_expiration=', $signer->sign('http://example.com/foo', 1)); + $this->assertStringContainsString('&_hash=', $signer->sign('http://example.com/foo', 1)); + $this->assertStringContainsString('?_expiration=', $signer->sign('http://example.com/foo?foo=bar', 1)); + $this->assertStringContainsString('&_hash=', $signer->sign('http://example.com/foo?foo=bar', 1)); + $this->assertStringContainsString('&foo=', $signer->sign('http://example.com/foo?foo=bar', 1)); } public function testCheck() { $signer = new UriSigner('foobar'); + $this->assertFalse($signer->check('http://example.com/foo')); $this->assertFalse($signer->check('http://example.com/foo?_hash=foo')); $this->assertFalse($signer->check('http://example.com/foo?foo=bar&_hash=foo')); $this->assertFalse($signer->check('http://example.com/foo?foo=bar&_hash=foo&bar=foo')); + $this->assertFalse($signer->check('http://example.com/foo?_expiration=4070908800')); + $this->assertFalse($signer->check('http://example.com/foo?_expiration=4070908800?_hash=foo')); + $this->assertFalse($signer->check('http://example.com/foo?_expiration=4070908800&foo=bar&_hash=foo')); + $this->assertFalse($signer->check('http://example.com/foo?_expiration=4070908800&foo=bar&_hash=foo&bar=foo')); + $this->assertTrue($signer->check($signer->sign('http://example.com/foo'))); $this->assertTrue($signer->check($signer->sign('http://example.com/foo?foo=bar'))); $this->assertTrue($signer->check($signer->sign('http://example.com/foo?foo=bar&0=integer'))); + $this->assertTrue($signer->check($signer->sign('http://example.com/foo', new \DateTimeImmutable('2099-01-01 00:00:00')))); + $this->assertTrue($signer->check($signer->sign('http://example.com/foo?foo=bar', new \DateTimeImmutable('2099-01-01 00:00:00')))); + $this->assertTrue($signer->check($signer->sign('http://example.com/foo?foo=bar&0=integer', new \DateTimeImmutable('2099-01-01 00:00:00')))); + $this->assertSame($signer->sign('http://example.com/foo?foo=bar&bar=foo'), $signer->sign('http://example.com/foo?bar=foo&foo=bar')); + $this->assertSame($signer->sign('http://example.com/foo?foo=bar&bar=foo', 1), $signer->sign('http://example.com/foo?bar=foo&foo=bar', 1)); } public function testCheckWithDifferentArgSeparator() @@ -51,6 +68,12 @@ public function testCheckWithDifferentArgSeparator() $signer->sign('http://example.com/foo?foo=bar&baz=bay') ); $this->assertTrue($signer->check($signer->sign('http://example.com/foo?foo=bar&baz=bay'))); + + $this->assertSame( + 'http://example.com/foo?_expiration=4070908800&_hash=xfui5FoP0vbD9Cp7pI0tHnqR1Fmj2UARqkIUw7SZVfQ%3D&baz=bay&foo=bar', + $signer->sign('http://example.com/foo?foo=bar&baz=bay', new \DateTimeImmutable('2099-01-01 00:00:00')) + ); + $this->assertTrue($signer->check($signer->sign('http://example.com/foo?foo=bar&baz=bay', new \DateTimeImmutable('2099-01-01 00:00:00')))); } public function testCheckWithRequest() @@ -60,17 +83,27 @@ public function testCheckWithRequest() $this->assertTrue($signer->checkRequest(Request::create($signer->sign('http://example.com/foo')))); $this->assertTrue($signer->checkRequest(Request::create($signer->sign('http://example.com/foo?foo=bar')))); $this->assertTrue($signer->checkRequest(Request::create($signer->sign('http://example.com/foo?foo=bar&0=integer')))); + + $this->assertTrue($signer->checkRequest(Request::create($signer->sign('http://example.com/foo', new \DateTimeImmutable('2099-01-01 00:00:00'))))); + $this->assertTrue($signer->checkRequest(Request::create($signer->sign('http://example.com/foo?foo=bar', new \DateTimeImmutable('2099-01-01 00:00:00'))))); + $this->assertTrue($signer->checkRequest(Request::create($signer->sign('http://example.com/foo?foo=bar&0=integer', new \DateTimeImmutable('2099-01-01 00:00:00'))))); } public function testCheckWithDifferentParameter() { - $signer = new UriSigner('foobar', 'qux'); + $signer = new UriSigner('foobar', 'qux', 'abc'); $this->assertSame( 'http://example.com/foo?baz=bay&foo=bar&qux=rIOcC%2FF3DoEGo%2FvnESjSp7uU9zA9S%2F%2BOLhxgMexoPUM%3D', $signer->sign('http://example.com/foo?foo=bar&baz=bay') ); $this->assertTrue($signer->check($signer->sign('http://example.com/foo?foo=bar&baz=bay'))); + + $this->assertSame( + 'http://example.com/foo?abc=4070908800&baz=bay&foo=bar&qux=hdhUhBVPpzKJdz5ZjC%2FkLvtOYdGKOvKVOczmmMIZK0A%3D', + $signer->sign('http://example.com/foo?foo=bar&baz=bay', new \DateTimeImmutable('2099-01-01 00:00:00')) + ); + $this->assertTrue($signer->check($signer->sign('http://example.com/foo?foo=bar&baz=bay', new \DateTimeImmutable('2099-01-01 00:00:00')))); } public function testSignerWorksWithFragments() @@ -81,6 +114,79 @@ public function testSignerWorksWithFragments() 'http://example.com/foo?_hash=EhpAUyEobiM3QTrKxoLOtQq5IsWyWedoXDPqIjzNj5o%3D&bar=foo&foo=bar#foobar', $signer->sign('http://example.com/foo?bar=foo&foo=bar#foobar') ); + $this->assertTrue($signer->check($signer->sign('http://example.com/foo?bar=foo&foo=bar#foobar'))); + + $this->assertSame( + 'http://example.com/foo?_expiration=4070908800&_hash=qHl626U5d7LMsVtBxPt9GNzysdSxyOQ1fHA59Y1ib0Y%3D&bar=foo&foo=bar#foobar', + $signer->sign('http://example.com/foo?bar=foo&foo=bar#foobar', new \DateTimeImmutable('2099-01-01 00:00:00')) + ); + + $this->assertTrue($signer->check($signer->sign('http://example.com/foo?bar=foo&foo=bar#foobar', new \DateTimeImmutable('2099-01-01 00:00:00')))); + } + + public function testSignWithUriExpiration() + { + $signer = new UriSigner('foobar'); + + $this->assertSame($signer->sign('http://example.com/foo?foo=bar&bar=foo', new \DateTimeImmutable('2099-01-01 00:00:00')), $signer->sign('http://example.com/foo?bar=foo&foo=bar', 4070908800)); + } + + public function testSignWithoutExpirationAndWithReservedHashParameter() + { + $signer = new UriSigner('foobar'); + + $this->expectException(\LogicException::class); + + $signer->sign('http://example.com/foo?_hash=bar'); + } + + public function testSignWithoutExpirationAndWithReservedParameter() + { + $signer = new UriSigner('foobar'); + + $this->expectException(\LogicException::class); + + $signer->sign('http://example.com/foo?_expiration=4070908800'); + } + + public function testSignWithExpirationAndWithReservedHashParameter() + { + $signer = new UriSigner('foobar'); + + $this->expectException(\LogicException::class); + + $signer->sign('http://example.com/foo?_hash=bar', new \DateTimeImmutable('2099-01-01 00:00:00')); + } + + public function testSignWithExpirationAndWithReservedParameter() + { + $signer = new UriSigner('foobar'); + + $this->expectException(\LogicException::class); + + $signer->sign('http://example.com/foo?_expiration=4070908800', new \DateTimeImmutable('2099-01-01 00:00:00')); + } + + public function testCheckWithUriExpiration() + { + $signer = new UriSigner('foobar'); + + $this->assertFalse($signer->check($signer->sign('http://example.com/foo', new \DateTimeImmutable('2000-01-01 00:00:00')))); + $this->assertFalse($signer->check($signer->sign('http://example.com/foo?foo=bar', new \DateTimeImmutable('2000-01-01 00:00:00')))); + $this->assertFalse($signer->check($signer->sign('http://example.com/foo?foo=bar&0=integer', new \DateTimeImmutable('2000-01-01 00:00:00')))); + + $this->assertFalse($signer->check($signer->sign('http://example.com/foo', 1577836800))); // 2000-01-01 + $this->assertFalse($signer->check($signer->sign('http://example.com/foo?foo=bar', 1577836800))); // 2000-01-01 + $this->assertFalse($signer->check($signer->sign('http://example.com/foo?foo=bar&0=integer', 1577836800))); // 2000-01-01 + + $relativeUriFromNow1 = $signer->sign('http://example.com/foo', new \DateInterval('PT3S')); + $relativeUriFromNow2 = $signer->sign('http://example.com/foo?foo=bar', new \DateInterval('PT3S')); + $relativeUriFromNow3 = $signer->sign('http://example.com/foo?foo=bar&0=integer', new \DateInterval('PT3S')); + sleep(10); + + $this->assertFalse($signer->check($relativeUriFromNow1)); + $this->assertFalse($signer->check($relativeUriFromNow2)); + $this->assertFalse($signer->check($relativeUriFromNow3)); } } diff --git a/src/Symfony/Component/HttpFoundation/UriSigner.php b/src/Symfony/Component/HttpFoundation/UriSigner.php index 5b7e00674f7d3..16d8828170531 100644 --- a/src/Symfony/Component/HttpFoundation/UriSigner.php +++ b/src/Symfony/Component/HttpFoundation/UriSigner.php @@ -18,11 +18,13 @@ class UriSigner { private string $secret; private string $parameter; + private string $expirationParameter; /** - * @param string $parameter Query string parameter to use + * @param string $parameter Query string parameter to use + * @param string $expirationParameter Query string parameter to use for expiration */ - public function __construct(#[\SensitiveParameter] string $secret, string $parameter = '_hash') + public function __construct(#[\SensitiveParameter] string $secret, string $parameter = '_hash', string $expirationParameter = '_expiration') { if (!$secret) { throw new \InvalidArgumentException('A non-empty secret is required.'); @@ -30,6 +32,7 @@ public function __construct(#[\SensitiveParameter] string $secret, string $param $this->secret = $secret; $this->parameter = $parameter; + $this->expirationParameter = $expirationParameter; } /** @@ -37,8 +40,16 @@ public function __construct(#[\SensitiveParameter] string $secret, string $param * * The given URI is signed by adding the query string parameter * which value depends on the URI and the secret. + * + * @param \DateTimeInterface|\DateInterval|int|null $expiration The expiration for the given URI. + * If $expiration is a \DateTimeInterface, it's expected to be the exact date + time. + * If $expiration is a \DateInterval, the interval is added to "now" to get the date + time. + * If $expiration is an int, it's expected to be a timestamp in seconds of the exact date + time. + * If $expiration is null, no expiration. + * + * The expiration is added as a query string parameter. */ - public function sign(string $uri): string + public function sign(string $uri, \DateTimeInterface|\DateInterval|int $expiration = null): string { $url = parse_url($uri); $params = []; @@ -47,6 +58,14 @@ public function sign(string $uri): string parse_str($url['query'], $params); } + if (isset($params[$this->parameter]) || isset($params[$this->expirationParameter])) { + throw new \LogicException(sprintf('URI query parameter conflict: parameter name "%s" and "%s" are reserved.', $this->parameter, $this->expirationParameter)); + } + + if (null !== $expiration) { + $params[$this->expirationParameter] = $this->getExpirationTime($expiration); + } + $uri = $this->buildUrl($url, $params); $params[$this->parameter] = $this->computeHash($uri); @@ -55,6 +74,7 @@ public function sign(string $uri): string /** * Checks that a URI contains the correct hash. + * Also checks if the URI has not expired (If you used expiration during signing). */ public function check(string $uri): bool { @@ -72,7 +92,15 @@ public function check(string $uri): bool $hash = $params[$this->parameter]; unset($params[$this->parameter]); - return hash_equals($this->computeHash($this->buildUrl($url, $params)), $hash); + if (!hash_equals($this->computeHash($this->buildUrl($url, $params)), $hash)) { + return false; + } + + if ($expiration = $params[$this->expirationParameter] ?? false) { + return time() < $expiration; + } + + return true; } public function checkRequest(Request $request): bool @@ -105,4 +133,17 @@ private function buildUrl(array $url, array $params = []): string return $scheme.$user.$pass.$host.$port.$path.$query.$fragment; } + + private function getExpirationTime(\DateTimeInterface|\DateInterval|int $expiration): string + { + if ($expiration instanceof \DateTimeInterface) { + return $expiration->format('U'); + } + + if ($expiration instanceof \DateInterval) { + return \DateTimeImmutable::createFromFormat('U', time())->add($expiration)->format('U'); + } + + return (string) $expiration; + } }