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:
parent
40d570f195
commit
cd06e10b1b
4 changed files with 45 additions and 10 deletions
|
|
@ -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.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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]),
|
||||
|
|
|
|||
Loading…
Reference in a new issue