Files
projectsend/tests/Feature/Auth/LdapAuthenticationTest.php
ignacionelson 6e47d76ba6 ProjectSend 2.0.0
Client file sharing, rebuilt from the ground up: a private area per
client, resumable uploads, folders, groups and categories, sharing with
expiry dates and download limits, comments, file versions, an activity
log, a REST API, and sixteen languages.

This repository begins here. ProjectSend 2 was developed privately, and
that development history is not published — the previous generation
remains available, with its own history, at projectsend/legacy.

Free software under the GNU General Public License v2, or (at your
option) any later version.
2026-08-14 01:38:12 -03:00

401 lines
16 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLog;
use App\Modules\Groups\Models\Group;
use App\Modules\Identity\AuthSource;
use App\Modules\Identity\Ldap\LdapAuthenticator;
use App\Modules\Identity\Ldap\LdapDirectory;
use App\Modules\Identity\Ldap\LdapSettings;
use App\Modules\Identity\Permissions\SystemRole;
use App\Modules\Platform\Capabilities\Edition;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Support\Facades\Cache;
use Tests\Support\FakeLdapDirectory;
beforeEach(function () {
// Every HTTP test needs a staff account or EnsureSetupIsComplete
// redirects the whole app to /setup.
User::factory()->create();
});
/**
* @param array<string, array{password: string, name?: string, dn?: string}> $entries
*/
function fakeDirectory(array $entries = []): FakeLdapDirectory
{
$fake = new FakeLdapDirectory($entries);
test()->swap(LdapDirectory::class, $fake);
return $fake;
}
function enableLdap(bool $autoProvision = false, bool $autoApprove = false): LdapSettings
{
$settings = LdapSettings::current();
$settings->forceFill([
'active' => true,
'host' => 'ldap.example.test',
'base_dn' => 'dc=example,dc=test',
'auto_provision' => $autoProvision,
'auto_approve' => $autoApprove,
])->save();
return $settings;
}
/*
|--------------------------------------------------------------------------
| When the directory is not consulted at all
|--------------------------------------------------------------------------
*/
test('a login is unchanged when LDAP is switched off', function () {
$fake = fakeDirectory(['someone@example.test' => ['password' => 'directory-pass']]);
$client = User::factory()->client()->create(['email' => 'someone@example.test']);
$this->post('/login', ['email' => $client->email, 'password' => 'directory-pass'])
->assertSessionHasErrors('email');
expect($fake->calls)->toBe(0);
$this->assertGuest();
});
// A directory should never see a password that already worked locally.
test('a valid local password costs no directory traffic', function () {
enableLdap();
$fake = fakeDirectory();
$client = User::factory()->client()->create();
$this->post('/login', ['email' => $client->email, 'password' => 'password'])->assertRedirect();
$this->assertAuthenticatedAs($client);
expect($fake->calls)->toBe(0);
});
// LDAP is client-only, enforced on the account rather than on a setting,
// so no misconfiguration can make the directory a route to staff authority.
test('a staff account never reaches the directory, even with a matching entry', function () {
enableLdap();
$staff = User::factory()->role(SystemRole::Uploader)->create(['email' => 'boss@example.test']);
$fake = fakeDirectory(['boss@example.test' => ['password' => 'directory-pass']]);
$this->post('/login', ['email' => $staff->email, 'password' => 'directory-pass'])
->assertSessionHasErrors('email');
expect($fake->calls)->toBe(0);
$this->assertGuest();
});
// RFC 4513: a simple bind with an empty password against a valid DN is an
// *unauthenticated* bind and succeeds — the classic LDAP auth bypass. Three
// layers stop it here, and this asserts the outcome they share rather than
// which one fired: no bind is attempted. (In practice the outermost wins —
// TrimStrings collapses " " to "" and `required` rejects it on the
// password field — but LdapAuthenticator trims again, because a route that
// ever opted out of TrimStrings must not silently become an auth bypass.)
test('a whitespace-only password never reaches the directory', function () {
enableLdap();
$client = User::factory()->client()->create(['email' => 'someone@example.test']);
$fake = fakeDirectory(['someone@example.test' => ['password' => ' ']]);
$this->post('/login', ['email' => $client->email, 'password' => ' '])
->assertSessionHasErrors('password');
expect($fake->calls)->toBe(0);
$this->assertGuest();
});
// The guard on its own, with the middleware out of the picture — the layer
// that would matter if the outer one were ever removed.
test('the authenticator itself refuses a whitespace password', function () {
enableLdap();
$client = User::factory()->client()->create(['email' => 'someone@example.test']);
$fake = fakeDirectory(['someone@example.test' => ['password' => ' ']]);
$identity = app(LdapAuthenticator::class)
->attempt('someone@example.test', ' ', $client);
expect($identity)->toBeNull()
->and($fake->calls)->toBe(0);
});
/*
|--------------------------------------------------------------------------
| Authenticating an existing client
|--------------------------------------------------------------------------
*/
test('a client whose local password fails is checked against the directory', function () {
enableLdap();
$client = User::factory()->client()->create(['email' => 'someone@example.test']);
fakeDirectory(['someone@example.test' => ['password' => 'directory-pass']]);
$this->post('/login', ['email' => $client->email, 'password' => 'directory-pass'])
->assertRedirect(route('dashboard', absolute: false));
$this->assertAuthenticatedAs($client);
// The entry it matched is recorded, so an administrator can see which
// directory object the account corresponds to.
expect($client->refresh()->ldap_dn)->toContain('someone@example.test')
->and($client->ldap_synced_at)->not->toBeNull();
});
test('a wrong directory password is refused', function () {
enableLdap();
$client = User::factory()->client()->create(['email' => 'someone@example.test']);
fakeDirectory(['someone@example.test' => ['password' => 'directory-pass']]);
$this->post('/login', ['email' => $client->email, 'password' => 'wrong'])
->assertSessionHasErrors('email');
$this->assertGuest();
});
// An account whose credentials live in the directory has a local hash
// nobody holds, so it must not be consulted.
test('a directory-sourced account skips the local password entirely', function () {
enableLdap();
$client = User::factory()->client()->create(['email' => 'someone@example.test']);
$client->forceFill(['auth_source' => AuthSource::Ldap])->save();
$fake = fakeDirectory();
// 'password' is the factory's local password and would work for a
// local account.
$this->post('/login', ['email' => $client->email, 'password' => 'password'])
->assertSessionHasErrors('email');
expect($fake->calls)->toBe(1);
$this->assertGuest();
});
/*
|--------------------------------------------------------------------------
| Account state still decides
|--------------------------------------------------------------------------
*/
test('a deactivated client with a valid directory password is told so', function () {
enableLdap();
$client = User::factory()->client()->create(['email' => 'someone@example.test', 'active' => false]);
fakeDirectory(['someone@example.test' => ['password' => 'directory-pass']]);
$this->post('/login', ['email' => $client->email, 'password' => 'directory-pass'])
->assertSessionHasErrors(['email' => 'Your account has been deactivated.']);
});
// The state must not leak to somebody without the password — the property
// the phase ordering exists to preserve.
test('a deactivated client with a wrong directory password gets the generic failure', function () {
enableLdap();
$client = User::factory()->client()->create(['email' => 'someone@example.test', 'active' => false]);
fakeDirectory(['someone@example.test' => ['password' => 'directory-pass']]);
$response = $this->post('/login', ['email' => $client->email, 'password' => 'wrong']);
expect(session('errors')->first('email'))->not->toBe('Your account has been deactivated.');
});
test('two-factor still challenges a directory-authenticated client', function () {
enableLdap();
$client = User::factory()->client()->create(['email' => 'someone@example.test']);
$client->forceFill([
'two_factor_secret' => 'secret',
'two_factor_confirmed_at' => now(),
])->save();
fakeDirectory(['someone@example.test' => ['password' => 'directory-pass']]);
$this->post('/login', ['email' => $client->email, 'password' => 'directory-pass'])
->assertRedirect(route('two-factor.challenge'));
$this->assertGuest();
expect(session('two_factor.login_id'))->toBe($client->id);
});
// Every failure, from any credential source, funnels through one refusal —
// the property most likely to be lost by bolting LDAP onto the end.
test('failed directory attempts are rate limited like any other', function () {
enableLdap();
$client = User::factory()->client()->create(['email' => 'someone@example.test']);
fakeDirectory(['someone@example.test' => ['password' => 'directory-pass']]);
foreach (range(1, 5) as $ignored) {
$this->post('/login', ['email' => $client->email, 'password' => 'wrong']);
}
$this->post('/login', ['email' => $client->email, 'password' => 'wrong'])
->assertSessionHasErrors('email');
expect(session('errors')->first('email'))->toContain('Too many login attempts');
});
/*
|--------------------------------------------------------------------------
| Provisioning
|--------------------------------------------------------------------------
*/
test('an unknown directory identity is refused when auto-provisioning is off', function () {
enableLdap(autoProvision: false);
fakeDirectory(['newcomer@example.test' => ['password' => 'directory-pass']]);
$this->post('/login', ['email' => 'newcomer@example.test', 'password' => 'directory-pass'])
->assertSessionHasErrors('email');
expect(User::query()->where('email', 'newcomer@example.test')->exists())->toBeFalse();
});
test('auto-provisioning creates a client, never staff, and signs them in', function () {
enableLdap(autoProvision: true, autoApprove: true);
fakeDirectory(['newcomer@example.test' => ['password' => 'directory-pass', 'name' => 'New Comer']]);
$this->post('/login', ['email' => 'newcomer@example.test', 'password' => 'directory-pass'])
->assertRedirect(route('dashboard', absolute: false));
$created = User::query()->where('email', 'newcomer@example.test')->sole();
expect($created->isClient())->toBeTrue()
->and($created->name)->toBe('New Comer')
->and($created->active)->toBeTrue()
->and($created->auth_source)->toBe(AuthSource::Ldap)
->and($created->ldap_dn)->not->toBeNull();
$this->assertAuthenticatedAs($created);
expect(ActivityLog::query()->where('action', Action::LdapClientProvisioned)->exists())->toBeTrue();
});
// The directory proves who you are; the installation still decides whether
// it wants you. This falls out of the phase ordering with no special case.
test('auto-provisioning honours the directory approval setting', function () {
enableLdap(autoProvision: true, autoApprove: false);
fakeDirectory(['newcomer@example.test' => ['password' => 'directory-pass']]);
$this->post('/login', ['email' => 'newcomer@example.test', 'password' => 'directory-pass'])
->assertSessionHasErrors(['email' => 'Your account request has not been approved yet.']);
$created = User::query()->where('email', 'newcomer@example.test')->sole();
expect($created->active)->toBeFalse()
->and($created->account_requested)->toBeTrue();
$this->assertGuest();
});
// The two approval settings answer different questions — strangers at a
// public form versus people the directory has already authenticated — so
// neither may quietly follow the other. Both are set explicitly here: the
// Settings cache outlives a RefreshDatabase rollback, so an assertion about
// a "default" value is not trustworthy.
test('directory accounts are approved on their own setting, not the registration one', function () {
enableLdap(autoProvision: true, autoApprove: true);
app(Settings::class)->set(Setting::ClientsAutoApprove, false);
fakeDirectory(['newcomer@example.test' => ['password' => 'directory-pass']]);
$this->post('/login', ['email' => 'newcomer@example.test', 'password' => 'directory-pass'])
->assertRedirect(route('dashboard', absolute: false));
$created = User::query()->where('email', 'newcomer@example.test')->sole();
expect($created->active)->toBeTrue()
->and($created->account_requested)->toBeFalse();
});
test('directory accounts can wait for approval while registrations do not', function () {
enableLdap(autoProvision: true, autoApprove: false);
app(Settings::class)->set(Setting::ClientsAutoApprove, true);
fakeDirectory(['newcomer@example.test' => ['password' => 'directory-pass']]);
$this->post('/login', ['email' => 'newcomer@example.test', 'password' => 'directory-pass'])
->assertSessionHasErrors(['email' => 'Your account request has not been approved yet.']);
$created = User::query()->where('email', 'newcomer@example.test')->sole();
expect($created->active)->toBeFalse()
->and($created->account_requested)->toBeTrue();
$this->assertGuest();
});
test('auto-provisioning honours the auto-join group', function () {
enableLdap(autoProvision: true, autoApprove: true);
$group = Group::query()->create(['name' => 'Directory folk']);
app(Settings::class)->set(Setting::ClientsAutoGroup, $group->id);
fakeDirectory(['newcomer@example.test' => ['password' => 'directory-pass']]);
$this->post('/login', ['email' => 'newcomer@example.test', 'password' => 'directory-pass']);
expect($group->fresh()->members)->toHaveCount(1);
});
// Closing the public registration form must not silently break directory
// sign-in — they have separate switches on purpose.
test('provisioning does not depend on the public registration setting', function () {
enableLdap(autoProvision: true, autoApprove: true);
app(Settings::class)->set(Setting::ClientsCanRegister, false);
fakeDirectory(['newcomer@example.test' => ['password' => 'directory-pass']]);
$this->post('/login', ['email' => 'newcomer@example.test', 'password' => 'directory-pass'])
->assertRedirect(route('dashboard', absolute: false));
expect(User::query()->where('email', 'newcomer@example.test')->exists())->toBeTrue();
});
/*
|--------------------------------------------------------------------------
| Editions
|--------------------------------------------------------------------------
*/
// LDAP is an admin setting, not an edition difference — it works in both.
test('the directory is consulted in the cloud edition too', function () {
config()->set('projectsend.edition', Edition::Cloud);
enableLdap();
$client = User::factory()->client()->create(['email' => 'someone@example.test']);
fakeDirectory(['someone@example.test' => ['password' => 'directory-pass']]);
$this->post('/login', ['email' => $client->email, 'password' => 'directory-pass'])
->assertRedirect(route('dashboard', absolute: false));
$this->assertAuthenticatedAs($client);
});
/*
|--------------------------------------------------------------------------
| Availability
|--------------------------------------------------------------------------
*/
// An unreachable directory must not become an outage on the login page for
// everyone, including local accounts.
test('the circuit breaker skips the directory while it is open', function () {
enableLdap();
$client = User::factory()->client()->create(['email' => 'someone@example.test']);
$fake = fakeDirectory(['someone@example.test' => ['password' => 'directory-pass']]);
Cache::put('identity.ldap.unreachable', true, 60);
$this->post('/login', ['email' => $client->email, 'password' => 'directory-pass'])
->assertSessionHasErrors('email');
expect($fake->calls)->toBe(0);
});
test('settings with no host are treated as switched off', function () {
$settings = LdapSettings::current();
$settings->forceFill(['active' => true, 'host' => null, 'base_dn' => null])->save();
$fake = fakeDirectory(['someone@example.test' => ['password' => 'directory-pass']]);
User::factory()->client()->create(['email' => 'someone@example.test']);
$this->post('/login', ['email' => 'someone@example.test', 'password' => 'directory-pass'])
->assertSessionHasErrors('email');
expect($fake->calls)->toBe(0);
});