Search code examples
laravellaravel-jetstreamlaravel-fortify

Is there a built-in way to remove account enumeration from registration?


I have created a new site using Jetstream and Inertia. Currently the application will return a "The email has already been taken." message if a user tries to register with an existing email. Notwithstanding timing analysis, I would like to keep the existence of user accounts private. Is there a way to keep the unique constraint on email but display the same outward behavior if someone registers with an existing email? Ideally, I would like to not create a second user, but email the existing user suggesting they reset their password or ignore the email.


Solution

  • Here's what worked for me:

    1. Create a new validation Exception in app/Exceptions/ExistingUserException.php
    namespace App\Exceptions;
    
    use Illuminate\Validation\ValidationException;
    
    class ExistingUserException extends ValidationException
    {
    
    }
    
    1. Break validation into 2 steps in app/Actions/Fortify/CreateNewUser.php, throwing the extended ValidationException if the form is otherwise good
            Validator::make($input, [
                'name' => ['required', 'string', 'max:255'],
                'email' => ['required', 'string', 'email', 'max:255'],
                'password' => $this->passwordRules(),
                'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['required', 'accepted'] : '',
            ])->validate();
    
            $validator =  Validator::make($input, [
                'email' => ['unique:users'],
            ], ['email.unique'=>'']);
    
            if ($validator->fails())
            {
                throw new ExistingUserException($validator);
            }
    
    1. Create a new middleware in app/Http/Middleware/CatchExistingUser.php
    <?php
    
    namespace App\Http\Middleware;
    
    use App\Exceptions\ExistingUserException;
    use Closure;
    use Illuminate\Http\Request;
    use Illuminate\Support\Facades\Redirect;
    use Illuminate\Support\Facades\URL;
    
    class CatchExistingUser
    {
        /**
         * Handle an incoming request.
         *
         * @param  \Illuminate\Http\Request  $request
         * @param  \Closure  $next
         * @return mixed
         */
        public function handle(Request $request, Closure $next, $redirectToRoute = null)
        {
            $response = $next($request);
    
            if ($response->exception && $response->exception instanceof ExistingUserException)
            {
                return $request->expectsJson()
                        ? abort(403, 'Your email address is not verified.')
                        : Redirect::guest(URL::route($redirectToRoute ?: 'verification.notice'));
            }
    
            return $response;
        }
    }
    
    1. Inject the middleware into all fortify routes via config/fortify.php
    'middleware' => [CatchExistingUser::class, 'web'],
    
    1. Remove the auth middleware from the verification page by overwriting the route in routes/web.php
    use Illuminate\Http\Request;
    use Laravel\Fortify\Contracts\VerifyEmailViewResponse;
    ...
    Route::get('/email/verify', function (Request $request) {
        $user = $request->user();
        if ($user && $user->hasVerifiedEmail())
        {
            return redirect()->intended(config('fortify.home'));
        }
        return app(VerifyEmailViewResponse::class);
    })
    ->name('verification.notice');
    

    The custom exception is not ideal but it seems cleaner than testing the validator stored in the ValidatorException and then removing one message if there's more than one error. I think this would be needed to allow the validation of other fields while not leaking email uniqueness.