Skip to content

Commit

Permalink
Merge pull request #9 from kbond/signed-uri-refactor
Browse files Browse the repository at this point in the history
[BC BREAK] `SignedUri` Refactor
  • Loading branch information
kbond authored Sep 14, 2022
2 parents 9de3e9d + 389cf91 commit 1605812
Show file tree
Hide file tree
Showing 7 changed files with 253 additions and 130 deletions.
93 changes: 54 additions & 39 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Object-oriented wrapper/manipulator for `parse_url` with the following features:
* Read URI _parts_ as objects (`Scheme`, `Host`, `Path`, `Query`), each with their own
set of features.
* Manipulate URI parts or build URI's using a fluent builder API.
* Sign and verify URI's and make them temporary and/or single-use.
* Mailto object to help with reading/manipulating `mailto:` URIs.
* Optional [twig](https://twig.symfony.com/) extension.

Expand Down Expand Up @@ -225,80 +226,63 @@ These URIs are generated with a token that should change _once the URI has been
$uri = Zenstruck\Uri::new('https://example.com/some/path');

(string) $uri->sign('a secret')->singleUse('some-token'); // "https://example.com/some/path?_token=...&_hash=..."

// create a single-use, temporary uri
(string) $uri->sign('a secret')
->singleUse('some-token')
->expires('+30 minutes')
; // "https://example.com/some/path?_expires=...&_token=...&_hash=..."
```

> **Note**: The URL is first hashed with this token, then hashed again with secret to ensure it hasn't
> been tampered with.
### `SignedUri`

Calling `Zenstruck\Uri::sign()` returns a `Zenstruck\Uri\Signed\Builder` object. Calling `Builder::create()`
returns a `Zenstruck\Uri\SignedUri` object that extends `Zenstruck\Uri` and has some helpful methods.
### Signed URI Builder

> **Note**: `Zenstruck\Uri\Signed\Builder` is immutable objects so any manipulations results in a new instance.
> **Note**: `Zenstruck\Uri\SignedUri` cannot be cloned so the [manipulation methods](#manipulating-uris) cannot be called.
Calling `Zenstruck\Uri::sign()` returns a `Zenstruck\Uri\Signed\Builder` object that can be used to
create single-use _and_ temporary URIs.

```php
$uri = Zenstruck\Uri::new('https://example.com/some/path');

$builder = $uri->sign('a secret'); // Zenstruck\Uri\Signed\Builder

$signedUri = $builder
->singleUse('a token')
->expires('tomorrow')
->create()
; // Zenstruck\Uri\SignedUri

$signedUri->isSingleUse(); // true
$signedUri->isTemporary(); // true
$signedUri->expiresAt(); // \DateTimeImmutable
// create a single-use, temporary uri
$builder = $uri->sign('a secret')
->singleUse('some-token')
->expires('+30 minutes')
;

// extends Zenstruck\Uri
$signedUri->query(); // Zenstruck\Uri\Query
(string) $builder; // "https://example.com/some/path?_expires=...&_token=...&_hash=..."
```

> **Note**: `Zenstruck\Uri\Signed\Builder` is immutable objects so any manipulations results in a new instance.
### Verification

To verify a signed URI, create an instance of `Zenstruck\Uri\SignedUri` and call `SignedUri::isVerified()` to
get true/false or `SignedUri::verify()` to throw specific exceptions:
To verify a signed URI, create an instance of `Zenstruck\Uri` and call `Uri::isVerified()` to
get true/false or `Uri::verify()` to throw specific exceptions:

```php
use Zenstruck\Uri\SignedUri;
use Zenstruck\Uri;
use Zenstruck\Uri\Signed\Exception\InvalidSignature;
use Zenstruck\Uri\Signed\Exception\ExpiredUri;
use Zenstruck\Uri\Signed\Exception\VerificationFailed;

$signedUri = SignedUri::new('http://example.com/some/path?_hash=...');

// can also create from an instance of Symfony\Component\HttpFoundation\Request
/** @var Symfony\Component\HttpFoundation\Request $request */
$signedUri = SignedUri::new($request);
$signedUri = Uri::new('http://example.com/some/path?_hash=...');

$signedUri->isVerified('a secret'); // true/false

try {
$signedUri->verify('a secret');
} catch (VerificationFailed $e) {
$e::REASON; // ie "Invalid signature."
$e->uri(); // SignedUri
$e->uri(); // \Zenstruck\Uri
}

// catch specific exceptions
try {
$signedUri->verify('a secret');
} catch (InvalidSignature $e) {
$e::REASON; // "Invalid signature."
$e->uri(); // SignedUri
$e->uri(); // \Zenstruck\Uri
} catch (ExpiredUri $e) {
$e::REASON; // "URI has expired."
$e->uri(); // SignedUri
$e->uri(); // \Zenstruck\Uri
$e->expiredAt(); // \DateTimeImmutable
}
```
Expand All @@ -312,7 +296,7 @@ use Zenstruck\Uri\Signed\Exception\InvalidSignature;
use Zenstruck\Uri\Signed\Exception\ExpiredUri;
use Zenstruck\Uri\Signed\Exception\UriAlreadyUsed;

/** @var \Zenstruck\Uri\SignedUri $uri */
/** @var \Zenstruck\Uri $uri */

$uri->isVerified('a secret', 'some token'); // true/false

Expand All @@ -321,17 +305,48 @@ try {
$signedUri->verify('a secret', 'some token');
} catch (InvalidSignature $e) {
$e::REASON; // "Invalid signature."
$e->uri(); // SignedUri
$e->uri(); // \Zenstruck\Uri
} catch (ExpiredUri $e) {
$e::REASON; // "URI has expired."
$e->uri(); // SignedUri
$e->uri(); // \Zenstruck\Uri
$e->expiredAt(); // \DateTimeImmutable
} catch (UriAlreadyUsed $e) {
$e::REASON; // "URI has already been used."
$e->uri(); // SignedUri
$e->uri(); // \Zenstruck\Uri
}
```

### `SignedUri`

`Zenstruck\Uri\Signed\Builder::create()` and `Zenstruck\Uri::verify()` both return a `Zenstruck\Uri\SignedUri`
object that extends `Zenstruck\Uri` and has some helpful methods.

> **Note**: `Zenstruck\Uri\SignedUri` is:
> 1. Always considered verified.
> 2. Cannot be cloned so the [manipulation methods](#manipulating-uris) cannot be called.
> 3. Cannot be constructed directly, must be created through `Uri::verify()` or `Builder::create()`
```php
$uri = Zenstruck\Uri::new('https://example.com/some/path');

// create from the builder
$signedUri = $uri->sign('a secret')
->singleUse('a token')
->expires('tomorrow')
->create()
; // Zenstruck\Uri\SignedUri

// create from verify
$uri->verify('a secret'); // Zenstruck\Uri\SignedUri

$signedUri->isSingleUse(); // true
$signedUri->isTemporary(); // true
$signedUri->expiresAt(); // \DateTimeImmutable

// extends Zenstruck\Uri
$signedUri->query(); // Zenstruck\Uri\Query
```

## `Mailto` URIs

> **Note**: `Zenstruck\Uri\Mailto` is an immutable object so any manipulations results in a new
Expand Down
45 changes: 38 additions & 7 deletions src/Uri.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@
use Zenstruck\Uri\Query;
use Zenstruck\Uri\Scheme;
use Zenstruck\Uri\Signed\Builder;
use Zenstruck\Uri\Signed\Exception\ExpiredUri;
use Zenstruck\Uri\Signed\Exception\InvalidSignature;
use Zenstruck\Uri\Signed\Exception\UriAlreadyUsed;
use Zenstruck\Uri\Signed\Exception\VerificationFailed;
use Zenstruck\Uri\SignedUri;
use Zenstruck\Uri\Stringable;

/**
Expand All @@ -30,7 +35,7 @@ class Uri implements \Stringable
/**
* @param string|self|null $value
*/
final public function __construct($value = null)
public function __construct($value = null)
{
if ($value instanceof self) {
$this->createFromSelf($value);
Expand All @@ -56,19 +61,17 @@ final public function __construct($value = null)

/**
* @param string|self|Request|null $value
*
* @return static
*/
final public static function new($value = null): self
public static function new($value = null): self
{
if ($value instanceof Request) {
$value = (new self($value->getUri()))
return (new self($value->getUri()))
->withUser($value->getUser())
->withPass($value->getPassword())
;
}

return $value instanceof static ? $value : new static($value);
return $value instanceof self && self::class === \get_class($value) ? $value : new self($value);
}

final public function scheme(): Scheme
Expand Down Expand Up @@ -339,11 +342,39 @@ final public function withoutFragment(): self
/**
* @param string|UriSigner $secret
*/
public function sign($secret): Builder
final public function sign($secret): Builder
{
return new Builder($this, $secret);
}

/**
* @param string|UriSigner $secret
* @param string|null $singleUseToken If passed, this value MUST change once the URL is considered "used"
*
* @throws ExpiredUri if the URI has expired
* @throws UriAlreadyUsed if the URI has already been used
* @throws InvalidSignature if the URI could not be verified
*/
final public function verify($secret, ?string $singleUseToken = null): SignedUri
{
return SignedUri::createVerified($this, $secret, $singleUseToken);
}

/**
* @param string|UriSigner $secret
* @param string|null $singleUseToken If passed, this value MUST change once the URL is considered "used"
*/
public function isVerified($secret, ?string $singleUseToken = null): bool
{
try {
$this->verify($secret, $singleUseToken);

return true;
} catch (VerificationFailed $e) {
return false;
}
}

final protected function generateString(): string
{
$ret = '';
Expand Down
36 changes: 23 additions & 13 deletions src/Uri/Signed/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ final class Builder implements \Stringable
{
private Uri $uri;
private UriSigner $signer;
private ?\DateTimeInterface $expiresAt = null;
private ?\DateTimeImmutable $expiresAt = null;
private ?string $singleUseToken = null;

/**
* @internal
*
* @param string|UriSigner $secret
*/
public function __construct(Uri $uri, $secret)
Expand All @@ -27,6 +29,10 @@ public function __construct(Uri $uri, $secret)
throw new \LogicException('symfony/http-kernel is required to sign URIs. composer require symfony/http-kernel.');
}

if ($uri instanceof SignedUri) {
throw new \LogicException(\sprintf('"%s" is already signed.', $uri));
}

$this->uri = $uri;
$this->signer = $secret instanceof UriSigner ? $secret : new UriSigner($secret);
}
Expand All @@ -47,17 +53,21 @@ public function __toString(): string
public function expires($when): self
{
if (\is_numeric($when)) {
$when = \DateTime::createFromFormat('U', (string) (\time() + $when));
$when = \DateTimeImmutable::createFromFormat('U', (string) (\time() + $when));
}

if (\is_string($when)) {
$when = new \DateTime($when);
$when = new \DateTimeImmutable($when);
}

if ($when instanceof \DateInterval) {
$when = (new \DateTime('now'))->add($when);
}

if ($when instanceof \DateTime) {
$when = \DateTimeImmutable::createFromMutable($when);
}

if (!$when instanceof \DateTimeInterface) {
throw new \InvalidArgumentException(\sprintf('%s is not a valid expires at.', get_debug_type($when)));
}
Expand All @@ -83,16 +93,16 @@ public function singleUse(string $token): self

public function create(): SignedUri
{
$uri = $this->uri;

if ($this->expiresAt) {
$uri = $uri->withQueryParam(SignedUri::EXPIRES_AT_KEY, $this->expiresAt->getTimestamp());
}

if ($this->singleUseToken) {
$uri = (new UriSigner($this->singleUseToken, SignedUri::SINGLE_USE_TOKEN_KEY))->sign($uri);
}
return SignedUri::new($this);
}

return new SignedUri($this->signer->sign($uri));
/**
* @internal
*
* @return array{0:Uri,1:UriSigner,2:\DateTimeImmutable|null,3:string|null}
*/
public function context(): array
{
return [$this->uri, $this->signer, $this->expiresAt, $this->singleUseToken];
}
}
18 changes: 14 additions & 4 deletions src/Uri/Signed/Exception/ExpiredUri.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,29 @@

namespace Zenstruck\Uri\Signed\Exception;

use Zenstruck\Uri;

/**
* @author Kevin Bond <[email protected]>
*/
final class ExpiredUri extends VerificationFailed
{
public const REASON = 'URI has expired.';

public function expiredAt(): \DateTimeImmutable
private \DateTimeImmutable $expiredAt;

/**
* @internal
*/
public function __construct(Uri $uri, \DateTimeImmutable $expiredAt, ?string $message = null, ?\Throwable $previous = null)
{
$expiredAt = $this->uri()->expiresAt();
parent::__construct($uri, $message, $previous);

\assert($expiredAt instanceof \DateTimeImmutable);
$this->expiredAt = $expiredAt;
}

return $expiredAt;
public function expiredAt(): \DateTimeImmutable
{
return $this->expiredAt;
}
}
11 changes: 7 additions & 4 deletions src/Uri/Signed/Exception/VerificationFailed.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

namespace Zenstruck\Uri\Signed\Exception;

use Zenstruck\Uri\SignedUri;
use Zenstruck\Uri;

/**
* @author Kevin Bond <[email protected]>
Expand All @@ -11,16 +11,19 @@ abstract class VerificationFailed extends \RuntimeException
{
public const REASON = '';

private SignedUri $uri;
private Uri $uri;

final public function __construct(SignedUri $uri, ?string $message = null, ?\Throwable $previous = null)
/**
* @internal
*/
public function __construct(Uri $uri, ?string $message = null, ?\Throwable $previous = null)
{
$this->uri = $uri;

parent::__construct($message ?? static::REASON, 0, $previous);
}

final public function uri(): SignedUri
final public function uri(): Uri
{
return $this->uri;
}
Expand Down
Loading

0 comments on commit 1605812

Please sign in to comment.