Account Security Hooks

1 views Markdown

The nine hooks over verification, password resets, sign-outs and access.

Overview

Every step that guards the identity of an account lives here: email and phone verification, the security question, password resets, login codes and sign-outs.

Two gates stand out. The password gate hands you the raw password; only check it. The access gate works the other way round from the rest: a filled return skips the built-in checks.

Reference

Following an email change

actionuser.email_changed
AccountUsers a verified address

Runs after the email address of an account changes. The new address is verified.

Parameters 3
$uidintThe account owner.
$oldEmailstringThe previous address.
$newEmailstringThe new, verified address.
Return 1
voidThe return is ignored.
Listener
Hook::add('action:user.email_changed', 10, function ($uid, $oldEmail, $newEmail) {
    // Tell the old address too: if the account was taken, that is the only warning path.
    Acme::alertOldAddress($oldEmail, $newEmail);
});

Following an email verification

actionuser.email_verified
AccountUsers at verification

Runs after a customer verifies their email address.

Parameters 1
$user_idintThe customer whose address was verified.
Return 1
voidThe return is ignored.
Listener
Hook::add('action:user.email_verified', 10, function ($user_id) {
    Acme::unlockOnboarding($user_id);
});

Following a phone verification

actionuser.phone_verified
AccountUsers international form

Runs after a customer verifies their phone number.

Parameters 2
$uidintThe customer whose number was verified.
$phonestringThe verified number as stored, in international form. The country code leads it; do not expect a local format.
Return 1
voidThe return is ignored.
Listener
Hook::add('action:user.phone_verified', 10, function ($uid, $phone) {
    // The number is in international form.
    Acme::enableSmsAlerts($uid, $phone);
});

Following a security question change

actionuser.security_question_changed
AccountUsers always the login id

Runs when the security question of an account is set or removed.

Parameters 2
$user_idintThe member whose question changed. This is always the person signed in: a sub-user cannot change somebody else’s question.
$has_questionboolTrue when a question is now set; false when the question and its answer were removed together.
Return 1
voidThe return is ignored.
Listener
Hook::add('action:user.security_question_changed', 10, function ($user_id, $has_question) {
    // Removal comes through this hook too.
    if (!$has_question) Acme::warnWeakerRecovery($user_id);
});

Following a password reset request

actionuser.password_reset_requested
Auth two account types

Runs when a password reset is requested. Both customer and administrator accounts pass here.

Parameters 3
$userIdintThe account the request opened for.
$typestringThe account type: member or admin. Administrator resets matter far more; keep the two apart.
$emailstringThe address it was requested from, confirmed to match the account.
Return 1
voidThe return is ignored. What your listener returns cannot change the reset response.
Listener
Hook::add('action:user.password_reset_requested', 10, function ($userId, $type, $email) {
    // An administrator reset matters far more.
    if ($type === 'admin') Acme::alertSecurityTeam($userId, $email);
});

Following a sign-out

actionuser.logged_out
Auth session captured first

Runs when a session is closed.

Parameters 2
$dataarrayThe session of the person leaving, captured before the sign-out. With no session it arrives empty: look before reaching for an id.
$typestringThe sign-out type: admin or member.
Return 1
voidThe return is ignored.
Listener
Hook::add('action:user.logged_out', 10, function ($data, $type) {
    // With no session the array arrives EMPTY.
    if (!($data['id'] ?? 0)) return;

    Acme::closeSession((int) $data['id'], $type);
});

Following a login code being sent

actionuser.login_code_issued
Auth the code is not passed

Runs when a one-time login code goes out.

Parameters 3
$userIdintThe member the code went to.
$channelstringThe delivery channel.
$expiryintWhen the code stops working. The code itself is not passed, deliberately: handing it to a listener would spread it to a second place.
Return 1
voidThe return is ignored; it cannot change the response.
Listener
Hook::add('action:user.login_code_issued', 10, function ($userId, $channel, $expiry) {
    // The code itself is deliberately withheld.
    Acme::noteLoginAttempt($userId, $channel);
});

Following a device being trusted

actionuser.device_trusted
Auth the trust token is not passed

Runs when an account ticks "trust this device" on the two-factor screen and the browser is remembered.

Parameters 4
$userIdintThe account the device now belongs to.
$typestringThe account type: member or admin.
$windowintHow many days the trust lasts.
$uastringThe browser's raw User-Agent string. The trust token is not passed: handing it to a listener would spread it to a second place.
Return 1
voidThe return is ignored.
Listener
Hook::add('action:user.device_trusted', 10, function ($userId, $type, $window, $ua) {
    Acme::securityLog('device_trusted', $userId, ['type' => $type, 'days' => $window]);
});

Following device trust being revoked

actionuser.device_trust_revoked
Auth rows are already gone

Runs when an account's trusted devices are removed: a password reset, two-factor being switched on or off, a recovery-code sign-in, "sign out everywhere", or a removal from the account's own list.

Parameters 3
$userIdintThe account whose trust was revoked.
$typestringThe account type: member or admin. On a bulk clear with no type given it arrives empty — every type went.
$countintHow many devices were removed; a single removal always passes 1.
Return 1
voidThe return is ignored; the rows are already deleted when it runs.
Listener
Hook::add('action:user.device_trust_revoked', 10, function ($userId, $type, $count) {
    Acme::securityLog('trust_revoked', $userId, ['count' => $count]);
});

Stopping a password reset

gateuser.password_reset
AdminUsers the raw password

Runs before an administrator password is reset. Enforce your own password policy here.

Parameters 3
$user_idintThe account being reset.
$passwordstringThe new password in the clear. ? For a policy check only: do not log it, store it or send it anywhere.
$bystringWhere the reset came from.
Return 1
mixedA filled return blocks it: either a text or an array carrying a message. An empty return lets it carry on.
Listener
Hook::add('gate:user.password_reset', 10, function ($user_id, $password, $by) {
    // The raw password: check it only, write it NOWHERE.
    if (strlen($password) < 16) return 'An administrator password needs at least 16 characters.';

    return null;
});

Sending a customer to a screen of your own

gateuser.full_access
website returns a redirect

Runs when a customer enters the panel, ahead of the built-in access checks: missing fields, data consent and billing details all come after it.

Parameters 1
$idintThe customer signing in.
Return 1
string|null? It works the other way round from other gates: returning a filled address sends the customer there and skips every built-in check. Returning an address unconditionally switches off the missing-field and consent checks for good. Keep your condition narrow and return empty otherwise.
Listener
Hook::add('gate:user.full_access', 10, function ($id) {
    // A filled return SKIPS THE BUILT-IN CHECKS: keep the condition narrow.
    if (Acme::mustAcceptTerms($id)) return Utility::AppAdress() . '/acme/terms';

    return null;
});

Pitfalls

The access gate works the other way round

In other gates a filled return blocks; in the access gate a filled return redirects and skips every built-in check. Returning an address unconditionally switches off the missing-field and consent checks for good.

The password gate receives the raw password

The reset gate hands you the new password in the clear, which a policy check needs. Logging it, storing it or sending it anywhere leaks exactly what you set out to protect.

Was this helpful?

Thanks for your feedback!

Still Need Help?

Our support team is here around the clock for anything you can't find above.