Skip to main content

Registration

Fortify's registration refuses a taken address with a validation error and signs a new account in. Either outcome tells the caller whether the address has an account. With the package, both read "We have sent an email to that address. Please check your inbox to continue." The new account gets its verification mail, and the holder of a taken address gets a mail saying that somebody tried to register with it.

On Fortify's route​

Nothing needs to be wired. The package puts its controller in place of Fortify's, so a POST to Fortify's registration route goes through these steps:

  1. The request is validated by the form request, with the same rules for every address.
  2. The address is looked up through the user provider of Fortify's guard.
  3. A free address is created by your CreatesNewUsers action, and Laravel's Registered event is dispatched, which sends the verification mail.
  4. A taken address gets no second account. Its holder gets the notice.
  5. Either way the caller receives the same answer, and nobody is signed in.

When fortify.lowercase_usernames is on, the address is lowercased before any of this, as Fortify would do it.

The form request​

Validation runs before the address is looked up, for a free address and a taken one alike, so a deliberately invalid field cannot tell them apart. The default form request, Pushery\FortifyHardening\Http\Requests\RegistrationRequest, carries the rules of Fortify's own CreateNewUser stub without the unique rule on the address: a unique rule would answer the very question the registration keeps closed.

If your registration takes more fields, extend it and add their rules:

namespace App\Http\Requests;

use Pushery\FortifyHardening\Http\Requests\RegistrationRequest;

class RegisterRequest extends RegistrationRequest
{
public function rules(): array
{
return [
...parent::rules(),
'terms' => ['accepted'],
];
}
}

Then name it in config/fortify-hardening.php:

'registration' => [
'request' => App\Http\Requests\RegisterRequest::class,
],

Keep these rules the ones your CreatesNewUsers action checks. The action runs only for a free address, so a field it refuses and the form request lets through is refused for a free address alone.

Your account creator​

Your CreatesNewUsers action stays as it is, its own unique rule included. Two requests that race for one new address can both find it free, and the second creation then fails at that rule or at the database's unique index. The package treats either failure as a taken address: the holder gets the notice, and the caller gets the same answer as everybody else.

An action that recognizes the race some other way throws Pushery\FortifyHardening\Registration\AddressTurnedOutTaken, with the same result. Any other failure is passed on as it would be without the package.

The answer​

RequestAnswer
a web forma redirect to the route named in registration.redirect_route, login by default, with the message flashed as status, or to the application's root when no route of that name exists
JSON202 Accepted with {"message": "We have sent an email to that address. Please check your inbox to continue."}

The message comes from the package's translations, in seven languages, and can be overridden like any other translation (see Configuration).

To answer differently, bind your own implementation of Pushery\FortifyHardening\Contracts\RegistrationAccepted in a service provider's register() method. It has to answer every outcome the same way, because any difference tells the caller whether the address has an account.

use App\Http\Responses\CheckYourInbox;
use Pushery\FortifyHardening\Contracts\RegistrationAccepted;

$this->app->bind(RegistrationAccepted::class, CheckYourInbox::class);

The notice to the holder​

The holder of a taken address receives Pushery\FortifyHardening\Notifications\AccountAlreadyExistsNotification: a short mail saying that somebody tried to create an account with the address, with a button to the sign-in page and a line about resetting a forgotten password. It is written in the application's locale, or in the user's own when the user model implements HasLocalePreference, and it loads no image, font or pixel from anywhere.

SettingDefaultWhat it does
registration.notificationAccountAlreadyExistsNotification::classthe notification the holder receives
registration.notice_channels['mail']the channels it goes out on
registration.sign_in_route'login'the route its button leads to, or the application's root when no route of that name exists

The notice is sent at once, the way Laravel sends the verification mail of a new account. If your application queues the verification mail, queue the notice as well, so both answers keep taking the same time:

namespace App\Notifications;

use Illuminate\Contracts\Queue\ShouldQueue;
use Pushery\FortifyHardening\Notifications\AccountAlreadyExistsNotification;

class QueuedAccountAlreadyExistsNotification extends AccountAlreadyExistsNotification implements ShouldQueue {}
'registration' => [
'notification' => App\Notifications\QueuedAccountAlreadyExistsNotification::class,
],

The mail budget​

Every registration mails the address it names, so the form could be used to fill somebody's inbox. A throttle on the route counts the client, and a sender who changes clients gets through it. The package therefore also counts the address: by default one mail per address and hour, set with registration.max_mails_per_hour. A mail over the budget is not sent, and the answer stays the same.

Both outcomes spend from the budget, the verification mail of a new account included. If only the notice counted, the second of two quick registrations for a new address would send a notice at once, and its timing would tell the address apart from one that had been taken all along.

The count is kept in the application's cache, under a lock per address, and the cache key is a hash of the address rather than the address itself. Use a cache store that supports locks, such as Redis, Memcached, DynamoDB or the database store. A store without locks still counts, but two requests that arrive at the same moment can then both send.

From a controller of your own​

An application that registers users in its own controller rather than on Fortify's route uses the registrar directly. It looks the address up, creates the account or sends the notice, spends the budget and dispatches Registered, exactly as on Fortify's route:

use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Pushery\FortifyHardening\Registration\Registrar;

public function store(RegisterRequest $request, Registrar $registrar): RedirectResponse
{
$registrar->register(
$request->string('email')->value(),
$request->string('password')->value(),
fn (string $hashedPassword): User => User::create([
'name' => $request->string('name')->value(),
'email' => $request->string('email')->value(),
'password' => $hashedPassword,
]),
);

return redirect()->route('login')
->with('status', __('fortify-hardening::messages.registration.accepted'));
}

register() hashes the password once, before the address is looked up, and passes the hash to your closure, so a free address and a taken one pay for the same hash. Store it as it is: a model with the hashed cast leaves an existing hash alone.

When the code that creates the account hashes the password itself, call registerHashingInside() instead. A taken address then pays one hash of the same password with the same hasher:

$registrar->registerHashingInside($email, $password, fn (): User => $creator->create($input));

Validate the request before either call, without a unique rule on the address, and give every caller the same answer afterward. The registrar signs nobody in, and neither should your controller.