๐ TOTP Two-Factor Authentication๏
Time-based One-Time Passwords (TOTP) add a powerful second layer of security to your application. After entering their password, users must provide a 6-digit code from an authenticator app such as Google Authenticator, Authy, or 1Password.
๐ Table of Contents๏
How It Works๏
User enters email + password
โ
Credentials verified โ
โ
System detects Totp2FA action is required
โ
User is shown a "Enter your 6-digit code" form
โ
User opens authenticator app โ copies code
โ
Code verified against TOTP secret โ
โ
Session created โ user is logged in
The TOTP secret is stored permanently in auth_users_identities with type totp_secret, AES-256 encrypted using CI4โs service('encrypter'). The raw secret is never stored in plain text.
Enrollment follows a two-phase flow:
Pending (
name = totp_pending) โ secret generated, QR shown, user not yet confirmedConfirmed (
name = totp) โ first code verified, TOTP fully active
The Totp2FA login action only challenges users whose TOTP is in the confirmed state.
Configuration๏
1. Enable the TOTP Post-Login Action๏
In app/Config/Auth.php:
use Daycry\Auth\Authentication\Actions\Totp2FA;
public array $actions = [
'register' => null,
'login' => Totp2FA::class, // Require TOTP on every login
];
Note: This only applies to users who have TOTP enabled (
hasTotpEnabled() === true). Users who have not enrolled skip the 2FA step and log in directly.
2. Set the Issuer Name๏
In app/Config/AuthSecurity.php, set the app name shown in the authenticator app:
public string $totpIssuer = 'My App';
// Number of 30-second steps to accept on either side of the current
// timestamp when verifying codes. 1 = ยฑ30s window (RFC 6238 default,
// recommended). Increase only to tolerate severe clock drift; lowering
// to 0 means clients must be perfectly in sync.
public int $totpWindow = 1;
3. Configure the Encryption Key๏
TOTP secrets are encrypted with CI4โs encrypter. Make sure app/Config/Encryption.php has a key set (or encryption.key in .env):
# .env
encryption.key = hex2bin:your64charhexstringhere
User Enrollment๏
The enrollment flow is handled by the HasTotp trait (mixed into User). It is a two-phase process:
Phase 1 โ Generate the QR code๏
$user = auth()->user();
// Always generates a fresh secret (replaces any previous pending one).
// Returns the otpauth:// URI for building a QR code.
$otpAuthUrl = $user->enableTotp('My App');
// getTotpSecret() transparently decrypts the stored value.
$secret = $user->getTotpSecret();
// Build a QR code data URI (rendered locally, no external service).
$qrCodeDataUri = \Daycry\Auth\Libraries\TOTP::getQRCodeUrl($otpAuthUrl);
// $qrCodeDataUri = "data:image/png;base64,..."
return view('security/totp_setup', [
'qrCodeDataUri' => $qrCodeDataUri,
'secret' => $secret, // plain-text fallback for manual entry
]);
enableTotp()stores the secret in the pending state. If the user navigates away before confirming, a fresh secret is generated the next time they visit the setup page.
Phase 2 โ Confirm the first code๏
$user = auth()->user();
$code = $this->request->getPost('token');
if (! $user->verifyTotpCode($code)) {
return redirect()->back()->with('error', 'Invalid code. Please try again.');
}
// Upgrades the secret from PENDING โ CONFIRMED. TOTP is now active.
$user->confirmTotp();
return redirect()->to('security')->with('message', 'Two-factor authentication is now enabled.');
Setup View๏
<!-- Phase 1: Show QR code -->
<h2>Enable Two-Factor Authentication</h2>
<h5>Step 1: Scan this QR Code</h5>
<p>Open your authenticator app and scan:</p>
<!-- QR code is a data URI โ no external service required -->
<img src="<?= esc($qrCodeDataUri) ?>" alt="TOTP QR Code" width="200" height="200">
<p class="text-muted mt-2">
Can't scan? Enter this code manually: <code><?= esc($secret) ?></code>
</p>
<!-- Phase 2: Confirm the code -->
<h5>Step 2: Enter the 6-digit code from your app</h5>
<?= form_open(url_to('totp-setup-confirm')) ?>
<input type="text" name="token" maxlength="6" placeholder="000000"
autocomplete="one-time-code" required>
<button type="submit">Confirm & Enable</button>
<?= form_close() ?>
Login Flow๏
Once TOTP is confirmed for a user, the flow is handled automatically by the Totp2FA action. No changes to LoginController are needed.
What Happens Automatically๏
User submits login form (email + password)
Session::check()verifies credentialsSession detects the
Totp2FAaction is configuredUser is redirected to the action show page (not logged in yet)
The built-in view asks for the 6-digit code
ActionController::verify()decrypts the stored secret and validates the codeOn success,
completeLogin()clears the pending action state and creates the session
Override the Default TOTP Views๏
In app/Config/Auth.php:
public array $views = [
// ... other views ...
'action_totp_setup_show' => '\Daycry\Auth\Views\totp_setup_show', // QR setup page
'action_totp_setup_success' => '\Daycry\Auth\Views\totp_setup_success', // Confirmation page
'action_totp_show' => '\Daycry\Auth\Views\totp_show', // Login 2FA prompt
'action_totp_verify' => '\Daycry\Auth\Views\totp_verify', // Login 2FA form
'security_overview' => '\Daycry\Auth\Views\profile\security', // User security dashboard
];
HasTotp Trait Reference๏
The User entity uses the HasTotp trait, which provides:
// === Enrollment ===
// Generate a new secret (pending), returns the otpauth:// URL.
// If called again, replaces any existing secret.
$user->enableTotp(?string $issuer = null): string
// Returns true while the secret is generated but not yet confirmed.
$user->hasTotpPending(): bool
// Returns true only after confirmTotp() has been called.
$user->hasTotpEnabled(): bool
// Upgrades the identity from PENDING to CONFIRMED.
// Call only after verifyTotpCode() returns true.
$user->confirmTotp(): void
// Returns the decrypted base32 secret (or null if not set).
$user->getTotpSecret(): ?string
// === Verification ===
// Checks a 6-digit code against the user's stored secret.
// $window defaults to AuthSecurity::$totpWindow when null.
$user->verifyTotpCode(string $code, ?int $window = null): bool
// === Backup codes ===
// Replaces the user's backup codes with a fresh set and returns the
// plain-text codes (only shown once โ display them to the user immediately).
$user->generateBackupCodes(int $count = 10): array
// Counts unused backup codes remaining for the user.
$user->backupCodesRemaining(): int
// Verifies + atomically consumes a single backup code.
$user->consumeBackupCode(string $code): bool
// === Removal ===
// Removes the TOTP secret identity AND purges any remaining backup codes.
$user->disableTotp(): void
Security Dashboard Example๏
public function securityIndex(): string
{
$user = auth()->user();
return view('security/index', [
'totpEnabled' => $user->hasTotpEnabled(),
'totpPending' => $user->hasTotpPending(),
'deviceCount' => count($user->getDeviceSessions()),
]);
}
Backup Codes๏
Backup codes let a user authenticate when their authenticator app is unavailable (lost phone, replaced device). They are one-time use โ once consumed, the code cannot be reused.
When they are generated๏
UserSecurityController::totpSetupConfirm() calls $user->generateBackupCodes() automatically right after the user confirms their first TOTP code. The plain-text codes are passed once to the success view (Views/totp_setup_success.php) โ store them, screenshot them, or print them. They cannot be retrieved later.
How they work during login๏
Totp2FA::verifyCodeForUser() first attempts to verify the input as a TOTP code. If that fails, it tries to consume a backup code:
User submits "abc123def4"
โ
TOTP::verify(...) โ false (not a 6-digit code)
โ
$user->consumeBackupCode('abc123def4')
โ
hash('sha256', 'abc123def4') matches an unused row โ success
โ
Row marked used_at = NOW() โ cannot be used again
The 10 codes are 10-character lowercase hex strings โ visually distinct from the 6-digit TOTP, so accidental collisions are essentially impossible.
Storage๏
Column |
Description |
|---|---|
|
Standard. |
|
SHA-256 of the lowercase plain code. The plaintext never enters the database. |
|
Datetime of consumption. Null = unused. |
|
Generation timestamp. |
Indexes: (user_id, used_at) for fast unused-code lookups; UNIQUE(user_id, code_hash) to prevent duplicates.
Programmatic regeneration๏
If a user thinks their backup codes are compromised:
$newCodes = $user->generateBackupCodes(10);
return view('account/new_backup_codes', ['codes' => $newCodes]);
generateBackupCodes() always replaces the entire set โ old codes (used or not) are deleted before the new ones are inserted.
Lifecycle with TOTP๏
Action |
Effect on backup codes |
|---|---|
|
No effect (codes only generated on |
|
Caller (typically |
|
All codes are purged automatically. |
|
All codes are purged automatically. |
Trust This Device๏
Lets the user opt out of repeating 2FA on devices they own. Combines with Device Sessions โ the trust flag is stored on the auth_device_sessions row, not in a stand-alone cookie payload.
Enable๏
// app/Config/AuthSecurity.php
// 30 days is a reasonable default. 0 = feature disabled (always require 2FA).
public int $trustedDeviceLifetime = 30 * DAY;
User flow๏
User logs in with email + password.
Totp2FA::createIdentity()checks for anauth_trusted_devicecookie. If the cookie maps to an activedevice_sessionsrow whosetrusted_untilis in the future and whoseuser_idmatches, the 2FA challenge is skipped entirely.Otherwise the standard 2FA form is shown โ with a โTrust this device for 30 daysโ checkbox if
trustedDeviceLifetime > 0.After successful verification, if the checkbox was ticked:
device_sessions.trusted_until = now + lifetimefor the current session.The
auth_trusted_devicecookie is set with the device UUID encrypted viaservice('encrypter')(HttpOnly, SameSite=Lax, secure whenApp.cookieSecure = true).An
EVENT_TRUSTED_DEVICE_ADDEDaudit entry is recorded.
Revoking trust๏
Trust is automatically revoked when:
trusted_untilpasses (no longer accepted at login).The user revokes the device session (
UserSecurityController::revokeSession) โlogged_out_atis set, the row no longer matches the trusted-device check.The user logs out manually.
The cookie is deleted by the browser.
To revoke trust programmatically (e.g. when the user changes their password):
/** @var \Daycry\Auth\Models\DeviceSessionModel $devices */
$devices = model(\Daycry\Auth\Models\DeviceSessionModel::class);
foreach ($devices->getAllForUser($user) as $session) {
$devices->revokeTrust((string) $session->uuid);
}
Security properties๏
The cookie carries the device UUID encrypted with the application key. An attacker who steals the cookie alone still needs:
The corresponding active
device_sessionsrow (joined to the sameuser_id).trusted_untilto be in the future.
Stealing only the cookie or only the DB row is not enough.
Revoking the device session immediately invalidates the trust regardless of cookie validity.
When NOT to use๏
Shared computers / kiosks โ keep
trustedDeviceLifetime = 0.Strict regulatory environments (PCI-DSS Level 1, HIPAA in some interpretations) โ review whether bypassing 2FA per device is acceptable.
UserSecurityController Integration๏
Daycry Auth ships with UserSecurityController which provides ready-to-use TOTP management endpoints. Register the routes in app/Config/Routes.php:
$routes->group('security', ['filter' => 'session', 'namespace' => 'Daycry\Auth\Controllers'], static function ($routes) {
$routes->get('/', 'UserSecurityController::index', ['as' => 'security']);
$routes->get('totp/setup', 'UserSecurityController::totpSetup', ['as' => 'totp-setup']);
$routes->post('totp/confirm', 'UserSecurityController::totpSetupConfirm', ['as' => 'totp-setup-confirm']);
$routes->post('totp/disable', 'UserSecurityController::totpDisable', ['as' => 'totp-disable']);
// Device session management
$routes->post('sessions/(:num)/revoke', 'UserSecurityController::revokeSession/$1', ['as' => 'revoke-session']);
$routes->post('sessions/revoke-all', 'UserSecurityController::revokeAllSessions', ['as' => 'revoke-all-sessions']);
});
The views are configured in app/Config/Auth.php under the $views array (see Override the Default TOTP Views above).
Admin TOTP Reset๏
When a user has lost both their authenticator and all backup codes, an administrator can reset TOTP from the CLI:
php spark auth:totp reset -e alice@example.com
This:
Calls
$user->disableTotp()โ removes the TOTP secret + every backup code.Writes an
EVENT_TOTP_ADMIN_RESETaudit entry withmetadata.initiator = cli.
The user re-enrolls TOTP from scratch the next time they visit /security/totp/setup. See CLI Commands โ auth:totp for full options.
Disabling TOTP๏
Always require password confirmation before disabling 2FA:
public function disableTotpAction(): RedirectResponse
{
$user = auth()->user();
$password = $this->request->getPost('current_password');
$passwords = service('passwords');
if (! $passwords->verify($password, $user->getPasswordHash())) {
return redirect()->back()->with('error', 'Incorrect password.');
}
$user->disableTotp();
return redirect()->to('security')->with('message', 'Two-factor authentication has been disabled.');
}
Testing TOTP๏
DatabaseTestCase automatically injects a 32-byte AES encryption key, so service('encrypter') works without any extra setup in your tests.
<?php
namespace Tests\Authentication;
use Tests\Support\DatabaseTestCase;
use Daycry\Auth\Libraries\TOTP;
class TotpTest extends DatabaseTestCase
{
public function testEnrollAndVerifyTotp(): void
{
$user = fake(UserModel::class);
// Phase 1: generate secret (creates a PENDING identity)
$otpAuthUrl = $user->enableTotp('TestApp');
$this->assertStringStartsWith('otpauth://totp/', $otpAuthUrl);
$this->assertTrue($user->hasTotpPending());
$this->assertFalse($user->hasTotpEnabled());
$this->assertNotEmpty($user->getTotpSecret());
// Phase 2: confirm โ TOTP becomes active
$user->confirmTotp();
$this->assertTrue($user->hasTotpEnabled());
$this->assertFalse($user->hasTotpPending());
}
public function testVerifyTotpCode(): void
{
$user = fake(UserModel::class);
$user->enableTotp('TestApp');
$user->confirmTotp();
// An obviously wrong code should fail
$this->assertFalse($user->verifyTotpCode('000000'));
}
public function testDisableTotpRemovesSecret(): void
{
$user = fake(UserModel::class);
$user->enableTotp('TestApp');
$user->confirmTotp();
$user->disableTotp();
$this->assertFalse($user->hasTotpEnabled());
$this->assertNull($user->getTotpSecret());
}
public function testSecretIsEncryptedInDatabase(): void
{
$user = fake(UserModel::class);
$user->enableTotp('TestApp');
/** @var \Daycry\Auth\Models\UserIdentityModel $model */
$model = model(\Daycry\Auth\Models\UserIdentityModel::class);
$identity = $model->where('user_id', $user->id)
->where('type', 'totp_secret')
->first();
// The DB value is base64-encoded ciphertext โ not the raw secret
$this->assertNotSame($user->getTotpSecret(), $identity->secret);
$this->assertNotEmpty(base64_decode($identity->secret, true));
}
}
Security Notes๏
Always require password confirmation before enabling or disabling TOTP.
The TOTP secret is stored AES-256 encrypted in
auth_users_identities. The raw base32 secret is never in the database in plain text.TOTP codes are valid for a 30-second window (ยฑ1 window tolerance for clock skew). Ensure your server clock is synchronized via NTP.
If a user loses access to their authenticator app they will be locked out. Consider implementing backup codes (not included) or an admin unlock procedure.
A user with a pending (unconfirmed) TOTP secret is not challenged at login. If they navigate away before confirming, they simply arenโt enrolled yet.
๐ See also:
Device Sessions โ Manage trusted devices
Authentication โ All authentication methods
Filters โ Protecting routes