mirror of
https://github.com/coollabsio/coolify.git
synced 2026-08-24 02:24:11 -05:00
Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -8,12 +8,15 @@ use App\Models\User;
|
||||
use App\Providers\RouteServiceProvider;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Contracts\Encryption\DecryptException;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -95,61 +98,105 @@ class Controller extends BaseController
|
||||
return response()->json(['message' => 'Transactional emails are not active'], 400);
|
||||
}
|
||||
|
||||
public function link()
|
||||
public function link(): View|RedirectResponse
|
||||
{
|
||||
$token = request()->get('token');
|
||||
if (is_string($token) && $token !== '') {
|
||||
try {
|
||||
$decrypted = Crypt::decryptString($token);
|
||||
} catch (DecryptException) {
|
||||
return redirect()->route('login')->with('error', 'Invalid credentials.');
|
||||
}
|
||||
|
||||
if (! str_contains($decrypted, '@@@')) {
|
||||
return redirect()->route('login')->with('error', 'Invalid credentials.');
|
||||
}
|
||||
|
||||
$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::query()
|
||||
->where('email', $email)
|
||||
->when($invitationUuid, fn ($query) => $query->where('uuid', $invitationUuid))
|
||||
->first();
|
||||
if (! $invitation || ! $this->invitationLinkMatchesToken($invitation, $token) || ! $invitation->isValid()) {
|
||||
return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.');
|
||||
}
|
||||
|
||||
if (Hash::check($password, $user->password)) {
|
||||
$team = $invitation->team;
|
||||
if (! $user->teams()->where('team_id', $team->id)->exists()) {
|
||||
$user->teams()->attach($team->id, ['role' => $invitation->role]);
|
||||
}
|
||||
$invitation->delete();
|
||||
|
||||
$user->forceFill([
|
||||
'password' => Hash::make(Str::random(64)),
|
||||
])->save();
|
||||
|
||||
Auth::login($user);
|
||||
session(['currentTeam' => $team]);
|
||||
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
$credentials = is_string($token) ? $this->magicLinkCredentials($token) : null;
|
||||
if (! $credentials) {
|
||||
return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.');
|
||||
}
|
||||
|
||||
return redirect()->route('login')->with('error', 'Invalid credentials.');
|
||||
[$user, $invitation] = $credentials;
|
||||
|
||||
return view('invitation.accept', [
|
||||
'invitation' => $invitation,
|
||||
'team' => $invitation->team,
|
||||
'alreadyMember' => $user->teams()->where('team_id', $invitation->team_id)->exists(),
|
||||
'formAction' => route('auth.link.accept'),
|
||||
'token' => $token,
|
||||
]);
|
||||
}
|
||||
|
||||
public function acceptLink(Request $request): RedirectResponse
|
||||
{
|
||||
$token = $request->input('token');
|
||||
if (! is_string($token)) {
|
||||
return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.');
|
||||
}
|
||||
|
||||
$acceptedInvitation = DB::transaction(function () use ($token) {
|
||||
$credentials = $this->magicLinkCredentials($token, lockForUpdate: true);
|
||||
if (! $credentials) {
|
||||
return null;
|
||||
}
|
||||
|
||||
[$user, $invitation] = $credentials;
|
||||
$team = $invitation->team;
|
||||
if (! $user->teams()->where('team_id', $team->id)->exists()) {
|
||||
$user->teams()->attach($team->id, ['role' => $invitation->role]);
|
||||
}
|
||||
|
||||
$user->forceFill([
|
||||
'password' => Hash::make(Str::random(64)),
|
||||
])->save();
|
||||
$invitation->delete();
|
||||
|
||||
return [$user, $team];
|
||||
});
|
||||
|
||||
if (! $acceptedInvitation) {
|
||||
return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.');
|
||||
}
|
||||
|
||||
[$user, $team] = $acceptedInvitation;
|
||||
|
||||
Auth::login($user);
|
||||
session(['currentTeam' => $team]);
|
||||
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: User, 1: TeamInvitation}|null
|
||||
*/
|
||||
private function magicLinkCredentials(string $token, bool $lockForUpdate = false): ?array
|
||||
{
|
||||
if ($token === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$decrypted = Crypt::decryptString($token);
|
||||
} catch (DecryptException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$payload = explode('@@@', $decrypted, 3);
|
||||
if (count($payload) === 3) {
|
||||
[$email, $invitationUuid, $password] = $payload;
|
||||
} elseif (count($payload) === 2) {
|
||||
[$email, $password] = $payload;
|
||||
$invitationUuid = null;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
$email = Str::lower($email);
|
||||
$user = User::query()->where('email', $email)->first();
|
||||
$invitationQuery = TeamInvitation::query()
|
||||
->where('email', $email)
|
||||
->when($lockForUpdate, fn ($query) => $query->lockForUpdate());
|
||||
$invitation = $invitationUuid
|
||||
? $invitationQuery->where('uuid', $invitationUuid)->first()
|
||||
: $invitationQuery->get()->first(
|
||||
fn (TeamInvitation $invitation) => $this->invitationLinkMatchesToken($invitation, $token)
|
||||
);
|
||||
|
||||
if (! $user || ! $invitation || $invitation->hasExpired() || ! $this->invitationLinkMatchesToken($invitation, $token)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Hash::check($password, $user->password) ? [$user, $invitation] : null;
|
||||
}
|
||||
|
||||
private function invitationLinkMatchesToken(TeamInvitation $invitation, string $token): bool
|
||||
@@ -185,6 +232,7 @@ class Controller extends BaseController
|
||||
'invitation' => $invitation,
|
||||
'team' => $invitation->team,
|
||||
'alreadyMember' => $alreadyMember,
|
||||
'formAction' => route('team.invitation.accept', $invitation->uuid),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -33,11 +33,9 @@ class TeamInvitation extends Model
|
||||
return TeamInvitation::whereTeamId(currentTeam()->id);
|
||||
}
|
||||
|
||||
public function isValid()
|
||||
public function isValid(): bool
|
||||
{
|
||||
$createdAt = $this->created_at;
|
||||
$diff = $createdAt->diffInDays(now());
|
||||
if ($diff <= config('constants.invitation.link.expiration_days')) {
|
||||
if (! $this->hasExpired()) {
|
||||
return true;
|
||||
} else {
|
||||
$this->delete();
|
||||
@@ -49,4 +47,9 @@ class TeamInvitation extends Model
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function hasExpired(): bool
|
||||
{
|
||||
return $this->created_at->diffInDays(now()) > config('constants.invitation.link.expiration_days');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +152,13 @@ class FortifyServiceProvider extends ServiceProvider
|
||||
return Limit::perMinute(5)->by($email.'|'.$realIp);
|
||||
});
|
||||
|
||||
RateLimiter::for('magic-link', function (Request $request) {
|
||||
$realIp = $request->server('REMOTE_ADDR') ?? $request->ip();
|
||||
$token = (string) $request->input('token');
|
||||
|
||||
return Limit::perMinute(5)->by(hash('sha256', $token.'|'.$realIp));
|
||||
});
|
||||
|
||||
RateLimiter::for('two-factor', function (Request $request) {
|
||||
return Limit::perMinute(5)->by($request->session()->get('login.id'));
|
||||
});
|
||||
|
||||
@@ -21,8 +21,11 @@
|
||||
<x-auth.alert type="warning">You are already a member of this team. Dismiss the invitation to continue.</x-auth.alert>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('team.invitation.accept', $invitation->uuid) }}">
|
||||
<form method="POST" action="{{ $formAction }}">
|
||||
@csrf
|
||||
@isset($token)
|
||||
<input type="hidden" name="token" value="{{ $token }}">
|
||||
@endisset
|
||||
<x-forms.button class="w-full justify-center" type="submit" isHighlighted>
|
||||
{{ $alreadyMember ? 'Dismiss invitation' : 'Accept invitation' }}
|
||||
</x-forms.button>
|
||||
|
||||
+2
-3
@@ -112,9 +112,8 @@ Route::post('/forgot-password', [Controller::class, 'forgot_password'])->name('p
|
||||
Route::get('/realtime', [Controller::class, 'realtime_test'])->middleware('auth');
|
||||
Route::get('/verify', [Controller::class, 'verify'])->middleware('auth')->name('verify.email');
|
||||
Route::get('/email/verify/{id}/{hash}', [Controller::class, 'email_verify'])->middleware(['auth'])->name('verify.verify');
|
||||
Route::middleware(['throttle:login'])->group(function () {
|
||||
Route::get('/auth/link', [Controller::class, 'link'])->name('auth.link');
|
||||
});
|
||||
Route::get('/auth/link', [Controller::class, 'link'])->name('auth.link');
|
||||
Route::post('/auth/link', [Controller::class, 'acceptLink'])->middleware('throttle:magic-link')->name('auth.link.accept');
|
||||
|
||||
Route::get('/auth/{provider}/redirect', [OauthController::class, 'redirect'])->name('auth.redirect');
|
||||
Route::get('/auth/{provider}/callback', [OauthController::class, 'callback'])->name('auth.callback');
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Middleware\CheckForcePasswordReset;
|
||||
use App\Http\Middleware\DecideWhatToDoWithUser;
|
||||
use App\Models\InstanceSettings;
|
||||
@@ -17,6 +18,7 @@ use Visus\Cuid2\Cuid2;
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->withoutVite();
|
||||
$this->withoutMiddleware([DecideWhatToDoWithUser::class, CheckForcePasswordReset::class]);
|
||||
Once::flush();
|
||||
Config::set('app.maintenance.driver', 'file');
|
||||
@@ -56,10 +58,86 @@ function createInvitationLinkFixture(array $invitationAttributes = []): array
|
||||
return [$team, $user, $password, $token, $invitation];
|
||||
}
|
||||
|
||||
it('accepts a valid magic link invitation only once and rotates the temporary password', function () {
|
||||
it('shows a valid magic link invitation without consuming it', function () {
|
||||
[$team, $user, $password, $token] = createInvitationLinkFixture();
|
||||
|
||||
$this->get(route('auth.link', ['token' => $token]))
|
||||
->assertSuccessful()
|
||||
->assertViewIs('invitation.accept')
|
||||
->assertSee($team->name)
|
||||
->assertSee('Accept invitation');
|
||||
|
||||
$this->assertGuest();
|
||||
$this->assertDatabaseHas('team_invitations', ['email' => $user->email]);
|
||||
expect($user->teams()->where('team_id', $team->id)->exists())->toBeFalse();
|
||||
|
||||
$user->refresh();
|
||||
expect(Hash::check($password, $user->password))->toBeTrue();
|
||||
});
|
||||
|
||||
it('finds the matching invitation for a legacy token when the email has multiple invitations', function () {
|
||||
$team = Team::factory()->create();
|
||||
$user = User::factory()->create([
|
||||
'email' => 'legacy-invitee@example.com',
|
||||
'password' => Hash::make($password = 'temporary-password-123'),
|
||||
]);
|
||||
$legacyToken = Crypt::encryptString("{$user->email}@@@{$password}");
|
||||
|
||||
TeamInvitation::create([
|
||||
'team_id' => Team::factory()->create()->id,
|
||||
'uuid' => (string) new Cuid2(32),
|
||||
'email' => $user->email,
|
||||
'role' => 'member',
|
||||
'link' => route('auth.link', ['token' => Crypt::encryptString("{$user->email}@@@another-password")]),
|
||||
'via' => 'link',
|
||||
]);
|
||||
|
||||
TeamInvitation::create([
|
||||
'team_id' => $team->id,
|
||||
'uuid' => (string) new Cuid2(32),
|
||||
'email' => $user->email,
|
||||
'role' => 'member',
|
||||
'link' => route('auth.link', ['token' => $legacyToken]),
|
||||
'via' => 'link',
|
||||
]);
|
||||
|
||||
$this->get(route('auth.link', ['token' => $legacyToken]))
|
||||
->assertSuccessful()
|
||||
->assertViewHas('team', $team);
|
||||
});
|
||||
|
||||
it('does not count confirmation requests against the acceptance throttle', function () {
|
||||
[, $user, , $token] = createInvitationLinkFixture();
|
||||
|
||||
foreach (range(1, 5) as $attempt) {
|
||||
$this->get(route('auth.link', ['token' => $token]))
|
||||
->assertSuccessful();
|
||||
}
|
||||
|
||||
$this->post(route('auth.link.accept'), ['token' => $token])
|
||||
->assertRedirect(route('dashboard'));
|
||||
|
||||
$this->assertAuthenticatedAs($user);
|
||||
});
|
||||
|
||||
it('throttles acceptance independently for different magic link tokens from the same IP', function () {
|
||||
[, $user, , $token] = createInvitationLinkFixture();
|
||||
|
||||
foreach (range(1, 5) as $attempt) {
|
||||
$this->post(route('auth.link.accept'), ['token' => 'another-token'])
|
||||
->assertRedirect(route('login'));
|
||||
}
|
||||
|
||||
$this->post(route('auth.link.accept'), ['token' => $token])
|
||||
->assertRedirect(route('dashboard'));
|
||||
|
||||
$this->assertAuthenticatedAs($user);
|
||||
});
|
||||
|
||||
it('accepts a valid magic link invitation on post only once and rotates the temporary password', function () {
|
||||
[$team, $user, $password, $token] = createInvitationLinkFixture();
|
||||
|
||||
$this->post(route('auth.link.accept'), ['token' => $token])
|
||||
->assertRedirect(route('dashboard'));
|
||||
|
||||
$this->assertAuthenticatedAs($user);
|
||||
@@ -72,16 +150,37 @@ it('accepts a valid magic link invitation only once and rotates the temporary pa
|
||||
auth()->logout();
|
||||
session()->flush();
|
||||
|
||||
$this->get(route('auth.link', ['token' => $token]))
|
||||
$this->post(route('auth.link.accept'), ['token' => $token])
|
||||
->assertRedirect(route('login'));
|
||||
|
||||
$this->assertGuest();
|
||||
});
|
||||
|
||||
it('rolls back invitation redemption when password rotation fails', function () {
|
||||
[$team, $user, $password, $token, $invitation] = createInvitationLinkFixture();
|
||||
$this->withoutExceptionHandling();
|
||||
|
||||
User::updating(function (User $updatingUser) use ($user) {
|
||||
if ($updatingUser->is($user)) {
|
||||
throw new RuntimeException('Password rotation failed.');
|
||||
}
|
||||
});
|
||||
|
||||
expect(fn () => $this->post(route('auth.link.accept'), ['token' => $token]))
|
||||
->toThrow(RuntimeException::class, 'Password rotation failed.');
|
||||
|
||||
$this->assertDatabaseHas('team_invitations', ['id' => $invitation->id]);
|
||||
expect($user->teams()->where('team_id', $team->id)->exists())->toBeFalse();
|
||||
|
||||
$user->refresh();
|
||||
expect(Hash::check($password, $user->password))->toBeTrue();
|
||||
$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))
|
||||
$this->post('https://coolify.example.com/auth/link', ['token' => $token])
|
||||
->assertRedirect(route('dashboard'));
|
||||
|
||||
$this->assertAuthenticatedAs($user);
|
||||
@@ -98,7 +197,7 @@ it('keeps the invited user authenticated after rotating the temporary password w
|
||||
|
||||
[$team, $user, $password, $token] = createInvitationLinkFixture();
|
||||
|
||||
$this->get(route('auth.link', ['token' => $token]))
|
||||
$this->post(route('auth.link.accept'), ['token' => $token])
|
||||
->assertRedirect(route('dashboard'));
|
||||
|
||||
expect(DB::table('sessions')->where('user_id', $user->id)->exists())->toBeTrue();
|
||||
@@ -170,7 +269,7 @@ it('rejects a magic link when the invitation expired', function () {
|
||||
->assertRedirect(route('login'));
|
||||
|
||||
$this->assertGuest();
|
||||
$this->assertDatabaseMissing('team_invitations', ['id' => $invitation->id]);
|
||||
$this->assertDatabaseHas('team_invitations', ['id' => $invitation->id]);
|
||||
});
|
||||
|
||||
it('rejects a malformed magic link token', function () {
|
||||
@@ -179,3 +278,16 @@ it('rejects a malformed magic link token', function () {
|
||||
|
||||
$this->assertGuest();
|
||||
});
|
||||
|
||||
it('declares the magic link method contracts', function () {
|
||||
$linkReturnType = (new ReflectionMethod(Controller::class, 'link'))->getReturnType();
|
||||
$acceptLinkReturnType = (new ReflectionMethod(Controller::class, 'acceptLink'))->getReturnType();
|
||||
$credentialsMethod = new ReflectionMethod(Controller::class, 'magicLinkCredentials');
|
||||
$isValidReturnType = (new ReflectionMethod(TeamInvitation::class, 'isValid'))->getReturnType();
|
||||
|
||||
expect((string) $linkReturnType)->toContain('Illuminate\\Contracts\\View\\View')
|
||||
->and((string) $linkReturnType)->toContain('Illuminate\\Http\\RedirectResponse')
|
||||
->and((string) $acceptLinkReturnType)->toBe('Illuminate\\Http\\RedirectResponse')
|
||||
->and($credentialsMethod->getDocComment())->toContain('@return array{0: User, 1: TeamInvitation}|null')
|
||||
->and((string) $isValidReturnType)->toBe('bool');
|
||||
});
|
||||
|
||||
@@ -50,7 +50,7 @@ describe('invitation link login', function () {
|
||||
'via' => 'link',
|
||||
]);
|
||||
|
||||
$this->get(route('auth.link', ['token' => $token]));
|
||||
$this->post(route('auth.link.accept'), ['token' => $token]);
|
||||
|
||||
$user->refresh();
|
||||
expect($user->email_verified_at)->toBeNull();
|
||||
@@ -77,7 +77,7 @@ describe('invitation link login', function () {
|
||||
'via' => 'link',
|
||||
]);
|
||||
|
||||
$this->get(route('auth.link', ['token' => $token]))
|
||||
$this->post(route('auth.link.accept'), ['token' => $token])
|
||||
->assertRedirect(route('dashboard'));
|
||||
|
||||
expect(auth()->id())->toBe($user->id);
|
||||
|
||||
Reference in New Issue
Block a user