Signed route for email verification does not pass signature validation
Asked Answered
S

1

3

I recently updated my project from Laravel 5.6 to 5.7 and added the email verification steps described by Laravel docs to my project. Everything works great on my development machine (which is http) but when I update my production server (which is https) with all changes then when laravel sends me the email with the link (signed route) it generated for me to click button or paste into my browser laravel seems to not be able to validate the signature it created. The side effect is every time I click the button or paste the link into the browser I get the error:

403 Sorry, you are not authorized to access this page.

What I have traced down so far is I found the code in laravel's ValidateSignature.php class and I added some log messages.

public function handle($request, Closure $next)
{
    Log::info('checking signature');
    if ($request->hasValidSignature()) {
        Log::info('signature is valid');
        return $next($request);
    }

    Log::info('throwing InvalidSignatureException');
    throw new InvalidSignatureException;
}

And more specifically I traced the exact issue inside the laravel unit UrlGenerator.php I added the Logs in the following method:

public function hasValidSignature(Request $request)
{
    $original = rtrim($request->url().'?'.Arr::query(
        Arr::except($request->query(), 'signature')
    ), '?');

    $expires = Arr::get($request->query(), 'expires');

    $signature = hash_hmac('sha256', $original, call_user_func($this->keyResolver));

    Log::info('url: '.$original);
    Log::info('expire: '.$expires);
    Log::info(' new signature: '.$signature);
    Log::info('link signature: '.$request->query('signature', ''));
    Log::info('hash equals: '.hash_equals($signature, $request->query('signature', '')));
    Log::info('expired: '.!($expires && Carbon::now()->getTimestamp() > $expires));

    return  hash_equals($signature, $request->query('signature', '')) &&
           ! ($expires && Carbon::now()->getTimestamp() > $expires);
}

When i click button or paste link in browser and press enter I get the following log messages: (I changed my real domain for obvious reasons.... not try to market my site or something)

checking signature
url: http://www.example.com/email/verify/2?expires=1538012234
expire: 1538012234
new signature: 1326b9e7402a51e0f05ddf1cb14f1e14852b4c5f0d1d6e726554806e7d85b4b1
link signature: e1d3ad5dc88faa8d8b0e6890ef60e216b75d26ef7ed5c6ab1cc661548e0ad8df
hash equals:
expired: 1
throwing InvalidSignatureException

So I don't know if the bug is in the logic where laravel creates initial signature or when it is trying to validate it. However like I said it all works great on my development machine. I have cleared cache, cleared routes, updated to latest code, rebooted server, everything I can think of. Any help would be greatly appreciated.

**** UPDATE *****

I dug a little deeper and have narrowed down the problem. I can't believe I didn't see this last night. If we look closely at the output logs listed above the one log message

url: http://www.example.com/email/verify/2?expires=1538012234

shows us the problem. So as I said before my development machine is http but my live server is https. I see this morning (after a good 4 hours sleep) that the log shows us that somehow the logic in the method hasValidSignature() is getting a route with http instead of https. So when I go back to my email the link in the email is https, if I paste the url in my browser it has https, and in my browser after this logic returns the 403 error the browser still shows https. So now we can focus on how does my route/url get converted to http? I am really struggling here cause I have no idea how that url is processed anyhow since /email/verify is not even listed in any of my routes files (that I know of) and I can't say I understand what to look for under the hood for this either so I am really hoping for some help here.

Also here are the settings in my .env file:

APP_USE_HTTPS=true
APP_URL=https://www.example.com
APP_ENV=production

And in the boot method of the AppServiceProvider I have

