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]),