cloudrepublic / shield-whatsapp-mfa
WhatsApp OTP MFA action for CodeIgniter Shield.
Package info
github.com/CloudRepublic-io/shield-whatsapp-mfa
pkg:composer/cloudrepublic/shield-whatsapp-mfa
Requires
- php: ^8.2
- codeigniter4/framework: ^4.6
- codeigniter4/shield: ^1.4
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A drop-in Authentication Action for CodeIgniter Shield that sends a 6-digit one-time code over WhatsApp instead of email, for use as a second factor after login (or as the confirmation step after registration).
It follows the exact same show() / handle() / verify() lifecycle as
Shield's built-in Email2FA action, just swapping the delivery channel.
Also includes WhatsAppSettingsController - lets an already-logged-in
user self-service verify (or change, or remove) a WhatsApp number, the
same "add this later" shape as shield-totp-mfa's
TotpSettingsController and shield-passkey-mfa's
PasskeySettingsController. Without this, WhatsApp MFA only works if
your app already has a verified phone number on file for every user;
with it, a user can turn WhatsApp on for themselves at any time, the
same as they'd add a passkey or set up an authenticator app.
Requirements
- PHP 8.2 or later
- CodeIgniter 4.6 or later
- CodeIgniter Shield 1.4 or later
Tested on CodeIgniter 4.6 and 4.7, up to PHP 8.5.
What's in the box
src/
Authentication/Actions/
WhatsAppMfa.php <- 'login' action: sends + verifies a fresh code each time
WhatsAppActivator.php <- 'register' action: optional phone verification at signup
Commands/
Setup.php <- `php spark whatsapp-mfa:setup`
ChannelStatus.php <- `php spark whatsapp-mfa:channel-status` - migration readiness report
Config/WhatsAppMfa.php <- provider + credentials config, step-up settings, view overrides
Controllers/
WhatsAppActivatorController.php <- handles WhatsAppActivator's "skip for now" link
WhatsAppSettingsController.php <- self-service phone verification + WhatsApp channel test flow
WhatsAppStepUpController.php <- step-up challenge (send + verify)
Filters/RequireFreshWhatsApp.php <- step-up auth filter for sensitive routes
Sender/WhatsAppSenderInterface.php <- contract for delivery providers
Sender/MetaCloudApiSender.php <- Meta WhatsApp Cloud API (default)
Sender/TwilioWhatsAppSender.php <- Twilio alternative - WhatsApp or SMS, via $channel
Language/en/WhatsAppMfa.php
Libraries/
PhoneNumberStore.php <- shared verified-phone storage/verification/step-up logic,
plus WhatsApp channel-test tracking
CompletesPendingAction.php <- shared "finish this pending action" trait
ChannelLabel.php <- resolves {channel} placeholders to "WhatsApp"/"SMS"
Views/
whatsapp_mfa_show.php <- "we're about to send you a code" (login)
whatsapp_mfa_verify.php <- "enter your code" + resend (login)
whatsapp_activator_enroll.php <- phone entry + skip (registration)
whatsapp_activator_verify.php <- confirm the code sent to it (registration)
whatsapp_settings_index.php <- current verified number, add/change/remove, channel-test status
whatsapp_settings_enroll.php <- enter a phone number to verify (self-service)
whatsapp_settings_verify.php <- confirm the code sent to it (self-service)
whatsapp_settings_test_enroll.php <- enter a number to test WhatsApp delivery on
whatsapp_settings_test_verify.php <- confirm the WhatsApp test code
whatsapp_step_up_show.php <- "we're about to send a code" (step-up)
whatsapp_step_up_verify.php <- confirm the code sent to it (step-up)
routes-snippet.php <- routes to add by hand
How Shield Actions work (quick recap)
Shield lets you plug in a class for what happens right after register
or login. You register it in app/Config/Auth.php:
public array $actions = [ 'register' => null, 'login' => \WhatsAppMfa\Authentication\Actions\WhatsAppMfa::class, ];
Shield's own routes (added by service('auth')->routes($routes), which
most Shield installs already call) point at a generic ActionController
that calls show(), handle(), and verify() on whichever class you
configured. You don't need to add routes yourself.
Installation
Option A - via Composer (recommended)
-
composer require cloudrepublic/shield-whatsapp-mfa. -
Run the setup command - publishes
Config/WhatsAppMfa.phpandLanguage/en/WhatsAppMfa.phpinto your app:php spark whatsapp-mfa:setup
Option B - manual drop-in
-
Copy
src/into your app (e.g.app/ThirdParty/WhatsAppMfa/src) and register the namespace inapp/Config/Autoload.php:public array $psr4 = [ APP_NAMESPACE => APPPATH, 'WhatsAppMfa' => APPPATH . 'ThirdParty/WhatsAppMfa/src', ];
-
Copy
Config/WhatsAppMfa.phptoapp/Config/WhatsAppMfa.php, andLanguage/en/WhatsAppMfa.phptoapp/Language/en/WhatsAppMfa.php.
Either way, finish with these
-
Register the action in
app/Config/Auth.php:public array $actions = [ 'register' => null, 'login' => \WhatsAppMfa\Authentication\Actions\WhatsAppMfa::class, ];
-
Add credentials to
.env(Meta Cloud API shown; seeConfig/WhatsAppMfa.phpfor Twilio equivalents):whatsAppMfa.phoneNumberId = "1234567890" whatsAppMfa.accessToken = "EAAG..."
Never commit real tokens —
.envis git-ignored by default in CI4 apps. -
Add the settings routes from
routes-snippet.phptoapp/Config/Routes.php, and link toaccount/whatsappfrom wherever your account settings page lives - this is what lets a user self-service verify their own WhatsApp number (see "Self-service phone verification" below), which is the recommended way to get a phone number on file at all. If you'd rather use a phone number your app already stores instead, skip this and see step 6. -
Or, if you already store phone numbers yourself: set
$phoneNumberFieldinapp/Config/WhatsAppMfa.phpto whatever property yourUserentity exposes it as (defaults tophone) - see customizing the User entity if you haven't added one yet.resolvePhoneNumber()checks the self-service verified record first and this as a fallback, so both can coexist if you want. -
Get a WhatsApp Business sender approved. Both Meta's Cloud API and Twilio require an approved authentication-category message template for OTP codes sent outside an existing conversation — you can't just fire free-form text. Set the template name in
app/Config/WhatsAppMfa.php($metaTemplateName/$twilioContentSid) to match what you got approved. -
If you'd rather verify a phone number at registration time instead of (or in addition to) self-service, register
WhatsAppActivatorfor'register'inapp/Config/Auth.php:public array $actions = [ 'register' => \WhatsAppMfa\Authentication\Actions\WhatsAppActivator::class, 'login' => \WhatsAppMfa\Authentication\Actions\WhatsAppMfa::class, ];
Then add
WhatsAppActivator's own routes fromroutes-snippet.php- including its "skip" route by default. The enrollment/verify
views always render a "skip for now" link, regardless of whether
this route exists - omitting it is safe (the views detect a missing
route and simply hide the link, rather than throwing when the very
first user registers), but you'd be silently taking away a "skip"
option from every new user unless that's actually what you want
(e.g. because MFA is mandatory for everyone via
shield-mfa-dispatcher's$required/$requiredMethodsForGroups). If you deliberately don't want "skip" offered at all, omitting the route is now enough on its own - you don't need to also edit the views.
- including its "skip" route by default. The enrollment/verify
views always render a "skip for now" link, regardless of whether
this route exists - omitting it is safe (the views detect a missing
route and simply hide the link, rather than throwing when the very
first user registers), but you'd be silently taking away a "skip"
option from every new user unless that's actually what you want
(e.g. because MFA is mandatory for everyone via
Self-service phone verification
WhatsAppSettingsController gives an already-logged-in user their own
account/whatsapp page to verify (or change, or remove) a WhatsApp
number - the same "add this later" model shield-totp-mfa and
shield-passkey-mfa use for their own methods. The flow:
account/whatsapp/enroll- enter a phone number.account/whatsapp/send- a 6-digit code is sent to it via whichever sender you've configured (the same$config->senderthe login action itself uses).account/whatsapp/verify→.../confirm- enter the code; on success, the number becomes the permanent, verified oneWhatsAppMfa::resolvePhoneNumber()reads by default.
This is deliberately a separate concern from the per-login OTP
code - see PhoneNumberStore's class doc comment for the full
explanation, but in short: WhatsAppMfa::createIdentity() generates a
fresh, short-lived code every single login attempt (a whatsapp_mfa
identity), while PhoneNumberStore manages one permanent, verified
number that only changes when the user explicitly re-verifies a new
one - stored via CodeIgniter's own Settings library (a per-user
context), not as a Shield identity record. See "Why the verified phone
number is stored via Settings, not a Shield identity" further down for
why - a real, confirmed bug with a Shield-identity-based approach,
fixed in the current version.
This is NOT wired into Shield's own pending-action check the way
TOTP/passkey enrollment is - verifying a phone number doesn't make
WhatsApp MFA "the" login method for anyone by itself. If you're using
shield-mfa-dispatcher, a user still needs to separately choose
'whatsapp' as their preferred method from the dispatcher's own
settings page after verifying their number here. If WhatsAppMfa is
registered directly as Auth::$actions['login'] (not via the
dispatcher), it already runs unconditionally for every login attempt
regardless of this page - self-service verification just controls
which number it sends to, not whether it runs at all.
Why the verified phone number is stored via Settings, not a Shield identity
Fixed in the current version - update if you're on an older copy.
An earlier version of PhoneNumberStore stored the permanent, verified
phone number as a Shield identity record, with the actual phone number
in the secret column - reusing Shield's own auth_identities table
the same way every package in this series reuses it for its own
per-user data.
That table's UNIQUE constraint, however, is on (type, secret) -
not (user_id, type, secret). Two different accounts both
verifying the same phone number - one person with two accounts, or a
shared family phone - produced two rows with the identical
(type='whatsapp_phone', secret='+15551234567') pair, and the second
account's own, entirely unrelated verification failed with a
duplicate-key database error. This is Shield's own base schema, not
something this package should (or safely could) alter - the exact same
bug, and the exact same fix, found and applied to
shield-mfa-dispatcher's MfaPreference (see that package's README
for the fuller account of the same underlying pattern).
PhoneNumberStore now stores the verified number through
CodeIgniter's own Settings library
instead, using its per-user context mechanism - each user's number
is stored and looked up under its own context string, with no shared
uniqueness constraint between different users' rows at all. Nothing
about PhoneNumberStore's own public API changed - only its internal
storage mechanism for this one identity type specifically. The other
three identity types (ID_TYPE_PHONE_PENDING, ID_TYPE_PHONE_ACTIVATE,
ID_TYPE_PHONE_STEP_UP) remain real Shield identity records - see
PhoneNumberStore's own class doc comment for why each one is safe to
remain one, or (for ID_TYPE_PHONE_ACTIVATE) needs to.
A second, more severe bug found and fixed alongside it
While fixing the above, a related but more severe bug was found in
ensureActivationMarker() (the registration-in-progress marker
WhatsAppActivator creates): it stored a fixed literal string
('n/a') as the secret, reasoning that "it's never read, only the
marker's existence matters." That reasoning missed the same
UNIQUE(type, secret) constraint - any two people registering at
roughly the same time, regardless of phone number, would both
produce (type='whatsapp_phone_activate', secret='n/a'), an identical
pair. Unlike the phone-sharing bug above, this one needed no
coincidence at all - it's a real risk for any live, multi-user app.
Fixed by randomizing the value (bin2hex(random_bytes(8))), matching
the pattern shield-passkey-mfa's own activation marker already used
correctly. This identity type still needs to remain a real Shield
record (Shield's own pending-check queries it directly), so it wasn't
moved to Settings the way the verified phone number was - only its
secret's content needed to change.
If you get a "must implement getType, createIdentity" fatal error
This package was written against a version of ActionInterface that
only required show()/handle()/verify(). Current Shield versions
also require getType() (returns the identity type string this
action uses - 'whatsapp_mfa' here) and createIdentity(User $user): string
(generates the code, stores it, and returns the plaintext value so the
caller can send it). Both are implemented in WhatsAppMfa.php -
handle() now calls $this->createIdentity($user) rather than doing
that work inline, matching how Shield's own Email2FA separates
"create the code" from "send the code". If you're reading this having
pulled an older copy of this file, update it from the current version.
Login completion: completeLogin(), not login()
verify() calls auth('session')->getAuthenticator()->completeLogin($user)
to finish the pending login. This was originally a guessed placeholder
(login($user)), flagged as something to double-check - and the
TotpMfa package (built later in this series) hit the actual failure
mode: Shield's login() is a stricter, separate public method (used
for things like remember-me auto-login) that refuses if it finds
leftover identities for the pending action type, with an error like
"The user has identities for action, so cannot complete login."
completeLogin() is the method Shield's own Session::attempt()
actually calls to finish a pending action, and doesn't have that
restriction. Confirmed against Shield v1.3.0's real source; still
worth a quick sanity check against whichever version you're running if
you hit login issues after upgrading Shield -
vendor/codeigniter4/shield/src/Authentication/Authenticators/Session.php.
Switching providers
Config/WhatsAppMfa.php has a single $sender property:
public string $sender = \WhatsAppMfa\Sender\MetaCloudApiSender::class; // or public string $sender = \WhatsAppMfa\Sender\TwilioWhatsAppSender::class;
To use a different provider entirely (360dialog, Vonage, an in-house
gateway), implement WhatsAppSenderInterface (one method: send()) and
point $sender at your class. The Action itself never talks to any
provider API directly.
Sending via SMS instead of WhatsApp (Twilio only)
TwilioWhatsAppSender can deliver the code via plain SMS instead of
WhatsApp - useful if your users don't reliably have WhatsApp, or you'd
rather not depend on it. Set:
public string $channel = 'sms'; // default is 'whatsapp'
This is an app-wide toggle, not a per-user choice - every user gets whichever channel is configured. If you need different users on different channels, this package doesn't support that today; you'd need your own sender implementing the branching yourself.
The same $twilioFromNumber is used for both channels - this class
strips or adds the whatsapp: prefix on that configured value as
needed, rather than requiring a second number configured specifically
for SMS. This assumes your Twilio number is capable of both channels,
which is common (many Twilio numbers are both WhatsApp-enabled and
SMS-capable) but not universal - if your WhatsApp-approved sender and
your SMS-capable number are genuinely two different numbers on your
Twilio account, update $twilioFromNumber itself to the SMS-capable
one before switching $channel to 'sms', since this class has no way
to know about a second number it was never given.
$twilioContentSid (WhatsApp's Content Template requirement) is
ignored entirely when $channel is 'sms', regardless of whether
it's set - SMS has no equivalent restriction on free-form text outside
a session window, and Twilio's own SMS API doesn't support
ContentSid/ContentVariables at all.
Every user-facing string that says "WhatsApp" is channel-aware
automatically - sendIntro, sendButton, settingsHeading,
phoneLabel, and others all use a {channel} placeholder rather than
hardcoding "WhatsApp", substituted at render time by
WhatsAppMfa\Libraries\ChannelLabel (a plain, autoloaded class -
deliberately not a global helper function requiring an explicit
helper() call, since this package has already hit two real bugs from
exactly that pattern - see "If you get 'Call to undefined function...'"
below). Switch $channel to 'sms' and every page correctly says
"SMS" instead, without editing the language file yourself - though you
can still override channelLabel_sms/channelLabel_whatsapp in your
own app's language file if you'd prefer different wording (e.g. "text
message" instead of "SMS").
If you're using shield-mfa-dispatcher, its own settings page
(account/mfa) needs one extra line to stay in sync. That page has
no idea this package - or WhatsApp, or SMS - exists; it only shows a
static label per method by default, which would leave it saying
"WhatsApp code" regardless of your $channel setting. Wire this
package's own ChannelLabel into its $methodLabelResolvers config
property to fix that:
// app/Config/MfaDispatcher.php public array $methodLabelResolvers = [ 'whatsapp' => [\WhatsAppMfa\Libraries\ChannelLabel::class, 'current'], ];
See shield-mfa-dispatcher's own README ("A method's own label can
reflect something that changes at runtime") for why this is a config
entry you add yourself, rather than something wired in automatically -
that package stays deliberately ignorant of what any given method
actually is.
Migrating an existing user base from SMS to WhatsApp (or back) safely
The risk, confirmed by a real report: a user's own preference for
this method ('whatsapp', the method key) is stored independently of
which channel actually delivers it. Someone who set this as their
preference while $channel was 'sms' keeps that same preference
after you switch to 'whatsapp' - it takes effect at their very next
login, with no grace period at all. If their number was only ever
confirmed to receive SMS, not WhatsApp specifically, a login attempt
can now silently fail: Twilio's WhatsApp API accepts the send request
regardless of whether the destination number can actually receive
WhatsApp - a real, documented failure mode (Twilio's own error 30013,
"Recipient not on WhatsApp") only surfaces later, via an asynchronous
delivery-status webhook this package doesn't implement - so from this
package's own point of view, the send looked like it succeeded, even
though the code never arrived.
The recommended path: test WhatsApp delivery individually, per user, before switching
The self-service settings page (account/whatsapp) has a "Test
WhatsApp delivery" action, available whenever $channel is currently
'sms' and the configured sender is TwilioWhatsAppSender (if
$channel is already 'whatsapp', or you're using a sender with no
channel concept at all, the normal verify flow already tests WhatsApp
directly, so this action doesn't appear at all - there's nothing extra
to test). It sends a real WhatsApp message - via
TwilioWhatsAppSender::sendViaWhatsAppRegardlessOfChannel(),
independent of whatever $channel is actually configured to - and, on
success, records that the user's number is confirmed to work over
WhatsApp specifically. This is a separate record from the main
verified number, so a user can test WhatsApp delivery at any time,
well before you ever touch $channel, without it affecting their
actual login method at all until you switch.
This solves the chicken-and-egg problem the single-channel-switch
approach has: rather than switching $channel for everyone at once and
hoping their numbers work, users can confirm WhatsApp delivery
individually, whenever it suits them, and you only flip the switch once
you know how many are actually ready.
php spark whatsapp-mfa:channel-status gives you that visibility -
a read-only command listing every user with a verified number, whether
WhatsApp delivery has been confirmed for it, and a summary count. Run
it with --unconfirmed-only to see just the users who still need to
test. It's genuinely read-only: it never sends anything or changes any
record, so it's safe to run as often as you like while migrating.
Fixed in the current version, following a real report: this
command previously produced no output at all, even with confirmed
users on file. The root cause - PhoneNumberStore::listVerificationStatuses()
queried the codeigniter4/settings package's own settings table
directly, via db_connect() with no database group specified. If your
app's actual Settings storage uses a different database group than
whichever one db_connect() treats as "default" - or a customized
table name - that query would silently return zero rows, no error at
all. Rather than continuing to guess at the exact mismatch, this method
now reuses getVerifiedPhoneNumber()/hasConfirmedWhatsAppDelivery()
directly - the exact same, already-proven service('settings') calls
every other part of this class already depends on - so there's no
second, separate assumption about the Settings library's own storage
details left to get wrong. The trade-off: this loads every user via
UserModel::findAll() rather than querying only the ones with a
verified number directly, doing more work than strictly necessary for
a very large user base - acceptable here specifically because this is
an occasional, developer-run diagnostic command, not something called
on every request.
Confirming which user's number matches which record deliberately
compares the actual number, not just a boolean flag. If a user later
changes their verified number after confirming WhatsApp delivery for
an earlier one, hasConfirmedWhatsAppDelivery() correctly reports
false for the new, untested number - there's no separate step you
need to remember to re-run when a number changes.
This is deliberately kept off shield-mfa-dispatcher's own settings
page entirely, even if you're using both packages together. This is
an operational, developer-facing migration tool for moving your user
base between channels in a controlled way - not a normal end-user MFA
concept a regular user needs to understand, so it stays specific to
this package's own settings page.
The older, still-valid fallback: re-verify via the normal flow
If you're not using TwilioWhatsAppSender's channel toggle at all (a
custom sender, or MetaCloudApiSender), or simply prefer not to add
the test flow, the normal verify flow still provides a safe path,
just requiring the switch to happen first:
beginVerification() only ever writes to a separate, temporary pending
record; the permanent verified number (stored via Settings) is only
overwritten by confirmVerification(), and only once a code has
actually been confirmed. This means starting a fresh verification
attempt - even re-entering the exact same number a user already has -
never touches their existing, working verified number unless the new
attempt actually succeeds. Concretely:
- Switch
$channelto'whatsapp'. - Before relying on it for anyone, have each existing user re-verify their number via the self-service settings page (the same "change number" flow, re-entering the number they already have) - while they're still fully logged in via whatever method currently works for them, not during a login challenge.
- If the WhatsApp message arrives and they confirm it, their number is now proven to actually work, and their permanent record is safely re-set to the same value.
- If it doesn't arrive, nothing about their account changes -
their existing, working verified number stays completely intact,
since
confirmVerification()never ran. They're still logged in, not locked out, and can retry, switch their own MFA preference to a different method, or flag it to you - all while still authenticated, rather than discovering the problem stuck at a login screen.
The test-flow approach above avoids this fallback's own main
drawback - everyone being affected by the $channel switch
simultaneously, rather than confirmed individually beforehand.
If you're also using shield-mfa-dispatcher's
$requiredMethodsForGroups, be extra careful with the order here,
regardless of which path you use above. Don't add this method as
required for any group until you've confirmed that everyone currently
in that group actually has it working. A required method overrides a
user's own preference entirely, which removes the fallback safety net
this migration path otherwise depends on - if it's required and their
number turns out not to work, they have no way to fall back to a
different method at all.
The same reasoning applies in reverse if you ever migrate back from WhatsApp to SMS - the test-flow action above is specific to testing WhatsApp, so for that direction, re-verifying via the normal flow (the fallback approach above) is the way to confirm SMS delivery still works for a given number, before an app-wide switch takes effect for everyone at once.
Security notes
- Codes are hashed with
password_hash()before storage — never stored or logged in plaintext. - Codes expire (
$config->codeLifetime, default 5 minutes) and are single-use (deleted on successful verify, and any older pending code is deleted before a new one is issued). - Consider adding rate limiting on the
auth/a/handle(resend) andauth/a/verify(guess attempts) routes via CodeIgniter'sthrottlefilter, the same way Shield already rate-limits login attempts. - WhatsApp delivery is not end-to-end guaranteed instant — a code should still expire and be resendable rather than assumed to arrive immediately.
A user with an already-verified number was still routed into enrollment - fixed
Fixed in the current version. Carried over from a confirmed, real
fix in shield-passkey-mfa: if you're pairing this package with
shield-mfa-dispatcher (register = WhatsAppActivator::class,
login = MfaDispatcher::class), a user who had already verified a
WhatsApp number could still be shown WhatsAppActivator's own
enrollment prompt on a later, ordinary login - despite
shield-mfa-dispatcher's own resolution logic correctly recognizing
them as already enrolled. Log tracing in the passkey package's own
investigation confirmed Shield itself was routing straight to the
register slot's activator, never reaching the login slot's action
at all for that request.
Confirmed against Shield's own official documentation on Auth Actions:
a custom action can implement ConditionalActionInterface's
appliesTo(User $user): bool to tell Shield directly whether it
should be considered pending for a given user at all - "when
appliesTo() returns false, Shield does not start the action and
ignores stored identities for that action while the condition remains
false." WhatsAppActivator didn't implement this. The likely
mechanism (not fully traced through Shield's own source - an honest
caveat, not a fully root-caused claim):
PhoneNumberStore::ID_TYPE_PHONE_ACTIVATE is a temporary marker
created before a phone number is even known; if it's never cleaned up
once registration completes, Shield could keep finding a match for the
register slot's own type indefinitely.
Fixed: WhatsAppActivator now implements
ConditionalActionInterface, returning false from appliesTo() once
the user already has a verified number. Genuine test coverage here -
WhatsAppActivatorTest completes a real verification (using
PhoneNumberStore::beginVerification()'s own returned code directly,
no message actually needing to be "sent") and confirms appliesTo()
correctly returns false afterward.
A sender failure left an orphaned pending record behind - fixed
Fixed in the current version. A real report: a Twilio API error
mid-send crashed the whole request, and the pending verification
record beginVerification() had already created was left behind
indefinitely - still present even after the user later completed
verification successfully through a separate attempt.
Root cause, confirmed directly in the code: every place this
package calls a configured sender -
WhatsAppActivator::handle() (registration), WhatsAppMfa::handle()
(login), WhatsAppSettingsController::send() (self-service), and
WhatsAppStepUpController::send() (step-up) - created its own
pending/temporary record first, then called $sender->send(...)
with no try/catch at all. A thrown exception (a real Twilio error,
in the reported case, but this applies to any sender failure -
network issues, provider outages, a misconfigured API key) propagated
straight through, uncaught, crashing the request. The pending record
was left orphaned, since confirmVerification() (the only code that
would otherwise delete it) never got a chance to run - the user never
even received a code to enter.
This explains a related, confusing symptom some installs may have
hit: a fully-verified user who still has an old whatsapp_phone_pending
and/or whatsapp_phone_activate row left over in auth_identities,
from an earlier crashed attempt that was never cleaned up, sitting
alongside their (unaffected, working) verified number. Both temporary
row types are otherwise correctly deleted on every successful
verification (see "Why the verified phone number is stored via
Settings, not a Shield identity" above) - this gap was specifically
about the crash path, not the success path.
Fixed: all four call sites now wrap the sender call in
try { ... } catch (\Throwable $e) { ... }, rolling back whatever was
just created (PhoneNumberStore::cancelVerification()/the new
cancelStepUp()/the login-time identity directly) and showing
WhatsAppMfa.sendFailedMessage instead of crashing. \Throwable is
used deliberately, not RuntimeException - several of these files
already import CodeIgniter\Shield\Exceptions\RuntimeException under
that exact name for other purposes, which would silently fail to catch
the plain, global RuntimeException a sender actually throws.
Tests\WhatsAppMfa\Support\FakeWhatsAppSender gained a $shouldFail
toggle specifically to test this without needing a real provider
failure, and each of the four call sites has its own regression test
confirming the rollback.
Not fixed by this change - a real, separate cleanup step: any
orphaned rows already sitting in an existing installation's database
from before this fix won't disappear on their own. If you're affected,
find and delete the stale whatsapp_phone_pending/whatsapp_phone_activate
rows for the affected user_id directly - they're inert once
orphaned (the permanent verified number lives via Settings, untouched
by any of this), but harmless clutter is still clutter.
A generic failure message gave no way to tell why - fixed
Fixed in the current version, following a real report. Every one
of the five sender-failure catch blocks above (the four listed there,
plus WhatsAppSettingsController::testSend() - the WhatsApp
channel-test flow's own send call) previously caught the real
exception and discarded it entirely, showing only a generic "please
try again" message. A real report confirmed this made it genuinely
impossible to tell why a send was failing - an invalid or unapproved
WhatsApp sender number, a missing Content Template (a real, common
cause the first time you test - see "Sending via SMS instead of
WhatsApp" above for why WhatsApp specifically requires one outside a
24h session window), a recipient who hasn't joined your Twilio
sandbox, bad credentials - without adding temporary debugging code to
find out.
Fixed: a new WhatsAppMfa\Libraries\DiagnosticLog class, matching
the identical one already used by shield-passkey-mfa and
shield-mfa-dispatcher in this same series - gated to only ever write
when ENVIRONMENT is 'development', so production logs aren't
polluted by this. All five catch blocks now log the actual exception
message via this class. The flash message shown to the user also gets
a [diagnostic: ...] suffix, but only in a development environment -
the same pattern shield-passkey-mfa's own PasskeyMfa::verify()
already uses, for the same reason: seeing the real failure reason
directly on the page is the fastest way to diagnose a problem while
you're actively testing, without needing to go check a log file at
all - but genuinely internal detail like this is never something a
real user in production should see.
If you're seeing the generic "we couldn't send a test WhatsApp
message" message yourself while testing, set CI_ENVIRONMENT=development
(or check your app's log with ENVIRONMENT already set to
'development') and try again - the actual reason will now be visible
either directly on the page or in your log, rather than hidden
entirely.
If the page loads but shows nothing at all
The views in this package wrap their content in a section named
'main', matching what Shield's own layout (setting('Auth.views')['layout'])
actually renders - Shield's own login.php uses the same name. An
earlier version of these files used a section called 'content'
instead, which that layout never displays: the page loads without any
error (nothing is actually wrong, syntactically), it just never gets
inserted into the page, so you'd see a blank content area with no log
entry to explain it. If you still see nothing after updating, check
whether you're using a custom Auth.views['layout'] and confirm what
section name it renders.
Overriding views
Every view this package renders is looked up through
Config\WhatsAppMfa::$views, the same pattern Shield itself uses for
Config\Auth::$views. To use your own view instead of a default,
override its entry in your app/Config/WhatsAppMfa.php:
public array $views = [ 'whatsapp_mfa_show' => 'App\Views\auth\my_whatsapp_show', // any key you don't list keeps using this package's default ];
Your replacement doesn't need to live under any particular namespace -
anywhere view() can resolve works. It does need to accept the same
variables the default expects; check the matching file under
src/Views/ for exactly what's passed. The overridable keys:
whatsapp_mfa_show and whatsapp_mfa_verify (login), and
whatsapp_settings_index, whatsapp_settings_enroll, and
whatsapp_settings_verify (self-service settings).
If you get "Declaration must be compatible" when loading this class
An earlier version of handle() declared a : string return type,
written before ActionInterface's actual current contract (confirmed
via the shield-totp-mfa package's real test suite) was known:
handle() must return Response, not a raw string. That mismatch
would fatal the moment this class is ever loaded under a Shield
version with that contract - it went unnoticed because this package
had never actually been exercised end-to-end until tests were written
directly against it. Fixed: handle() now builds the view body and
returns it via service('response')->setBody($body), matching
show()'s own confirmed-correct pattern in the other packages in this
series. If you're on an older copy of this file, replace it.
If you get "Call to undefined function WhatsAppMfa\Libraries\random_string()"
Fixed in the current version - update if you're on an older copy. Both
PhoneNumberStore::beginVerification() and WhatsAppMfa::createIdentity()
used to call CI4's random_string() text helper to generate the
6-digit code, without ever calling helper('text') first - that
helper isn't autoloaded by default, and nothing else in either class
loaded it. This is a real bug that broke both methods in a real app,
not a hypothetical one.
Worth knowing if you're wondering why the existing test suite didn't
catch this despite exercising both methods extensively: PHP function
definitions, once loaded via helper(), stay loaded for the rest of
that process - something else running earlier in the same shared
PHPUnit process most likely loaded the text helper for an unrelated
reason, masking the bug there while it broke a real app directly. This
is exactly the kind of load-order fragility that's easy to not notice
in a test environment - fixed by removing the dependency on the helper
entirely (a small, self-contained generateCode() method using only
random_int()), not just adding the missing helper('text') call,
which would still leave this fragile to whatever happens to run first.
If you get "Can't find a route for 'GET: auth/a/handle'" after entering a code
Fixed in the current version - update if you're on an older copy. This
was a real, confirmed bug in WhatsAppMfa::verify() and
WhatsAppActivator::verify()'s error handling, not something specific
to your setup.
The code-entry page is rendered directly by handle() - a POST
response to auth/a/handle, with no redirect in between - so the
browser's address bar stays on that POST-only route while the user is
looking at the form. redirect()->back() targets wherever the browser
was last on, which is that same URL; browsers only ever follow a
redirect via GET, and there's no GET route registered at
auth/a/handle - hence the error, the moment a wrong or empty code
was submitted.
TotpMfa/PasskeyMfa never had this problem, and don't need this
fix: their verify forms are rendered by show() itself (a GET route),
not by a separate handle() step, so back() correctly lands on a
real, GET-accessible page for them. WhatsApp's flow has an extra step
(send the code, then show the form) that TOTP/passkey don't, which
is exactly what created the gap.
Fixed by redirecting to the named auth-action-show route explicitly
instead of back() - already the pattern this same method used
correctly for its other error cases (an expired or already-consumed
code), just inconsistently missed for a wrong or empty one. Worth
knowing about the one UX trade-off this introduces for
WhatsAppActivator specifically: since its show() renders the
phone entry form (not a code-retry), a wrong code during
registration sends the user back to re-entering their number rather
than just retrying - correct and safe, if a little more friction than
ideal.
Also worth being upfront about why the existing test suite never
caught this either: every test in this whole series calls these
methods directly rather than through real HTTP, so redirect()->back()'s
actual behavior (which depends on session-tracked "previous URL"
state that only a real browser request populates) was never once
genuinely exercised by any test here. Regression tests have been added
that check the redirect target explicitly instead.
Step-up auth for sensitive pages (RequireFreshWhatsApp filter)
Everything above concerns login. This is different: a route filter
that forces a fresh WhatsApp challenge before reaching a specific page,
even for a user who's already fully logged in - useful for gating
sensitive actions (updating payment/API settings, changing an email
address, etc.) behind re-confirmed identity, the way Stripe, GitHub,
and AWS all do before letting you touch billing or security settings.
Mirrors shield-totp-mfa's own RequireFreshTotp filter - see that
package's README for more detail on the design; the short version is
repeated here, plus what's different about WhatsApp specifically.
Setup
-
Register the filter alias in
app/Config/Filters.php:public array $aliases = [ // ... your existing aliases 'whatsapp-fresh' => \WhatsAppMfa\Filters\RequireFreshWhatsApp::class, ];
-
Add the step-up challenge routes from
routes-snippet.php(already included if you copied the whole snippet earlier). -
Apply it to whichever routes need protecting, alongside your normal login-required filter:
$routes->group('admin/billing', ['filter' => ['session', 'whatsapp-fresh']], static function ($routes) { $routes->get('stripe-settings', 'Admin\BillingController::index'); $routes->post('stripe-settings', 'Admin\BillingController::update'); });
That's it - a user reaching admin/billing/stripe-settings without a
recent-enough WhatsApp challenge gets sent to a short challenge flow
first, then bounced back to where they were headed.
Three steps, not two - unlike shield-totp-mfa/shield-passkey-mfa
TOTP and passkey step-up challenges are a single page: show the form,
submit, done - there's nothing to "send" first. WhatsApp needs an
explicit send step, the same way the login action does:
WhatsAppStepUpController::show() ("we're about to send a code to the
number ending in...") → send() (generates + sends a fresh code,
renders the code-entry form) → verify() (checks it, stamps the
step-up session).
This challenges the user's already-verified number specifically -
PhoneNumberStore::beginStepUpChallenge()/verifyStepUpChallenge()
use a dedicated identity type (ID_TYPE_PHONE_STEP_UP), deliberately
separate from the phone-verification flow's own type. A step-up
challenge in progress and a "change my number" attempt in progress
don't interfere with each other, even if a user somehow has both open
at once.
How freshness works
A timestamp is stashed in session the moment a challenge succeeds.
Subsequent requests to any whatsapp-fresh-protected route within
$config->stepUpFreshnessSeconds (default 15 minutes) pass straight
through without asking again; after that window, the next protected
page reached asks again.
What happens if the user has no verified number at all
By default, RequireFreshWhatsApp lets them through - there's nothing
to challenge them with, so the filter doesn't lock them out of a page
they have no way to unlock. If you'd rather force verification before
such pages are reachable at all, set:
public bool $stepUpRequiresEnrollment = true; public string $stepUpEnrollRouteName = 'whatsapp-settings-enroll'; // or // 'mfa-settings-whatsapp-enroll' // if using shield-mfa-dispatcher
This is separate machinery from the login Action, deliberately
RequireFreshWhatsApp/WhatsAppStepUpController don't touch Shield's
ActionInterface/pending-login mechanism at all - they're an ordinary
CodeIgniter filter and controller operating on auth()->user(). See
shield-totp-mfa's README for the fuller explanation of why step-up
auth is deliberately kept out of the Action system entirely.
Built with the redirect()->back() fix already in place
WhatsAppStepUpController::verify()'s error path redirects to the
named whatsapp-step-up route, never back() - see the confirmed bug
this exact pattern caused in the login action's own verify()
(documented earlier in this README) for why. The step-up flow has the
identical shape (a code-entry page rendered by a POST response, no
redirect in between), so it was built with that fix from the start
rather than needing it discovered here separately.
Tests
If you're using shield-mfa-dispatcher (or anything else that
makes Config\Auth::$actions point at something other than
WhatsAppMfa/WhatsAppActivator directly): the confirmed fixes
shield-totp-mfa needed for this exact same architecture (session
leakage between test methods, resetServices()'s own route-wiping
side effect, and reflection-based pending-state simulation for the
registration-time activator once 'login' points elsewhere) are
applied here too - see shield-totp-mfa's README and its
TotpMfaTest/TotpActivatorTest class doc comments for the full,
diagnostic-backed account; not repeated here in full since the
mechanism is identical. WhatsAppMfaTest and WhatsAppActivatorTest
have the complete fix; RequireFreshWhatsAppTest,
WhatsAppStepUpControllerTest, and WhatsAppSettingsControllerTest
have the defensive Services::routes()->loadRoutes() piece only,
since they use actingAs() rather than attempt() and were never
affected by the session/pending-state issues specifically.
tests/WhatsAppMfa/ covers WhatsAppMfa (handle()/verify(): code
generation and sending via a fake sender, so no real network call ever
happens, plus correct/wrong/empty/expired code handling),
WhatsAppActivator (the registration-time counterpart - phone entry,
send, verify, skip), PhoneNumberStore (including the WhatsApp
channel-test flow's own tracking - see "Migrating an existing user
base" above), the self-service settings controller (including its own
test-flow actions), the step-up auth filter/controller,
TwilioWhatsAppSender's own channel-switching field-building logic
($channel = 'whatsapp' vs 'sms') and its forced-WhatsApp sending
(sendViaWhatsAppRegardlessOfChannel()), ChannelLabel (the
{channel} placeholder substitution that feature relies on), and the
whatsapp-mfa:channel-status CLI command itself (via CI4's own
StreamFilterTrait, capturing real command output rather than only
testing the underlying data method in isolation).
tests/WhatsAppMfa/
Support/FakeWhatsAppSender.php <- records what would have been sent, no real network call -
$shouldFail simulates a real provider failure, for testing
the sender-failure rollback fix (see that section above)
Support/TestableWhatsAppMfa.php <- fixes the phone number for testing, since Shield's
stock User entity has no phone column
Support/TestableTwilioWhatsAppSender.php <- exposes TwilioWhatsAppSender's protected
buildFields()/forceWhatsAppChannel() for direct
testing, no HTTP call needed
Support/FakeTwilioWhatsAppSender.php <- a TwilioWhatsAppSender SUBCLASS (not the more general
FakeWhatsAppSender) overriding only send() itself, so a
test exercises the real sendViaWhatsAppRegardlessOfChannel()/
forceWhatsAppChannel() logic while avoiding a real network
call - needed since the controller's own relevance check
requires an actual TwilioWhatsAppSender subclass
Authentication/Actions/WhatsAppMfaTest.php
Authentication/Actions/WhatsAppActivatorTest.php <- registration-time counterpart, including
the $wasAlreadyActive / forced-setup-reuse fix
Libraries/PhoneNumberStoreTest.php <- pending-to-permanent record lifecycle, step-up challenges,
regression tests for the fixed duplicate-key bugs, and
the WhatsApp channel-test tracking (including staleness
when the verified number later changes)
Libraries/ChannelLabelTest.php <- {channel} placeholder resolution/substitution
Controllers/WhatsAppSettingsControllerTest.php <- enroll/send/verify/confirm/disable, plus the
test-flow actions and their own relevance guard
Filters/RequireFreshWhatsAppTest.php <- step-up freshness/enrollment logic
Controllers/WhatsAppStepUpControllerTest.php <- step-up show/send/verify, including a
regression test for the back()-vs-route() fix
Sender/TwilioWhatsAppSenderTest.php <- WhatsApp vs SMS field-building (To/From prefixing,
Content Template ignored entirely for SMS), plus
forceWhatsAppChannel()'s own override behavior
Commands/ChannelStatusTest.php <- the whatsapp-mfa:channel-status CLI command itself
Fixes from running the suite on CodeIgniter 4.7 / PHP 8.5
Class "Tests\WhatsAppMfa\Support\FakeWhatsAppSender" not found(and the same forTestableTwilioWhatsAppSender). A typical CodeIgniter app only autoloadsTests\Support\fromtests/_support, and PHPUnit loads only*Test.phpfiles itself. Each test file now usesrequire_onceto load the support classes it needs. If you'd rather autoload them, add"Tests\\": "tests/"toautoload-devin your app'scomposer.json. Therequire_oncelines are harmless either way.Table 'users' doesn't existinChannelStatusTest. This was the only database test missingprotected $namespace = null;, so only the defaultTests\Supportmigrations ran and Shield's tables were never created. It now has that line.- A verified number from one test leaking into the next. This showed
up in
PhoneNumberStoreTestand in the "unverified user passes through" step-up test.PhoneNumberStorekeeps numbers in the Settings library, which caches every value it has read on the sharedsettingsservice.$refreshresets the database between tests but not that cache, and user ids start at 1 again after each refresh. A number verified foruser:1in one test was therefore still returned for the next test's brand-newuser:1. Every database-backed test now callsServices::resetSingle('settings')insetUp(). This only affects tests: a real request gets a fresh service anyway. - Username too long. Shield's
users.usernameisVARCHAR(30), anduniqid()adds 13 characters. The longer prefixes are nowwaactest,wasettestandwasutest. --unconfirmed-onlyignored unless typed on the real command line (a real bug, fixed insrc/Commands/ChannelStatus.php). The command read the flag only throughCLI::getOption(), which sees the actual process's arguments. When the command was run any other way, throughcommand('whatsapp-mfa:channel-status --unconfirmed-only')(as its test does) or$this->call()from another command, the flag was silently dropped and every user was listed. Typing it afterphp sparkalways worked. The command now also checks$params, where CodeIgniter passes options in every case.- Tests asserting on text the views had since changed. Two tests
still looked for the raw
{channel} number/You haven't verified a {channel} number yet.strings. The views run those throughChannelLabel::inject(), so the page actually says "WhatsApp" (or the SMS label), and the tests now compare against the same substituted text. Two others looked for1234567in pages that deliberately show the number masked to its last four digits (********4567). They now check for the masked form, and that the full number is not on the page. - POST data invisible on CodeIgniter 4.7+. From 4.7, a request reads
POST data from a shared
superglobalssnapshot, taken the first time anything touches the request. The tests' request helpers now also call$request->setGlobal('post', $post), which works on 4.6 and 4.7.
Setup
-
Copy
tests/WhatsAppMfainto your app's owntests/folder, the same waysrc/gets installed - see "Installation" above. -
Make sure your test database has Shield's own migrations applied (
users,auth_identities, etc.) -protected $namespace = null;in the test class triggers this automatically, equivalent tophp spark migrate --all, as long as the connection itself works. -
Run it the same way as the rest of your suite:
vendor/bin/phpunit tests/WhatsAppMfa
Why two different testing patterns in this one suite
WhatsAppMfaTest and WhatsAppActivatorTest (the login and
registration-time actions) use a real Session::attempt() call with
real credentials to put the authenticator into a genuinely pending
login state - actingAs() puts it into a fully logged in state
instead, a different thing from what getPendingUser() checks for;
this was worked out through several rounds of trial and error,
documented in shield-totp-mfa's own TotpMfaTest.
WhatsAppSettingsControllerTest uses actingAs() instead, because
that controller is for someone already fully logged in managing
their own settings (auth()->user(), not getPendingUser()), which is
exactly the state actingAs() produces.
WhatsAppActivatorTest can test something PasskeyActivatorTest can't
Both packages' activator classes gained the same fix for
shield-mfa-dispatcher's forced-setup reuse: verify() now checks
whether the user was already active before deciding where to redirect
(see shield-totp-mfa's README, "Design decisions worth knowing
about", for the full explanation). PasskeyActivatorTest can't
exercise that branch directly - it only runs after a genuinely valid
signed WebAuthn response, which the test suite can't produce (see
PasskeyIdentityStoreTest's class doc comment in that package). This
package's equivalent check can be tested directly
(testVerifyForAnAlreadyActiveUserRedirectsToLoginNotRegistration),
since WhatsApp's code verification is plain 6-digit matching, not real
cryptography - the same reason shield-totp-mfa's TotpActivatorTest
can test it too.
Why a fake sender and a phone-number override
FakeWhatsAppSender replaces whatever's configured in
Config\WhatsAppMfa::$sender for the duration of each test, so running
this suite never sends a real WhatsApp message or calls a real
Meta/Twilio API - used by both WhatsAppMfaTest and
WhatsAppSettingsControllerTest. TestableWhatsAppMfa (used only by
WhatsAppMfaTest) overrides resolvePhoneNumber() to a fixed test
number for the login-action tests specifically - not needed for the
settings controller tests, since those exercise PhoneNumberStore
directly rather than going through resolvePhoneNumber()'s fallback
chain.
If you hit "Declaration must be compatible" loading WhatsAppMfa
See "If you get 'Declaration must be compatible'..." earlier in this
README - fixed in the current version of WhatsAppMfa.php.