fix(auth): validate invitation magic link tokens (#10651)
This commit is contained in:
commit
bf37db3b5f
6 changed files with 145 additions and 26 deletions
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -86,7 +86,6 @@
|
|||
|
||||
'invitation' => [
|
||||
'link' => [
|
||||
'base_url' => '/invitations/',
|
||||
'expiration_days' => 3,
|
||||
],
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue