Laravel6のマルチドメインで、メールアドレス認証で悲しみの 403 Invalid signature が発生してしまう

Laravelのマルチドメイン(マルチ認証)の機能、使っていますか?

先日、両方のドメイン(実際はサブドメインでした。)でログイン機能と、メールアドレス承認が欲しい要件があり結構ハマりました。Laravelで生成された署名付きURL(シグネチャーURL)が片方のドメインのみ有効で、もう片方で生成したものが403エラーで弾かれてしまうといった現象に。

原因は至ってシンプルなものだったのですが、解決したので記録に残します。

異常時の routes/web.php の中身

Route::namespace('hogehoge')->domain('hogehoge.jp')->group(function () {

    Route::get('register', 'Auth\RegisterController@showRegistrationForm')->name('register');
    Route::post('register', 'Auth\RegisterController@register');

    Route::get('email/verify', 'Auth\VerificationController@show')->name('verification.notice');
    Route::get('email/verify/{id}/{hash}', 'Auth\VerificationController@verify')->name('verification.verify');
    
    // 認証
    Route::middleware('auth:admin')->group(function () {
        Route::resource('/', 'HomeController', ['only' => 'index']);
    });

});

Route::namespace('fugafuga')->domain('fugafuga.jp')->group(function () {

    Route::get('register', 'Auth\RegisterController@showRegistrationForm')->name('register');
    Route::post('register', 'Auth\RegisterController@register');

    Route::get('email/verify', 'Auth\VerificationController@show')->name('verification.notice');
    Route::get('email/verify/{id}/{hash}', 'Auth\VerificationController@verify')->name('verification.verify');
    
    // 認証
    Route::middleware('auth:admin')->group(function () {
        Route::resource('/', 'HomeController', ['only' => 'index']);
    });

});

ざっくりこんな感じで、ドメインによってrouteを分けてました。(実際はサブドメインでの運用っす)

URLの発行

どちらのルートでも最終的には、verificationUrlを通じて署名付きのURLを発行し、メールを送っています。

    public function toMail($notifiable)
    {
        return (new MailMessage)
            ->view('mail.sendHogehogeMails')
            ->subject('Hogehogeのメール')
            ->action(null, $this->verificationUrl($notifiable));
    }

署名のチェック

んでもって、署名付きのURLへのアクセスは、UrlGeneratorクラスのhasCorrectSignatureメゾットで突き合わされます。

/vendor/laravel/framework/src/Illuminate/Routing/UrlGenerator.php

    public function hasCorrectSignature(Request $request, $absolute = true)
    {
        $url = $absolute ? $request->url() : '/'.$request->path();

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

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

        return hash_equals($signature, (string) $request->query('signature', ''));
    }

上記をみて、あら。
署名のハッシュ化に $request->url() で取得したアクセスされたURLが含まれてます。なるほど。

発行時の処理を追いかけてみると……

    protected function verificationUrl($notifiable)
    {
        return URL::temporarySignedRoute(
            'verification.verify',
            Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)),
            [
                'id' => $notifiable->getKey(),
                'hash' => sha1($notifiable->getEmailForVerification()),
            ]
        );
    }

UrlGeneratorクラスのtemporarySignedRouteにroute名[ verification.verify ]を渡して発行しているみたいで。

    public function signedRoute($name, $parameters = [], $expiration = null, $absolute = true)
    {
        $parameters = $this->formatParameters($parameters);

        if (array_key_exists('signature', $parameters)) {
            throw new InvalidArgumentException(
                '"Signature" is a reserved parameter when generating signed routes. Please rename your route parameter.'
            );
        }

        if ($expiration) {
            $parameters = $parameters + ['expires' => $this->availableAt($expiration)];
        }

        ksort($parameters);

        $key = call_user_func($this->keyResolver);

        return $this->route($name, $parameters + [
            'signature' => hash_hmac('sha256', $this->route($name, $parameters, $absolute), $key),
        ], $absolute);
    }

    public function route($name, $parameters = [], $absolute = true)
    {
        if (! is_null($route = $this->routes->getByName($name))) {
            return $this->toRoute($route, $parameters, $absolute);
        }

        throw new RouteNotFoundException("Route [{$name}] not defined.");
    }

中を見てみると、routes に中にある、[ verification.verify ]のキーを持つドメインを取得していました。

この routes が鬼門でした。

routes/web.php でのマルチドメインの分岐に関わらず、同じroute名がある場合、最後の値が有効になるっぽく……

ご察しの通り、今回の場合、hogehoge.jp、fugafuga.jp、ともにroute名[ verification.verify ]が使われていた為、後者のドメイン[ fugafuga.jp ]が有効。hogehoge.jp でのメールアドレス承認にも関わらず、署名の中には fugafuga.jp を含み、突き合わせの際は、hogehoge.jp で突き合わせるといった事象に。

もちろん、fugafuga.jpでのメールアドレス承認は問題なく通ります(笑)

対応方法

原因さえ分かれば、解決の方法も、いろいろとあると思いますが。

今回は、route名をキッチリと分けました。

Route::namespace('hogehoge')->domain('hogehoge.jp')->group(function () {

    Route::get('register', 'Auth\RegisterController@showRegistrationForm')->name('register');
    Route::post('register', 'Auth\RegisterController@register');

    Route::get('email/verify', 'Auth\VerificationController@show')->name('verification.notice');
    Route::get('email/verify/{id}/{hash}', 'Auth\VerificationController@verify')->name('verification.verify.hogehoge');
    
    // 認証
    Route::middleware('auth:admin')->group(function () {
        Route::resource('/', 'HomeController', ['only' => 'index']);
    });

});

Route::namespace('fugafuga')->domain('fugafuga.jp')->group(function () {

    Route::get('register', 'Auth\RegisterController@showRegistrationForm')->name('register');
    Route::post('register', 'Auth\RegisterController@register');

    Route::get('email/verify', 'Auth\VerificationController@show')->name('verification.notice');
    Route::get('email/verify/{id}/{hash}', 'Auth\VerificationController@verify')->name('verification.verify');
    
    // 認証
    Route::middleware('auth:admin')->group(function () {
        Route::resource('/', 'HomeController', ['only' => 'index']);
    });

});

hogehoge.jp の方だけ、[ verification.verify.hogehoge ] と命名。

んでもって、

    public function toMail($notifiable)
    {

        return (new MailMessage)
            ->view('mail.sendUserRegisterAdminMails')
            ->cc(env('MAIL_CC_ADDRESS'))
            ->subject('管理者の登録[ファミリーオフィスドットコム]')
            ->action(null, $this->verificationUrl($notifiable));
    }

    protected function verificationUrl($notifiable)
    {
        return URL::temporarySignedRoute(
            'verification.verify.hogehoge',
            Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)),
            [
                'id' => $notifiable->getKey(),
                'hash' => sha1($notifiable->getEmailForVerification()),
            ]
        );
    }

hogehoge.jp の場合だけ、verificationUrlメゾットをオーバーライドし、[ verification.verify.hogehoge ]のキーで署名を作成して無事動きました。

相当ニッチな要件なんですかねぇ、どれだけ探しても解決策が見つかりませんでした^^;