fix(auth): validate invitation magic link tokens (#10651)

This commit is contained in:
Andras Bacsai 2026-07-03 09:38:48 +02:00 committed by GitHub
commit bf37db3b5f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 145 additions and 26 deletions

View file

@ -98,7 +98,7 @@ public function forgot_password(Request $request)
public function link()
{
$token = request()->get('token');
if ($token) {
if (is_string($token) && $token !== '') {
try {
$decrypted = Crypt::decryptString($token);
} catch (DecryptException) {
@ -126,9 +126,8 @@ public function link()
$invitation = TeamInvitation::query()
->where('email', $email)
->when($invitationUuid, fn ($query) => $query->where('uuid', $invitationUuid))
->where('link', request()->fullUrl())
->first();
if (! $invitation || ! $invitation->isValid()) {
if (! $invitation || ! $this->invitationLinkMatchesToken($invitation, $token) || ! $invitation->isValid()) {
return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.');
}
@ -139,10 +138,11 @@ public function link()
}
$invitation->delete();
Auth::login($user);
$user->forceFill([
'password' => Hash::make(Str::random(64)),
])->save();
Auth::login($user);
session(['currentTeam' => $team]);
return redirect()->route('dashboard');
@ -152,6 +152,19 @@ public function link()
return redirect()->route('login')->with('error', 'Invalid credentials.');
}
private function invitationLinkMatchesToken(TeamInvitation $invitation, string $token): bool
{
$query = parse_url($invitation->link, PHP_URL_QUERY);
if (! is_string($query)) {
return false;
}
parse_str($query, $parameters);
$storedToken = $parameters['token'] ?? null;
return is_string($storedToken) && hash_equals($storedToken, $token);
}
public function showInvitation()
{
$invitationUuid = request()->route('uuid');

View file

@ -39,6 +39,16 @@ public function viaLink()
$this->generateInviteLink(sendEmail: false);
}
private function invitationUrl(string $routeName, array $parameters): string
{
$fqdn = instanceSettings()->fqdn;
if (filled($fqdn)) {
return rtrim($fqdn, '/').route($routeName, $parameters, false);
}
return route($routeName, $parameters);
}
private function generateInviteLink(bool $sendEmail = false)
{
try {
@ -61,7 +71,7 @@ private function generateInviteLink(bool $sendEmail = false)
return handleError(livewire: $this, customErrorMessage: "$this->email is already a member of ".currentTeam()->name.'.');
}
$uuid = new_public_id(32);
$link = url('/').config('constants.invitation.link.base_url').$uuid;
$link = $this->invitationUrl('team.invitation.show', ['uuid' => $uuid]);
$user = User::whereEmail($this->email)->first();
if (is_null($user)) {
@ -73,7 +83,7 @@ private function generateInviteLink(bool $sendEmail = false)
'force_password_reset' => true,
]);
$token = Crypt::encryptString("{$user->email}@@@{$uuid}@@@{$password}");
$link = route('auth.link', ['token' => $token]);
$link = $this->invitationUrl('auth.link', ['token' => $token]);
}
$invitation = TeamInvitation::whereEmail($this->email)->first();
if (! is_null($invitation)) {

View file

@ -86,7 +86,6 @@
'invitation' => [
'link' => [
'base_url' => '/invitations/',
'expiration_days' => 3,
],
],

View file

@ -1,20 +1,22 @@
@can('manageInvitations', currentTeam())
<form wire:submit='viaLink' class="flex gap-2 flex-col lg:flex-row items-end">
<div class="flex flex-1 lg:w-fit w-full gap-2">
<x-forms.input id="email" type="email" label="Email" name="email" placeholder="Email" required />
<x-forms.select id="role" name="role" label="Role">
@if (auth()->user()->role() === 'owner')
<option value="owner">Owner</option>
<div>
@can('manageInvitations', currentTeam())
<form wire:submit='viaLink' class="flex gap-2 flex-col lg:flex-row items-end">
<div class="flex flex-1 lg:w-fit w-full gap-2">
<x-forms.input id="email" type="email" label="Email" name="email" placeholder="Email" required />
<x-forms.select id="role" name="role" label="Role">
@if (auth()->user()->role() === 'owner')
<option value="owner">Owner</option>
@endif
<option value="admin">Admin</option>
<option value="member">Member</option>
</x-forms.select>
</div>
<div class="flex gap-2 lg:w-fit w-full">
<x-forms.button type="submit">Generate Invitation Link</x-forms.button>
@if (is_transactional_emails_enabled())
<x-forms.button wire:click.prevent='viaEmail'>Send Invitation via Email</x-forms.button>
@endif
<option value="admin">Admin</option>
<option value="member">Member</option>
</x-forms.select>
</div>
<div class="flex gap-2 lg:w-fit w-full">
<x-forms.button type="submit">Generate Invitation Link</x-forms.button>
@if (is_transactional_emails_enabled())
<x-forms.button wire:click.prevent='viaEmail'>Send Invitation via Email</x-forms.button>
@endif
</div>
</form>
@endcan
</div>
</form>
@endcan
</div>

View file

@ -9,6 +9,7 @@
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Once;
use Visus\Cuid2\Cuid2;
@ -77,6 +78,55 @@ function createInvitationLinkFixture(array $invitationAttributes = []): array
$this->assertGuest();
});
it('accepts a magic link when opened from a different public origin', function () {
[$team, $user, $password, $token] = createInvitationLinkFixture();
$this->get('https://coolify.example.com/auth/link?token='.urlencode($token))
->assertRedirect(route('dashboard'));
$this->assertAuthenticatedAs($user);
$this->assertDatabaseMissing('team_invitations', ['email' => $user->email]);
expect($user->teams()->where('team_id', $team->id)->exists())->toBeTrue();
$user->refresh();
expect(Hash::check($password, $user->password))->toBeFalse();
});
it('keeps the invited user authenticated after rotating the temporary password with database sessions', function () {
$this->withMiddleware([CheckForcePasswordReset::class, DecideWhatToDoWithUser::class]);
Config::set('session.driver', 'database');
[$team, $user, $password, $token] = createInvitationLinkFixture();
$this->get(route('auth.link', ['token' => $token]))
->assertRedirect(route('dashboard'));
expect(DB::table('sessions')->where('user_id', $user->id)->exists())->toBeTrue();
$this->get(route('dashboard'))
->assertRedirect(route('auth.force-password-reset'));
$this->assertAuthenticatedAs($user);
expect($user->teams()->where('team_id', $team->id)->exists())->toBeTrue();
$user->refresh();
expect(Hash::check($password, $user->password))->toBeFalse();
});
it('rejects a magic link when the stored invitation token differs', function () {
[, $user, , $token, $invitation] = createInvitationLinkFixture();
$differentToken = Crypt::encryptString("{$user->email}@@@{$invitation->uuid}@@@different-password");
$invitation->forceFill([
'link' => route('auth.link', ['token' => $differentToken]),
])->save();
$this->get(route('auth.link', ['token' => $token]))
->assertRedirect(route('login'));
$this->assertGuest();
});
it('rejects a magic link when the invitation was revoked', function () {
[, $user, , $token, $invitation] = createInvitationLinkFixture();
$invitation->delete();

View file

@ -1,7 +1,9 @@
<?php
use App\Livewire\Team\InviteLink;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\TeamInvitation;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
@ -9,6 +11,8 @@
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['fqdn' => null]));
// Create a team with owner, admin, and member
$this->team = Team::factory()->create();
@ -161,6 +165,47 @@
]);
});
test('new user invitation magic link uses instance fqdn when configured', function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(
['id' => 0],
['fqdn' => 'https://coolify.example.com']
));
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
Livewire::test(InviteLink::class)
->set('email', 'fqdn-invitee@example.com')
->set('role', 'member')
->call('viaLink')
->assertDispatched('success');
$invitation = TeamInvitation::whereEmail('fqdn-invitee@example.com')->firstOrFail();
expect($invitation->link)->toStartWith('https://coolify.example.com/auth/link?token=');
});
test('new user invitation magic link falls back to route url when instance fqdn is not configured', function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(
['id' => 0],
['fqdn' => null]
));
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
Livewire::test(InviteLink::class)
->set('email', 'fallback-invitee@example.com')
->set('role', 'member')
->call('viaLink')
->assertDispatched('success');
$invitation = TeamInvitation::whereEmail('fallback-invitee@example.com')->firstOrFail();
$expectedPrefix = route('auth.link', ['token' => '']);
expect($invitation->link)->toStartWith($expectedPrefix);
});
test('member cannot bypass policy by calling viaEmail', function () {
// Login as member
$this->actingAs($this->member);