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
This commit is contained in:
Víctor Falcón 2026-04-21 08:28:59 +01:00 committed by GitHub
parent 69665c3c58
commit 240fcf1703
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 370 additions and 13 deletions

View File

@ -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);
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Console\Commands;
use App\Services\LandingAuthOverrideService;
use Illuminate\Console\Command;
class GenerateLandingAuthLinkCommand extends Command
{
public function __construct(private LandingAuthOverrideService $landingAuthOverrideService)
{
parent::__construct();
}
protected $signature = 'landing:auth-link
{--days=7 : Number of days before the link expires}';
protected $description = 'Generate a signed landing page link that unlocks authentication';
public function handle(): int
{
$days = filter_var($this->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;
}
}

View File

@ -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'),
]));

View File

@ -0,0 +1,175 @@
<?php
namespace App\Services;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cookie;
use Illuminate\Support\Facades\URL;
use Symfony\Component\HttpFoundation\Cookie as HttpFoundationCookie;
class LandingAuthOverrideService
{
public function authButtonsHidden(Request $request): bool
{
if (! config('landing.hide_auth_buttons', false)) {
return false;
}
return ! $this->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<string, mixed> $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');
}
}

View File

@ -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',
],
],
];

View File

@ -2713,11 +2713,17 @@ export default function Welcome({
)}
</p>
<div className="flex flex-col gap-3 sm:flex-row">
<Link href="/register">
<Button className="h-12 cursor-pointer bg-gradient-to-t from-zinc-700 to-zinc-900 px-8 text-base text-white shadow-sm transition-all hover:from-zinc-800 hover:to-black hover:shadow-md dark:from-zinc-200 dark:to-zinc-300 dark:text-[#1C1C1A] hover:dark:from-zinc-50">
{__('Get Started for Free')}
</Button>
</Link>
{hideAuthButtons ? (
<WaitlistForm />
) : isMobile ? (
<InstallAppButton />
) : (
<Link href="/register">
<Button className="h-12 cursor-pointer bg-gradient-to-t from-zinc-700 to-zinc-900 px-8 text-base text-white shadow-sm transition-all hover:from-zinc-800 hover:to-black hover:shadow-md dark:from-zinc-200 dark:to-zinc-300 dark:text-[#1C1C1A] hover:dark:from-zinc-50">
{__('Get Started for Free')}
</Button>
</Link>
)}
</div>
</div>
</div>

View File

@ -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');

View File

@ -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'));

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Support\Facades\Artisan;
use Inertia\Testing\AssertableInertia as Assert;
use function Pest\Laravel\artisan;
test('command outputs a signed landing auth link that works on another host', function () {
config(['landing.hide_auth_buttons' => 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();
});

View File

@ -0,0 +1,63 @@
<?php
use App\Services\LandingAuthOverrideService;
use Illuminate\Support\Facades\Queue;
use Inertia\Testing\AssertableInertia as Assert;
beforeEach(function () {
config(['landing.hide_auth_buttons' => 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));
});