diff --git a/README.md b/README.md index 45e85e3..8eadb92 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,14 @@ This package can create URLs with a limited lifetime. This is done by adding an expiration date and a signature to the URL. ```php -$urlSigner = new MD5UrlSigner('randomkey'); +$urlSigner = new Sha256UrlSigner('randomkey'); $urlSigner->sign('https://myapp.com', 30); // => The generated url will be valid for 30 seconds ``` -This will output an URL that looks like `https://myapp.com/?expires=xxxx&signature=xxxx`. +This will output a URL that looks like `https://myapp.com/?expires=xxxx&signature=xxxx`. Imagine mailing this URL out to the users of your application. When a user clicks on a signed URL your application can validate it with: @@ -53,9 +53,9 @@ composer require spatie/url-signer A signer-object can sign URLs and validate signed URLs. A secret key is used to generate signatures. ```php -use Spatie\UrlSigner\Md5UrlSigner; +use Spatie\UrlSigner\Sha256UrlSigner; -$urlSigner = new Md5UrlSigner('mysecretkey'); +$urlSigner = new Sha256UrlSigner('mysecretkey'); ``` ### Generating URLs @@ -63,19 +63,19 @@ $urlSigner = new Md5UrlSigner('mysecretkey'); Signed URLs can be generated by providing a regular URL and an expiration date to the `sign` method. ```php -$expirationDate = (new DateTime)->modify('10 days'); +$expirationDate = (new DateTime())->modify('10 days'); $urlSigner->sign('https://myapp.com', $expirationDate); // => The generated url will be valid for 10 days ``` -If an integer is provided as expiration date, the url will be valid for that amount of seconds. +If an integer is provided as expiration date, the URL will be valid for that amount of seconds. ```php $urlSigner->sign('https://myapp.com', 30); -// => The generated url will be valid for 30 seconds +// => The generated URL will be valid for 30 seconds ``` ### Validating URLs @@ -83,20 +83,20 @@ $urlSigner->sign('https://myapp.com', 30); To validate a signed URL, simply call the `validate()` method. This will return a boolean. ```php -$urlSigner->validate('https://myapp.com/?expires=1439223344&signature=2d42f65bd023362c6b61f7432705d811'); +$urlSigner->validate('https://myapp.com/?expires=1439223344&signature=a479abde194d111022a6831edbda29b14e7bdb760438a8a0be2556cd1a6c23fa'); // => true -$urlSigner->validate('https://myapp.com/?expires=1439223344&signature=2d42f65bd0-INVALID-23362c6b61f7432705d811'); +$urlSigner->validate('https://myapp.com/?expires=1439223344&signature=a479abde194d111022a6831edbda-INVALID-29b14e7bdb760438a8a0be2556cd1a6c23fa'); // => false ``` ## Writing custom signers -This packages provides a signer that uses md5 to generate signature. You can create your own -signer by implementing the `Spatie\UrlSigner\UrlSigner`-interface. If you let your signer extend -`Spatie\UrlSigner\BaseUrlSigner` you'll only need to provide the `createSignature`-method. +This packages provides a signer that uses SHA256 to generate signature. You can create your own +signer by implementing the `Spatie\UrlSigner\Contracts\UrlSigner`-interface. If you let your signer extend +`Spatie\UrlSigner\AbstractUrlSigner` you'll only need to provide the `createSignature`-method. ## Tests diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 11953fd..a8b098a 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,22 +1,22 @@ - + tests - - + + src/ - - + + diff --git a/src/AbstractUrlSigner.php b/src/AbstractUrlSigner.php new file mode 100644 index 0000000..9d40af8 --- /dev/null +++ b/src/AbstractUrlSigner.php @@ -0,0 +1,132 @@ +defaultSignatureKey == '') { + throw InvalidSignatureKey::signatureEmpty(); + } + } + + abstract protected function createSignature( + string $url, + string $expiration, + string $signatureKey, + ): string; + + public function sign( + string $url, + int|DateTimeInterface $expiration, + string $signatureKey = null, + ): string { + $signatureKey ??= $this->defaultSignatureKey; + + $expiration = $this->getExpirationTimestamp($expiration); + + $signature = $this->createSignature($url, $expiration, $signatureKey); + + return $this->signUrl($url, $expiration, $signature); + } + + protected function signUrl(string $url, string $expiration, $signature): string + { + return Url::addQueryParameters($url, [ + $this->expiresParameterName => $expiration, + $this->signatureParameterName => $signature, + ]); + } + + public function validate(string $url, string $signatureKey = null): bool + { + $signatureKey ??= $this->defaultSignatureKey; + + $queryParameters = Url::queryParameters($url); + if ($this->isMissingAQueryParameter($queryParameters)) { + return false; + } + + $expiration = $queryParameters[$this->expiresParameterName]; + + if (! $this->isFuture($expiration)) { + return false; + } + + if (! $this->hasValidSignature($url, $signatureKey)) { + return false; + } + + return true; + } + + protected function isMissingAQueryParameter(array $query): bool + { + if (! isset($query[$this->expiresParameterName])) { + return true; + } + + if (! isset($query[$this->signatureParameterName])) { + return true; + } + + return false; + } + + protected function isFuture(int $timestamp): bool + { + return $timestamp >= (new DateTime())->getTimestamp(); + } + + protected function getIntendedUrl(string $url): string + { + return Url::withoutParameters($url, [ + $this->expiresParameterName, + $this->signatureParameterName, + ]); + } + + protected function getExpirationTimestamp(DateTimeInterface|int $expirationInSeconds): string + { + if (is_int($expirationInSeconds)) { + $expirationInSeconds = (new DateTime())->modify($expirationInSeconds.' seconds'); + } + + if (! $expirationInSeconds instanceof DateTimeInterface) { + throw InvalidExpiration::wrongType(); + } + + if (! $this->isFuture($expirationInSeconds->getTimestamp())) { + throw InvalidExpiration::isInPast(); + } + + return (string) $expirationInSeconds->getTimestamp(); + } + + protected function hasValidSignature( + string $url, + string $signatureKey, + ): bool { + $queryParameters = Url::queryParameters($url); + + $expiration = $queryParameters[$this->expiresParameterName]; + $providedSignature = $queryParameters[$this->signatureParameterName]; + + $intendedUrl = $this->getIntendedUrl($url); + + $validSignature = $this->createSignature($intendedUrl, $expiration, $signatureKey); + + return hash_equals($validSignature, $providedSignature); + } +} diff --git a/src/BaseUrlSigner.php b/src/BaseUrlSigner.php index 58d342f..acdbf5a 100644 --- a/src/BaseUrlSigner.php +++ b/src/BaseUrlSigner.php @@ -7,6 +7,9 @@ use Spatie\UrlSigner\Exceptions\InvalidSignatureKey; use Spatie\UrlSigner\Support\Url; +/** + * @deprecated Use {@see AbstractUrlSigner} instead + */ abstract class BaseUrlSigner implements UrlSigner { public function __construct( diff --git a/src/Contracts/UrlSigner.php b/src/Contracts/UrlSigner.php new file mode 100644 index 0000000..063a47c --- /dev/null +++ b/src/Contracts/UrlSigner.php @@ -0,0 +1,16 @@ +urlSigner = new Sha256UrlSigner('random_monkey'); +}); + +it('can be initialized', function () { + expect($this->urlSigner)->toBeInstanceOf(UrlSigner::class); +}); + +it('will throw an exception from an empty signature key', function () { + new Sha256UrlSigner(''); +})->throws(InvalidSignatureKey::class); + +it('returns false when validating a forged url', function () { + $signedUrl = 'http://myapp.com/somewhereelse/?expires=4594900544&signature=79379e8012ebebf75a4679099477c42b16bea303e3e1cb5cb59040ab6e895f08'; + + expect($this->urlSigner->validate($signedUrl))->toBeFalse(); +}); + +it('returns false when validating an expired url', function () { + $signedUrl = 'http://myapp.com/?expires=1123690544&signature=28a85b78db3c09bcc8194c0eff9a3db7c276371b1380296f910b77277e4f88d1'; + + expect($this->urlSigner->validate($signedUrl))->toBeFalse(); +}); + +it('returns true when validating a non-expired url', function () { + $url = 'http://myapp.com/'; + + $expiration = 10000; + $signedUrl = $this->urlSigner->sign($url, $expiration); + + expect($this->urlSigner->validate($signedUrl))->toBeTrue(); +}); + +it('can sign with a DateTimeImmutable instance', function () { + $url = 'http://myapp.com/'; + + $expiration = (new DateTimeImmutable())->modify('10000 seconds'); + $signedUrl = $this->urlSigner->sign($url, $expiration); + + expect($this->urlSigner->validate($signedUrl))->toBeTrue(); +}); + +dataset('unsignedUrls', [ + ['http://myapp.com/?expires=4594900544'], + ['http://myapp.com/?signature=79379e8012ebebf75a4679099477c42b16bea303e3e1cb5cb59040ab6e895f08'], +]); + +it('returns false when validating an unsigned url', function (string $unsignedUrl) { + expect($this->urlSigner->validate($unsignedUrl))->toBeFalse(); +})->with('unsignedUrls'); + +it('does not allow expirations in the past', function ($pastExpiration) { + $url = 'http://myapp.com/'; + + $this->urlSigner->sign($url, $pastExpiration); +})->with([ + [DateTime::createFromFormat('d/m/Y H:i:s', '10/08/2005 18:15:44')], + [-10], +])->throws(InvalidExpiration::class); + +it('will keep url query parameters intact', function () { + $url = 'https://myapp.com/?foo=bar&baz=qux'; + $expiration = DateTime::createFromFormat( + 'd/m/Y H:i:s', + '10/08/2115 18:15:44', + new DateTimeZone('Europe/Brussels') + ); + + $signedUrl = $this->urlSigner->sign($url, $expiration); + + expect($signedUrl)->toContain('?foo=bar&baz=qux'); + expect($this->urlSigner->validate($signedUrl))->toBeTrue(); +}); + +it('using a custom key results in a different signed url', function () { + $signedUsingRegularKey = $this->urlSigner->sign('https://spatie.be', 5); + $signedUsingCustomKey = $this->urlSigner->sign('https://spatie.be', 5, 'custom-key'); + + expect($signedUsingRegularKey)->not()->toBe($signedUsingCustomKey); +}); + +it('can sign and validate urls with a custom key', function () { + $signedUsingCustomKey = $this->urlSigner->sign('https://spatie.be', 5, 'custom-key'); + + expect($this->urlSigner->validate($signedUsingCustomKey, 'custom-key'))->toBeTrue(); + expect($this->urlSigner->validate($signedUsingCustomKey, 'wrong-custom-key'))->toBeFalse(); +});