diff --git a/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/app/Http/Controllers/Auth/AuthenticatedSessionController.php index 2824e7b8..662574d1 100644 --- a/app/Http/Controllers/Auth/AuthenticatedSessionController.php +++ b/app/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -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(), ]); } diff --git a/app/Http/Requests/Auth/LoginRequest.php b/app/Http/Requests/Auth/LoginRequest.php index 7cf5ce77..579c77e9 100644 --- a/app/Http/Requests/Auth/LoginRequest.php +++ b/app/Http/Requests/Auth/LoginRequest.php @@ -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. * diff --git a/app/Modules/Identity/Http/Controllers/LdapSettingsController.php b/app/Modules/Identity/Http/Controllers/LdapSettingsController.php index 5f087cfb..46d13f0f 100644 --- a/app/Modules/Identity/Http/Controllers/LdapSettingsController.php +++ b/app/Modules/Identity/Http/Controllers/LdapSettingsController.php @@ -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'], ]); diff --git a/app/Modules/Identity/Ldap/LdapAuthenticator.php b/app/Modules/Identity/Ldap/LdapAuthenticator.php index 86d95821..830a55b0 100644 --- a/app/Modules/Identity/Ldap/LdapAuthenticator.php +++ b/app/Modules/Identity/Ldap/LdapAuthenticator.php @@ -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. diff --git a/app/Modules/Identity/Ldap/LdapDirectory.php b/app/Modules/Identity/Ldap/LdapDirectory.php index 9daf2e9c..b87ca201 100644 --- a/app/Modules/Identity/Ldap/LdapDirectory.php +++ b/app/Modules/Identity/Ldap/LdapDirectory.php @@ -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 diff --git a/app/Modules/Identity/Ldap/LdapRecordDirectory.php b/app/Modules/Identity/Ldap/LdapRecordDirectory.php index e8d9aa64..7b0fc854 100644 --- a/app/Modules/Identity/Ldap/LdapRecordDirectory.php +++ b/app/Modules/Identity/Ldap/LdapRecordDirectory.php @@ -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|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. diff --git a/app/Modules/Identity/Ldap/LdapSettings.php b/app/Modules/Identity/Ldap/LdapSettings.php index 71bf615a..40f013a8 100644 --- a/app/Modules/Identity/Ldap/LdapSettings.php +++ b/app/Modules/Identity/Ldap/LdapSettings.php @@ -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 !== ''; + } } diff --git a/database/migrations/2026_10_05_090000_add_username_attribute_to_ldap_settings.php b/database/migrations/2026_10_05_090000_add_username_attribute_to_ldap_settings.php new file mode 100644 index 00000000..40486e77 --- /dev/null +++ b/database/migrations/2026_10_05_090000_add_username_attribute_to_ldap_settings.php @@ -0,0 +1,28 @@ +string('username_attribute')->nullable(); + }); + } + + public function down(): void + { + Schema::table('ldap_settings', function (Blueprint $table) { + $table->dropColumn('username_attribute'); + }); + } +}; diff --git a/lang/ca.json b/lang/ca.json index bf2341ff..3cc406d5 100644 --- a/lang/ca.json +++ b/lang/ca.json @@ -2319,5 +2319,9 @@ "This token can set passwords and remove two-factor authentication on the accounts it may edit, so whoever holds it can sign in as those accounts. Choose these abilities only for a holder you would trust with the accounts themselves.": "Aquest token pot crear contrasenyes i eliminar la verificació en dos passos dels comptes que pot editar, així que qui el tingui pot iniciar la sessió com aquests comptes. Tria aquests permisos només per a algú a qui confiaries aquests comptes.", "Upload a logo before cropping it.": "Puja un logo abans de retallar-lo.", "We sent a link to your email address. It works for one hour.": "T'hem enviat un enllaç al correu. Funciona durant una hora.", - "We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "T'enviarem un enllaç per correu per crear-la. En obrir-lo es tancaran totes les teves sessions, així que després torna a iniciar la sessió amb la nova contrasenya." + "We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "T'enviarem un enllaç per correu per crear-la. En obrir-lo es tancaran totes les teves sessions, així que després torna a iniciar la sessió amb la nova contrasenya.", + "Username attribute (optional)": "Atribut de nom d'usuari (opcional)", + "Lets people sign in with their directory username as well as their address: uid, cn or sAMAccountName, for example. Leave it empty to sign in by address only.": "Permet que les persones iniciïn la sessió amb el seu nom d'usuari del directori a més de l'adreça: per exemple uid, cn o sAMAccountName. Deixa-ho buit per iniciar la sessió només amb l'adreça.", + "Email or username": "Correu o nom d'usuari", + "Enter your email or username and password below to log in": "Introdueix a sota el teu correu o nom d'usuari i la teva contrasenya per iniciar la sessió" } diff --git a/lang/cs.json b/lang/cs.json index ff307403..a290274e 100644 --- a/lang/cs.json +++ b/lang/cs.json @@ -2319,5 +2319,9 @@ "This token can set passwords and remove two-factor authentication on the accounts it may edit, so whoever holds it can sign in as those accounts. Choose these abilities only for a holder you would trust with the accounts themselves.": "Tento token může nastavovat hesla a odstraňovat dvoufaktorové ověření u účtů, které smí upravovat, takže kdokoli ho má, se může přihlásit jako tyto účty. Tato oprávnění zvolte jen pro držitele, kterému byste svěřili samotné účty.", "Upload a logo before cropping it.": "Před oříznutím nahrajte logo.", "We sent a link to your email address. It works for one hour.": "Na vaši e-mailovou adresu jsme poslali odkaz. Platí jednu hodinu.", - "We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "Pošleme vám e-mailem odkaz k jeho nastavení. Otevření odkazu vás všude odhlásí, takže se poté znovu přihlaste novým heslem." + "We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "Pošleme vám e-mailem odkaz k jeho nastavení. Otevření odkazu vás všude odhlásí, takže se poté znovu přihlaste novým heslem.", + "Username attribute (optional)": "Atribut uživatelského jména (nepovinné)", + "Lets people sign in with their directory username as well as their address: uid, cn or sAMAccountName, for example. Leave it empty to sign in by address only.": "Umožní lidem přihlásit se kromě adresy i uživatelským jménem z adresáře, například uid, cn nebo sAMAccountName. Ponechte prázdné, pokud se má přihlašovat jen adresou.", + "Email or username": "E-mail nebo uživatelské jméno", + "Enter your email or username and password below to log in": "Pro přihlášení zadejte níže svůj e-mail nebo uživatelské jméno a heslo" } diff --git a/lang/de.json b/lang/de.json index 8fc44d44..f46dd0ef 100644 --- a/lang/de.json +++ b/lang/de.json @@ -2319,5 +2319,9 @@ "This token can set passwords and remove two-factor authentication on the accounts it may edit, so whoever holds it can sign in as those accounts. Choose these abilities only for a holder you would trust with the accounts themselves.": "Dieser Token kann Passwörter festlegen und die Zwei-Faktor-Authentifizierung der Konten entfernen, die er bearbeiten darf. Wer ihn besitzt, kann sich also als diese Konten anmelden. Wählen Sie diese Berechtigungen nur für jemanden, dem Sie diese Konten selbst anvertrauen würden.", "Upload a logo before cropping it.": "Laden Sie zuerst ein Logo hoch, bevor Sie es zuschneiden.", "We sent a link to your email address. It works for one hour.": "Wir haben Ihnen einen Link per E-Mail gesendet. Er ist eine Stunde lang gültig.", - "We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "Wir senden Ihnen einen Link per E-Mail, um es festzulegen. Wenn Sie ihn öffnen, werden Sie überall abgemeldet. Melden Sie sich danach mit Ihrem neuen Passwort wieder an." + "We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "Wir senden Ihnen einen Link per E-Mail, um es festzulegen. Wenn Sie ihn öffnen, werden Sie überall abgemeldet. Melden Sie sich danach mit Ihrem neuen Passwort wieder an.", + "Username attribute (optional)": "Benutzernamen-Attribut (optional)", + "Lets people sign in with their directory username as well as their address: uid, cn or sAMAccountName, for example. Leave it empty to sign in by address only.": "Erlaubt die Anmeldung zusätzlich zur Adresse mit dem Benutzernamen aus dem Verzeichnis, zum Beispiel uid, cn oder sAMAccountName. Leer lassen, um nur die Anmeldung per Adresse zu erlauben.", + "Email or username": "E-Mail-Adresse oder Benutzername", + "Enter your email or username and password below to log in": "Geben Sie unten Ihre E-Mail-Adresse oder Ihren Benutzernamen und Ihr Passwort ein, um sich anzumelden" } diff --git a/lang/es.json b/lang/es.json index 93706dc3..9af26e01 100644 --- a/lang/es.json +++ b/lang/es.json @@ -2319,5 +2319,9 @@ "This token can set passwords and remove two-factor authentication on the accounts it may edit, so whoever holds it can sign in as those accounts. Choose these abilities only for a holder you would trust with the accounts themselves.": "Este token puede crear contraseñas y quitar la verificación en dos pasos en las cuentas que puede editar, así que quien lo tenga puede iniciar sesión como esas cuentas. Elige estos permisos solo para alguien a quien confiarías esas cuentas.", "Upload a logo before cropping it.": "Sube un logo antes de recortarlo.", "We sent a link to your email address. It works for one hour.": "Te enviamos un enlace a tu correo. Sirve durante una hora.", - "We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "Te enviaremos un enlace por correo para crearla. Al abrirlo se cierran todas tus sesiones, así que después inicia sesión de nuevo con tu nueva contraseña." + "We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "Te enviaremos un enlace por correo para crearla. Al abrirlo se cierran todas tus sesiones, así que después inicia sesión de nuevo con tu nueva contraseña.", + "Username attribute (optional)": "Atributo de nombre de usuario (opcional)", + "Lets people sign in with their directory username as well as their address: uid, cn or sAMAccountName, for example. Leave it empty to sign in by address only.": "Permite que las personas inicien sesión con su nombre de usuario del directorio además de con su dirección: por ejemplo uid, cn o sAMAccountName. Déjalo vacío para iniciar sesión solo con la dirección.", + "Email or username": "Correo o nombre de usuario", + "Enter your email or username and password below to log in": "Ingresa tu correo electrónico o nombre de usuario y tu contraseña para iniciar sesión" } diff --git a/lang/fr.json b/lang/fr.json index 0d7041d4..e0f82f28 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -2319,5 +2319,9 @@ "This token can set passwords and remove two-factor authentication on the accounts it may edit, so whoever holds it can sign in as those accounts. Choose these abilities only for a holder you would trust with the accounts themselves.": "Ce jeton peut définir des mots de passe et supprimer l'authentification à deux facteurs des comptes qu'il peut modifier : quiconque le détient peut donc se connecter avec ces comptes. Ne choisissez ces autorisations que pour une personne à qui vous confieriez ces comptes eux-mêmes.", "Upload a logo before cropping it.": "Téléversez un logo avant de le recadrer.", "We sent a link to your email address. It works for one hour.": "Nous avons envoyé un lien à votre adresse e-mail. Il est valable une heure.", - "We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "Nous vous enverrons un lien par e-mail pour le définir. L'ouvrir vous déconnecte partout : reconnectez-vous ensuite avec votre nouveau mot de passe." + "We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "Nous vous enverrons un lien par e-mail pour le définir. L'ouvrir vous déconnecte partout : reconnectez-vous ensuite avec votre nouveau mot de passe.", + "Username attribute (optional)": "Attribut nom d'utilisateur (facultatif)", + "Lets people sign in with their directory username as well as their address: uid, cn or sAMAccountName, for example. Leave it empty to sign in by address only.": "Permet de se connecter avec son nom d'utilisateur de l'annuaire en plus de son adresse : uid, cn ou sAMAccountName, par exemple. Laissez vide pour ne se connecter qu'avec l'adresse.", + "Email or username": "Adresse e-mail ou nom d'utilisateur", + "Enter your email or username and password below to log in": "Saisissez ci-dessous votre adresse e-mail ou votre nom d'utilisateur et votre mot de passe pour vous connecter" } diff --git a/lang/id.json b/lang/id.json index 533960de..67ced5ad 100644 --- a/lang/id.json +++ b/lang/id.json @@ -2319,5 +2319,9 @@ "This token can set passwords and remove two-factor authentication on the accounts it may edit, so whoever holds it can sign in as those accounts. Choose these abilities only for a holder you would trust with the accounts themselves.": "Token ini dapat menetapkan kata sandi dan menghapus autentikasi dua faktor pada akun yang boleh diubahnya, jadi siapa pun yang memegangnya dapat masuk sebagai akun-akun tersebut. Pilih izin ini hanya untuk pemegang yang Anda percayai dengan akun-akun itu sendiri.", "Upload a logo before cropping it.": "Unggah logo sebelum memangkasnya.", "We sent a link to your email address. It works for one hour.": "Kami telah mengirim tautan ke alamat email Anda. Tautan berlaku selama satu jam.", - "We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "Kami akan mengirimkan tautan lewat email untuk membuatnya. Membuka tautan itu akan mengeluarkan Anda dari semua sesi, jadi masuklah lagi dengan kata sandi baru Anda setelahnya." + "We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "Kami akan mengirimkan tautan lewat email untuk membuatnya. Membuka tautan itu akan mengeluarkan Anda dari semua sesi, jadi masuklah lagi dengan kata sandi baru Anda setelahnya.", + "Username attribute (optional)": "Atribut nama pengguna (opsional)", + "Lets people sign in with their directory username as well as their address: uid, cn or sAMAccountName, for example. Leave it empty to sign in by address only.": "Memungkinkan orang masuk dengan nama pengguna direktori selain alamatnya, misalnya uid, cn, atau sAMAccountName. Biarkan kosong untuk masuk hanya dengan alamat.", + "Email or username": "Email atau nama pengguna", + "Enter your email or username and password below to log in": "Masukkan email atau nama pengguna dan kata sandi Anda di bawah ini untuk masuk" } diff --git a/lang/it.json b/lang/it.json index 09b7bec2..ec73bf80 100644 --- a/lang/it.json +++ b/lang/it.json @@ -2319,5 +2319,9 @@ "This token can set passwords and remove two-factor authentication on the accounts it may edit, so whoever holds it can sign in as those accounts. Choose these abilities only for a holder you would trust with the accounts themselves.": "Questo token può impostare password e rimuovere l'autenticazione a due fattori sugli account che può modificare, quindi chi lo possiede può accedere come quegli account. Scegli questi permessi solo per qualcuno a cui affideresti quegli account.", "Upload a logo before cropping it.": "Carica un logo prima di ritagliarlo.", "We sent a link to your email address. It works for one hour.": "Ti abbiamo inviato un link al tuo indirizzo email. Vale per un'ora.", - "We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "Ti invieremo un link via email per impostarla. Aprendo il link verrai disconnesso ovunque, quindi poi accedi di nuovo con la tua nuova password." + "We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "Ti invieremo un link via email per impostarla. Aprendo il link verrai disconnesso ovunque, quindi poi accedi di nuovo con la tua nuova password.", + "Username attribute (optional)": "Attributo nome utente (facoltativo)", + "Lets people sign in with their directory username as well as their address: uid, cn or sAMAccountName, for example. Leave it empty to sign in by address only.": "Consente di accedere con il nome utente della directory oltre che con l'indirizzo: per esempio uid, cn o sAMAccountName. Lascialo vuoto per accedere solo con l'indirizzo.", + "Email or username": "E-mail o nome utente", + "Enter your email or username and password below to log in": "Inserisci qui sotto la tua e-mail o il tuo nome utente e la tua password per accedere" } diff --git a/lang/ja.json b/lang/ja.json index fa6c6f60..aac085a8 100644 --- a/lang/ja.json +++ b/lang/ja.json @@ -2319,5 +2319,9 @@ "This token can set passwords and remove two-factor authentication on the accounts it may edit, so whoever holds it can sign in as those accounts. Choose these abilities only for a holder you would trust with the accounts themselves.": "このトークンは、編集できるアカウントのパスワードを設定し、二要素認証を削除できます。つまり、トークンを持つ人はそれらのアカウントとしてサインインできます。これらの権限は、アカウントそのものを任せられる相手にだけ付与してください。", "Upload a logo before cropping it.": "切り抜く前にロゴをアップロードしてください。", "We sent a link to your email address. It works for one hour.": "メールアドレスにリンクを送信しました。有効期限は1時間です。", - "We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "設定用のリンクをメールでお送りします。リンクを開くとすべての場所からサインアウトされるので、その後、新しいパスワードでもう一度サインインしてください。" + "We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "設定用のリンクをメールでお送りします。リンクを開くとすべての場所からサインアウトされるので、その後、新しいパスワードでもう一度サインインしてください。", + "Username attribute (optional)": "ユーザー名属性 (任意)", + "Lets people sign in with their directory username as well as their address: uid, cn or sAMAccountName, for example. Leave it empty to sign in by address only.": "アドレスに加えて、ディレクトリのユーザー名でもログインできるようにします。例: uid、cn、sAMAccountName。空のままにすると、アドレスでのみログインできます。", + "Email or username": "メールアドレスまたはユーザー名", + "Enter your email or username and password below to log in": "ログインするには、以下にメールアドレスまたはユーザー名とパスワードを入力してください" } diff --git a/lang/nl.json b/lang/nl.json index a5a2100a..51e80fbe 100644 --- a/lang/nl.json +++ b/lang/nl.json @@ -2319,5 +2319,9 @@ "This token can set passwords and remove two-factor authentication on the accounts it may edit, so whoever holds it can sign in as those accounts. Choose these abilities only for a holder you would trust with the accounts themselves.": "Dit token kan wachtwoorden instellen en tweefactorauthenticatie verwijderen bij de accounts die het mag bewerken, dus wie het heeft, kan als die accounts inloggen. Kies deze rechten alleen voor iemand aan wie je die accounts zelf zou toevertrouwen.", "Upload a logo before cropping it.": "Upload een logo voordat je het bijsnijdt.", "We sent a link to your email address. It works for one hour.": "We hebben een link naar je e-mailadres gestuurd. Hij werkt een uur lang.", - "We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "We sturen je per e-mail een link om het in te stellen. Als je de link opent, word je overal afgemeld, dus log daarna opnieuw in met je nieuwe wachtwoord." + "We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "We sturen je per e-mail een link om het in te stellen. Als je de link opent, word je overal afgemeld, dus log daarna opnieuw in met je nieuwe wachtwoord.", + "Username attribute (optional)": "Gebruikersnaamattribuut (optioneel)", + "Lets people sign in with their directory username as well as their address: uid, cn or sAMAccountName, for example. Leave it empty to sign in by address only.": "Laat mensen naast hun adres ook inloggen met hun gebruikersnaam uit de directory, bijvoorbeeld uid, cn of sAMAccountName. Laat leeg om alleen met het adres in te loggen.", + "Email or username": "E-mailadres of gebruikersnaam", + "Enter your email or username and password below to log in": "Voer hieronder je e-mailadres of gebruikersnaam en wachtwoord in om in te loggen" } diff --git a/lang/pl.json b/lang/pl.json index 66f1676f..7582d359 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -2319,5 +2319,9 @@ "This token can set passwords and remove two-factor authentication on the accounts it may edit, so whoever holds it can sign in as those accounts. Choose these abilities only for a holder you would trust with the accounts themselves.": "Ten token może ustawiać hasła i usuwać uwierzytelnianie dwuskładnikowe na kontach, które może edytować, więc każdy, kto go ma, może zalogować się jako te konta. Wybieraj te uprawnienia tylko dla osoby, której można by powierzyć same te konta.", "Upload a logo before cropping it.": "Prześlij logo, zanim je przytniesz.", "We sent a link to your email address. It works for one hour.": "Wysłaliśmy link na Twój adres e-mail. Działa przez godzinę.", - "We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "Wyślemy Ci e-mailem link do jego ustawienia. Otwarcie linku wyloguje Cię wszędzie, więc potem zaloguj się ponownie nowym hasłem." + "We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "Wyślemy Ci e-mailem link do jego ustawienia. Otwarcie linku wyloguje Cię wszędzie, więc potem zaloguj się ponownie nowym hasłem.", + "Username attribute (optional)": "Atrybut nazwy użytkownika (opcjonalnie)", + "Lets people sign in with their directory username as well as their address: uid, cn or sAMAccountName, for example. Leave it empty to sign in by address only.": "Pozwala logować się oprócz adresu także nazwą użytkownika z katalogu, na przykład uid, cn lub sAMAccountName. Zostaw puste, aby logować się tylko adresem.", + "Email or username": "Adres e-mail lub nazwa użytkownika", + "Enter your email or username and password below to log in": "Wpisz poniżej swój adres e-mail lub nazwę użytkownika i hasło, aby się zalogować" } diff --git a/lang/pt_BR.json b/lang/pt_BR.json index 6a7c3b9a..18ef6733 100644 --- a/lang/pt_BR.json +++ b/lang/pt_BR.json @@ -2319,5 +2319,9 @@ "This token can set passwords and remove two-factor authentication on the accounts it may edit, so whoever holds it can sign in as those accounts. Choose these abilities only for a holder you would trust with the accounts themselves.": "Este token pode definir senhas e remover a verificação em duas etapas das contas que ele pode editar, então quem o tiver pode entrar como essas contas. Escolha essas permissões só para alguém a quem você confiaria essas próprias contas.", "Upload a logo before cropping it.": "Envie um logo antes de recortá-lo.", "We sent a link to your email address. It works for one hour.": "Enviamos um link para o seu e-mail. Ele vale por uma hora.", - "We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "Vamos enviar um link por e-mail para criá-la. Abrir o link encerra todas as suas sessões, então depois entre de novo com a sua nova senha." + "We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "Vamos enviar um link por e-mail para criá-la. Abrir o link encerra todas as suas sessões, então depois entre de novo com a sua nova senha.", + "Username attribute (optional)": "Atributo de nome de usuário (opcional)", + "Lets people sign in with their directory username as well as their address: uid, cn or sAMAccountName, for example. Leave it empty to sign in by address only.": "Permite entrar com o nome de usuário do diretório além do endereço: por exemplo uid, cn ou sAMAccountName. Deixe em branco para entrar só com o endereço.", + "Email or username": "E-mail ou nome de usuário", + "Enter your email or username and password below to log in": "Informe abaixo seu e-mail ou nome de usuário e sua senha para entrar" } diff --git a/lang/ru.json b/lang/ru.json index a00028b7..d66c56c1 100644 --- a/lang/ru.json +++ b/lang/ru.json @@ -2319,5 +2319,9 @@ "This token can set passwords and remove two-factor authentication on the accounts it may edit, so whoever holds it can sign in as those accounts. Choose these abilities only for a holder you would trust with the accounts themselves.": "Этот токен может задавать пароли и удалять двухфакторную аутентификацию у учётных записей, которые ему разрешено изменять, поэтому любой его владелец может войти под этими учётными записями. Выбирайте эти права только для того, кому доверили бы сами эти учётные записи.", "Upload a logo before cropping it.": "Загрузите логотип, прежде чем обрезать его.", "We sent a link to your email address. It works for one hour.": "Мы отправили ссылку на ваш адрес электронной почты. Она действует один час.", - "We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "Мы отправим вам ссылку по электронной почте, чтобы его задать. Открыв ссылку, вы выйдете из всех сеансов, поэтому затем войдите снова с новым паролем." + "We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "Мы отправим вам ссылку по электронной почте, чтобы его задать. Открыв ссылку, вы выйдете из всех сеансов, поэтому затем войдите снова с новым паролем.", + "Username attribute (optional)": "Атрибут имени пользователя (необязательно)", + "Lets people sign in with their directory username as well as their address: uid, cn or sAMAccountName, for example. Leave it empty to sign in by address only.": "Позволяет входить не только по адресу, но и по имени пользователя из каталога, например uid, cn или sAMAccountName. Оставьте пустым, чтобы входить только по адресу.", + "Email or username": "Адрес эл. почты или имя пользователя", + "Enter your email or username and password below to log in": "Введите ниже адрес эл. почты или имя пользователя и пароль, чтобы войти" } diff --git a/lang/sw.json b/lang/sw.json index 626c5f15..c1ff986e 100644 --- a/lang/sw.json +++ b/lang/sw.json @@ -2319,5 +2319,9 @@ "This token can set passwords and remove two-factor authentication on the accounts it may edit, so whoever holds it can sign in as those accounts. Choose these abilities only for a holder you would trust with the accounts themselves.": "Tokeni hii inaweza kuweka manenosiri na kuondoa uthibitishaji wa hatua mbili kwenye akaunti inazoruhusiwa kuhariri, kwa hivyo yeyote aliye nayo anaweza kuingia kama akaunti hizo. Chagua ruhusa hizi tu kwa mtu ambaye ungemwamini na akaunti hizo zenyewe.", "Upload a logo before cropping it.": "Pakia nembo kabla ya kuipunguza.", "We sent a link to your email address. It works for one hour.": "Tumetuma kiungo kwenye anwani yako ya barua pepe. Kinafanya kazi kwa saa moja.", - "We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "Tutakutumia kiungo kwa barua pepe ili kuliweka. Kufungua kiungo kutakutoa kila mahali, kwa hivyo baadaye ingia tena kwa nenosiri lako jipya." + "We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "Tutakutumia kiungo kwa barua pepe ili kuliweka. Kufungua kiungo kutakutoa kila mahali, kwa hivyo baadaye ingia tena kwa nenosiri lako jipya.", + "Username attribute (optional)": "Sifa ya jina la mtumiaji (si lazima)", + "Lets people sign in with their directory username as well as their address: uid, cn or sAMAccountName, for example. Leave it empty to sign in by address only.": "Huwaruhusu watu kuingia kwa jina la mtumiaji la saraka pamoja na anwani yao, kwa mfano uid, cn au sAMAccountName. Acha tupu ili kuingia kwa anwani pekee.", + "Email or username": "Barua pepe au jina la mtumiaji", + "Enter your email or username and password below to log in": "Weka barua pepe au jina la mtumiaji na nenosiri lako hapa chini ili kuingia" } diff --git a/lang/tr.json b/lang/tr.json index 8e2b38e7..fd909aba 100644 --- a/lang/tr.json +++ b/lang/tr.json @@ -2319,5 +2319,9 @@ "This token can set passwords and remove two-factor authentication on the accounts it may edit, so whoever holds it can sign in as those accounts. Choose these abilities only for a holder you would trust with the accounts themselves.": "Bu token, düzenleyebildiği hesaplarda parola belirleyebilir ve iki adımlı doğrulamayı kaldırabilir; dolayısıyla tokene sahip olan herkes bu hesaplar olarak oturum açabilir. Bu yetkileri yalnızca bu hesapların kendisini emanet edeceğiniz biri için seçin.", "Upload a logo before cropping it.": "Kırpmadan önce bir logo yükleyin.", "We sent a link to your email address. It works for one hour.": "E-posta adresinize bir bağlantı gönderdik. Bir saat geçerlidir.", - "We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "Parolayı oluşturmanız için size e-postayla bir bağlantı göndereceğiz. Bağlantıyı açtığınızda her yerden oturumunuz kapanır; ardından yeni parolanızla tekrar oturum açın." + "We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "Parolayı oluşturmanız için size e-postayla bir bağlantı göndereceğiz. Bağlantıyı açtığınızda her yerden oturumunuz kapanır; ardından yeni parolanızla tekrar oturum açın.", + "Username attribute (optional)": "Kullanıcı adı özniteliği (isteğe bağlı)", + "Lets people sign in with their directory username as well as their address: uid, cn or sAMAccountName, for example. Leave it empty to sign in by address only.": "Kişilerin adreslerinin yanı sıra dizindeki kullanıcı adlarıyla da giriş yapmasını sağlar; örneğin uid, cn veya sAMAccountName. Yalnızca adresle giriş için boş bırakın.", + "Email or username": "E-posta veya kullanıcı adı", + "Enter your email or username and password below to log in": "Giriş yapmak için e-posta adresinizi veya kullanıcı adınızı ve parolanızı aşağıya girin" } diff --git a/lang/vi.json b/lang/vi.json index 022abfcc..3ba56e48 100644 --- a/lang/vi.json +++ b/lang/vi.json @@ -2319,5 +2319,9 @@ "This token can set passwords and remove two-factor authentication on the accounts it may edit, so whoever holds it can sign in as those accounts. Choose these abilities only for a holder you would trust with the accounts themselves.": "Token này có thể đặt mật khẩu và gỡ xác thực hai yếu tố trên các tài khoản mà nó được phép chỉnh sửa, nên bất kỳ ai giữ nó đều có thể đăng nhập bằng các tài khoản đó. Chỉ chọn các quyền này cho người mà bạn tin tưởng giao chính các tài khoản đó.", "Upload a logo before cropping it.": "Hãy tải logo lên trước khi cắt.", "We sent a link to your email address. It works for one hour.": "Chúng tôi đã gửi một liên kết đến địa chỉ email của bạn. Liên kết có hiệu lực trong một giờ.", - "We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "Chúng tôi sẽ gửi email cho bạn một liên kết để đặt mật khẩu. Mở liên kết sẽ đăng xuất bạn ở mọi nơi, vì vậy sau đó hãy đăng nhập lại bằng mật khẩu mới." + "We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "Chúng tôi sẽ gửi email cho bạn một liên kết để đặt mật khẩu. Mở liên kết sẽ đăng xuất bạn ở mọi nơi, vì vậy sau đó hãy đăng nhập lại bằng mật khẩu mới.", + "Username attribute (optional)": "Thuộc tính tên người dùng (không bắt buộc)", + "Lets people sign in with their directory username as well as their address: uid, cn or sAMAccountName, for example. Leave it empty to sign in by address only.": "Cho phép đăng nhập bằng tên người dùng trong thư mục ngoài địa chỉ, ví dụ uid, cn hoặc sAMAccountName. Để trống nếu chỉ đăng nhập bằng địa chỉ.", + "Email or username": "Email hoặc tên người dùng", + "Enter your email or username and password below to log in": "Nhập email hoặc tên người dùng và mật khẩu bên dưới để đăng nhập" } diff --git a/lang/zh_CN.json b/lang/zh_CN.json index 56702edf..e1858a9a 100644 --- a/lang/zh_CN.json +++ b/lang/zh_CN.json @@ -2319,5 +2319,9 @@ "This token can set passwords and remove two-factor authentication on the accounts it may edit, so whoever holds it can sign in as those accounts. Choose these abilities only for a holder you would trust with the accounts themselves.": "此令牌可以为它有权编辑的账户设置密码并移除两步验证,因此持有它的任何人都能以这些账户的身份登录。只把这些权限授予你愿意把这些账户本身托付给的人。", "Upload a logo before cropping it.": "请先上传标志再裁剪。", "We sent a link to your email address. It works for one hour.": "我们已向你的邮箱发送了链接,有效期为一小时。", - "We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "我们会通过邮件给你发送设置密码的链接。打开链接后,你在所有地方都会被登出,之后请用新密码重新登录。" + "We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "我们会通过邮件给你发送设置密码的链接。打开链接后,你在所有地方都会被登出,之后请用新密码重新登录。", + "Username attribute (optional)": "用户名属性(可选)", + "Lets people sign in with their directory username as well as their address: uid, cn or sAMAccountName, for example. Leave it empty to sign in by address only.": "除邮箱地址外,还允许使用目录中的用户名登录,例如 uid、cn 或 sAMAccountName。留空则只能用邮箱地址登录。", + "Email or username": "邮箱或用户名", + "Enter your email or username and password below to log in": "在下方输入邮箱或用户名和密码即可登录" } diff --git a/resources/js/pages/auth/login.tsx b/resources/js/pages/auth/login.tsx index a0477d4c..e1c8c246 100644 --- a/resources/js/pages/auth/login.tsx +++ b/resources/js/pages/auth/login.tsx @@ -26,9 +26,11 @@ interface LoginProps { status?: string; canResetPassword: boolean; canRegister: boolean; + /** A directory username attribute is configured, so the field also takes a username. */ + usernameSignIn: boolean; } -export default function Login({ status, canResetPassword, canRegister }: LoginProps) { +export default function Login({ status, canResetPassword, canRegister, usernameSignIn }: LoginProps) { const { t } = useTranslation(); const { flash } = usePage().props; const captcha = useRef(null); @@ -59,7 +61,12 @@ export default function Login({ status, canResetPassword, canRegister }: LoginPr }; return ( - + {/* The app-wide Toaster lives in the authenticated layout, so a @@ -75,14 +82,14 @@ export default function Login({ status, canResetPassword, canRegister }: LoginPr
- + setData('email', e.target.value)} placeholder="email@example.com" diff --git a/resources/js/pages/system/settings/ldap.tsx b/resources/js/pages/system/settings/ldap.tsx index 98b8a9e9..6c120852 100644 --- a/resources/js/pages/system/settings/ldap.tsx +++ b/resources/js/pages/system/settings/ldap.tsx @@ -33,6 +33,7 @@ interface LdapSettings { user_filter: string | null; email_attribute: string; name_attribute: string; + username_attribute: string | null; auto_provision: boolean; auto_approve: boolean; } @@ -68,6 +69,7 @@ export default function LdapSettingsPage({ ldap, encryptions, extension_availabl 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, }); @@ -268,6 +270,23 @@ export default function LdapSettingsPage({ ldap, encryptions, extension_availabl
+
+ + form.setData('username_attribute', e.target.value)} + /> +

+ {t( + 'Lets people sign in with their directory username as well as their address: uid, cn or sAMAccountName, for example. Leave it empty to sign in by address only.', + )} +

+ +
+
$entries + * @param array $entries */ function fakeDirectory(array $entries = []): FakeLdapDirectory { @@ -35,7 +35,7 @@ function fakeDirectory(array $entries = []): FakeLdapDirectory return $fake; } -function enableLdap(bool $autoProvision = false, bool $autoApprove = false): LdapSettings +function enableLdap(bool $autoProvision = false, bool $autoApprove = false, ?string $usernameAttribute = null): LdapSettings { $settings = LdapSettings::current(); $settings->forceFill([ @@ -44,6 +44,7 @@ function enableLdap(bool $autoProvision = false, bool $autoApprove = false): Lda 'base_dn' => 'dc=example,dc=test', 'auto_provision' => $autoProvision, 'auto_approve' => $autoApprove, + 'username_attribute' => $usernameAttribute, ])->save(); return $settings; @@ -512,3 +513,113 @@ test('a local account still confirms against its own hash, with LDAP on', functi ->assertRedirect() ->assertSessionHasNoErrors(); }); + +/* +|-------------------------------------------------------------------------- +| Signing in with a directory username +|-------------------------------------------------------------------------- +*/ + +test('a client signs in with their directory username', function () { + enableLdap(usernameAttribute: 'uid'); + $fake = fakeDirectory(['someone@example.test' => ['password' => 'directory-pass', 'username' => 'someone']]); + $client = User::factory()->client()->create(['email' => 'someone@example.test']); + + $this->post('/login', ['email' => 'someone', 'password' => 'directory-pass'])->assertRedirect(); + + $this->assertAuthenticatedAs($client); + expect($fake->lookedUpUsernames)->toBe(['someone']) + ->and($fake->attemptedEmails)->toBe(['someone@example.test']); +}); + +test('a username signs in a client the directory has not met yet, when auto-provisioning is on', function () { + enableLdap(autoProvision: true, autoApprove: true, usernameAttribute: 'uid'); + fakeDirectory(['newcomer@example.test' => ['password' => 'directory-pass', 'username' => 'newcomer', 'name' => 'New Comer']]); + + $this->post('/login', ['email' => 'newcomer', 'password' => 'directory-pass'])->assertRedirect(); + + $user = User::query()->where('email', 'newcomer@example.test')->sole(); + expect($user->isClient())->toBeTrue() + ->and($user->auth_source)->toBe(AuthSource::Ldap); + $this->assertAuthenticatedAs($user); +}); + +test('a username never signs in a staff account, even with its own password', function () { + enableLdap(usernameAttribute: 'uid'); + fakeDirectory(['admin@example.test' => ['password' => 'directory-pass', 'username' => 'admin']]); + User::factory()->create(['email' => 'admin@example.test']); + + $this->post('/login', ['email' => 'admin', 'password' => 'password'])->assertSessionHasErrors('email'); + $this->post('/login', ['email' => 'admin', 'password' => 'directory-pass'])->assertSessionHasErrors('email'); + + $this->assertGuest(); +}); + +test('a wrong password with a valid username is refused', function () { + enableLdap(usernameAttribute: 'uid'); + fakeDirectory(['someone@example.test' => ['password' => 'directory-pass', 'username' => 'someone']]); + User::factory()->client()->create(['email' => 'someone@example.test']); + + $this->post('/login', ['email' => 'someone', 'password' => 'wrong']) + ->assertSessionHasErrors(['email' => __('auth.failed')]); + + $this->assertGuest(); +}); + +test('an unknown username gets the same failure as a wrong password, and counts toward the limit', function () { + enableLdap(usernameAttribute: 'uid'); + fakeDirectory(); + + foreach (range(1, 5) as $_) { + $this->post('/login', ['email' => 'nobody', 'password' => 'whatever']) + ->assertSessionHasErrors(['email' => __('auth.failed')]); + } + + $this->post('/login', ['email' => 'nobody', 'password' => 'whatever']) + ->assertSessionHasErrors('email'); + expect(session('errors')->first('email'))->not->toBe(__('auth.failed')); +}); + +test('a username is only accepted once a username attribute is set', function () { + enableLdap(); + $fake = fakeDirectory(['someone@example.test' => ['password' => 'directory-pass', 'username' => 'someone']]); + User::factory()->client()->create(['email' => 'someone@example.test']); + + $this->post('/login', ['email' => 'someone', 'password' => 'directory-pass'])->assertSessionHasErrors('email'); + + expect($fake->calls)->toBe(0); + $this->assertGuest(); +}); + +test('an email address still signs in by email with username sign-in on', function () { + enableLdap(usernameAttribute: 'uid'); + $fake = fakeDirectory(['someone@example.test' => ['password' => 'directory-pass', 'username' => 'someone']]); + $client = User::factory()->client()->create(['email' => 'someone@example.test']); + + $this->post('/login', ['email' => 'someone@example.test', 'password' => 'directory-pass'])->assertRedirect(); + + $this->assertAuthenticatedAs($client); + expect($fake->lookedUpUsernames)->toBe([]); +}); + +test('the login page offers username sign-in only when it is configured', function () { + $this->get('/login')->assertInertia(fn ($page) => $page->where('usernameSignIn', false)); + + enableLdap(usernameAttribute: 'uid'); + + $this->get('/login')->assertInertia(fn ($page) => $page->where('usernameSignIn', true)); +}); + +// Decided by the same rule that admitted the address, or an account whose +// address the `email` rule accepts and filter_var does not (a dotless +// domain, say) would be taken for a username and locked out. +test('an address the email rule accepts is still an address with username sign-in on', function () { + enableLdap(usernameAttribute: 'uid'); + $fake = fakeDirectory(); + $client = User::factory()->client()->create(['email' => 'someone@localhost']); + + $this->post('/login', ['email' => 'someone@localhost', 'password' => 'password'])->assertRedirect(); + + $this->assertAuthenticatedAs($client); + expect($fake->lookedUpUsernames)->toBe([]); +}); diff --git a/tests/Feature/Identity/LdapSettingsTest.php b/tests/Feature/Identity/LdapSettingsTest.php index 39322381..a5e5716f 100644 --- a/tests/Feature/Identity/LdapSettingsTest.php +++ b/tests/Feature/Identity/LdapSettingsTest.php @@ -190,3 +190,16 @@ test('settings are only usable once they are complete', function () { $settings->forceFill(['active' => true, 'host' => 'h', 'base_dn' => 'b'])->save(); expect($settings->refresh()->usable())->toBe(extension_loaded('ldap')); }); + +test('the username attribute is optional, saved, and cleared when left empty', function () { + expect(LdapSettings::current()->username_attribute)->toBeNull(); + + $this->actingAs($this->admin)->patch('/system/settings/ldap', ldapPayload(['username_attribute' => 'uid']))->assertRedirect(); + expect(LdapSettings::current()->username_attribute)->toBe('uid'); + + $this->actingAs($this->admin)->get('/system/settings/ldap') + ->assertInertia(fn (AssertableInertia $page) => $page->where('ldap.username_attribute', 'uid')); + + $this->actingAs($this->admin)->patch('/system/settings/ldap', ldapPayload(['username_attribute' => '']))->assertRedirect(); + expect(LdapSettings::current()->username_attribute)->toBeNull(); +}); diff --git a/tests/Support/FakeLdapDirectory.php b/tests/Support/FakeLdapDirectory.php index 82e214f0..00d70269 100644 --- a/tests/Support/FakeLdapDirectory.php +++ b/tests/Support/FakeLdapDirectory.php @@ -25,8 +25,11 @@ class FakeLdapDirectory implements LdapDirectory /** @var list */ public array $attemptedEmails = []; + /** @var list */ + public array $lookedUpUsernames = []; + /** - * @param array $entries keyed by email + * @param array $entries keyed by email */ public function __construct(private readonly array $entries = []) {} @@ -48,6 +51,19 @@ class FakeLdapDirectory implements LdapDirectory ); } + public function emailForUsername(string $username): ?string + { + $this->calls++; + $this->lookedUpUsernames[] = $username; + + $matches = array_keys(array_filter( + $this->entries, + fn (array $entry): bool => ($entry['username'] ?? null) === $username, + )); + + return count($matches) === 1 ? $matches[0] : null; + } + public function probe(?string $email = null, ?string $password = null): LdapProbeResult { return LdapProbeResult::ok(LdapProbeResult::STAGE_SERVICE_BIND, 'Fake directory reachable.');