CakeDC/Users gives you TOTP two-factor authentication almost for free: flip one config key and every login grows a "enter your 6-digit code" step. The catch is that word every. The built-in flow is all-or-nothing — turn it on and all your users are forced through the OTP challenge on their next login, whether they ever set up an authenticator app or not. Lock yourself out on a fresh install and you'll find out fast.
What most apps actually want is the model you see everywhere else: 2FA is off by default, and each user opts in from their own account settings. This post shows how to get there with a surprisingly small change — one overridden method — plus a self-service enrolment screen and one QR-code gotcha that will bite you on modern dependencies.
The one insight: isRequired()
CakeDC/Users decides whether to demand the OTP step through an
OneTimePasswordAuthenticationCheckerInterface. The default implementation,
DefaultOneTimePasswordAuthenticationChecker, answers "is 2FA required for this
request?" — and once the authenticator is enabled in the login flow, it answers
yes for everybody.
That checker is a swappable dependency. So "per-user 2FA" reduces to: keep the
default behaviour, but also require that this specific user has opted in. One
method:
<?php
declare(strict_types=1);
namespace App\Authentication;
use CakeDC\Auth\Authentication\DefaultOneTimePasswordAuthenticationChecker;
class PerUserOneTimePasswordAuthenticationChecker extends DefaultOneTimePasswordAuthenticationChecker
{
/**
* @param array<mixed>|null $user User data.
*/
public function isRequired(?array $user = null): bool
{
// Default rules AND the user enrolled.
return parent::isRequired($user) && !empty($user['two_steps']);
}
}
parent::isRequired() keeps every rule CakeDC already applies (the authenticator
is on, the user has a verified secret, remember-me isn't skipping it, …). We just
&& a per-user flag on top. Users who never enrolled fail the two_steps check
and log in normally; users who did get challenged. That's the whole hook.
The two_steps flag
CakeDC's own initial migration already gives the users table the columns the TOTP
flow needs — secret (the shared TOTP secret) and secret_verified (proof the
user typed a valid code at least once). The only thing it doesn't have is a column
that means "this user wants 2FA." Add it:
<?php
declare(strict_types=1);
use Migrations\BaseMigration;
class AddTwoStepsToUsers extends BaseMigration
{
public function up(): void
{
$this->table('users')
->addColumn('two_steps', 'boolean', ['default' => false, 'null' => false])
->update();
}
public function down(): void
{
$this->table('users')->removeColumn('two_steps')->update();
}
}
default => false is the important part: existing users are untouched and new
users start with 2FA off. Nobody is forced into anything.
Wiring the checker (and the QR gotcha)
Point the OTP authenticator at your checker in config/users.php. This is also
where you enable the authenticator in the login flow (login => true):
use App\Authentication\PerUserOneTimePasswordAuthenticationChecker;
use RobThree\Auth\Providers\Qr\BaconQrCodeProvider;
$config['OneTimePasswordAuthenticator'] = [
'checker' => PerUserOneTimePasswordAuthenticationChecker::class,
'login' => true,
'issuer' => 'My App', // the label users see in their authenticator app
'qrcodeprovider' => new BaconQrCodeProvider(format: 'svg'),
];
That last line is the gotcha. CakeDC defaults the QR provider to robthree's
EndroidQrCodeProvider, which calls QrCode::setSize() — a method removed in
endroid/qr-code v6. On a modern install you'll get a fatal the moment you try
to render the enrolment QR. Swap in the BaconQrCodeProvider with format: 'svg':
it's pure PHP (no ext-imagick), and the SVG embeds cleanly as a data: URI, so
the QR drops straight into your template with no file writes. digits, period
and algorithm inherit from CakeDC's defaults.
Self-service enrolment
Now the part that makes it per-user in practice: a screen where a signed-in user
turns 2FA on for themselves, no logout round-trip. The flow is:
- GET — make sure a secret exists (unverified), show the QR + the manual key + a 6-digit code field.
-
POST — verify the typed code against the secret; if it checks out, flip
secret_verifiedandtwo_stepson and refresh the identity.
public function twoFactor(): ?Response
{
$userId = $this->getRequest()->getAttribute('identity')?->getIdentifier();
if ($userId === null) {
return $this->redirect(['action' => 'login']);
}
$table = $this->getUsersTable();
$user = $table->get($userId);
if ($user->get('secret_verified') && $user->get('two_steps')) {
$this->Flash->success(__('Two-factor authentication is already enabled.'));
return $this->redirect(['action' => 'profile']);
}
// Create a secret to display, but keep it unverified until the user proves
// they can generate a valid code from it.
$secret = (string)$user->get('secret');
if ($secret === '') {
$secret = $this->OneTimePasswordAuthenticator->createSecret();
$user->set('secret', $secret);
$user->set('secret_verified', false);
$user->set('two_steps', false);
$table->saveOrFail($user);
}
if ($this->getRequest()->is(['post', 'put'])) {
$code = (string)$this->getRequest()->getData('code');
if ($this->OneTimePasswordAuthenticator->verifyCode($secret, $code)) {
$user->set('secret_verified', true);
$user->set('two_steps', true);
$table->saveOrFail($user);
$this->Authentication->setIdentity($user); // so this session sees the change
$this->Flash->success(__('Two-factor authentication is now enabled.'));
return $this->redirect(['action' => 'profile']);
}
$this->Flash->error(__('That code is not valid. Please try again.'));
}
$secretDataUri = $this->OneTimePasswordAuthenticator->getQRCodeImageAsDataUri(
(string)$user->get('email'),
$secret,
);
$this->set(compact('user', 'secret', 'secretDataUri'));
return null;
}
Two details worth calling out:
-
The secret is written before it's verified, but
two_stepsstaysfalseuntil the code checks out. So a half-finished enrolment never arms the login challenge — the user can't lock themselves out by navigating away. -
$this->Authentication->setIdentity($user)after saving refreshes the identity in the current session, so the new flags are live immediately instead of on the next request.
The OneTimePasswordAuthenticator component gives you createSecret(),
verifyCode() and getQRCodeImageAsDataUri() — load it (CakeDC/Auth.OneTimePasswordAuthenticator)
in the controller that hosts this action.
Let users turn it off, too
Opt-in implies opt-out. Clear the secret and both flags, refresh the identity, and
the next login skips the challenge:
public function disableTwoFactor(): ?Response
{
$this->getRequest()->allowMethod(['post', 'delete']);
$userId = $this->getRequest()->getAttribute('identity')?->getIdentifier();
$table = $this->getUsersTable();
$user = $table->get($userId);
$user->set('secret', null);
$user->set('secret_verified', false);
$user->set('two_steps', false);
$table->save($user);
$this->Authentication->setIdentity($user);
$this->Flash->success(__('Two-factor authentication has been disabled.'));
return $this->redirect(['action' => 'profile']);
}
Render it as a POST link on the profile so it can't be triggered by a stray GET.
Keep the whole thing behind a switch
Because the wiring adds an authenticator to the login flow, guard it so apps that
don't want 2FA are completely unaffected. A single config flag, read in both
config/users.php (to decide whether to register the authenticator) and the
controller (to 404 the enrolment action when off), keeps it a clean opt-in:
if (Configure::read('MyApp.TwoFactor.enable', false)) {
$config['OneTimePasswordAuthenticator'] = [ /* …as above… */ ];
}
Recap
Per-user 2FA on CakeDC/Users comes down to four small pieces:
- A
two_stepsboolean onusers, defaulting tofalse. - A checker subclass that
&&s that flag ontoparent::isRequired(). - A self-service enrol/disable pair that flips the flag after verifying a code.
-
BaconQrCodeProvider(format: 'svg')to dodge theendroid/qr-codev6 breakage.
No forked plugin, no reimplemented TOTP — you keep every rule CakeDC already
enforces and add exactly one: did this user ask for it? That's the difference
between "2FA is on" and "2FA is available," which is almost always what you
actually want.
Top comments (0)