From 3ed733fa0061a10cc5cfbfeefbc0170e58bbc276 Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Tue, 13 Mar 2018 10:00:13 -0500 Subject: [PATCH] Signed Routes (#23519) This implements the ability to generate signed (and tempoorary) URLs. These URLs may be easily verified as having been generated by your application and not modified by the end-user. --- .../Providers/FoundationServiceProvider.php | 18 ++++- .../Exceptions/InvalidSignatureException.php | 19 +++++ .../Routing/Middleware/ValidateSignature.php | 27 +++++++ .../Routing/RoutingServiceProvider.php | 7 ++ src/Illuminate/Routing/UrlGenerator.php | 78 ++++++++++++++++++- tests/Integration/Routing/UrlSigningTest.php | 65 ++++++++++++++++ 6 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 src/Illuminate/Routing/Exceptions/InvalidSignatureException.php create mode 100644 src/Illuminate/Routing/Middleware/ValidateSignature.php create mode 100644 tests/Integration/Routing/UrlSigningTest.php diff --git a/src/Illuminate/Foundation/Providers/FoundationServiceProvider.php b/src/Illuminate/Foundation/Providers/FoundationServiceProvider.php index fe384e757d0f..31bd0d5ecdcb 100644 --- a/src/Illuminate/Foundation/Providers/FoundationServiceProvider.php +++ b/src/Illuminate/Foundation/Providers/FoundationServiceProvider.php @@ -4,6 +4,7 @@ use Illuminate\Support\Str; use Illuminate\Http\Request; +use Illuminate\Support\Facades\URL; use Illuminate\Support\AggregateServiceProvider; class FoundationServiceProvider extends AggregateServiceProvider @@ -26,7 +27,8 @@ public function register() { parent::register(); - $this->registerRequestValidate(); + $this->registerRequestValidation(); + $this->registerRequestSignatureValidation(); } /** @@ -34,7 +36,7 @@ public function register() * * @return void */ - public function registerRequestValidate() + public function registerRequestValidation() { Request::macro('validate', function (array $rules, ...$params) { validator()->validate($this->all(), $rules, ...$params); @@ -44,4 +46,16 @@ public function registerRequestValidate() })->unique()->toArray()); }); } + + /** + * Register the "hasValidSignature" macro on the request. + * + * @return void + */ + public function registerRequestSignatureValidation() + { + Request::macro('hasValidSignature', function () { + return URL::hasValidSignature($this); + }); + } } diff --git a/src/Illuminate/Routing/Exceptions/InvalidSignatureException.php b/src/Illuminate/Routing/Exceptions/InvalidSignatureException.php new file mode 100644 index 000000000000..b79e433c6903 --- /dev/null +++ b/src/Illuminate/Routing/Exceptions/InvalidSignatureException.php @@ -0,0 +1,19 @@ +hasValidSignature($request)) { + return $next($request); + } + + throw new InvalidSignatureException; + } +} diff --git a/src/Illuminate/Routing/RoutingServiceProvider.php b/src/Illuminate/Routing/RoutingServiceProvider.php index 7dc496b68004..74b4a1fbe58f 100755 --- a/src/Illuminate/Routing/RoutingServiceProvider.php +++ b/src/Illuminate/Routing/RoutingServiceProvider.php @@ -62,10 +62,17 @@ protected function registerUrlGenerator() ) ); + // Next we will set a few service resolvers on the URL generator so it can + // get the information it needs to function. This just provides some of + // the convenience features to this URL generator like "signed" URLs. $url->setSessionResolver(function () { return $this->app['session']; }); + $url->setKeyResolver(function () { + return $this->app->make('config')->get('app.key'); + }); + // If the route collection is "rebound", for example, when the routes stay // cached for the application, we will need to rebind the routes on the // URL generator instance so it has the latest version of the routes. diff --git a/src/Illuminate/Routing/UrlGenerator.php b/src/Illuminate/Routing/UrlGenerator.php index bf1839b3f5f6..26a964b95e87 100755 --- a/src/Illuminate/Routing/UrlGenerator.php +++ b/src/Illuminate/Routing/UrlGenerator.php @@ -7,13 +7,15 @@ use Illuminate\Support\Str; use Illuminate\Http\Request; use InvalidArgumentException; +use Illuminate\Support\Carbon; use Illuminate\Support\Traits\Macroable; +use Illuminate\Support\InteractsWithTime; use Illuminate\Contracts\Routing\UrlRoutable; use Illuminate\Contracts\Routing\UrlGenerator as UrlGeneratorContract; class UrlGenerator implements UrlGeneratorContract { - use Macroable; + use InteractsWithTime, Macroable; /** * The route collection. @@ -71,6 +73,13 @@ class UrlGenerator implements UrlGeneratorContract */ protected $sessionResolver; + /** + * The encryption key resolver callable. + * + * @var callable + */ + protected $keyResolver; + /** * The callback to use to format hosts. * @@ -286,6 +295,60 @@ public function formatScheme($secure) return $this->cachedSchema; } + /** + * Create a signed route URL for a named route. + * + * @param string $name + * @param array $parameters + * @param \DateTimeInterface|int $expiration + * @return string + */ + public function signedRoute($name, $parameters = [], $expiration = null) + { + if ($expiration) { + $parameters = $parameters + ['expires' => $this->availableAt($expiration)]; + } + + $key = call_user_func($this->keyResolver); + + return $this->route($name, $parameters + [ + 'signature' => hash_hmac('sha256', $this->route($name, $parameters), $key), + ]); + } + + /** + * Create a temporary signed route URL for a named route. + * + * @param string $name + * @param \DateTimeInterface|int $expiration + * @param array $parameters + * @return string + */ + public function temporarySignedRoute($name, $expiration, $parameters = []) + { + return $this->signedRoute($name, $parameters, $expiration); + } + + /** + * Determine if the given request has a valid signature. + * + * @param \Illuminate\Http\Request $request + * @return bool + */ + public function hasValidSignature(Request $request) + { + $original = rtrim($request->url().'?'.http_build_query( + Arr::except($request->query(), 'signature') + ), '?'); + + $expires = Arr::get($request->query(), 'expires'); + + $signature = hash_hmac('sha256', $original, call_user_func($this->keyResolver)); + + return $request->query('signature') === $signature && + ! ($expires && Carbon::now()->getTimestamp() > $expires); + } + /** * Get the URL to a named route. * @@ -614,6 +677,19 @@ public function setSessionResolver(callable $sessionResolver) return $this; } + /** + * Set the encryption key resolver. + * + * @param callable $keyResolver + * @return $this + */ + public function setKeyResolver(callable $keyResolver) + { + $this->keyResolver = $keyResolver; + + return $this; + } + /** * Set the root controller namespace. * diff --git a/tests/Integration/Routing/UrlSigningTest.php b/tests/Integration/Routing/UrlSigningTest.php new file mode 100644 index 000000000000..2ac0edd3f5e6 --- /dev/null +++ b/tests/Integration/Routing/UrlSigningTest.php @@ -0,0 +1,65 @@ +hasValidSignature() ? 'valid' : 'invalid'; + })->name('foo'); + + $this->assertTrue(is_string($url = URL::signedRoute('foo', ['id' => 1]))); + $this->assertEquals('valid', $this->get($url)->original); + } + + public function test_temporary_signed_urls() + { + Route::get('/foo/{id}', function (Request $request, $id) { + return $request->hasValidSignature() ? 'valid' : 'invalid'; + })->name('foo'); + + Carbon::setTestNow(Carbon::create(2018, 1, 1)); + $this->assertTrue(is_string($url = URL::temporarySignedRoute('foo', now()->addMinutes(5), ['id' => 1]))); + $this->assertEquals('valid', $this->get($url)->original); + + Carbon::setTestNow(Carbon::create(2018, 1, 1)->addMinutes(10)); + $this->assertEquals('invalid', $this->get($url)->original); + } + + public function test_signed_middleware() + { + Route::get('/foo/{id}', function (Request $request, $id) { + return $request->hasValidSignature() ? 'valid' : 'invalid'; + })->name('foo')->middleware(ValidateSignature::class); + + Carbon::setTestNow(Carbon::create(2018, 1, 1)); + $this->assertTrue(is_string($url = URL::temporarySignedRoute('foo', now()->addMinutes(5), ['id' => 1]))); + $this->assertEquals('valid', $this->get($url)->original); + } + + public function test_signed_middleware_with_invalid_url() + { + Route::get('/foo/{id}', function (Request $request, $id) { + return $request->hasValidSignature() ? 'valid' : 'invalid'; + })->name('foo')->middleware(ValidateSignature::class); + + Carbon::setTestNow(Carbon::create(2018, 1, 1)); + $this->assertTrue(is_string($url = URL::temporarySignedRoute('foo', now()->addMinutes(5), ['id' => 1]))); + Carbon::setTestNow(Carbon::create(2018, 1, 1)->addMinutes(10)); + + $response = $this->get($url); + $response->assertStatus(401); + } +}