Files
projectsend/app/Http/Requests/Auth/LoginRequest.php
T
ignacionelson b96d060ad8 Compare an address ourselves, instead of asking the collation
Reported by @choewonwoo1817 as GHSA-wgxf-v8cr-37mj, with a working
end-to-end reproducer against Keycloak.

`where('email', $address)` is not an exact match. It is whatever the
database says equality means, and the collation INSTALL.md tells people to
create — utf8mb4_unicode_ci — folds accents:

    administrator@example.com = administrator@éxample.com   -> 1

Those are two different domains. The second is xn--xample-9ua.com, which
somebody else can register and honestly verify at an OIDC provider. So an
attacker with no account here could sign in as themselves and be handed
the first account: SocialAuthenticator found it, linked their subject to
it permanently, and started a session. No password, no interaction from
the owner, an administrator session where that account was one.

Comparison now happens in PHP, in one place, on every driver. Case is
still folded because that is a real requirement — addresses are stored
lowercased and a provider may send any case — and mb_strtolower folds case
without folding accents, which is exactly the line to draw.

Three call sites move to it and two deliberately do not. Loose matching is
right when *refusing* and wrong when *selecting*: AvailableEmailRule and
ClientProvisioning ask "is this address free", where a collation that says
no to a near-miss refuses more registrations, which is the safe direction.
The three that ask "which account is this" are the social path, the login
form (where a password still gated it, so it was confusion rather than
takeover) and the erasure command (irreversible, and the wrong row is the
wrong person).

The test story is the part worth reading. The suite runs on SQLite, whose
`=` is byte-exact, so this defect does not exist there and never did —
which is how it survived six releases with everything green. A test
written the obvious way passes on unfixed code. So the comparison is
pinned by driver-independent tests that always run, and the chain is
proved by AccountLookupCollationTest, which skips unless the connection is
MySQL and carries the command to run it. Run against real MySQL with the
real collation: it fails on the old code and passes on the new.
2026-09-09 07:32:26 -03:00

182 lines
6.0 KiB
PHP

<?php
namespace App\Http\Requests\Auth;
use App\Models\User;
use App\Modules\Identity\AccountLookup;
use App\Modules\Identity\Ldap\LdapProvisioner;
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\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 [
'email' => ['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();
// 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'));
// 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'),
);
}
$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;
}
/**
* 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());
}
}