fix: route new PWA guests to signup (#313)
## Summary - redirect guest access to protected routes based on returning-user cookie - send new PWA guests to registration and returning guests to login - persist returning-user cookie after register, login, and 2FA login - keep explicit login links working via `force=1` ## Testing - vendor/bin/pint --dirty --format agent - php artisan test --compact tests/Feature/Auth/AuthenticationTest.php tests/Feature/DashboardTest.php tests/Feature/Auth/RegistrationTest.php
This commit is contained in:
parent
240fcf1703
commit
905edeb4a2
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Http\Responses;
|
||||
|
||||
use App\Services\AuthEntryPointService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
|
||||
|
|
@ -9,13 +10,15 @@ use Laravel\Fortify\Fortify;
|
|||
|
||||
class LoginResponse implements LoginResponseContract
|
||||
{
|
||||
public function __construct(private readonly AuthEntryPointService $authEntryPointService) {}
|
||||
|
||||
/**
|
||||
* Create an HTTP response that represents the object.
|
||||
*/
|
||||
public function toResponse($request): JsonResponse|RedirectResponse
|
||||
{
|
||||
// Flash a session variable to indicate the user just logged in
|
||||
session()->flash('show_encryption_prompt', true);
|
||||
$this->authEntryPointService->queueReturningUserCookie();
|
||||
|
||||
return $request->wantsJson()
|
||||
? response()->json(['two_factor' => false])
|
||||
|
|
|
|||
|
|
@ -2,14 +2,19 @@
|
|||
|
||||
namespace App\Http\Responses;
|
||||
|
||||
use App\Services\AuthEntryPointService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class RegisterResponse implements RegisterResponseContract
|
||||
{
|
||||
public function __construct(private readonly AuthEntryPointService $authEntryPointService) {}
|
||||
|
||||
public function toResponse($request): Response
|
||||
{
|
||||
$this->authEntryPointService->queueReturningUserCookie();
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return new JsonResponse('', 201);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Http\Responses;
|
||||
|
||||
use App\Services\AuthEntryPointService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Laravel\Fortify\Contracts\TwoFactorLoginResponse as TwoFactorLoginResponseContract;
|
||||
|
|
@ -9,13 +10,15 @@ use Laravel\Fortify\Fortify;
|
|||
|
||||
class TwoFactorLoginResponse implements TwoFactorLoginResponseContract
|
||||
{
|
||||
public function __construct(private readonly AuthEntryPointService $authEntryPointService) {}
|
||||
|
||||
/**
|
||||
* Create an HTTP response that represents the object.
|
||||
*/
|
||||
public function toResponse($request): JsonResponse|RedirectResponse
|
||||
{
|
||||
// Flash a session variable to indicate the user just logged in
|
||||
session()->flash('show_encryption_prompt', true);
|
||||
$this->authEntryPointService->queueReturningUserCookie();
|
||||
|
||||
return $request->wantsJson()
|
||||
? new JsonResponse('', 204)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cookie;
|
||||
use Laravel\Fortify\Features;
|
||||
use Symfony\Component\HttpFoundation\Cookie as HttpFoundationCookie;
|
||||
|
||||
class AuthEntryPointService
|
||||
{
|
||||
private const COOKIE_NAME = 'whisper_money_returning_user';
|
||||
|
||||
private const COOKIE_MINUTES = 60 * 24 * 365 * 5;
|
||||
|
||||
public function __construct(private readonly LandingAuthOverrideService $landingAuthOverrideService) {}
|
||||
|
||||
/**
|
||||
* @api
|
||||
*/
|
||||
public function guestRedirectRoute(Request $request): string
|
||||
{
|
||||
if (
|
||||
$this->hasAuthenticatedBefore($request)
|
||||
|| ! Features::enabled(Features::registration())
|
||||
|| $this->landingAuthOverrideService->authButtonsHidden($request)
|
||||
) {
|
||||
return route('login');
|
||||
}
|
||||
|
||||
return route('register');
|
||||
}
|
||||
|
||||
public function queueReturningUserCookie(): void
|
||||
{
|
||||
Cookie::queue($this->makeReturningUserCookie());
|
||||
}
|
||||
|
||||
private function hasAuthenticatedBefore(Request $request): bool
|
||||
{
|
||||
return filter_var($request->cookie(self::COOKIE_NAME), FILTER_VALIDATE_BOOL);
|
||||
}
|
||||
|
||||
private function makeReturningUserCookie(): HttpFoundationCookie
|
||||
{
|
||||
return Cookie::make(
|
||||
self::COOKIE_NAME,
|
||||
'1',
|
||||
self::COOKIE_MINUTES,
|
||||
'/',
|
||||
config('session.domain'),
|
||||
config('session.secure'),
|
||||
true,
|
||||
false,
|
||||
config('session.same_site', 'lax'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ use App\Http\Middleware\EnsureUserIsSubscribed;
|
|||
use App\Http\Middleware\HandleAppearance;
|
||||
use App\Http\Middleware\HandleInertiaRequests;
|
||||
use App\Http\Middleware\SetLocale;
|
||||
use App\Services\AuthEntryPointService;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
|
|
@ -21,6 +22,8 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->redirectGuestsTo(fn (Request $request) => app(AuthEntryPointService::class)->guestRedirectRoute($request));
|
||||
|
||||
$middleware->encryptCookies(except: ['appearance', 'sidebar_state', 'chart-color-scheme']);
|
||||
|
||||
$middleware->trustProxies(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { cn } from '@/lib/utils';
|
||||
import { dashboard } from '@/routes';
|
||||
import { dashboard, login } from '@/routes';
|
||||
import { type SharedData } from '@/types';
|
||||
import { __ } from '@/utils/i18n';
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
|
|
@ -119,7 +119,7 @@ export default function Header({
|
|||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<Link href="/login">
|
||||
<Link href={login({ query: { force: 1 } })}>
|
||||
<Button
|
||||
variant={'ghost'}
|
||||
size="sm"
|
||||
|
|
@ -227,7 +227,9 @@ export default function Header({
|
|||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<Link href="/login">
|
||||
<Link
|
||||
href={login({ query: { force: 1 } })}
|
||||
>
|
||||
<Button
|
||||
variant={'ghost'}
|
||||
className="cursor-pointer"
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export default function Register({
|
|||
|
||||
useEffect(() => {
|
||||
if (hideAuthButtons) {
|
||||
router.visit(login());
|
||||
router.visit(login({ query: { force: 1 } }));
|
||||
}
|
||||
}, [hideAuthButtons]);
|
||||
|
||||
|
|
@ -153,7 +153,10 @@ export default function Register({
|
|||
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
{__('Already have an account?')}{' '}
|
||||
<TextLink href={login()} tabIndex={6}>
|
||||
<TextLink
|
||||
href={login({ query: { force: 1 } })}
|
||||
tabIndex={6}
|
||||
>
|
||||
{__('Log in')}
|
||||
</TextLink>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -11,22 +11,24 @@ use App\Models\RealEstateDetail;
|
|||
use App\Models\User;
|
||||
|
||||
beforeEach(function () {
|
||||
config(['landing.hide_auth_buttons' => false]);
|
||||
|
||||
$this->user = User::factory()->onboarded()->create();
|
||||
$this->actingAs($this->user);
|
||||
});
|
||||
|
||||
test('guests are redirected to the login page for accounts index', function () {
|
||||
test('guests are redirected to the registration page for accounts index', function () {
|
||||
auth()->logout();
|
||||
|
||||
$this->get(route('accounts.list'))->assertRedirect(route('login'));
|
||||
$this->get(route('accounts.list'))->assertRedirect(route('register'));
|
||||
});
|
||||
|
||||
test('guests are redirected to the login page for account show', function () {
|
||||
test('guests are redirected to the registration page for account show', function () {
|
||||
auth()->logout();
|
||||
|
||||
$account = Account::factory()->create(['user_id' => $this->user->id]);
|
||||
|
||||
$this->get(route('accounts.show', $account))->assertRedirect(route('login'));
|
||||
$this->get(route('accounts.show', $account))->assertRedirect(route('register'));
|
||||
});
|
||||
|
||||
test('authenticated users can visit the accounts index', function () {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,23 @@ test('login screen can be rendered', function () {
|
|||
$response->assertStatus(200);
|
||||
});
|
||||
|
||||
test('login screen can be rendered with an intended destination in session', function () {
|
||||
$response = $this
|
||||
->withSession(['url.intended' => route('dashboard')])
|
||||
->get(route('login'));
|
||||
|
||||
$response->assertSuccessful();
|
||||
});
|
||||
|
||||
test('login screen stays available for returning users with an intended destination', function () {
|
||||
$response = $this
|
||||
->withCookie('whisper_money_returning_user', '1')
|
||||
->withSession(['url.intended' => route('dashboard')])
|
||||
->get(route('login'));
|
||||
|
||||
$response->assertSuccessful();
|
||||
});
|
||||
|
||||
test('users can authenticate using the login screen', function () {
|
||||
$user = User::factory()->withoutTwoFactor()->create();
|
||||
|
||||
|
|
@ -19,7 +36,9 @@ test('users can authenticate using the login screen', function () {
|
|||
]);
|
||||
|
||||
$this->assertAuthenticated();
|
||||
$response->assertRedirect(route('dashboard', absolute: false));
|
||||
$response
|
||||
->assertRedirect(route('dashboard', absolute: false))
|
||||
->assertCookie('whisper_money_returning_user');
|
||||
});
|
||||
|
||||
test('users with two factor enabled are redirected to two factor challenge', function () {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@
|
|||
use App\Models\User;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
beforeEach(function () {
|
||||
config(['landing.hide_auth_buttons' => false]);
|
||||
});
|
||||
|
||||
test('confirm password screen can be rendered', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
|
|
@ -18,5 +22,5 @@ test('confirm password screen can be rendered', function () {
|
|||
test('password confirmation requires authentication', function () {
|
||||
$response = $this->get(route('password.confirm'));
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
$response->assertRedirect(route('register'));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -56,7 +56,9 @@ test('new users can register with force query when auth buttons are hidden', fun
|
|||
]);
|
||||
|
||||
$this->assertAuthenticated();
|
||||
$response->assertRedirect(route('onboarding', absolute: false));
|
||||
$response
|
||||
->assertRedirect(route('onboarding', absolute: false))
|
||||
->assertCookie('whisper_money_returning_user');
|
||||
});
|
||||
|
||||
test('new users can register', function () {
|
||||
|
|
@ -70,7 +72,9 @@ test('new users can register', function () {
|
|||
]);
|
||||
|
||||
$this->assertAuthenticated();
|
||||
$response->assertRedirect(route('onboarding', absolute: false));
|
||||
$response
|
||||
->assertRedirect(route('onboarding', absolute: false))
|
||||
->assertCookie('whisper_money_returning_user');
|
||||
});
|
||||
|
||||
test('new users store their detected timezone on registration', function () {
|
||||
|
|
|
|||
|
|
@ -3,8 +3,12 @@
|
|||
use App\Models\User;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
|
||||
test('guests are redirected to login', function () {
|
||||
$this->get(route('cashflow'))->assertRedirect(route('login'));
|
||||
beforeEach(function () {
|
||||
config(['landing.hide_auth_buttons' => false]);
|
||||
});
|
||||
|
||||
test('guests are redirected to registration', function () {
|
||||
$this->get(route('cashflow'))->assertRedirect(route('register'));
|
||||
});
|
||||
|
||||
test('period prop is null when no query param given', function () {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,49 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Laravel\Fortify\Features;
|
||||
|
||||
beforeEach(function () {
|
||||
config(['landing.hide_auth_buttons' => false]);
|
||||
});
|
||||
|
||||
test('new guests are redirected to the registration page', function () {
|
||||
$this->get(route('dashboard'))->assertRedirect(route('register'));
|
||||
});
|
||||
|
||||
test('returning guests are redirected to the login page', function () {
|
||||
$this
|
||||
->withCookie('whisper_money_returning_user', '1')
|
||||
->get(route('dashboard'))
|
||||
->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('new guests are redirected to the login page when registration is disabled', function () {
|
||||
config([
|
||||
'fortify.features' => array_values(array_filter(
|
||||
config('fortify.features'),
|
||||
fn (string $feature): bool => $feature !== Features::registration(),
|
||||
)),
|
||||
]);
|
||||
|
||||
test('guests are redirected to the login page', function () {
|
||||
$this->get(route('dashboard'))->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('new guests are redirected to the login page when auth buttons are hidden', function () {
|
||||
config(['landing.hide_auth_buttons' => true]);
|
||||
|
||||
$this->get(route('dashboard'))->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('new guests with landing auth override are redirected to the registration page', function () {
|
||||
config(['landing.hide_auth_buttons' => true]);
|
||||
|
||||
$this
|
||||
->withCookie(config('landing.auth_override.cookie_name'), '1')
|
||||
->get(route('dashboard'))
|
||||
->assertRedirect(route('register'));
|
||||
});
|
||||
|
||||
test('authenticated users can visit the dashboard', function () {
|
||||
$this->actingAs(User::factory()->onboarded()->create());
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
<?php
|
||||
|
||||
beforeEach(function () {
|
||||
config(['landing.hide_auth_buttons' => false]);
|
||||
});
|
||||
|
||||
test('guests cannot access institutions route', function () {
|
||||
$this->getJson('/open-banking/institutions?country=ES')
|
||||
->assertUnauthorized();
|
||||
|
|
@ -14,10 +18,10 @@ test('guests cannot access authorize route', function () {
|
|||
|
||||
test('guests are redirected away from callback route', function () {
|
||||
$this->get('/open-banking/callback?code=test')
|
||||
->assertRedirect(route('login'));
|
||||
->assertRedirect(route('register'));
|
||||
});
|
||||
|
||||
test('guests are redirected away from connections index', function () {
|
||||
$this->get('/settings/connections')
|
||||
->assertRedirect(route('login'));
|
||||
->assertRedirect(route('register'));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ use App\Models\Category;
|
|||
use App\Models\User;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
|
||||
beforeEach(function () {
|
||||
config(['landing.hide_auth_buttons' => false]);
|
||||
});
|
||||
|
||||
test('authenticated users can view their categories', function () {
|
||||
$user = User::factory()->create();
|
||||
$categories = Category::factory()->count(3)->create(['user_id' => $user->id]);
|
||||
|
|
@ -256,10 +260,10 @@ test('users cannot delete categories they do not own', function () {
|
|||
|
||||
test('guests cannot access category management', function () {
|
||||
$response = $this->get(route('categories.index'));
|
||||
$response->assertRedirect(route('login'));
|
||||
$response->assertRedirect(route('register'));
|
||||
|
||||
$response = $this->post(route('categories.store'), []);
|
||||
$response->assertRedirect(route('login'));
|
||||
$response->assertRedirect(route('register'));
|
||||
});
|
||||
|
||||
test('default categories are created when user registers', function () {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@ use App\Enums\ChartColorScheme;
|
|||
use App\Models\User;
|
||||
use App\Models\UserSetting;
|
||||
|
||||
beforeEach(function () {
|
||||
config(['landing.hide_auth_buttons' => false]);
|
||||
});
|
||||
|
||||
test('chart color scheme can be updated', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
|
|
@ -35,7 +39,7 @@ test('chart color scheme requires authentication', function () {
|
|||
'chart_color_scheme' => 'blue',
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
$response->assertRedirect(route('register'));
|
||||
});
|
||||
|
||||
test('chart color scheme creates setting when none exists', function () {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@
|
|||
use App\Models\User;
|
||||
use App\Models\UserSetting;
|
||||
|
||||
beforeEach(function () {
|
||||
config(['landing.hide_auth_buttons' => false]);
|
||||
});
|
||||
|
||||
test('net worth chart loan preference can be updated', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
|
|
@ -34,7 +38,7 @@ test('net worth chart loan preference requires authentication', function () {
|
|||
'include_loans_in_net_worth_chart' => false,
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
$response->assertRedirect(route('register'));
|
||||
});
|
||||
|
||||
test('net worth chart loan preference creates setting when none exists', function () {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@
|
|||
use App\Models\User;
|
||||
use App\Models\UserSetting;
|
||||
|
||||
beforeEach(function () {
|
||||
config(['landing.hide_auth_buttons' => false]);
|
||||
});
|
||||
|
||||
test('net worth chart real estate preference can be updated', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
|
|
@ -34,7 +38,7 @@ test('net worth chart real estate preference requires authentication', function
|
|||
'include_real_estate_in_net_worth_chart' => false,
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
$response->assertRedirect(route('register'));
|
||||
});
|
||||
|
||||
test('net worth chart real estate preference creates setting when none exists', function () {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
use App\Models\User;
|
||||
|
||||
beforeEach(function () {
|
||||
config(['landing.hide_auth_buttons' => false]);
|
||||
});
|
||||
|
||||
test('timezone can be backfilled for authenticated users without one', function () {
|
||||
$user = User::factory()->create(['timezone' => null]);
|
||||
|
||||
|
|
@ -42,5 +46,5 @@ test('timezone backfill requires authentication', function () {
|
|||
'timezone' => 'Europe/Madrid',
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
$response->assertRedirect(route('register'));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,13 +10,16 @@ use App\Models\User;
|
|||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
beforeEach(function () {
|
||||
config(['subscriptions.enabled' => true]);
|
||||
config([
|
||||
'landing.hide_auth_buttons' => false,
|
||||
'subscriptions.enabled' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
test('guests cannot access subscription pages', function () {
|
||||
$this->get(route('subscribe'))->assertRedirect(route('login'));
|
||||
$this->get(route('subscribe.checkout'))->assertRedirect(route('login'));
|
||||
$this->get(route('subscribe.success'))->assertRedirect(route('login'));
|
||||
$this->get(route('subscribe'))->assertRedirect(route('register'));
|
||||
$this->get(route('subscribe.checkout'))->assertRedirect(route('register'));
|
||||
$this->get(route('subscribe.success'))->assertRedirect(route('register'));
|
||||
});
|
||||
|
||||
test('users without subscription are redirected to paywall when accessing protected routes', function () {
|
||||
|
|
|
|||
|
|
@ -12,10 +12,14 @@ use App\Services\BudgetTransactionService;
|
|||
|
||||
use function Pest\Laravel\actingAs;
|
||||
|
||||
beforeEach(function () {
|
||||
config(['landing.hide_auth_buttons' => false]);
|
||||
});
|
||||
|
||||
test('guests cannot access transactions page', function () {
|
||||
$response = $this->get(route('transactions.index'));
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
$response->assertRedirect(route('register'));
|
||||
});
|
||||
|
||||
test('authenticated users can access transactions page', function () {
|
||||
|
|
@ -76,7 +80,7 @@ test('authenticated users can access categorize transactions page', function ()
|
|||
test('guests cannot access categorize transactions page', function () {
|
||||
$response = $this->get(route('transactions.categorize'));
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
$response->assertRedirect(route('register'));
|
||||
});
|
||||
|
||||
test('users can update their own transaction category', function () {
|
||||
|
|
@ -765,5 +769,5 @@ test('categorize page does not return transactions from other users', function (
|
|||
test('guests are redirected from categorize page', function () {
|
||||
$response = $this->get(route('transactions.categorize'));
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
$response->assertRedirect(route('register'));
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue