๐Ÿ” 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:

  1. Pending (name = totp_pending) โ€” secret generated, QR shown, user not yet confirmed

  2. Confirmed (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 &amp; 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๏ƒ

  1. User submits login form (email + password)

  2. Session::check() verifies credentials

  3. Session detects the Totp2FA action is configured

  4. User is redirected to the action show page (not logged in yet)

  5. The built-in view asks for the 6-digit code

  6. ActionController::verify() decrypts the stored secret and validates the code

  7. On 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

id, user_id

Standard.

code_hash

SHA-256 of the lowercase plain code. The plaintext never enters the database.

used_at

Datetime of consumption. Null = unused.

created_at

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

enableTotp()

No effect (codes only generated on confirmTotp()).

confirmTotp() (first time)

Caller (typically UserSecurityController) generates the initial set.

disableTotp()

All codes are purged automatically.

auth:totp reset (admin)

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๏ƒ

  1. User logs in with email + password.

  2. Totp2FA::createIdentity() checks for an auth_trusted_device cookie. If the cookie maps to an active device_sessions row whose trusted_until is in the future and whose user_id matches, the 2FA challenge is skipped entirely.

  3. Otherwise the standard 2FA form is shown โ€” with a โ€œTrust this device for 30 daysโ€ checkbox if trustedDeviceLifetime > 0.

  4. After successful verification, if the checkbox was ticked:

    • device_sessions.trusted_until = now + lifetime for the current session.

    • The auth_trusted_device cookie is set with the device UUID encrypted via service('encrypter') (HttpOnly, SameSite=Lax, secure when App.cookieSecure = true).

    • An EVENT_TRUSTED_DEVICE_ADDED audit entry is recorded.

Revoking trust๏ƒ

Trust is automatically revoked when:

  • trusted_until passes (no longer accepted at login).

  • The user revokes the device session (UserSecurityController::revokeSession) โ€” logged_out_at is 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_sessions row (joined to the same user_id).

    • trusted_until to 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:

  1. Calls $user->disableTotp() โ€” removes the TOTP secret + every backup code.

  2. Writes an EVENT_TOTP_ADMIN_RESET audit entry with metadata.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: