From 240fcf17030c605ed5daaa3fffa77018e20968c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Tue, 21 Apr 2026 08:28:59 +0100 Subject: [PATCH] feat(landing): add signed auth links (#312) ## Summary - add signed landing links that unlock auth buttons while HIDE_AUTH_BUTTONS is enabled - persist the unlock in a secure cookie so desktop and installed PWA users can still sign up - add artisan command to generate signed landing auth links and test coverage for the flow ## Testing - php artisan test --compact tests/Feature/LandingAuthOverrideTest.php tests/Feature/Console/GenerateLandingAuthLinkCommandTest.php tests/Feature/Auth/RegistrationTest.php - php artisan test --compact tests/Feature/WelcomeBanksOrderingTest.php tests/Feature/SubscriptionTest.php - vendor/bin/pint --dirty --format agent --- app/Actions/Fortify/CreateNewUser.php | 5 +- .../GenerateLandingAuthLinkCommand.php | 36 ++++ app/Providers/FortifyServiceProvider.php | 8 +- app/Services/LandingAuthOverrideService.php | 175 ++++++++++++++++++ config/landing.php | 30 ++- resources/js/pages/welcome.tsx | 16 +- routes/web.php | 12 +- tests/Feature/Auth/RegistrationTest.php | 4 + .../GenerateLandingAuthLinkCommandTest.php | 34 ++++ tests/Feature/LandingAuthOverrideTest.php | 63 +++++++ 10 files changed, 370 insertions(+), 13 deletions(-) create mode 100644 app/Console/Commands/GenerateLandingAuthLinkCommand.php create mode 100644 app/Services/LandingAuthOverrideService.php create mode 100644 tests/Feature/Console/GenerateLandingAuthLinkCommandTest.php create mode 100644 tests/Feature/LandingAuthOverrideTest.php diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index 5e792268..b65e3816 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -3,6 +3,7 @@ namespace App\Actions\Fortify; use App\Models\User; +use App\Services\LandingAuthOverrideService; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; use Laravel\Fortify\Contracts\CreatesNewUsers; @@ -11,6 +12,8 @@ class CreateNewUser implements CreatesNewUsers { use PasswordValidationRules; + public function __construct(private LandingAuthOverrideService $landingAuthOverrideService) {} + /** * Validate and create a newly registered user. * @@ -18,7 +21,7 @@ class CreateNewUser implements CreatesNewUsers */ public function create(array $input): User { - if (config('landing.hide_auth_buttons', false) && ! request()->boolean('force')) { + if ($this->landingAuthOverrideService->authButtonsHidden(request())) { abort(404); } diff --git a/app/Console/Commands/GenerateLandingAuthLinkCommand.php b/app/Console/Commands/GenerateLandingAuthLinkCommand.php new file mode 100644 index 00000000..0bee32ae --- /dev/null +++ b/app/Console/Commands/GenerateLandingAuthLinkCommand.php @@ -0,0 +1,36 @@ +option('days'), FILTER_VALIDATE_INT); + + if ($days === false || $days < 1) { + $this->error('Days must be a positive integer.'); + + return self::FAILURE; + } + + $url = $this->landingAuthOverrideService->generateSignedUrl($days); + + $this->line($url); + + return self::SUCCESS; + } +} diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index c9dff507..8ba7d090 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -8,6 +8,7 @@ use App\Actions\Fortify\ResetUserPassword; use App\Http\Responses\LoginResponse; use App\Http\Responses\TwoFactorLoginResponse; use App\Models\User; +use App\Services\LandingAuthOverrideService; use Illuminate\Auth\Events\Registered; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; @@ -57,9 +58,12 @@ class FortifyServiceProvider extends ServiceProvider */ private function configureViews(): void { + $landingAuthOverrideService = app(LandingAuthOverrideService::class); + Fortify::loginView(fn (Request $request) => Inertia::render('auth/login', [ 'canResetPassword' => Features::enabled(Features::resetPasswords()), - 'canRegister' => Features::enabled(Features::registration()) && ! config('landing.hide_auth_buttons', false), + 'canRegister' => Features::enabled(Features::registration()) + && ! $landingAuthOverrideService->authButtonsHidden($request), 'status' => $request->session()->get('status'), ])); @@ -77,7 +81,7 @@ class FortifyServiceProvider extends ServiceProvider ])); Fortify::registerView(fn (Request $request) => Inertia::render('auth/register', [ - 'hideAuthButtons' => config('landing.hide_auth_buttons', false) && ! $request->boolean('force'), + 'hideAuthButtons' => $landingAuthOverrideService->authButtonsHidden($request), 'forcedRegistration' => $request->boolean('force'), ])); diff --git a/app/Services/LandingAuthOverrideService.php b/app/Services/LandingAuthOverrideService.php new file mode 100644 index 00000000..a35b10a0 --- /dev/null +++ b/app/Services/LandingAuthOverrideService.php @@ -0,0 +1,175 @@ +allowsAuthentication($request); + } + + public function allowsAuthentication(Request $request): bool + { + if (! config('landing.hide_auth_buttons', false)) { + return true; + } + + if ($request->boolean('force')) { + return true; + } + + if ($this->hasOverrideCookie($request)) { + return true; + } + + if (! $this->hasValidSignedOverride($request)) { + return false; + } + + $this->queueOverrideCookie(); + + return true; + } + + public function generateSignedUrl(int $days): string + { + $path = $this->signedPath(now()->addDays($days)); + + return rtrim(config('app.url'), '/').$path; + } + + public function signedPath(\DateTimeInterface|\DateInterval|int $expiration): string + { + $parameters = [ + $this->queryParameter() => 1, + 'expires' => $this->availableAt($expiration), + ]; + + ksort($parameters); + + $signature = hash_hmac('sha256', $this->originalString('/', $parameters), $this->signingKey()); + + return '/?'.Arr::query($parameters + ['signature' => $signature]); + } + + private function queueOverrideCookie(): void + { + Cookie::queue($this->makeOverrideCookie()); + } + + private function makeOverrideCookie(): HttpFoundationCookie + { + return Cookie::make( + $this->cookieName(), + '1', + (int) config('landing.auth_override.cookie_minutes', 60 * 24 * 7), + '/', + config('session.domain'), + config('session.secure'), + true, + false, + config('session.same_site', 'lax'), + ); + } + + private function hasValidSignedOverride(Request $request): bool + { + if (! $request->boolean($this->queryParameter())) { + return false; + } + + if ($this->signatureHasExpired($request)) { + return false; + } + + $parameters = $request->query(); + unset($parameters['signature']); + + foreach (config('landing.auth_override.ignore_signature_query_parameters', []) as $ignoredParameter) { + unset($parameters[$ignoredParameter]); + } + + ksort($parameters); + + $signature = (string) $request->query('signature', ''); + + return hash_equals( + hash_hmac('sha256', $this->originalString($request->getPathInfo(), $parameters), $this->signingKey()), + $signature, + ); + } + + private function signatureHasExpired(Request $request): bool + { + $expires = $request->query('expires'); + + if (! is_numeric($expires)) { + return true; + } + + return (int) $expires < now()->getTimestamp(); + } + + /** + * @param array $parameters + */ + private function originalString(string $path, array $parameters): string + { + $normalizedPath = $path === '' ? '/' : $path; + $query = Arr::query($parameters); + + return rtrim($normalizedPath.'?'.$query, '?'); + } + + private function signingKey(): string + { + $key = app('config')->get('app.key'); + + if (! is_string($key) || $key === '') { + $url = URL::to('/'); + + throw new \RuntimeException("Unable to sign landing auth URL for {$url} without app.key."); + } + + return $key; + } + + private function availableAt(\DateTimeInterface|\DateInterval|int $delay): int + { + if ($delay instanceof \DateTimeInterface) { + return $delay->getTimestamp(); + } + + if ($delay instanceof \DateInterval) { + return now()->add($delay)->getTimestamp(); + } + + return now()->addSeconds($delay)->getTimestamp(); + } + + private function hasOverrideCookie(Request $request): bool + { + return filter_var($request->cookie($this->cookieName()), FILTER_VALIDATE_BOOL); + } + + private function queryParameter(): string + { + return (string) config('landing.auth_override.query_parameter', 'signup'); + } + + private function cookieName(): string + { + return (string) config('landing.auth_override.cookie_name', 'landing_auth_override'); + } +} diff --git a/config/landing.php b/config/landing.php index 0875b458..ce0212c4 100644 --- a/config/landing.php +++ b/config/landing.php @@ -8,10 +8,38 @@ return [ |-------------------------------------------------------------------------- | | When set to true, this will hide authentication buttons (login/register) - | from the landing page and return 404 for auth routes. + | from the landing page and block registration unless a valid override is + | present. | */ 'hide_auth_buttons' => env('HIDE_AUTH_BUTTONS', false), + /* + |-------------------------------------------------------------------------- + | Authentication Override + |-------------------------------------------------------------------------- + | + | Temporary signed landing links can unlock authentication buttons for a + | limited time. Once a user opens a valid signed link, a secure cookie is + | stored so the same browser session can continue into the installed PWA. + | + */ + + 'auth_override' => [ + 'query_parameter' => 'signup', + 'cookie_name' => 'landing_auth_override', + 'cookie_minutes' => 60 * 24 * 7, + 'ignore_signature_query_parameters' => [ + 'lang', + 'utm_source', + 'utm_medium', + 'utm_campaign', + 'utm_term', + 'utm_content', + 'gclid', + 'fbclid', + ], + ], + ]; diff --git a/resources/js/pages/welcome.tsx b/resources/js/pages/welcome.tsx index 71a241ca..46e66340 100644 --- a/resources/js/pages/welcome.tsx +++ b/resources/js/pages/welcome.tsx @@ -2713,11 +2713,17 @@ export default function Welcome({ )}

- - - + {hideAuthButtons ? ( + + ) : isMobile ? ( + + ) : ( + + + + )}
diff --git a/routes/web.php b/routes/web.php index e2734730..c7da1533 100644 --- a/routes/web.php +++ b/routes/web.php @@ -20,13 +20,15 @@ use App\Http\Controllers\SubscriptionController; use App\Http\Controllers\TransactionController; use App\Http\Controllers\UserLeadController; use App\Models\Bank; +use App\Services\LandingAuthOverrideService; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Route; use Inertia\Inertia; use Laravel\Fortify\Features; -Route::get('/', function () { - $user = request()->user(); +Route::get('/', function (Request $request, LandingAuthOverrideService $landingAuthOverrideService) { + $user = $request->user(); $popularBanks = Cache::remember('popular-banks', now()->addDay(), function () { return Bank::query() @@ -53,9 +55,11 @@ Route::get('/', function () { ->toArray(); }); + $hideAuthButtons = $landingAuthOverrideService->authButtonsHidden($request); + return Inertia::render('welcome', [ - 'canRegister' => Features::enabled(Features::registration()), - 'hideAuthButtons' => config('landing.hide_auth_buttons', false), + 'canRegister' => Features::enabled(Features::registration()) && ! $hideAuthButtons, + 'hideAuthButtons' => $hideAuthButtons, 'popularBanks' => $popularBanks, ]); })->name('home'); diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php index a4b7c89b..e8ef4e48 100644 --- a/tests/Feature/Auth/RegistrationTest.php +++ b/tests/Feature/Auth/RegistrationTest.php @@ -6,6 +6,10 @@ use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Queue; use Inertia\Testing\AssertableInertia as Assert; +beforeEach(function () { + config(['landing.hide_auth_buttons' => false]); +}); + test('registration screen can be rendered', function () { $response = $this->withoutVite()->get(route('register')); diff --git a/tests/Feature/Console/GenerateLandingAuthLinkCommandTest.php b/tests/Feature/Console/GenerateLandingAuthLinkCommandTest.php new file mode 100644 index 00000000..0a83ea2d --- /dev/null +++ b/tests/Feature/Console/GenerateLandingAuthLinkCommandTest.php @@ -0,0 +1,34 @@ + true]); + + Artisan::call('landing:auth-link', ['--days' => 3]); + + $output = trim(Artisan::output()); + $query = []; + parse_str(parse_url($output, PHP_URL_QUERY) ?: '', $query); + $devUrl = 'https://dev.whisper.money.localhost:1355/?'.parse_url($output, PHP_URL_QUERY); + + expect($query['signup'] ?? null)->toBe('1'); + expect($query)->toHaveKeys(['expires', 'signature']); + + $this->withoutVite()->get($devUrl) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('welcome') + ->where('hideAuthButtons', false) + ->where('canRegister', true) + ); +}); + +test('command fails for invalid expiration days', function () { + artisan('landing:auth-link', ['--days' => 0]) + ->expectsOutputToContain('Days must be a positive integer.') + ->assertFailed(); +}); diff --git a/tests/Feature/LandingAuthOverrideTest.php b/tests/Feature/LandingAuthOverrideTest.php new file mode 100644 index 00000000..0c4749af --- /dev/null +++ b/tests/Feature/LandingAuthOverrideTest.php @@ -0,0 +1,63 @@ + true]); +}); + +test('signed landing link unlocks auth buttons and queues override cookie', function () { + $signedUrl = 'https://dev.whisper.money.localhost:1355' + .app(LandingAuthOverrideService::class)->signedPath(now()->addHour()) + .'&lang=es'; + + $this->withoutVite()->get($signedUrl) + ->assertOk() + ->assertCookie(config('landing.auth_override.cookie_name')) + ->assertInertia(fn (Assert $page) => $page + ->component('welcome') + ->where('hideAuthButtons', false) + ->where('canRegister', true) + ); +}); + +test('unsigned landing link keeps auth buttons hidden', function () { + $this->withoutVite()->get(route('home', ['signup' => 1])) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('welcome') + ->where('hideAuthButtons', true) + ->where('canRegister', false) + ); +}); + +test('login page allows registration with the override cookie', function () { + $cookieName = config('landing.auth_override.cookie_name'); + + $this->withCookie($cookieName, '1') + ->withoutVite() + ->get(route('login')) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('auth/login') + ->where('canRegister', true) + ); +}); + +test('new users can register with the override cookie', function () { + Queue::fake(); + + $cookieName = config('landing.auth_override.cookie_name'); + + $response = $this->withCookie($cookieName, '1')->post(route('register.store'), [ + 'name' => 'Signed Link User', + 'email' => 'signed@example.com', + 'password' => 'password', + 'password_confirmation' => 'password', + ]); + + $this->assertAuthenticated(); + $response->assertRedirect(route('onboarding', absolute: false)); +});