Opt-In Two-Factor Authentication in CakePHP with CakeDC/Users
CakeDC/Users, a popular authentication plugin for CakePHP, traditionally enforced two-factor authentication (2FA) universally once enabled. This meant all users were immediately prompted for a one-time password (OTP) on their next login, regardless of whether they had configured an authenticator app. This all-or-nothing approach could inadvertently lock users out, especially on fresh installations or for those unaware of the new requirement.
The common expectation for 2FA in modern applications is an opt-in model. Users should be able to enable and configure 2FA from their personal account settings. Fortunately, achieving this granular control with CakeDC/Users requires a surprisingly minimal change: overriding a single method, implementing a self-service enrollment screen, and addressing a specific QR code generation quirk.
The core logic CakeDC/Users uses to determine if an OTP is required on login resides within the OneTimePasswordAuthenticationCheckerInterface. Specifically, the plugin checks the return value of the isRequired() method. In the default implementation, this method typically checks a global configuration setting, thus enforcing 2FA for all users.
To shift to an opt-in model, developers need to override this behavior. The goal is to make isRequired() return true only for users who have explicitly enabled 2FA in their profile. This involves checking a flag or setting associated with the individual user record.
Implementing Per-User 2FA
The most effective way to implement per-user 2FA is by modifying the authentication checker. Instead of relying on a global configuration, the overridden method should inspect the authenticated user's data.
First, ensure your Users table (or your custom user table) has a field to track whether 2FA is enabled for a user. A boolean field, such as two_factor_enabled, is suitable. Initialize this field to false for all existing and new users.
Next, you'll need to create a custom authentication checker that extends or replaces the default one provided by CakeDC/Users. This custom checker will implement the OneTimePasswordAuthenticationCheckerInterface. The critical part is overriding the isRequired() method.
Within your custom isRequired() method, you will retrieve the currently logged-in user. If a user is authenticated, you check the value of their two_factor_enabled field. If the field is true, the method returns true, triggering the OTP prompt. If the field is false, it returns false, and the login proceeds without the OTP step.
The structure of the overridden method would look something like this:
use CakeDC\Users\Auth\AuthenticationChecker\OneTimePasswordAuthenticationChecker;
use Cake\{Auth\CakeRequest, Auth\AbstractPasswordHasher};
class MyCustomOtpChecker extends OneTimePasswordAuthenticationChecker
{
public function isRequired(CakeRequest $request, array $user): bool
{
// Check if the user has explicitly enabled 2FA
if (isset($user['two_factor_enabled']) && $user['two_factor_enabled']) {
return true;
}
// If not enabled, OTP is not required for this login
return false;
}
}
This approach ensures that the OTP step is only enforced for users who have actively opted in and configured their 2FA.
Self-Service Enrollment
With the authentication logic updated, users need a way to enable and configure 2FA. This typically involves a dedicated section within their user profile or account settings page.
The enrollment process should include:
- Generating a TOTP Secret: When a user decides to enable 2FA, the system must generate a new, unique secret key for them. This secret key is essential for the authenticator app to generate time-based one-time passwords.
- Displaying a QR Code: The generated secret should be encoded into a QR code. This QR code can then be displayed to the user, allowing them to easily scan it with their preferred authenticator app (e.g., Google Authenticator, Authy, Microsoft Authenticator).
- Verification Step: After scanning the QR code, the user will be prompted to enter a 6-digit code from their authenticator app. This step verifies that the user has successfully set up their app and that the secret key is correctly associated with their account.
- Enabling the Flag: Upon successful verification, the
two_factor_enabledfield in the user's record should be updated totrue.
The generation of the QR code requires a library capable of encoding the TOTP secret into the appropriate format, often using the otpauth://totp/ URI scheme. This includes the issuer name and the user's email or username for clarity within the authenticator app.
The QR Code Gotcha with Modern Dependencies
A common pitfall when implementing QR code generation, especially with updated dependencies or newer PHP versions, relates to the underlying libraries used for OTP provisioning and QR code creation. The otpauth:// URI format is standard, but the way secret keys are generated and encoded can sometimes lead to issues.
Older libraries might generate secrets that are not Base32 encoded or use different character sets. Modern authenticator apps expect secrets to be strictly Base32 encoded. If the secret key generated by your server-side code is not correctly Base32 encoded, the authenticator app will fail to generate valid OTPs, even if the QR code scans successfully. This is a subtle but critical point.
When generating the secret, ensure it conforms to the RFC 6238 standard for TOTP. The secret should typically be 16 characters long and consist of Base32 characters (A-Z, 2-7). Libraries like `spomky-labs/otphp` or similar can be leveraged for robust TOTP secret generation and validation.
The otpauth:// URI itself needs to be correctly formatted. It typically looks like this:
otpauth://totp/IssuerName:UserName?secret=BASE32SECRETKEY&issuer=IssuerName
Ensure that the secret parameter is the Base32 encoded secret key. Double-check that no characters are lost or corrupted during the encoding and URI generation process. This is where many implementations stumble, leading to a frustrating user experience where 2FA appears to be set up but never works.
Conclusion: Enhanced Security with User Control
By overriding the isRequired() method in the authentication checker and providing a user-friendly enrollment flow, developers can implement opt-in, per-user 2FA in CakePHP applications using CakeDC/Users. This offers a more flexible and user-centric security model, aligning with modern expectations for account protection while avoiding the pitfalls of a mandatory, system-wide enforcement.