public function boot()
{
    Schema::defaultStringLength(191);

    if (env('APP_USE_HTTPS'))
    {
        Log::info('forcing URLs to use https');
        \URL::forceScheme('https');
    }
Shurwood answered 26/9, 2018 at 20:59 Comment(15)
Check Laravel logs, put it in dev mode, check your PHP logs.Mozambique
Have you tried different browsers? Also - have you tried to check that there is no firewall / .htaccess rule blocking access to the folder which the verification link directs to? Also - check what the httpd / apache logs say - especially the access logs - if there is an access violation, it will show up hereTerraqueous
checked all logs, nothing i could see related to this issue. i have no idea what I would look for in .htaccess that I have not already checked cause as far as i can tell there is no "folder" to gain access to. the route in the email is just /email/verify/### and there is no route in my file for email/verify so it is one of those Laravelisms and I am not sure where that route goes but it is basically only executing code somewhere then going to my dashboard which I can navigate directly to so its not that. as I said all other pages in the site seem to work just fine so I am not sure where to lokShurwood
and I tried chrome, firefox, ie and edge, they all have same issue.Shurwood
Does your route use some middleware?Naos
I use the built in auth middlewareShurwood
I am not sure where that route goes - don't be afraid to actually read the source code. It's one of the best ways to learn. Dive into the Laravel codebase, and to follow that route through the entire request/response lifecycle. The best way to understand these things is to follow them through, step-by-step.Rapparee
I agree and afraid I am not. I found some of the code and included an update in my post with another test I did but try as I may I could not find any other code inside Laravel to help lead me to the problem here. I try to "look under the hood" as often as I can but in this case my ignorance is showing that I can't connect the dots completely.Shurwood
Have I done something wrong by asking my question here? Maybe there is some rule I am not aware of? I find it strange for someone to downvote the question? I have researched to the best of my ability first. This is not a duplicate question.... I hope it was not for the "fake" url I posted in my message. Obviously there is no local.test.com but maybe that is it... Just not sure. I felt I need an example url since it is so tightly bound to the problem.???Shurwood
Something obvious to check off - are the emails containing the signed routes being generated within the same Laravel app as the app that's validating the signiature?Rapparee
More specifically, they don't have to be the same app, but they do have to have the same APP_KEY in the .env file. So, for example, if you've queued the notification, you need to make sure the codebase the queue worker is using has the same APP_KEY that the codebase the user will hit does.Willamina
@patricus, that's precisely where I was headed with my line of inquiry.Rapparee
yes the app that generates the email/link is the same app that the link points back too. I am going to see how deep I can go into the code on both generating the link as well as the hash_hmac() method to see if I can see where the difference is.Shurwood
Hey guys I posted an update above which now points to some sort of routing issue. Any help would be greatly appreciated as this has become very timely for me.Shurwood
Did you fix the issue??. If you fix, kindly post in the answer. I am also getting the error :(Spurry
P
9

If you have a Laravel app behind an apache proxy this also happens. In our case, we have more or less the same .env configuration and we also have

URL::forceScheme('https'); 

in our AppServiceProvider.

This creates the following urls: while signing the signature: https://..../email/verify/174?expires=1556027661 While verifying the signature: http://..../email/verify/174

our workaround is to replace the the 'signed' middleware: in app/Http/Kernel.php use 'signed' => \App\Http\Middleware\ValidateHttpsSignature::class, and then create this class with the following code:

namespace App\Http\Middleware;

use Closure;
use Illuminate\Routing\Exceptions\InvalidSignatureException;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Carbon;

class ValidateHttpsSignature
{
    var $keyResolver;

    public function __construct()
    {
        $this->keyResolver = function () {
            return App::make('config')->get('app.key');
        };
    }

    /**
     * gebaseerd op vendor/laravel/framework/src/Illuminate/Routing/Middleware/ValidateSignature.php
     * maar zorgt er voor dat een url altijd als https behandeld wordt. dit fixt het feit dat
     * laravel achter een rewrite proxy draait en urls binnenkrijgt als http.
     *
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @return mixed
     */
    public function handle($request, Closure $next)
    {
        if ($this->hasValidSignature($request)) {
            return $next($request);
        }
        throw new InvalidSignatureException;

    }

    /**
     * Determine if the given request has a valid signature.
     * copied and modified from
     * vendor/laravel/framework/src/Illuminate/Routing/UrlGenerator.php:363
     * @param  \Illuminate\Http\Request  $request
     * @param  bool  $absolute
     * @return bool
     */
    public function hasValidSignature(Request $request, $absolute = true)
    {
        $url = $absolute ? $request->url() : '/'.$request->path();

        // THE FIX:
        $url = str_replace("http://","https://", $url);

        $original = rtrim($url.'?'.Arr::query(
                Arr::except($request->query(), 'signature')
            ), '?');

        $expires = $request->query('expires');

        $signature = hash_hmac('sha256', $original, call_user_func($this->keyResolver));

        return  hash_equals($signature, (string) $request->query('signature', '')) &&
            ! ($expires && Carbon::now()->getTimestamp() > $expires);
    }

}
Personalism answered 23/4, 2019 at 14:15 Comment(1)
It's 2020 and I still get that problem with Laravel 7.x. Why isn't it enough by just matching the APP_URL at the .envfile? Here is my question, if you can help please.Undecided

© 2022 - 2024 — McMap. All rights reserved.