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.
This commit is contained in:
Andras Bacsai
2026-06-02 12:57:30 +02:00
parent 40d570f195
commit cd06e10b1b
4 changed files with 45 additions and 10 deletions
+13 -2
View File
@@ -109,14 +109,25 @@ class Controller extends BaseController
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.');
}
+2 -2
View File
@@ -61,7 +61,7 @@ class InviteLink extends Component
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 @@ class InviteLink extends Component
'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();
+24 -2
View File
@@ -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 @@ it('rejects a magic link when the invitation was revoked', function () {
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([
@@ -39,10 +39,11 @@ describe('invitation link login', function () {
]);
$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 @@ describe('invitation link login', function () {
]);
$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]),