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:
parent
69665c3c58
commit
240fcf1703
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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'),
|
||||
]));
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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));
|
||||
});
|
||||
Loading…
Reference in New Issue