mirror of
https://github.com/projectsend/projectsend.git
synced 2026-10-07 22:01:21 +00:00
9c43f9cb9a
LDAP sign-in only took an email address. The LDAP settings now have an optional username attribute (cn, uid, sAMAccountName and so on). Once it is set, the login field also takes a username. The service account looks the username up, and the login carries on with the address the directory holds for it, through the same checks, single user bind, provisioning and rate limiting as an email login. Whether the input is an address is decided by the same email rule that accepted every stored address, so an address such as someone@localhost is never taken for a username. The username goes through the query builder, so it is escaped, and it has to match exactly one entry. The directory is client-only, so a username never signs in a staff account. With the attribute left empty, nothing changes. This ports feat/ldap_signin_by_username, which was written against v1 and has no history in common with this codebase.
209 lines
7.3 KiB
PHP
209 lines
7.3 KiB
PHP
<?php
|
|
|
|
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;
|
|
use App\Support\Rules;
|
|
use Illuminate\Auth\Events\Lockout;
|
|
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;
|
|
|
|
class LoginRequest extends FormRequest
|
|
{
|
|
/**
|
|
* Determine if the user is authorized to make this request.
|
|
*/
|
|
public function authorize(): bool
|
|
{
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Get the validation rules that apply to the request.
|
|
*
|
|
* @return array<string, ValidationRule|array<mixed>|string>
|
|
*/
|
|
public function rules(): array
|
|
{
|
|
return [
|
|
// 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
|
|
// an honest visitor whose token expired never burns one of
|
|
// their five attempts.
|
|
...Rules::captcha(CaptchaForm::Login),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Attempt to authenticate the request's credentials.
|
|
*
|
|
* Returns true when the credentials are valid but the account has
|
|
* two-factor authentication enabled: no session is created and the
|
|
* pending user id is stored for the challenge step.
|
|
*
|
|
* Three phases, deliberately in this order:
|
|
*
|
|
* 1. Identify and verify — is this password correct, from any source
|
|
* this installation accepts?
|
|
* 2. Account state — is this account allowed to sign in at all?
|
|
* 3. Two-factor, then the session.
|
|
*
|
|
* Splitting 1 from 2 is what lets a directory be consulted without
|
|
* restating anything. The property that account state is only revealed
|
|
* to somebody holding the right password now falls out of the ordering,
|
|
* rather than being re-established by a second Auth::validate() inside
|
|
* each branch — and rate limiting covers every credential source,
|
|
* because every failure funnels through one refusal.
|
|
*
|
|
* @throws ValidationException
|
|
*/
|
|
public function authenticate(): bool
|
|
{
|
|
$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 = $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 && $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);
|
|
|
|
if ($verified === null) {
|
|
$this->failWithInvalidCredentials();
|
|
}
|
|
|
|
$signIn = app(SignIn::class);
|
|
|
|
$refusal = $signIn->refusalReason($verified);
|
|
|
|
if ($refusal !== null) {
|
|
// Reached only with correct credentials, so this reveals the
|
|
// account state to its owner and to nobody else.
|
|
throw ValidationException::withMessages(['email' => $refusal]);
|
|
}
|
|
|
|
// Phases 2 and 3 are shared with every other way into this
|
|
// application — see SignIn. Rate limiting stays here, because it
|
|
// is a property of this form (keyed on email and IP) rather than
|
|
// of signing in.
|
|
$pendingTwoFactor = $signIn->begin($verified, $this->boolean('remember'));
|
|
|
|
RateLimiter::clear($this->throttleKey());
|
|
|
|
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.
|
|
*
|
|
* The rule itself -- local hash first, directory when the credentials
|
|
* live there -- is PasswordVerification's, because this is no longer
|
|
* the only screen that has to ask it. See that class.
|
|
*/
|
|
private function verifyCredentials(?User $user): ?User
|
|
{
|
|
if ($user === null) {
|
|
return null;
|
|
}
|
|
|
|
return app(PasswordVerification::class)->verify($user, (string) $this->string('password'))
|
|
? $user
|
|
: null;
|
|
}
|
|
|
|
/**
|
|
* @throws ValidationException
|
|
*/
|
|
protected function failWithInvalidCredentials(): never
|
|
{
|
|
RateLimiter::hit($this->throttleKey());
|
|
|
|
throw ValidationException::withMessages([
|
|
'email' => __('auth.failed'),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Ensure the login request is not rate limited.
|
|
*
|
|
* @throws ValidationException
|
|
*/
|
|
public function ensureIsNotRateLimited(): void
|
|
{
|
|
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
|
|
return;
|
|
}
|
|
|
|
event(new Lockout($this));
|
|
|
|
$seconds = RateLimiter::availableIn($this->throttleKey());
|
|
|
|
throw ValidationException::withMessages([
|
|
'email' => __('auth.throttle', [
|
|
'seconds' => $seconds,
|
|
'minutes' => ceil($seconds / 60),
|
|
]),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Get the rate limiting throttle key for the request.
|
|
*/
|
|
public function throttleKey(): string
|
|
{
|
|
return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
|
|
}
|
|
}
|