mirror of
https://github.com/projectsend/projectsend.git
synced 2026-10-11 23:42:35 +00:00
Merge pull request #1810 from veenone/feat/ldap-signin-by-username
Feat/ldap signin by username
This commit is contained in:
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\LoginRequest;
|
||||
use App\Modules\Identity\Ldap\LdapSettings;
|
||||
use App\Modules\Identity\StartPages;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
@@ -25,6 +26,7 @@ class AuthenticatedSessionController extends Controller
|
||||
'canResetPassword' => Route::has('password.request'),
|
||||
'canRegister' => app(Settings::class)->get(Setting::ClientsCanRegister) === true,
|
||||
'status' => $request->session()->get('status'),
|
||||
'usernameSignIn' => LdapSettings::current()->allowsUsernameSignIn(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ namespace App\Http\Requests\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Identity\AccountLookup;
|
||||
use App\Modules\Identity\Ldap\LdapAuthenticator;
|
||||
use App\Modules\Identity\Ldap\LdapProvisioner;
|
||||
use App\Modules\Identity\Ldap\LdapSettings;
|
||||
use App\Modules\Identity\PasswordVerification;
|
||||
use App\Modules\Identity\SignIn;
|
||||
use App\Modules\Platform\Captcha\CaptchaForm;
|
||||
@@ -14,6 +16,7 @@ use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
@@ -35,7 +38,12 @@ class LoginRequest extends FormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => ['required', 'string', 'email'],
|
||||
// The field keeps its name either way: with a directory username
|
||||
// attribute configured it also takes a username. See
|
||||
// loginEmail().
|
||||
'email' => LdapSettings::current()->allowsUsernameSignIn()
|
||||
? ['required', 'string', 'max:255']
|
||||
: ['required', 'string', 'email'],
|
||||
'password' => ['required', 'string'],
|
||||
// Deliberately here rather than inside authenticate(): rules
|
||||
// run first, so a bot never reaches the credential check, and
|
||||
@@ -72,23 +80,32 @@ class LoginRequest extends FormRequest
|
||||
{
|
||||
$this->ensureIsNotRateLimited();
|
||||
|
||||
// Anything that is not an address is a directory username, and from
|
||||
// here on the login is for the address the directory holds for it.
|
||||
$login = (string) $this->string('email');
|
||||
$byUsername = ! $this->isEmail($login);
|
||||
$email = $byUsername ? app(LdapAuthenticator::class)->emailForUsername($login) : $login;
|
||||
|
||||
// Exact, for the reason SocialAuthenticator is: a collation that
|
||||
// folds accents would otherwise let somebody typing
|
||||
// admin@éxample.com be *identified* as admin@example.com. A
|
||||
// password still gates this one, so it was never the takeover the
|
||||
// social path was — but identifying the wrong account is the bug,
|
||||
// and the credential check is a second line rather than the rule.
|
||||
$user = app(AccountLookup::class)->byEmail((string) $this->string('email'));
|
||||
$user = $email !== null ? app(AccountLookup::class)->byEmail($email) : null;
|
||||
|
||||
// A directory identity with no local account yet. Returns null
|
||||
// unless LDAP is on, auto-provisioning is on, and the bind
|
||||
// succeeds — so an unknown email costs nothing on an installation
|
||||
// that does not use a directory.
|
||||
if ($user === null) {
|
||||
$user = app(LdapProvisioner::class)->provision(
|
||||
(string) $this->string('email'),
|
||||
(string) $this->string('password'),
|
||||
);
|
||||
if ($user === null && $email !== null) {
|
||||
$user = app(LdapProvisioner::class)->provision($email, (string) $this->string('password'));
|
||||
}
|
||||
|
||||
// The directory is client-only. A username is a directory name, so
|
||||
// it never leads to a staff account, whichever password is typed.
|
||||
if ($byUsername && $user !== null && ! $user->isClient()) {
|
||||
$user = null;
|
||||
}
|
||||
|
||||
$verified = $this->verifyCredentials($user);
|
||||
@@ -118,6 +135,16 @@ class LoginRequest extends FormRequest
|
||||
return $pendingTwoFactor;
|
||||
}
|
||||
|
||||
/**
|
||||
* By the same `email` rule that admitted every stored address, so an
|
||||
* address it accepts and filter_var() does not (a dotless domain, a
|
||||
* non-ASCII local part) is never mistaken for a username.
|
||||
*/
|
||||
private function isEmail(string $login): bool
|
||||
{
|
||||
return Validator::make(['email' => $login], ['email' => 'email'])->passes();
|
||||
}
|
||||
|
||||
/**
|
||||
* The account whose password checks out, or null.
|
||||
*
|
||||
|
||||
@@ -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 !== '';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user