Skip to content

Commit

Permalink
Signed Routes (#23519)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
taylorotwell authored Mar 13, 2018
1 parent 5bc990e commit 3ed733f
Show file tree
Hide file tree
Showing 6 changed files with 211 additions and 3 deletions.
18 changes: 16 additions & 2 deletions src/Illuminate/Foundation/Providers/FoundationServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -26,15 +27,16 @@ public function register()
{
parent::register();

$this->registerRequestValidate();
$this->registerRequestValidation();
$this->registerRequestSignatureValidation();
}

/**
* Register the "validate" macro on the request.
*
* @return void
*/
public function registerRequestValidate()
public function registerRequestValidation()
{
Request::macro('validate', function (array $rules, ...$params) {
validator()->validate($this->all(), $rules, ...$params);
Expand All @@ -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);
});
}
}
19 changes: 19 additions & 0 deletions src/Illuminate/Routing/Exceptions/InvalidSignatureException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace Illuminate\Routing\Exceptions;

use Exception;
use Symfony\Component\HttpKernel\Exception\HttpException;

class InvalidSignatureException extends HttpException
{
/**
* Create a new exception instance.
*
* @return void
*/
public function __construct()
{
parent::__construct(401, 'Invalid signature.');
}
}
27 changes: 27 additions & 0 deletions src/Illuminate/Routing/Middleware/ValidateSignature.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

namespace Illuminate\Routing\Middleware;

use Closure;
use Illuminate\Routing\Exceptions\InvalidSignatureException;

class ValidateSignature
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return \Illuminate\Http\Response
*
* @throws \Illuminate\Routing\Exceptions\InvalidSignatureException
*/
public function handle($request, Closure $next)
{
if ($request->hasValidSignature($request)) {
return $next($request);
}

throw new InvalidSignatureException;
}
}
7 changes: 7 additions & 0 deletions src/Illuminate/Routing/RoutingServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
78 changes: 77 additions & 1 deletion src/Illuminate/Routing/UrlGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.
*
Expand Down
65 changes: 65 additions & 0 deletions tests/Integration/Routing/UrlSigningTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

namespace Illuminate\Tests\Integration\Routing;

use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Orchestra\Testbench\TestCase;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Facades\Route;
use Illuminate\Routing\Middleware\ValidateSignature;

/**
* @group integration
*/
class UrlSigningTest extends TestCase
{
public function test_signing_url()
{
Route::get('/foo/{id}', function (Request $request, $id) {
return $request->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);
}
}

1 comment on commit 3ed733f

@garygreen
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suprised this made it in the core, seems like something better suited as a package.

So would a good use case for this be the password-resetting feature in core? It will generate a signed url to reset the password so won't any longer need the password_resets table? Related: #17499

Please sign in to comment.