From a7a6479e93749ee6a06e1cd25965261e78b31f33 Mon Sep 17 00:00:00 2001 From: DariusIII Date: Mon, 15 Jun 2026 01:26:34 +0200 Subject: [PATCH] Fix email verification url --- app/Notifications/VerifyEmailBranded.php | 47 ++++++++++++++++++ config/app.php | 18 +++++++ tests/Feature/Mail/VerifyEmailBrandedTest.php | 48 +++++++++++++++++++ 3 files changed, 113 insertions(+) diff --git a/app/Notifications/VerifyEmailBranded.php b/app/Notifications/VerifyEmailBranded.php index 2f18bf780..880a284ca 100644 --- a/app/Notifications/VerifyEmailBranded.php +++ b/app/Notifications/VerifyEmailBranded.php @@ -8,6 +8,9 @@ use Illuminate\Auth\Notifications\VerifyEmail; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Notifications\Messages\MailMessage; +use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Config; +use Illuminate\Support\Facades\URL; /** * Branded replacement for Laravel's framework `VerifyEmail` notification. @@ -42,4 +45,48 @@ class VerifyEmailBranded extends VerifyEmail implements ShouldQueue 'preheader' => "Confirm your email address to activate your {$site} account.", ]); } + + protected function verificationUrl($notifiable) + { + if (static::$createUrlCallback) { + return call_user_func(static::$createUrlCallback, $notifiable); + } + + $origin = $this->normalizedAppUrl(); + + if ($origin === null) { + return parent::verificationUrl($notifiable); + } + + $parameters = [ + 'id' => $notifiable->getKey(), + 'hash' => sha1($notifiable->getEmailForVerification()), + 'expires' => Carbon::now() + ->addMinutes((int) Config::get('auth.verification.expire', 60)) + ->getTimestamp(), + ]; + + ksort($parameters); + + $unsignedUrl = $origin.URL::route('verification.verify', $parameters, false); + $key = Config::get('app.key'); + $signature = hash_hmac('sha256', $unsignedUrl, is_array($key) ? $key[0] : (string) $key); + + return $unsignedUrl.(str_contains($unsignedUrl, '?') ? '&' : '?').'signature='.$signature; + } + + private function normalizedAppUrl(): ?string + { + $url = trim((string) Config::get('app.url', '')); + + if ($url === '') { + return null; + } + + if (! preg_match('~^https?://~i', $url)) { + $url = 'https://'.$url; + } + + return rtrim($url, '/'); + } } diff --git a/config/app.php b/config/app.php index dfe897a6c..12ad8f7a6 100644 --- a/config/app.php +++ b/config/app.php @@ -7,6 +7,24 @@ use Illuminate\Support\Facades\Redis; return [ + /* + |-------------------------------------------------------------------------- + | Application Name, Environment, Debug Mode and URL + |-------------------------------------------------------------------------- + | + | These values are used throughout the framework, including by queued mail + | notifications that need to generate absolute links without an HTTP request. + | + */ + + 'name' => env('APP_NAME', 'NNTmux'), + + 'env' => env('APP_ENV', 'production'), + + 'debug' => (bool) env('APP_DEBUG', false), + + 'url' => env('APP_URL', 'http://localhost'), + /* |-------------------------------------------------------------------------- | Application Timezone diff --git a/tests/Feature/Mail/VerifyEmailBrandedTest.php b/tests/Feature/Mail/VerifyEmailBrandedTest.php index 7dc5dded5..637ff78b7 100644 --- a/tests/Feature/Mail/VerifyEmailBrandedTest.php +++ b/tests/Feature/Mail/VerifyEmailBrandedTest.php @@ -8,8 +8,10 @@ use App\Models\User; use App\Notifications\VerifyEmailBranded; use Illuminate\Auth\Notifications\VerifyEmail; use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Http\Request; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Support\Facades\Notification; +use Illuminate\Support\Facades\URL; use stdClass; use Tests\TestCase; @@ -70,6 +72,30 @@ class VerifyEmailBrandedTest extends TestCase $this->assertNotEmpty($message->viewData['url']); } + public function test_branded_verification_uses_configured_app_url_for_signed_links(): void + { + config(['app.url' => 'https://example.test']); + + $message = (new VerifyEmailBranded)->toMail($this->verificationNotifiable()); + $url = (string) $message->viewData['url']; + + $this->assertStringStartsWith('https://example.test/email/verify/', $url); + $this->assertStringNotContainsString('localhost', $url); + $this->assertTrue(URL::hasValidSignature(Request::create($url))); + } + + public function test_branded_verification_normalizes_host_only_app_url_for_signed_links(): void + { + config(['app.url' => 'example.test']); + + $message = (new VerifyEmailBranded)->toMail($this->verificationNotifiable()); + $url = (string) $message->viewData['url']; + + $this->assertStringStartsWith('https://example.test/email/verify/', $url); + $this->assertStringNotContainsString('localhost', $url); + $this->assertTrue(URL::hasValidSignature(Request::create($url))); + } + public function test_user_dispatches_branded_verification_when_notifying(): void { Notification::fake(); @@ -83,4 +109,26 @@ class VerifyEmailBrandedTest extends TestCase Notification::assertSentTo($user, VerifyEmailBranded::class); } + + private function verificationNotifiable(): object + { + return new class extends stdClass + { + public int $id = 7; + + public string $username = 'tester'; + + public string $email = 'tester@example.test'; + + public function getKey(): int + { + return $this->id; + } + + public function getEmailForVerification(): string + { + return $this->email; + } + }; + } }