From cd06e10b1ba049c36252b64de083a63d83d3a479 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 2 Jun 2026 12:57:30 +0200 Subject: [PATCH] fix(auth): bind magic links to their invitation Include the invitation UUID in generated magic link tokens and validate the matching stored invitation link before logging the user in, preventing stale or same-email invitations from being reused. --- app/Http/Controllers/Controller.php | 15 +++++++++-- app/Livewire/Team/InviteLink.php | 4 +-- tests/Feature/InvitationLinkHandlingTest.php | 26 +++++++++++++++++-- .../LinkLoginEmailVerificationTest.php | 10 ++++--- 4 files changed, 45 insertions(+), 10 deletions(-) diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 03f7d6dae..3090538c3 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -109,14 +109,25 @@ public function link() return redirect()->route('login')->with('error', 'Invalid credentials.'); } - [$email, $password] = explode('@@@', $decrypted, 2); + $payload = explode('@@@', $decrypted, 3); + if (count($payload) === 3) { + [$email, $invitationUuid, $password] = $payload; + } else { + [$email, $password] = $payload; + $invitationUuid = null; + } + $email = Str::lower($email); $user = User::whereEmail($email)->first(); if (! $user) { return redirect()->route('login'); } - $invitation = TeamInvitation::whereEmail($email)->first(); + $invitation = TeamInvitation::query() + ->where('email', $email) + ->when($invitationUuid, fn ($query) => $query->where('uuid', $invitationUuid)) + ->where('link', request()->fullUrl()) + ->first(); if (! $invitation || ! $invitation->isValid()) { return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.'); } diff --git a/app/Livewire/Team/InviteLink.php b/app/Livewire/Team/InviteLink.php index ee6d535e9..fb30961e9 100644 --- a/app/Livewire/Team/InviteLink.php +++ b/app/Livewire/Team/InviteLink.php @@ -61,7 +61,7 @@ private function generateInviteLink(bool $sendEmail = false) if ($member_emails->contains($this->email)) { return handleError(livewire: $this, customErrorMessage: "$this->email is already a member of ".currentTeam()->name.'.'); } - $uuid = new Cuid2(32); + $uuid = (string) new Cuid2(32); $link = url('/').config('constants.invitation.link.base_url').$uuid; $user = User::whereEmail($this->email)->first(); @@ -73,7 +73,7 @@ private function generateInviteLink(bool $sendEmail = false) 'password' => Hash::make($password), 'force_password_reset' => true, ]); - $token = Crypt::encryptString("{$user->email}@@@$password"); + $token = Crypt::encryptString("{$user->email}@@@{$uuid}@@@{$password}"); $link = route('auth.link', ['token' => $token]); } $invitation = TeamInvitation::whereEmail($this->email)->first(); diff --git a/tests/Feature/InvitationLinkHandlingTest.php b/tests/Feature/InvitationLinkHandlingTest.php index 4668a73b6..e45207cc5 100644 --- a/tests/Feature/InvitationLinkHandlingTest.php +++ b/tests/Feature/InvitationLinkHandlingTest.php @@ -39,12 +39,13 @@ function createInvitationLinkFixture(array $invitationAttributes = []): array 'force_password_reset' => true, 'email_verified_at' => null, ]); - $token = Crypt::encryptString("{$user->email}@@@{$password}"); + $uuid = (string) new Cuid2(32); + $token = Crypt::encryptString("{$user->email}@@@{$uuid}@@@{$password}"); $link = route('auth.link', ['token' => $token]); $invitation = TeamInvitation::create(array_merge([ 'team_id' => $team->id, - 'uuid' => (string) new Cuid2(32), + 'uuid' => $uuid, 'email' => $user->email, 'role' => 'member', 'link' => $link, @@ -87,6 +88,27 @@ function createInvitationLinkFixture(array $invitationAttributes = []): array expect($user->teams()->where('personal_team', false)->exists())->toBeFalse(); }); +it('rejects a magic link when another invitation exists for the same email', function () { + [, $user, , $token, $invitation] = createInvitationLinkFixture(); + $invitation->delete(); + + $otherTeam = Team::factory()->create(); + TeamInvitation::create([ + 'team_id' => $otherTeam->id, + 'uuid' => (string) new Cuid2(32), + 'email' => $user->email, + 'role' => 'admin', + 'link' => url('/invitations/other-invitation'), + 'via' => 'link', + ]); + + $this->get(route('auth.link', ['token' => $token])) + ->assertRedirect(route('login')); + + $this->assertGuest(); + expect($user->teams()->where('team_id', $otherTeam->id)->exists())->toBeFalse(); +}); + it('rejects a magic link when the invitation expired', function () { [, $user, , $token, $invitation] = createInvitationLinkFixture(); $invitation->forceFill([ diff --git a/tests/Feature/LinkLoginEmailVerificationTest.php b/tests/Feature/LinkLoginEmailVerificationTest.php index 1e3e32f6d..39ade93eb 100644 --- a/tests/Feature/LinkLoginEmailVerificationTest.php +++ b/tests/Feature/LinkLoginEmailVerificationTest.php @@ -39,10 +39,11 @@ ]); $user->teams()->attach($team->id, ['role' => 'member']); - $token = Crypt::encryptString("{$user->email}@@@{$password}"); + $uuid = 'email-verification-test-invitation'; + $token = Crypt::encryptString("{$user->email}@@@{$uuid}@@@{$password}"); TeamInvitation::create([ 'team_id' => $team->id, - 'uuid' => 'email-verification-test-invitation', + 'uuid' => $uuid, 'email' => $user->email, 'role' => 'member', 'link' => route('auth.link', ['token' => $token]), @@ -65,10 +66,11 @@ ]); $user->teams()->attach($team->id, ['role' => 'member']); - $token = Crypt::encryptString("{$user->email}@@@{$password}"); + $uuid = 'email-verification-login-test-invitation'; + $token = Crypt::encryptString("{$user->email}@@@{$uuid}@@@{$password}"); TeamInvitation::create([ 'team_id' => $team->id, - 'uuid' => 'email-verification-login-test-invitation', + 'uuid' => $uuid, 'email' => $user->email, 'role' => 'member', 'link' => route('auth.link', ['token' => $token]),