Files
projectsend/routes/auth.php
T
ignacionelson 717852ff6a A provider account's first password comes from its inbox, not its session
An account that signs in through a provider has no password to prove,
so the password screen let the signed-in session choose one with no
proof at all. A stolen session could then make itself permanent: set a
password, confirm it, enrol its own second factor and remove the owner's
last provider, since the account now read as local.

The screen now refuses to set a provider account's password and offers
to email a link instead: the ordinary reset link, to the account's own
address, so whoever sets the password must read that inbox. The reset
pages accept a signed-in visitor, since the owner opens the link in the
browser they are signed in with; the token, not the session, is the
authority. Using the link signs out every session holding the old
password, the one that asked for it included. Compulsory two-factor lets
the link through, so a provider account still has a way to enrol.

Ordinary accounts are unchanged: they prove their current password.

GHSA-4r8h-mwfm-f5f4
2026-10-05 00:44:26 -03:00

142 lines
6.9 KiB
PHP

<?php
use App\Http\Controllers\Auth\AuthenticatedSessionController;
use App\Http\Controllers\Auth\ConfirmablePasswordController;
use App\Http\Controllers\Auth\EmailVerificationNotificationController;
use App\Http\Controllers\Auth\EmailVerificationPromptController;
use App\Http\Controllers\Auth\NewPasswordController;
use App\Http\Controllers\Auth\PasswordResetLinkController;
use App\Http\Controllers\Auth\VerifyEmailController;
use App\Modules\Clients\Http\Controllers\InvitationRedemptionController;
use App\Modules\Clients\Http\Controllers\RegistrationController;
use App\Modules\Identity\Http\Controllers\SocialLoginController;
use App\Modules\Identity\Http\Controllers\TwoFactorChallengeController;
use Illuminate\Support\Facades\Route;
// NOTE: there is deliberately no staff registration route. /register is
// CLIENT self-registration (v1's register.php), gated by the
// clients_can_register setting inside the controller.
//
// **Every `throttle:` below names its own bucket, and must.** The bare
// two-argument form does not key on the route at all — Laravel keys it on
// `sha1(domain|ip)` for a guest and `sha1(user_id)` for a signed-in user
// (ThrottleRequests::resolveRequestSignature) — so all of these counted
// into one number together with the public share links in web.php, and the
// tightest limit on that number applied to all of them. Opening six share
// links locked the visitor out of the two-factor challenge. The numbers
// here are unchanged; the third argument is what makes each of them mean
// what it says.
//
// POST login is deliberately absent from this: it is rate-limited per
// email *and* IP inside LoginRequest, which is a stronger boundary than a
// per-IP count and does not lock out a whole office behind one address.
Route::middleware('guest')->group(function () {
Route::get('register', [RegistrationController::class, 'create'])
->name('register');
Route::post('register', [RegistrationController::class, 'store'])
->middleware('throttle:6,1,register');
Route::get('invite/{token}', [InvitationRedemptionController::class, 'create'])
->name('invitations.show');
Route::post('invite/{token}', [InvitationRedemptionController::class, 'store'])
->middleware('throttle:6,1,invite-accept')
->name('invitations.accept');
// Its own bucket, tighter than accepting one: this is the door an
// anonymous visitor can knock on repeatedly on purpose, since a real
// invitation legitimately expires while nobody is looking.
Route::post('invite/{token}/resend', [InvitationRedemptionController::class, 'resend'])
->middleware('throttle:3,1,invite-resend')
->name('invitations.resend');
Route::get('login', [AuthenticatedSessionController::class, 'create'])
->name('login');
Route::post('login', [AuthenticatedSessionController::class, 'store']);
// Beginning a provider exchange is a guest action; completing one is
// not necessarily — see the callback below, which sits outside every
// group.
Route::get('auth/{provider}/redirect', [SocialLoginController::class, 'redirect'])
->middleware('throttle:20,1,social-redirect')
->name('social.redirect');
Route::get('forgot-password', [PasswordResetLinkController::class, 'create'])
->name('password.request');
// The broker's own throttle is per-address (config/auth.php), which
// does nothing to stop one host walking a list of addresses — so the
// endpoint is throttled per IP as well, same as register/2FA below.
Route::post('forgot-password', [PasswordResetLinkController::class, 'store'])
->middleware('throttle:6,1,password-email')
->name('password.email');
Route::get('two-factor-challenge', [TwoFactorChallengeController::class, 'create'])
->name('two-factor.challenge');
Route::post('two-factor-challenge', [TwoFactorChallengeController::class, 'store'])
->middleware('throttle:6,1,two-factor');
});
// In neither group too. A reset link is also how an account that signs in
// through a provider sets its first password (PasswordController::sendLink),
// and its owner opens that link in the browser they are signed in with. The
// token is the authority here, not the session: it was emailed to the
// account's own address, so a signed-in visitor gains nothing a stranger
// holding the same link would not.
Route::get('reset-password/{token}', [NewPasswordController::class, 'create'])
->name('password.reset');
Route::post('reset-password', [NewPasswordController::class, 'store'])
->middleware('throttle:6,1,password-reset')
->name('password.store');
// Deliberately in neither group. Signing in through a provider must not
// require a session, and connecting one to an existing account requires
// exactly that — so the guard is the intent written into the session
// before the redirect, which also refuses a callback nobody asked for.
Route::get('auth/{provider}/callback', [SocialLoginController::class, 'callback'])
->middleware('throttle:20,1,social-callback')
->name('social.callback');
Route::middleware('auth')->group(function () {
Route::get('verify-email', EmailVerificationPromptController::class)
->name('verification.notice');
Route::get('verify-email/{id}/{hash}', VerifyEmailController::class)
->middleware(['signed', 'throttle:6,1,verify-email'])
->name('verification.verify');
Route::post('email/verification-notification', [EmailVerificationNotificationController::class, 'store'])
->middleware('throttle:6,1,verification-send')
->name('verification.send');
Route::get('confirm-password', [ConfirmablePasswordController::class, 'show'])
->name('password.confirm');
// Named so EnforceTwoFactor can exempt it. Its exemption list matches
// on route names, and an unnamed route matches nothing -- which left
// the form reachable and its submission not, closing the enrolment
// path enforcement depends on.
//
// Throttled because it checks a password. It was the one credential
// check in this file with no bucket at all: not the per-email-and-IP
// limiter POST login has, not a named `throttle:` like the rest --
// nothing, so an attacker holding a stolen session could sit on it
// and guess. That is the wrong door to leave open, because passing it
// is exactly what re-proving the password is meant to make expensive:
// beyond it lie disabling two-factor, regenerating recovery codes and
// minting an API token, and the password is then known for everything
// else too. Six a minute, matching the other credential-facing
// buckets here.
Route::post('confirm-password', [ConfirmablePasswordController::class, 'store'])
->middleware('throttle:6,1,password-confirm')
->name('password.confirm.store');
Route::post('logout', [AuthenticatedSessionController::class, 'destroy'])
->name('logout');
});