Skip to content

Commit d5c7042

Browse files
committed
Merge remote-tracking branch 'origin/next' into 10525-directory-file-conversion
2 parents b361510 + bf37db3 commit d5c7042

6 files changed

Lines changed: 145 additions & 26 deletions

File tree

app/Http/Controllers/Controller.php

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ public function forgot_password(Request $request)
9898
public function link()
9999
{
100100
$token = request()->get('token');
101-
if ($token) {
101+
if (is_string($token) && $token !== '') {
102102
try {
103103
$decrypted = Crypt::decryptString($token);
104104
} catch (DecryptException) {
@@ -126,9 +126,8 @@ public function link()
126126
$invitation = TeamInvitation::query()
127127
->where('email', $email)
128128
->when($invitationUuid, fn ($query) => $query->where('uuid', $invitationUuid))
129-
->where('link', request()->fullUrl())
130129
->first();
131-
if (! $invitation || ! $invitation->isValid()) {
130+
if (! $invitation || ! $this->invitationLinkMatchesToken($invitation, $token) || ! $invitation->isValid()) {
132131
return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.');
133132
}
134133

@@ -139,10 +138,11 @@ public function link()
139138
}
140139
$invitation->delete();
141140

142-
Auth::login($user);
143141
$user->forceFill([
144142
'password' => Hash::make(Str::random(64)),
145143
])->save();
144+
145+
Auth::login($user);
146146
session(['currentTeam' => $team]);
147147

148148
return redirect()->route('dashboard');
@@ -152,6 +152,19 @@ public function link()
152152
return redirect()->route('login')->with('error', 'Invalid credentials.');
153153
}
154154

155+
private function invitationLinkMatchesToken(TeamInvitation $invitation, string $token): bool
156+
{
157+
$query = parse_url($invitation->link, PHP_URL_QUERY);
158+
if (! is_string($query)) {
159+
return false;
160+
}
161+
162+
parse_str($query, $parameters);
163+
$storedToken = $parameters['token'] ?? null;
164+
165+
return is_string($storedToken) && hash_equals($storedToken, $token);
166+
}
167+
155168
public function showInvitation()
156169
{
157170
$invitationUuid = request()->route('uuid');

app/Livewire/Team/InviteLink.php

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,16 @@ public function viaLink()
3939
$this->generateInviteLink(sendEmail: false);
4040
}
4141

42+
private function invitationUrl(string $routeName, array $parameters): string
43+
{
44+
$fqdn = instanceSettings()->fqdn;
45+
if (filled($fqdn)) {
46+
return rtrim($fqdn, '/').route($routeName, $parameters, false);
47+
}
48+
49+
return route($routeName, $parameters);
50+
}
51+
4252
private function generateInviteLink(bool $sendEmail = false)
4353
{
4454
try {
@@ -61,7 +71,7 @@ private function generateInviteLink(bool $sendEmail = false)
6171
return handleError(livewire: $this, customErrorMessage: "$this->email is already a member of ".currentTeam()->name.'.');
6272
}
6373
$uuid = new_public_id(32);
64-
$link = url('/').config('constants.invitation.link.base_url').$uuid;
74+
$link = $this->invitationUrl('team.invitation.show', ['uuid' => $uuid]);
6575
$user = User::whereEmail($this->email)->first();
6676

6777
if (is_null($user)) {
@@ -73,7 +83,7 @@ private function generateInviteLink(bool $sendEmail = false)
7383
'force_password_reset' => true,
7484
]);
7585
$token = Crypt::encryptString("{$user->email}@@@{$uuid}@@@{$password}");
76-
$link = route('auth.link', ['token' => $token]);
86+
$link = $this->invitationUrl('auth.link', ['token' => $token]);
7787
}
7888
$invitation = TeamInvitation::whereEmail($this->email)->first();
7989
if (! is_null($invitation)) {

config/constants.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,6 @@
8686

8787
'invitation' => [
8888
'link' => [
89-
'base_url' => '/invitations/',
9089
'expiration_days' => 3,
9190
],
9291
],
Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,22 @@
1-
@can('manageInvitations', currentTeam())
2-
<form wire:submit='viaLink' class="flex gap-2 flex-col lg:flex-row items-end">
3-
<div class="flex flex-1 lg:w-fit w-full gap-2">
4-
<x-forms.input id="email" type="email" label="Email" name="email" placeholder="Email" required />
5-
<x-forms.select id="role" name="role" label="Role">
6-
@if (auth()->user()->role() === 'owner')
7-
<option value="owner">Owner</option>
1+
<div>
2+
@can('manageInvitations', currentTeam())
3+
<form wire:submit='viaLink' class="flex gap-2 flex-col lg:flex-row items-end">
4+
<div class="flex flex-1 lg:w-fit w-full gap-2">
5+
<x-forms.input id="email" type="email" label="Email" name="email" placeholder="Email" required />
6+
<x-forms.select id="role" name="role" label="Role">
7+
@if (auth()->user()->role() === 'owner')
8+
<option value="owner">Owner</option>
9+
@endif
10+
<option value="admin">Admin</option>
11+
<option value="member">Member</option>
12+
</x-forms.select>
13+
</div>
14+
<div class="flex gap-2 lg:w-fit w-full">
15+
<x-forms.button type="submit">Generate Invitation Link</x-forms.button>
16+
@if (is_transactional_emails_enabled())
17+
<x-forms.button wire:click.prevent='viaEmail'>Send Invitation via Email</x-forms.button>
818
@endif
9-
<option value="admin">Admin</option>
10-
<option value="member">Member</option>
11-
</x-forms.select>
12-
</div>
13-
<div class="flex gap-2 lg:w-fit w-full">
14-
<x-forms.button type="submit">Generate Invitation Link</x-forms.button>
15-
@if (is_transactional_emails_enabled())
16-
<x-forms.button wire:click.prevent='viaEmail'>Send Invitation via Email</x-forms.button>
17-
@endif
18-
</div>
19-
</form>
20-
@endcan
19+
</div>
20+
</form>
21+
@endcan
22+
</div>

tests/Feature/InvitationLinkHandlingTest.php

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use Illuminate\Foundation\Testing\RefreshDatabase;
1010
use Illuminate\Support\Facades\Config;
1111
use Illuminate\Support\Facades\Crypt;
12+
use Illuminate\Support\Facades\DB;
1213
use Illuminate\Support\Facades\Hash;
1314
use Illuminate\Support\Once;
1415
use Visus\Cuid2\Cuid2;
@@ -77,6 +78,55 @@ function createInvitationLinkFixture(array $invitationAttributes = []): array
7778
$this->assertGuest();
7879
});
7980

81+
it('accepts a magic link when opened from a different public origin', function () {
82+
[$team, $user, $password, $token] = createInvitationLinkFixture();
83+
84+
$this->get('https://coolify.example.com/auth/link?token='.urlencode($token))
85+
->assertRedirect(route('dashboard'));
86+
87+
$this->assertAuthenticatedAs($user);
88+
$this->assertDatabaseMissing('team_invitations', ['email' => $user->email]);
89+
expect($user->teams()->where('team_id', $team->id)->exists())->toBeTrue();
90+
91+
$user->refresh();
92+
expect(Hash::check($password, $user->password))->toBeFalse();
93+
});
94+
95+
it('keeps the invited user authenticated after rotating the temporary password with database sessions', function () {
96+
$this->withMiddleware([CheckForcePasswordReset::class, DecideWhatToDoWithUser::class]);
97+
Config::set('session.driver', 'database');
98+
99+
[$team, $user, $password, $token] = createInvitationLinkFixture();
100+
101+
$this->get(route('auth.link', ['token' => $token]))
102+
->assertRedirect(route('dashboard'));
103+
104+
expect(DB::table('sessions')->where('user_id', $user->id)->exists())->toBeTrue();
105+
106+
$this->get(route('dashboard'))
107+
->assertRedirect(route('auth.force-password-reset'));
108+
109+
$this->assertAuthenticatedAs($user);
110+
expect($user->teams()->where('team_id', $team->id)->exists())->toBeTrue();
111+
112+
$user->refresh();
113+
expect(Hash::check($password, $user->password))->toBeFalse();
114+
});
115+
116+
it('rejects a magic link when the stored invitation token differs', function () {
117+
[, $user, , $token, $invitation] = createInvitationLinkFixture();
118+
$differentToken = Crypt::encryptString("{$user->email}@@@{$invitation->uuid}@@@different-password");
119+
120+
$invitation->forceFill([
121+
'link' => route('auth.link', ['token' => $differentToken]),
122+
])->save();
123+
124+
$this->get(route('auth.link', ['token' => $token]))
125+
->assertRedirect(route('login'));
126+
127+
$this->assertGuest();
128+
});
129+
80130
it('rejects a magic link when the invitation was revoked', function () {
81131
[, $user, , $token, $invitation] = createInvitationLinkFixture();
82132
$invitation->delete();

tests/Feature/Team/TeamInvitationPrivilegeEscalationTest.php

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
<?php
22

33
use App\Livewire\Team\InviteLink;
4+
use App\Models\InstanceSettings;
45
use App\Models\Team;
6+
use App\Models\TeamInvitation;
57
use App\Models\User;
68
use Illuminate\Foundation\Testing\RefreshDatabase;
79
use Livewire\Livewire;
810

911
uses(RefreshDatabase::class);
1012

1113
beforeEach(function () {
14+
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['fqdn' => null]));
15+
1216
// Create a team with owner, admin, and member
1317
$this->team = Team::factory()->create();
1418

@@ -161,6 +165,47 @@
161165
]);
162166
});
163167

