Account Security Hooks

346 visualizzazioni 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.

Ti è stato utile?

Grazie per il tuo feedback!

Hai ancora bisogno di aiuto?

Il nostro team di assistenza è disponibile 24 ore su 24 per aiutarti a trovare le risposte che cerchi.