Skip to content

Lab 13: Two-Factor Authentication (2FA)


In this lab, you will implement Two-Factor Authentication (2FA) for your e-commerce application using TOTP (Time-based One-Time Password). You will generate QR codes for authenticator app setup, create middleware for 2FA verification, and build a “Remember this device” feature. Refer to the Understanding 2FA reading material for background on how TOTP works and why 2FA matters.


By completing this lab, you will:

  • Implement TOTP-based authentication using the robthree/twofactorauth library
  • Generate QR codes for authenticator app setup
  • Create middleware for 2FA verification flow
  • Build a “Remember this device” feature with secure cookies

  • Session Middleware lab completed (you will use SessionManager for all session operations)
  • Authentication Part 2 lab completed (you will apply middleware patterns learned there)
  • PHP 8.2+
  • Smartphone with Google Authenticator, Microsoft Authenticator, or Authy app installed

Open a terminal in VS Code and run the following commands:

Install the TOTP and QR Code Libraries
composer require robthree/twofactorauth
composer require bacon/bacon-qr-code

Open phpMyAdmin, select your project database, click on the “SQL” tab, and execute the following SQL statements:

SQL Statements to Create the Tables for 2FA
-- Two-factor authentication table
CREATE TABLE IF NOT EXISTS two_factor_auth (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
secret VARCHAR(255) NOT NULL,
enabled BOOLEAN DEFAULT FALSE,
enabled_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE KEY unique_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Trusted devices table for "Remember this device" feature
CREATE TABLE IF NOT EXISTS trusted_devices (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
device_token VARCHAR(255) UNIQUE NOT NULL,
device_name VARCHAR(100),
user_agent VARCHAR(255),
ip_address VARCHAR(45),
last_used_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_device_token (device_token),
INDEX idx_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

Create app/Domain/Models/TwoFactorAuthModel.php:

app/Domain/Models/TwoFactorAuthModel.php
<?php
declare(strict_types=1);
namespace App\Domain\Models;
class TwoFactorAuthModel extends BaseModel
{
public function findByUserId(int $userId): ?array
{
// TODO: Query the two_factor_auth table to find the record
// for the given user.
}
public function create(int $userId, string $secret): int
{
// TODO: Insert a new 2FA record with the user ID and secret.
// Return the ID of the created record.
}
public function enable(int $userId): bool
{
// TODO: Update the record to mark 2FA as enabled and set
// the enabled timestamp.
}
public function disable(int $userId): bool
{
// TODO: Update the record to mark 2FA as disabled.
}
public function isEnabled(int $userId): bool
{
// TODO: Check whether the user has 2FA enabled.
}
public function getSecret(int $userId): ?string
{
// TODO: Return the TOTP secret for the given user.
}
}

The controller handles 2FA setup (generating QR codes), verification (validating codes during login), and disabling (with password confirmation).

The following are the key TwoFactorAuth and BaconQrCodeProvider methods you will need:

$qrProvider = new BaconQrCodeProvider(int $size, string $bgColor, string $fgColor, string $format);
$tfa = new TwoFactorAuth(string $issuer, qrcodeprovider: QRCodeProviderInterface $provider);
$tfa->createSecret(): string;
$tfa->getQRCodeImageAsDataUri(string $label, string $secret): string;
$tfa->verifyCode(string $secret, string $code): bool;

Create app/Controllers/TwoFactorController.php:

app/Controllers/TwoFactorController.php
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Domain\Models\TwoFactorAuthModel;
use App\Domain\Models\UserModel;
use App\Helpers\FlashMessage;
use App\Helpers\SessionManager;
use RobThree\Auth\TwoFactorAuth;
use RobThree\Auth\Providers\Qr\BaconQrCodeProvider;
use Psr\Container\ContainerInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
class TwoFactorController extends BaseController
{
public function __construct(
ContainerInterface $container,
private TwoFactorAuthModel $twoFactorModel,
private UserModel $userModel
) {
parent::__construct($container);
}
public function showSetup(Request $request, Response $response): Response
{
// TODO:
// 1. Query the database to check if the user already has
// 2FA enabled. If so, set a flash error and redirect
// to the dashboard.
// 2. Create a QR code provider and a TwoFactorAuth instance.
// 3. Generate a new TOTP secret.
// 4. Store the secret in the session temporarily (not in the
// database yet, since the user has not verified the code).
// 5. Generate a QR code data URI for the authenticator app.
// 6. Render the setup view, passing the QR code and the secret.
}
public function verifyAndEnable(Request $request, Response $response): Response
{
// TODO:
// 1. Retrieve the setup secret from the session. If missing,
// the setup session has expired: redirect to the setup page.
// 2. Verify the submitted code against the session secret.
// 3. If the code is invalid, regenerate the QR code and
// re-render the setup page with an error message.
// 4. If the code is valid, save the secret to the database
// and enable 2FA for the user.
// 5. Remove the setup secret from the session.
// 6. Redirect to the dashboard with a success flash message.
}
public function showVerify(Request $request, Response $response): Response
{
// TODO: Render the 2FA verification view.
}
public function verify(Request $request, Response $response): Response
{
// TODO:
// 1. Retrieve the user's stored TOTP secret from the database.
// 2. Verify the submitted code against the stored secret.
// 3. If the code is invalid, increment a failed attempts counter
// in the session. After 5 failed attempts, destroy the session
// and redirect to the login page. Otherwise, re-render the
// verification page with an error.
// 4. If the code is valid, mark 2FA as verified in the session.
// 5. Clear the attempts counter.
// 6. Regenerate the session ID for security.
// 7. Redirect to the dashboard.
}
public function disable(Request $request, Response $response): Response
{
// TODO:
// 1. Retrieve the submitted password from the form data.
// 2. Look up the user in the database and use password_verify()
// to confirm the password is correct. If invalid, re-render
// the disable page with an error.
// 3. Disable 2FA in the database for this user.
// 4. Redirect to the dashboard with a success flash message.
}
public function showDisable(Request $request, Response $response): Response
{
// TODO: Render the disable confirmation view.
}
}

This middleware runs after AuthMiddleware. It checks whether the authenticated user has 2FA enabled and whether it has been verified in the current session. If 2FA is required but not verified, the user is redirected to the verification page.

Create app/Middleware/TwoFactorMiddleware.php:

app/Middleware/TwoFactorMiddleware.php
<?php
declare(strict_types=1);
namespace App\Middleware;
use App\Domain\Models\TwoFactorAuthModel;
use App\Helpers\SessionManager;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Server\MiddlewareInterface;
use Slim\Routing\RouteContext;
class TwoFactorMiddleware implements MiddlewareInterface
{
public function __construct(
private TwoFactorAuthModel $twoFactorModel,
private ResponseFactoryInterface $responseFactory
) {
}
public function process(Request $request, RequestHandler $handler): ResponseInterface
{
// TODO: Retrieve the user's authentication status from the session.
// If not authenticated, pass the request to the next handler.
// If authenticated, check whether the user has 2FA enabled
// and whether it has been verified this session.
// If 2FA is required but not verified, redirect to the
// verification page.
//
// Use the following to generate the verification URL and
// create a redirect response:
// $routeParser = RouteContext::fromRequest($request)->getRouteParser();
// $verifyUrl = $routeParser->urlFor('2fa.verify');
// $response = $this->responseFactory->createResponse(302);
// return $response->withHeader('Location', $verifyUrl);
}
}

Create app/Views/auth/2fa-setup.php:

app/Views/auth/2fa-setup.php
<?php
use App\Helpers\ViewHelper;
ViewHelper::loadHeader($title);
?>
<div class="container mt-5" style="max-width: 500px;">
<h1>Enable Two-Factor Authentication</h1>
<?php if (isset($error)): ?>
<div class="alert alert-danger"><?= hs($error) ?></div>
<?php endif; ?>
<div class="mb-4">
<h3>Setup Instructions:</h3>
<ol>
<li>Install Google Authenticator or Authy on your smartphone</li>
<li>Scan the QR code below with the app</li>
<li>Enter the 6-digit code from the app to verify</li>
</ol>
</div>
<div class="text-center my-4 p-4 bg-white">
<img src="<?= $qrCodeDataUri ?? '' ?>" alt="QR Code for 2FA Setup">
</div>
<div class="bg-light p-3 my-4">
<p><strong>Can't scan?</strong> Enter this code manually:</p>
<code style="font-size: 1.2em; letter-spacing: 2px;"><?= hs($secret ?? '') ?></code>
</div>
<form method="POST" action="<?= APP_BASE_URL ?>/2fa/verify-and-enable">
<div class="mb-3">
<label for="code" class="form-label">Verification Code:</label>
<input type="text"
id="code"
name="code"
class="form-control text-center"
pattern="[0-9]{6}"
maxlength="6"
required
autofocus
placeholder="Enter 6-digit code"
style="font-size: 1.5em; letter-spacing: 5px;">
</div>
<button type="submit" class="btn btn-primary">Verify and Enable 2FA</button>
<a href="<?= APP_BASE_URL ?>/dashboard" class="btn btn-secondary">Cancel</a>
</form>
</div>
<?php ViewHelper::loadFooter(); ?>

Create app/Views/auth/2fa-verify.php:

app/Views/auth/2fa-verify.php
<?php
use App\Helpers\ViewHelper;
ViewHelper::loadHeader($title);
?>
<div class="container mt-5" style="max-width: 400px;">
<h1>Two-Factor Verification</h1>
<p>Enter the 6-digit code from your authenticator app.</p>
<?php if (isset($error)): ?>
<div class="alert alert-danger"><?= hs($error) ?></div>
<?php endif; ?>
<form method="POST" action="<?= APP_BASE_URL ?>/2fa/verify">
<div class="mb-3">
<label for="code" class="form-label">Verification Code:</label>
<input type="text"
id="code"
name="code"
class="form-control text-center"
pattern="[0-9]{6}"
maxlength="6"
required
autofocus
placeholder="000000"
style="font-size: 2em; letter-spacing: 10px;">
</div>
<div class="mb-3">
<label>
<input type="checkbox" name="trust_device" value="1">
Trust this device for 30 days
</label>
</div>
<button type="submit" class="btn btn-primary w-100">Verify</button>
</form>
<div class="mt-4 text-center">
<form method="POST" action="<?= APP_BASE_URL ?>/logout">
<button type="submit" class="btn btn-link">Cancel and Logout</button>
</form>
</div>
</div>
<?php ViewHelper::loadFooter(); ?>

Create app/Views/auth/2fa-disable.php:

app/Views/auth/2fa-disable.php
<?php
use App\Helpers\ViewHelper;
ViewHelper::loadHeader($title);
?>
<div class="container mt-5" style="max-width: 400px;">
<h1>Disable Two-Factor Authentication</h1>
<p>Enter your password to confirm disabling 2FA.</p>
<?php if (isset($error)): ?>
<div class="alert alert-danger"><?= hs($error) ?></div>
<?php endif; ?>
<form method="POST" action="<?= APP_BASE_URL ?>/2fa/disable">
<div class="mb-3">
<label for="password" class="form-label">Password:</label>
<input type="password" id="password" name="password" class="form-control" required>
</div>
<button type="submit" class="btn btn-danger">Disable 2FA</button>
<a href="<?= APP_BASE_URL ?>/dashboard" class="btn btn-secondary">Cancel</a>
</form>
</div>
<?php ViewHelper::loadFooter(); ?>

app/Views/dashboard.php
<?php
use App\Helpers\ViewHelper;
use App\Helpers\SessionManager;
ViewHelper::loadHeader($title);
?>
<div class="container mt-5" style="max-width: 800px;">
<h1>Dashboard</h1>
<div class="bg-light p-4 rounded mb-4">
<h2>Welcome, <?= hs(SessionManager::get('user_first_name', 'User')) ?>!</h2>
<p>Email: <?= hs(SessionManager::get('user_email', '')) ?></p>
</div>
<hr>
<h3>Security Settings</h3>
<div class="border rounded p-4 bg-white">
<h4>Two-Factor Authentication (2FA)</h4>
<?php if ($has2FA): ?>
<div class="alert alert-success">2FA is enabled</div>
<p>Your account is protected with two-factor authentication.</p>
<a href="<?= APP_BASE_URL ?>/2fa/disable" class="btn btn-danger">Disable 2FA</a>
<?php else: ?>
<div class="alert alert-warning">2FA is disabled</div>
<p>Enable 2FA to add an extra layer of security to your account.</p>
<a href="<?= APP_BASE_URL ?>/2fa/setup" class="btn btn-primary">Enable 2FA</a>
<?php endif; ?>
</div>
<hr>
<form method="POST" action="<?= APP_BASE_URL ?>/logout">
<button type="submit" class="btn btn-secondary">Logout</button>
</form>
</div>
<?php ViewHelper::loadFooter(); ?>

Open app/Routes/web-routes.php and register the following routes for TwoFactorController. Use the same routing syntax from the authentication labs. Remember that middleware is chained using ->add() and executes in reverse order (last added runs first).

MethodPathController MethodRoute NameMiddleware
GET/2fa/setupshowSetup2fa.setupAuthMiddleware
POST/2fa/verify-and-enableverifyAndEnable2fa.enableAuthMiddleware
GET/2fa/verifyshowVerify2fa.verifyAuthMiddleware
POST/2fa/verifyverify2fa.verify.postAuthMiddleware
GET/2fa/disableshowDisable2fa.disable.showTwoFactorMiddleware, AuthMiddleware
POST/2fa/disabledisable2fa.disableTwoFactorMiddleware, AuthMiddleware

The setup and verification routes only need AuthMiddleware (the user must be logged in, but does not need to have verified 2FA yet). The disable routes need both AuthMiddleware and TwoFactorMiddleware (the user must have already verified 2FA before they can disable it).

Also update your existing dashboard route to add TwoFactorMiddleware so that users with 2FA enabled must verify before accessing the dashboard.


Update your existing AuthController to integrate 2FA into the login process. Import TwoFactorAuthModel and inject it via the constructor.

In your authenticate() method, add the following after setting the user session data:

app/Controllers/AuthController.php
// TODO:
// 1. Query the database to check whether the user has 2FA enabled.
// 2. Store the result in the session as 'requires_2fa'.
// 3. Set 'two_factor_verified' in the session: if the user does not
// have 2FA enabled, mark it as already verified so they are not
// prompted. If they do have 2FA, mark it as not yet verified.

Update your dashboard() method:

app/Controllers/AuthController.php
public function dashboard(Request $request, Response $response): Response
{
// TODO:
// 1. Query the database to check whether the current user has
// 2FA enabled.
// 2. Render 'dashboard.php', passing the 2FA status so the
// view can display the correct toggle button.
}

This model manages the “Remember this device” feature, allowing users to skip 2FA on trusted devices for 30 days.

Create app/Domain/Models/TrustedDeviceModel.php:

app/Domain/Models/TrustedDeviceModel.php
<?php
declare(strict_types=1);
namespace App\Domain\Models;
class TrustedDeviceModel extends BaseModel
{
public function create(int $userId, string $deviceToken, array $deviceInfo): int
{
// TODO: Insert a new record into the trusted_devices table with the
// user ID, device token, and device info (name, user agent,
// IP address, expiration date).
}
public function isValid(string $deviceToken, int $userId): bool
{
// TODO: Check whether the token exists, belongs to the user,
// and has not expired.
}
public function updateLastUsed(string $deviceToken): bool
{
// TODO: Update the last_used_at timestamp for the device.
}
public function getAllByUserId(int $userId): array
{
// TODO: Return all non-expired trusted devices for the user.
}
public function revoke(int $deviceId, int $userId): bool
{
// TODO: Delete the device record. Include user_id in the
// WHERE clause to prevent unauthorized deletion.
}
public function revokeAll(int $userId): bool
{
// TODO: Delete all trusted devices for the user.
}
}

Step 10: Update TwoFactorController for Device Trust

Section titled “Step 10: Update TwoFactorController for Device Trust”

Update the TwoFactorController constructor to inject TrustedDeviceModel:

app/Controllers/TwoFactorController.php
use App\Domain\Models\TrustedDeviceModel;
public function __construct(
ContainerInterface $container,
private TwoFactorAuthModel $twoFactorModel,
private UserModel $userModel,
private TrustedDeviceModel $trustedDeviceModel
) {
parent::__construct($container);
}

Then add device trust functionality to the verify() method, after the line SessionManager::set('two_factor_verified', true);:

app/Controllers/TwoFactorController.php
$trustDevice = $data['trust_device'] ?? false;
if ($trustDevice) {
// TODO: Generate a secure random device token.
// Calculate the expiration date (30 days from now).
// Build a device info array with the device name, user agent,
// IP address, and expiration date.
// Save the trusted device to the database.
// Set a secure cookie named 'trusted_device' with the token,
// configured with httponly and samesite flags.
}

Add helper methods to TwoFactorController:

app/Controllers/TwoFactorController.php
private function getDeviceName(Request $request): string
{
$userAgent = $request->getHeaderLine('User-Agent');
if (stripos($userAgent, 'Windows') !== false) return 'Windows PC';
if (stripos($userAgent, 'Mac') !== false) return 'Mac';
if (stripos($userAgent, 'iPhone') !== false) return 'iPhone';
if (stripos($userAgent, 'Android') !== false) return 'Android';
if (stripos($userAgent, 'Linux') !== false) return 'Linux PC';
return 'Unknown Device';
}
private function getClientIp(Request $request): string
{
$serverParams = $request->getServerParams();
if (!empty($serverParams['HTTP_X_FORWARDED_FOR'])) {
$ipList = explode(',', $serverParams['HTTP_X_FORWARDED_FOR']);
return trim($ipList[0]);
}
return $serverParams['REMOTE_ADDR'] ?? '0.0.0.0';
}

Step 11: Update TwoFactorMiddleware for Device Trust

Section titled “Step 11: Update TwoFactorMiddleware for Device Trust”

Update the TwoFactorMiddleware constructor to inject TrustedDeviceModel:

app/Middleware/TwoFactorMiddleware.php
use App\Domain\Models\TrustedDeviceModel;
public function __construct(
private TwoFactorAuthModel $twoFactorModel,
private TrustedDeviceModel $trustedDeviceModel,
private ResponseFactoryInterface $responseFactory
) {
}

Add trusted device check in the process() method, after checking if 2FA is enabled but before redirecting to the verification page:

app/Middleware/TwoFactorMiddleware.php
$cookies = $request->getCookieParams();
$deviceToken = $cookies['trusted_device'] ?? null;
if ($deviceToken) {
if ($this->trustedDeviceModel->isValid($deviceToken, $userId)) {
SessionManager::set('two_factor_verified', true);
$this->trustedDeviceModel->updateLastUsed($deviceToken);
return $handler->handle($request);
} else {
setcookie('trusted_device', '', time() - 3600, '/' . APP_ROOT_DIR_NAME);
}
}

Add TwoFactorMiddleware to your admin route group so that all admin routes require 2FA verification:

app/Routes/web-routes.php
$app->group('/admin', function ($group) {
$group->get('/dashboard', [AdminController::class, 'dashboard']);
$group->get('/users', [AdminController::class, 'users']);
$group->get('/products', [ProductsController::class, 'index']);
$group->post('/products/create', [ProductsController::class, 'createProduct']);
// ... other admin routes
})
->add(TwoFactorMiddleware::class)
->add(AdminAuthMiddleware::class)
->add(AuthMiddleware::class);

Middleware executes in reverse order of the ->add() calls:

Request → AuthMiddleware → AdminAuthMiddleware → TwoFactorMiddleware → Controller
↓ ↓ ↓
"Is logged in?" "Is user admin?" "Is 2FA verified?"

You can also require admin users to enable 2FA before accessing the admin panel. Update your AdminAuthMiddleware to inject TwoFactorAuthModel and check whether the admin has 2FA enabled. If not, redirect them to the 2FA setup page with a flash message.

app/Middleware/AdminAuthMiddleware.php
// After verifying the user is an admin, check if 2FA is enabled:
$userId = SessionManager::get('user_id');
if (!$this->twoFactorModel->isEnabled($userId)) {
FlashMessage::warning('Admin accounts require Two-Factor Authentication. Please enable 2FA to continue.');
$routeParser = RouteContext::fromRequest($request)->getRouteParser();
$setupUrl = $routeParser->urlFor('2fa.setup');
$response = $this->responseFactory->createResponse(302);
return $response->withHeader('Location', $setupUrl);
}

  1. Enable 2FA: login, visit /2fa/setup, scan the QR code with your authenticator app, enter the 6-digit code. Verify 2FA is enabled on the dashboard.
  2. Login with 2FA: logout, login again. After entering your password, you should be redirected to /2fa/verify. Enter the correct code to reach the dashboard.
  3. Failed attempts: enter 5 incorrect codes. You should be logged out and the session destroyed.
  4. Disable 2FA: navigate to the disable page, enter an incorrect password (should show error), then enter the correct password. Verify the next login does not require 2FA.
  5. Trust device: login with 2FA, check “Trust this device for 30 days”, and verify. Logout and login again: you should skip 2FA verification. Open a different browser or incognito window and login: you should still be prompted for 2FA.