168+
test('new user invitation magic link uses instance fqdn when configured', function () {
169+
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(
170+
['id' => 0],
171+
['fqdn' => 'https://coolify.example.com']
172+
));
173+
174+
$this->actingAs($this->owner);
175+
session(['currentTeam' => $this->team]);
176+
177+
Livewire::test(InviteLink::class)
178+
->set('email', 'fqdn-invitee@example.com')
179+
->set('role', 'member')
180+
->call('viaLink')
181+
->assertDispatched('success');
182+
183+
$invitation = TeamInvitation::whereEmail('fqdn-invitee@example.com')->firstOrFail();
184+
185+
expect($invitation->link)->toStartWith('https://coolify.example.com/auth/link?token=');
186+
});
187+
188+
test('new user invitation magic link falls back to route url when instance fqdn is not configured', function () {
189+
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(
190+
['id' => 0],
191+
['fqdn' => null]
192+
));
193+
194+
$this->actingAs($this->owner);
195+
session(['currentTeam' => $this->team]);
196+
197+
Livewire::test(InviteLink::class)
198+
->set('email', 'fallback-invitee@example.com')
199+
->set('role', 'member')
200+
->call('viaLink')
201+
->assertDispatched('success');
202+
203+
$invitation = TeamInvitation::whereEmail('fallback-invitee@example.com')->firstOrFail();
204+
205+
$expectedPrefix = route('auth.link', ['token' => '']);
206+
expect($invitation->link)->toStartWith($expectedPrefix);
207+
});
208+
164209
test('member cannot bypass policy by calling viaEmail', function () {
165210
// Login as member
166211
$this->actingAs($this->member);

0 commit comments

Comments
 (0)