# 🔐 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](#how-it-works) - [Configuration](#configuration) - [User Enrollment](#user-enrollment) - [Login Flow](#login-flow) - [HasTotp Trait Reference](#hastotp-trait-reference) - [Backup Codes](#backup-codes) - [Trust This Device](#trust-this-device) - [UserSecurityController Integration](#usersecuritycontroller-integration) - [Disabling TOTP](#disabling-totp) - [Admin TOTP Reset](#admin-totp-reset) - [Testing TOTP](#testing-totp) - [Security Notes](#security-notes) --- ## 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`: ```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: ```php 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`): ```bash # .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 ```php $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 ```php $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 ```html

Enable Two-Factor Authentication

Step 1: Scan this QR Code

Open your authenticator app and scan:

TOTP QR Code

Can't scan? Enter this code manually:

Step 2: Enter the 6-digit code from your app
``` --- ## 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`: ```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: ```php // === 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 ```php 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: ```php $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](11-device-sessions.md) — the trust flag is stored on the `auth_device_sessions` row, not in a stand-alone cookie payload. ### Enable ```php // 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): ```php /** @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`: ```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](#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: ```bash 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`](14-cli-commands.md#auth-totp) for full options. --- ## Disabling TOTP Always require password confirmation before disabling 2FA: ```php 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 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](11-device-sessions.md) — Manage trusted devices - [Authentication](03-authentication.md) — All authentication methods - [Filters](04-filters.md) — Protecting routes