Merge pull request #1810 from veenone/feat/ldap-signin-by-username

Feat/ldap signin by username
This commit is contained in:
ignacionelson
2026-10-05 16:12:13 -03:00
29 changed files with 394 additions and 42 deletions
@@ -56,6 +56,7 @@ class LdapSettingsController extends Controller
'user_filter' => $ldap->user_filter,
'email_attribute' => $ldap->email_attribute,
'name_attribute' => $ldap->name_attribute,
'username_attribute' => $ldap->username_attribute,
'auto_provision' => $ldap->auto_provision,
'auto_approve' => $ldap->auto_approve,
],
@@ -93,6 +94,7 @@ class LdapSettingsController extends Controller
'user_filter' => ['nullable', 'string', 'max:255'],
'email_attribute' => ['required', 'string', 'max:64'],
'name_attribute' => ['required', 'string', 'max:64'],
'username_attribute' => ['nullable', 'string', 'max:64'],
'auto_provision' => ['required', 'boolean'],
'auto_approve' => ['required', 'boolean'],
]);
@@ -110,6 +112,7 @@ class LdapSettingsController extends Controller
'user_filter' => $validated['user_filter'] ?? null,
'email_attribute' => $validated['email_attribute'],
'name_attribute' => $validated['name_attribute'],
'username_attribute' => $validated['username_attribute'] ?? null,
'auto_provision' => (bool) $validated['auto_provision'],
'auto_approve' => (bool) $validated['auto_approve'],
]);
@@ -72,6 +72,20 @@ class LdapAuthenticator
return $identity;
}
/**
* The address a directory username belongs to, so a login typed as a
* username can carry on exactly as if the address had been typed.
* Null whenever username sign-in is off or the breaker is open.
*/
public function emailForUsername(string $username): ?string
{
if (! LdapSettings::current()->allowsUsernameSignIn() || $this->breakerOpen()) {
return null;
}
return $this->directory->emailForUsername($username);
}
/**
* Record what the directory told us about an account that already
* exists, so an administrator can see which entry it corresponds to.
@@ -104,13 +118,18 @@ class LdapAuthenticator
return false;
}
if (Cache::get(self::BREAKER_KEY) === true) {
if ($this->breakerOpen()) {
return false;
}
return $this->enabled();
}
private function breakerOpen(): bool
{
return Cache::get(self::BREAKER_KEY) === true;
}
/**
* Whether this account's password lives in the directory rather than
* here — in which case the local hash is not consulted at all.
@@ -25,6 +25,14 @@ interface LdapDirectory
*/
public function authenticate(string $email, string $password): ?LdapIdentity;
/**
* The address of the one entry whose username attribute matches, found
* with the service account. No bind as the person, so nothing is
* verified here: the caller still signs in by that address, through
* authenticate(). Null for no match, more than one, or any failure.
*/
public function emailForUsername(string $username): ?string;
/**
* Exercise the configuration and report which stage failed, for the
* settings screen's test button. This is the one place that is allowed
@@ -42,7 +42,7 @@ class LdapRecordDirectory implements LdapDirectory
$connection = LdapConnectionFactory::make($settings);
$connection->connect();
$entry = $this->findEntry($connection, $settings, $email);
$entry = $this->findEntry($connection, $settings, $settings->email_attribute, $email);
if ($entry === null) {
return null;
@@ -66,6 +66,28 @@ class LdapRecordDirectory implements LdapDirectory
}
}
public function emailForUsername(string $username): ?string
{
$settings = LdapSettings::current();
if (! $settings->allowsUsernameSignIn()) {
return null;
}
try {
$connection = LdapConnectionFactory::make($settings);
$connection->connect();
$entry = $this->findEntry($connection, $settings, (string) $settings->username_attribute, $username);
return $entry === null ? null : $this->attribute($entry, $settings->email_attribute);
} catch (Throwable $e) {
Log::warning('LDAP username lookup could not be completed.', ['exception' => $e::class]);
return null;
}
}
public function probe(?string $email = null, ?string $password = null): LdapProbeResult
{
$settings = LdapSettings::current();
@@ -95,7 +117,7 @@ class LdapRecordDirectory implements LdapDirectory
}
try {
$entry = $this->findEntry($connection, $settings, $email);
$entry = $this->findEntry($connection, $settings, $settings->email_attribute, $email);
} catch (Throwable $e) {
return LdapProbeResult::failed(
LdapProbeResult::STAGE_SEARCH,
@@ -131,21 +153,22 @@ class LdapRecordDirectory implements LdapDirectory
}
/**
* The one entry matching this address, or null.
* The one entry whose attribute holds this value (an address, or a
* username), or null.
*
* Two results is a misconfiguration — two objects sharing an address —
* and choosing one of them is how you sign the wrong person in, so it
* fails closed.
* Two results is a misconfiguration — two objects sharing an address or
* a username — and choosing one of them is how you sign the wrong person
* in, so it fails closed.
*
* @return array<string, mixed>|null
*/
private function findEntry(Connection $connection, LdapSettings $settings, string $email): ?array
private function findEntry(Connection $connection, LdapSettings $settings, string $attribute, string $value): ?array
{
$query = $connection->query()
->in($settings->base_dn)
// The email goes through the builder, which escapes it. It is
// never concatenated into a filter string.
->whereEquals($settings->email_attribute, $email);
// What the visitor typed goes through the builder, which
// escapes it. It is never concatenated into a filter string.
->whereEquals($attribute, $value);
if (is_string($settings->user_filter) && $settings->user_filter !== '') {
// Admin-supplied, never visitor-supplied.
@@ -25,6 +25,7 @@ use Illuminate\Database\Eloquent\Model;
* @property string|null $user_filter
* @property string $email_attribute
* @property string $name_attribute
* @property string|null $username_attribute
* @property bool $auto_provision
* @property bool $auto_approve
*/
@@ -80,4 +81,15 @@ class LdapSettings extends Model
&& is_string($this->host) && $this->host !== ''
&& is_string($this->base_dn) && $this->base_dn !== '';
}
/**
* Whether people may sign in with a directory username as well as an
* address: only once an administrator has named the attribute that
* holds it.
*/
public function allowsUsernameSignIn(): bool
{
return $this->usable()
&& is_string($this->username_attribute) && $this->username_attribute !== '';
}
}