Files
denkfabrik-li bde86c10e4 Make linking a provider re-prove the password
Connecting a provider needed nothing but the session. Anyone holding one
could POST /settings/connected-accounts/google, follow the returned
Inertia::location(), sign in at the provider as *themselves*, and
completeLink() would bind their identity to the victim's account.

SocialAccount says what that row is:

    This row *is* the authorization to sign in as that account.

So it is not a preference -- it is a credential, and one that outlives
every way the victim has of ending the session that created it. It
survives a password change, it survives Auth::logoutOtherDevices(), it
survives invalidating every session. Where a stolen session gives an
attacker access until it is noticed, this gives them an account.

routes/settings.php already makes exactly this argument, twenty lines
down, for the two-factor block and the API token routes:

    a token outlives the session that minted it, so a stolen session must
    not be enough to mint one

The link has that property too, and was the one thing on this screen
without the gate. Now it has it.

The gate goes on `connect`, not on the callback: starting the flow is what
writes the intent the callback completes, and the callback deliberately
sits outside every group so a provider sign-in works without a session.

Not changed, deliberately: `connected-accounts.destroy`. Disconnecting
removes a way in rather than adding one, and destroy() already refuses to
remove the last one ("This is the only way you can sign in. Set a password
first"). Putting it behind password.confirm would fall hardest on the
accounts a provider provisioned -- they hold a Str::password(64) nobody
has ever seen -- and leave them unable to disconnect anything at all.
There is a test pinning that it stays reachable.

Also not changed: the account owner still is not told. SocialLoginController
writes an activity log entry, and that sits behind `staff` +
can:view_actions_log, so a client never sees it. Notifying them is a real
gap and a separate change; this one closes the door rather than adding a
bell to it.

Tests: two that fail against the ungated route -- the redirect, and the
whole attack end to end with a stranger identity never binding. The
existing connect() helper now confirms the password, the way
enableTwoFactor() already did, so the rest of the file keeps exercising
the real gate rather than asserting around it.
2026-08-28 23:59:11 +02:00

276 lines
11 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLog;
use App\Modules\Identity\AuthSource;
use App\Modules\Identity\Social\SocialAccount;
use App\Modules\Identity\Social\SocialGateway;
use App\Modules\Identity\Social\SocialIdentity;
use App\Modules\Identity\Social\SocialProvider;
use App\Modules\Identity\Social\SocialSettings;
use Illuminate\Support\Facades\Password;
use Illuminate\Testing\TestResponse;
use Tests\Support\FakeSocialGateway;
beforeEach(function () {
$this->staff = User::factory()->create();
$settings = SocialSettings::for(SocialProvider::Google);
$settings->forceFill([
'provider' => 'google',
'enabled' => true,
'client_id' => 'id',
'client_secret' => 'secret',
])->save();
});
function connect(User $user, SocialIdentity $identity): TestResponse
{
test()->swap(SocialGateway::class, new FakeSocialGateway($identity));
// Starting the flow is behind password.confirm -- see the route, and
// the test below that pins it.
confirmPassword($user);
test()->actingAs($user)->post(route('connected-accounts.connect', ['provider' => $identity->provider->value]));
return test()->actingAs($user)->get(route('social.callback', ['provider' => $identity->provider->value]));
}
function linkRow(User $user, string $subject, SocialProvider $provider = SocialProvider::Google): SocialAccount
{
return SocialAccount::query()->create([
'user_id' => $user->id,
'provider' => $provider->value,
'provider_user_id' => $subject,
'email' => $user->email,
]);
}
/*
|--------------------------------------------------------------------------
| Connecting
|--------------------------------------------------------------------------
|
| Connecting from here is the safe way to use a provider that cannot
| verify an address: the account is established by the session, so the
| address is never taken on trust at all.
|
*/
test('a signed-in person connects a provider, unverified address and all', function () {
$identity = new SocialIdentity(SocialProvider::Google, 'sub-1', 'anything@elsewhere.test', false, 'Someone');
connect($this->staff, $identity)->assertRedirect(route('connected-accounts.edit'));
expect(SocialAccount::query()->where('user_id', $this->staff->id)->count())->toBe(1)
->and(ActivityLog::query()->where('action', Action::SocialAccountLinked)->exists())->toBeTrue();
});
test('a provider already connected to somebody else is refused', function () {
$other = User::factory()->client()->create();
linkRow($other, 'sub-1');
connect($this->staff, new SocialIdentity(SocialProvider::Google, 'sub-1', 'x@example.test', true, 'X'))
->assertRedirect(route('connected-accounts.edit'));
expect(SocialAccount::query()->where('user_id', $this->staff->id)->exists())->toBeFalse()
->and(SocialAccount::query()->where('user_id', $other->id)->exists())->toBeTrue();
});
test('reconnecting a different account at the same provider replaces the link', function () {
connect($this->staff, new SocialIdentity(SocialProvider::Google, 'sub-1', 'one@example.test', true, 'One'));
connect($this->staff, new SocialIdentity(SocialProvider::Google, 'sub-2', 'two@example.test', true, 'Two'));
$links = SocialAccount::query()->where('user_id', $this->staff->id)->get();
expect($links)->toHaveCount(1)
->and($links->first()->provider_user_id)->toBe('sub-2');
});
/*
|--------------------------------------------------------------------------
| Starting the exchange
|--------------------------------------------------------------------------
|
| Connecting starts as an Inertia XHR, and an XHR cannot follow a 302 to
| the provider: the browser refuses the cross-origin hop and nobody goes
| anywhere. Inertia's 409 + X-Inertia-Location pair is what turns the
| same answer into a real top-level navigation.
|
*/
test('connecting from the settings screen navigates the browser, not the XHR', function () {
test()->swap(SocialGateway::class, new FakeSocialGateway);
confirmPassword($this->staff);
$this->actingAs($this->staff)
->post(route('connected-accounts.connect', ['provider' => 'google']), [], ['X-Inertia' => 'true'])
->assertStatus(409)
->assertHeader('X-Inertia-Location', 'https://provider.test/authorize');
});
test('a plain request is still given the provider redirect itself', function () {
test()->swap(SocialGateway::class, new FakeSocialGateway);
confirmPassword($this->staff);
$this->actingAs($this->staff)
->post(route('connected-accounts.connect', ['provider' => 'google']))
->assertRedirect('https://provider.test/authorize');
});
/*
|--------------------------------------------------------------------------
| Disconnecting
|--------------------------------------------------------------------------
*/
test('a local account can disconnect its only provider', function () {
linkRow($this->staff, 'sub-1');
$this->actingAs($this->staff)
->delete(route('connected-accounts.destroy', ['provider' => 'google']))
->assertSessionHasNoErrors();
expect(SocialAccount::query()->count())->toBe(0)
->and(ActivityLog::query()->where('action', Action::SocialAccountUnlinked)->exists())->toBeTrue();
});
// The mirror image of AccountConversion::requiresNewPassword(): an account
// created by a provider holds a random password nobody has ever seen, so
// the provider is the only way in.
test('an account created by a provider cannot remove its last connection', function () {
$client = User::factory()->client()->create();
$client->forceFill(['auth_source' => AuthSource::Social])->save();
linkRow($client, 'sub-1');
$this->actingAs($client)
->delete(route('connected-accounts.destroy', ['provider' => 'google']))
->assertSessionHasErrors('provider');
expect(SocialAccount::query()->count())->toBe(1);
});
// And the instruction inside that refusal, followed. AuthSource says a
// social account "may later set a real password"; a reset by emailed token
// is where somebody does, and nothing else in the application records it.
test('a provider account that resets its password can then disconnect', function () {
$client = User::factory()->client()->create();
$client->forceFill(['auth_source' => AuthSource::Social])->save();
linkRow($client, 'sub-1');
$this->post(route('password.store'), [
'token' => Password::createToken($client),
'email' => $client->email,
'password' => 'a-password-of-her-own',
'password_confirmation' => 'a-password-of-her-own',
])->assertSessionHasNoErrors();
expect($client->fresh()->auth_source)->toBe(AuthSource::Local);
$this->actingAs($client->fresh())
->delete(route('connected-accounts.destroy', ['provider' => 'google']))
->assertSessionHasNoErrors();
expect(SocialAccount::query()->count())->toBe(0);
});
test('it can remove one of two connections', function () {
$client = User::factory()->client()->create();
$client->forceFill(['auth_source' => AuthSource::Social])->save();
linkRow($client, 'sub-1');
linkRow($client, 'gh-1', SocialProvider::Github);
$this->actingAs($client)
->delete(route('connected-accounts.destroy', ['provider' => 'google']))
->assertSessionHasNoErrors();
expect(SocialAccount::query()->where('user_id', $client->id)->count())->toBe(1);
});
test('one account cannot disconnect another account link', function () {
$other = User::factory()->client()->create();
linkRow($other, 'sub-1');
$this->actingAs($this->staff)->delete(route('connected-accounts.destroy', ['provider' => 'google']));
expect(SocialAccount::query()->where('user_id', $other->id)->exists())->toBeTrue();
});
/*
|--------------------------------------------------------------------------
| The screen
|--------------------------------------------------------------------------
*/
test('the screen is reachable by clients as well as staff', function () {
$client = User::factory()->client()->create();
$this->actingAs($client)->get('/settings/connected-accounts')->assertOk();
$this->actingAs($this->staff)->get('/settings/connected-accounts')->assertOk();
});
test('a guest is sent to the login page', function () {
$this->get('/settings/connected-accounts')->assertRedirect(route('login'));
});
test('only usable providers are listed', function () {
$this->actingAs($this->staff)->get('/settings/connected-accounts')
->assertInertia(fn ($page) => $page->has('providers', 1)->where('providers.0.provider', 'google'));
});
// A SocialAccount row "*is* the authorization to sign in as that account",
// so binding one is exactly the kind of thing a stolen session must not be
// enough to do -- the same argument routes/settings.php already makes for
// the two-factor block and for minting an API token.
test('connecting a provider requires a fresh password confirmation', function () {
$this->withSession(['auth.password_confirmed_at' => null]);
$this->actingAs($this->staff)
->post(route('connected-accounts.connect', ['provider' => 'google']))
->assertRedirect(route('password.confirm'));
expect(SocialAccount::query()->where('user_id', $this->staff->id)->exists())->toBeFalse();
});
test('a session that never proved the password cannot bind a stranger identity', function () {
// The whole attack in one test: the flow is never started, so the
// callback has no intent in the session to complete, and nothing is
// bound to the victim's account.
$attacker = new SocialIdentity(SocialProvider::Google, 'attacker-sub', 'attacker@evil.test', true, 'Attacker');
$this->swap(SocialGateway::class, new FakeSocialGateway($attacker));
$this->withSession(['auth.password_confirmed_at' => null]);
$this->actingAs($this->staff)
->post(route('connected-accounts.connect', ['provider' => 'google']))
->assertRedirect(route('password.confirm'));
$this->actingAs($this->staff)->get(route('social.callback', ['provider' => 'google']));
expect(SocialAccount::query()
->where('user_id', $this->staff->id)
->where('provider_user_id', 'attacker-sub')
->exists())->toBeFalse();
});
test('disconnecting stays reachable without one', function () {
// Deliberately outside the gate: it removes a way in rather than
// adding one, and destroy() already refuses to remove the last. An
// account provisioned by a provider has no password to confirm with,
// so requiring one here would strand exactly them.
linkRow($this->staff, 'google-sub');
$this->withSession(['auth.password_confirmed_at' => null]);
$this->actingAs($this->staff)
->delete(route('connected-accounts.destroy', ['provider' => 'google']))
->assertRedirect()
->assertSessionHasNoErrors();
expect(SocialAccount::query()->where('user_id', $this->staff->id)->exists())->toBeFalse();
});