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:
- The request is validated by the form request, with the same rules for every address.
- The address is looked up through the user provider of Fortify's guard.
- A free address is created by your
CreatesNewUsersaction, and Laravel'sRegisteredevent is dispatched, which sends the verification mail. - A taken address gets no second account. Its holder gets the notice.
- 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
| Request | Answer |
|---|---|
| a web form | a 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 |
| JSON | 202 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.
| Setting | Default | What it does |
|---|---|---|
registration.notification | AccountAlreadyExistsNotification::class | the 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.