Skip to content

Lab: Two-Factor Authentication (2FA)


In this lab, you’ll implement Two-Factor Authentication (2FA) for your e-commerce application using TOTP (Time-based One-Time Password).

  • 2FA adds an extra layer of security by requiring users to provide two different authentication factors: something they know (password) and something they have (authenticator app on their phone).

What is TOTP? TOTP generates a 6-digit code that changes every 30 seconds. The code is calculated using a shared secret key and the current time. Both the server and the authenticator app (like Google Authenticator or Authy) use the same algorithm, so they generate matching codes without needing to communicate.

How 2FA Works in This Lab:

  1. User enables 2FA by scanning a QR code with their authenticator app
  2. The QR code contains a secret key that both the server and app will use
  3. When logging in, after entering their password, users must also enter the 6-digit code from their app
  4. The server verifies the code matches what it expects based on the shared secret

Real-World Applications:

  • Banking and financial applications
  • Email providers (Gmail, Outlook)
  • Social media platforms
  • Corporate VPN access
  • E-commerce checkout protection

By completing this lab, you will:

  • Implement TOTP (Time-based One-Time Password) authentication
  • Generate QR codes for authenticator app setup
  • Create middleware for 2FA verification flow
  • Build a “Remember this device” feature

  • 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

Important: This lab builds on concepts from previous labs:

  • SessionManager (from Session lab) - All session operations use SessionManager::set(), SessionManager::get(), etc.
  • AuthMiddleware pattern (from Part 2) - TwoFactorMiddleware follows the same pattern

Objective: Add the TOTP and QR code generation libraries to your project.

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

Composer Commands to Install the Required Packages
composer require robthree/twofactorauth
composer require bacon/bacon-qr-code

What Gets Installed:

  • robthree/twofactorauth - TOTP generation and validation library
  • bacon/bacon-qr-code - QR code generation (required dependency for displaying setup QR codes)

Verify Installation: Check that both packages appear in your composer.json file under the require section.


Objective: Import the database schema needed for 2FA functionality using phpMyAdmin.

Open phpMyAdmin in your browser. 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;
-- Login attempts table for rate limiting (optional - for future enhancement)
CREATE TABLE IF NOT EXISTS login_attempts (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(100) NOT NULL,
ip_address VARCHAR(45) NOT NULL,
attempted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
successful BOOLEAN DEFAULT FALSE,
INDEX idx_email_ip (email, ip_address),
INDEX idx_attempted_at (attempted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

Tables Created:

  • two_factor_auth - Stores TOTP secrets for users
  • trusted_devices - Stores trusted device tokens for “Remember this device” feature
  • login_attempts - (Optional) For implementing login rate limiting in future enhancements

Objective: Create a model to manage 2FA data in the database.

Create app/Domain/Models/TwoFactorAuthModel.php:

app/Domain/Models/TwoFactorAuthModel.php
<?php
declare(strict_types=1);
namespace App\Domain\Models;
/**
* Model for managing Two-Factor Authentication data.
*/
class TwoFactorAuthModel extends BaseModel
{
/**
* Find 2FA record by user ID.
*
* @param int $userId The user's ID
* @return array|null 2FA data or null if not found
*/
public function findByUserId(int $userId): ?array
{
// TODO: Query the two_factor_auth table to find record by user_id
// HINT: Use $this->selectOne() method from BaseModel
// SQL: SELECT * FROM two_factor_auth WHERE user_id = ?
// Return the result, or null if false
return null; // Replace with your implementation
}
/**
* Create a new 2FA record for a user.
*
* @param int $userId The user's ID
* @param string $secret The TOTP secret key
* @return int The ID of the created record
*/
public function create(int $userId, string $secret): int
{
// TODO: Insert a new record into two_factor_auth table
// HINT: Use $this->execute() for INSERT
// Fields: user_id, secret, enabled (default false)
// Return $this->lastInsertId()
return 0; // Replace with your implementation
}
/**
* Enable 2FA for a user.
*
* @param int $userId The user's ID
* @return bool True if successful
*/
public function enable(int $userId): bool
{
// TODO: Update the record to set enabled = true and enabled_at = NOW()
// HINT: Use $this->execute() and check rowCount() > 0
return false; // Replace with your implementation
}
/**
* Disable 2FA for a user.
*
* @param int $userId The user's ID
* @return bool True if successful
*/
public function disable(int $userId): bool
{
// TODO: Update the record to set enabled = false
// HINT: Use $this->execute() and check rowCount() > 0
return false; // Replace with your implementation
}
/**
* Check if user has 2FA enabled.
*
* @param int $userId The user's ID
* @return bool True if 2FA is enabled
*/
public function isEnabled(int $userId): bool
{
// TODO: Query to check if user has enabled = true
// HINT: Use $this->selectOne() and check the 'enabled' field
return false; // Replace with your implementation
}
/**
* Get the secret for a user.
*
* @param int $userId The user's ID
* @return string|null The secret or null if not found
*/
public function getSecret(int $userId): ?string
{
// TODO: Get the secret field for the user
// HINT: Use findByUserId() and return the 'secret' field
return null; // Replace with your implementation
}
}

Objective: Create a controller to handle 2FA setup, verification, and disabling.

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;
/**
* Controller for Two-Factor Authentication operations.
*/
class TwoFactorController extends BaseController
{
public function __construct(
ContainerInterface $container,
private TwoFactorAuthModel $twoFactorModel,
private UserModel $userModel
) {
parent::__construct($container);
}
/**
* Display the 2FA setup page with QR code.
*/
public function showSetup(Request $request, Response $response): Response
{
$userId = SessionManager::get('user_id');
$userEmail = SessionManager::get('user_email');
// Check if user already has 2FA enabled
if ($this->twoFactorModel->isEnabled($userId)) {
FlashMessage::error('2FA is already enabled.');
return $this->redirect($request, $response, 'dashboard');
}
// TODO: Create a QR code provider instance
// HINT: Use BaconQrCodeProvider with parameters: (4, '#ffffff', '#000000', 'svg')
// The parameters are: size, background color, foreground color, image format
// TODO: Create a TwoFactorAuth instance
// HINT: Pass the QR provider and your app name (e.g., 'YourAppName') to the constructor
// TODO: Generate a new TOTP secret
// HINT: Use the TFA instance's createSecret() method
$secret = ''; // Replace with your implementation
// Store secret in session temporarily (not in database yet)
SessionManager::set('2fa_setup_secret', $secret);
// TODO: Generate QR code as a data URI for display in an <img> tag
// HINT: Use $tfa->getQRCodeImageAsDataUri($userEmail, $secret)
// This returns a string like "data:image/svg+xml;base64,..." ready for img src
$qrCodeDataUri = ''; // Replace with your implementation
return $this->render($response, 'auth/2fa-setup.php', [
'title' => 'Enable 2FA',
'qrCodeDataUri' => $qrCodeDataUri,
'secret' => $secret
]);
}
/**
* Verify the code and enable 2FA.
*/
public function verifyAndEnable(Request $request, Response $response): Response
{
$userId = SessionManager::get('user_id');
$userEmail = SessionManager::get('user_email');
$data = $request->getParsedBody();
$code = $data['code'] ?? '';
// Get secret from session
$secret = SessionManager::get('2fa_setup_secret');
if (!$secret) {
FlashMessage::error('Setup session expired. Please try again.');
return $this->redirect($request, $response, '2fa.setup');
}
// TODO: Create a QR code provider and TFA instance (same as showSetup)
// HINT: Use BaconQrCodeProvider and TFA classes
// TODO: Verify the user's code against the secret
// HINT: Use $tfa->verifyCode($secret, $code) - returns true if valid
$valid = false; // Replace with your implementation
if (!$valid) {
// TODO: Regenerate QR code for retry (user entered wrong code)
// HINT: Use $tfa->getQRCodeImageAsDataUri($userEmail, $secret)
$qrCodeDataUri = ''; // Replace with your implementation
return $this->render($response, 'auth/2fa-setup.php', [
'title' => 'Enable 2FA',
'error' => 'Invalid verification code. Please try again.',
'qrCodeDataUri' => $qrCodeDataUri,
'secret' => $secret
]);
}
// TODO: Save secret to database and enable 2FA
// Step 1: Create a new 2FA record: $this->twoFactorModel->create($userId, $secret)
// Step 2: Enable 2FA for the user: $this->twoFactorModel->enable($userId)
// Clear the setup secret from session
SessionManager::remove('2fa_setup_secret');
FlashMessage::success('2FA has been enabled successfully!');
return $this->redirect($request, $response, 'dashboard');
}
/**
* Show the 2FA verification page (during login).
*/
public function showVerify(Request $request, Response $response): Response
{
return $this->render($response, 'auth/2fa-verify.php', [
'title' => 'Verify 2FA'
]);
}
/**
* Verify 2FA code during login.
*/
public function verify(Request $request, Response $response): Response
{
$userId = SessionManager::get('user_id');
$data = $request->getParsedBody();
$code = $data['code'] ?? '';
// TODO: Get the user's TOTP secret from the database
// HINT: Use $this->twoFactorModel->getSecret($userId) to retrieve the secret
$secret = ''; // Replace with your implementation
// TODO: Create a TwoFactorAuth instance (QR provider not needed for verification)
// HINT: new TwoFactorAuth('YourAppName')
// TODO: Verify the user's code against their stored secret
// HINT: Use $tfa->verifyCode($secret, $code)
$valid = false; // Replace with your implementation
if (!$valid) {
// Track failed attempts
$attempts = (SessionManager::get('2fa_attempts') ?? 0) + 1;
SessionManager::set('2fa_attempts', $attempts);
// Lockout after 5 failed attempts
if ($attempts >= 5) {
SessionManager::destroy();
return $this->redirect($request, $response, 'auth.login');
}
return $this->render($response, 'auth/2fa-verify.php', [
'title' => 'Verify 2FA',
'error' => 'Invalid code. Please try again.'
]);
}
// Success! Mark 2FA as verified in session
SessionManager::set('two_factor_verified', true);
SessionManager::remove('2fa_attempts');
// Regenerate session ID for security
session_regenerate_id(true);
// Redirect to dashboard
return $this->redirect($request, $response, 'dashboard');
}
/**
* Disable 2FA for the user.
*/
public function disable(Request $request, Response $response): Response
{
$userId = SessionManager::get('user_id');
$userEmail = SessionManager::get('user_email');
$data = $request->getParsedBody();
$password = $data['password'] ?? '';
// Verify password before disabling 2FA
$validUser = $this->userModel->verifyPassword($userEmail, $password);
if (!$validUser) {
return $this->render($response, 'auth/2fa-disable.php', [
'title' => 'Disable 2FA',
'error' => 'Invalid password.'
]);
}
// TODO: Disable 2FA in the database
// HINT: Call $this->twoFactorModel->disable($userId)
FlashMessage::success('2FA has been disabled.');
return $this->redirect($request, $response, 'dashboard');
}
/**
* Show disable confirmation page.
*/
public function showDisable(Request $request, Response $response): Response
{
return $this->render($response, 'auth/2fa-disable.php', [
'title' => 'Disable 2FA'
]);
}
}

Objective: Create middleware to enforce 2FA verification for authenticated users who have it enabled.

Note: This middleware follows the same pattern as AuthMiddleware from the Authentication Part 2 lab. If you need a refresher on middleware fundamentals, refer back to that lab.

This middleware runs AFTER AuthMiddleware. It checks:

  1. Is the user authenticated?
  2. Does the user have 2FA enabled?
  3. Has the user already verified 2FA in this session?

If 2FA is required but not verified, the user is redirected to /2fa/verify.

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\ServerRequestInterface as Request;
use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Server\MiddlewareInterface;
use Slim\Routing\RouteContext;
/**
* Middleware to check if user needs to verify 2FA.
*
* This middleware runs AFTER AuthMiddleware. It checks:
* 1. Is the user authenticated?
* 2. Does the user have 2FA enabled?
* 3. Has the user already verified 2FA in this session?
*
* If 2FA is required but not verified, redirect to /2fa/verify
*/
class TwoFactorMiddleware implements MiddlewareInterface
{
public function __construct(private TwoFactorAuthModel $twoFactorModel)
{
}
public function process(Request $request, RequestHandler $handler): ResponseInterface
{
// Check if user is authenticated
if (!SessionManager::get('is_authenticated')) {
// Not authenticated, let AuthMiddleware handle it
return $handler->handle($request);
}
$userId = SessionManager::get('user_id');
// TODO: Check if user has 2FA enabled
// HINT: Use $this->twoFactorModel->isEnabled($userId)
$has2FAEnabled = false; // Replace with your implementation
// If 2FA is not enabled, proceed normally
if (!$has2FAEnabled) {
return $handler->handle($request);
}
// TODO: Check if 2FA has already been verified in this session
// HINT: Check SessionManager::get('two_factor_verified')
$isVerified = false; // Replace with: SessionManager::get('two_factor_verified')
if ($isVerified) {
// 2FA already verified, proceed
return $handler->handle($request);
}
// 2FA required but not verified - redirect to verification page
$routeParser = RouteContext::fromRequest($request)->getRouteParser();
$verifyUrl = $routeParser->urlFor('2fa.verify');
$response = new \Nyholm\Psr7\Response();
return $response
->withStatus(302)
->withHeader('Location', $verifyUrl);
}
}

Objective: Create the view templates for 2FA setup, verification, and disabling.

Objective: Create the 2FA setup view to display the QR code and secret key to the user.

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

app/Views/auth/2fa-setup.php
<?php require __DIR__ . '/../common/header.php'; ?>
<div class="container" style="max-width: 500px; margin: 50px auto;">
<h1>Enable Two-Factor Authentication</h1>
<?php if (isset($error)): ?>
<div class="alert alert-danger"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<div class="setup-steps">
<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="qr-code" style="text-align: center; margin: 20px 0; background: white; padding: 20px;">
<!-- QR code displays as an image using data URI -->
<img src="<?= $qrCodeDataUri ?? '' ?>" alt="QR Code for 2FA Setup">
</div>
<div class="manual-entry" style="background: #f5f5f5; padding: 15px; margin: 20px 0;">
<p><strong>Can't scan?</strong> Enter this code manually:</p>
<code style="font-size: 1.2em; letter-spacing: 2px;"><?= htmlspecialchars($secret ?? '') ?></code>
</div>
<form method="POST" action="<?= '/' . APP_ROOT_DIR_NAME . '/2fa/verify-and-enable' ?>">
<div class="form-group">
<label for="code">Verification Code:</label>
<input type="text"
id="code"
name="code"
pattern="[0-9]{6}"
maxlength="6"
required
autofocus
placeholder="Enter 6-digit code"
style="font-size: 1.5em; letter-spacing: 5px; text-align: center;">
</div>
<button type="submit" class="btn btn-primary">Verify and Enable 2FA</button>
<a href="<?= '/' . APP_ROOT_DIR_NAME . '/dashboard' ?>" class="btn btn-secondary">Cancel</a>
</form>
</div>
<style>
.form-group { margin: 20px 0; }
.form-group label { display: block; margin-bottom: 5px; }
.form-group input { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; }
.btn { padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; text-decoration: none; display: inline-block; margin-right: 10px; }
.btn-primary { background: #007bff; color: white; }
.btn-secondary { background: #6c757d; color: white; }
.alert-danger { background: #f8d7da; color: #721c24; padding: 10px; border-radius: 4px; margin-bottom: 20px; }
</style>
<?php require __DIR__ . '/../common/footer.php'; ?>

Objective: Create the 2FA verification view to verify the user’s code.

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

app/Views/auth/2fa-verify.php
<?php require __DIR__ . '/../common/header.php'; ?>
<div class="container" style="max-width: 400px; margin: 100px auto;">
<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"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<form method="POST" action="<?= '/' . APP_ROOT_DIR_NAME . '/2fa/verify' ?>">
<div class="form-group">
<label for="code">Verification Code:</label>
<input type="text"
id="code"
name="code"
pattern="[0-9]{6}"
maxlength="6"
required
autofocus
placeholder="000000"
style="font-size: 2em; letter-spacing: 10px; text-align: center;">
</div>
<!-- TODO: Add trust device checkbox (Part 3) -->
<div class="form-group">
<label>
<input type="checkbox" name="trust_device" value="1">
Trust this device for 30 days
</label>
</div>
<button type="submit" class="btn btn-primary" style="width: 100%;">Verify</button>
</form>
<div style="margin-top: 20px; text-align: center;">
<form method="POST" action="<?= '/' . APP_ROOT_DIR_NAME . '/logout' ?>">
<button type="submit" class="btn-link">Cancel and Logout</button>
</form>
</div>
</div>
<style>
.form-group { margin: 20px 0; }
.form-group label { display: block; margin-bottom: 5px; }
.form-group input[type="text"] { width: 100%; padding: 15px; border: 1px solid #ddd; border-radius: 4px; }
.btn { padding: 15px 20px; border: none; border-radius: 4px; cursor: pointer; }
.btn-primary { background: #007bff; color: white; }
.btn-link { background: none; border: none; color: #007bff; cursor: pointer; text-decoration: underline; }
.alert-danger { background: #f8d7da; color: #721c24; padding: 10px; border-radius: 4px; }
</style>
<?php require __DIR__ . '/../common/footer.php'; ?>

Objective: Create the disable 2FA view to confirm the user’s password before disabling 2FA.

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

app/Views/auth/2fa-disable.php
<?php require __DIR__ . '/../common/header.php'; ?>
<div class="container" style="max-width: 400px; margin: 100px auto;">
<h1>Disable Two-Factor Authentication</h1>
<p>Enter your password to confirm disabling 2FA.</p>
<?php if (isset($error)): ?>
<div class="alert alert-danger"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<form method="POST" action="<?= '/' . APP_ROOT_DIR_NAME . '/2fa/disable' ?>">
<div class="form-group">
<label for="password">Password:</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit" class="btn btn-danger">Disable 2FA</button>
<a href="<?= '/' . APP_ROOT_DIR_NAME . '/dashboard' ?>" class="btn btn-secondary">Cancel</a>
</form>
</div>
<style>
.form-group { margin: 20px 0; }
.form-group label { display: block; margin-bottom: 5px; }
.form-group input { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; }
.btn { padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; text-decoration: none; display: inline-block; margin-right: 10px; }
.btn-danger { background: #dc3545; color: white; }
.btn-secondary { background: #6c757d; color: white; }
.alert-danger { background: #f8d7da; color: #721c24; padding: 10px; border-radius: 4px; }
</style>
<?php require __DIR__ . '/../common/footer.php'; ?>

Objective: Create the dashboard view to display the user’s information and security settings.

app/Views/dashboard.php
<?php require __DIR__ . '/../common/header.php'; ?>
<div class="container" style="max-width: 800px; margin: 50px auto;">
<h1>Dashboard</h1>
<div class="welcome-section">
<h2>Welcome, <?= htmlspecialchars($_SESSION['user_first_name'] ?? 'User') ?>!</h2>
<p>Email: <?= htmlspecialchars($_SESSION['user_email'] ?? '') ?></p>
</div>
<hr>
<div class="security-section">
<h3>Security Settings</h3>
<div class="security-card">
<h4>Two-Factor Authentication (2FA)</h4>
<?php if ($has2FA): ?>
<p class="status status-enabled"> 2FA is <strong>enabled</strong></p>
<p>Your account is protected with two-factor authentication.</p>
<form method="POST" action="<?= '/' . APP_ROOT_DIR_NAME . '/2fa/disable' ?>">
<button type="submit" class="btn btn-danger">Disable 2FA</button>
</form>
<?php else: ?>
<p class="status status-disabled"> 2FA is <strong>disabled</strong></p>
<p>Enable 2FA to add an extra layer of security to your account.</p>
<a href="<?= '/' . APP_ROOT_DIR_NAME . '/2fa/setup' ?>" class="btn btn-primary">Enable 2FA</a>
<?php endif; ?>
</div>
</div>
<hr>
<div class="actions">
<form method="POST" action="<?= '/' . APP_ROOT_DIR_NAME . '/logout' ?>">
<button type="submit" class="btn btn-secondary">Logout</button>
</form>
</div>
</div>
<style>
.welcome-section {
background-color: #f8f9fa;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
}
.security-section {
margin: 20px 0;
}
.security-card {
border: 1px solid #ddd;
padding: 20px;
border-radius: 8px;
background-color: #fff;
}
.status {
padding: 10px;
border-radius: 4px;
margin: 10px 0;
}
.status-enabled {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.status-disabled {
background-color: #fff3cd;
color: #856404;
border: 1px solid #ffeaa7;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
text-decoration: none;
display: inline-block;
}
.btn-primary {
background-color: #007bff;
color: white;
}
.btn-primary:hover {
background-color: #0056b3;
}
.btn-danger {
background-color: #dc3545;
color: white;
}
.btn-danger:hover {
background-color: #c82333;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
.btn-secondary:hover {
background-color: #5a6268;
}
.actions {
margin-top: 30px;
}
hr {
margin: 30px 0;
border: none;
border-top: 1px solid #ddd;
}
</style>
<?php require __DIR__ . '/../common/footer.php'; ?>

Objective: Add the 2FA routes to your application.

Note: This follows the same routing and middleware syntax from Part 2. Middleware is chained using ->add() and executes in reverse order (last added runs first).

Add these routes to app/Routes/web-routes.php:

app/Routes/web-routes.php
use App\Controllers\TwoFactorController;
use App\Middleware\TwoFactorMiddleware;
// 2FA Setup routes (requires auth, but not 2FA verification)
$app->get('/2fa/setup', [TwoFactorController::class, 'showSetup'])
->setName('2fa.setup')
->add(AuthMiddleware::class);
$app->post('/2fa/verify-and-enable', [TwoFactorController::class, 'verifyAndEnable'])
->setName('2fa.enable')
->add(AuthMiddleware::class);
// 2FA Verification during login
$app->get('/2fa/verify', [TwoFactorController::class, 'showVerify'])
->setName('2fa.verify')
->add(AuthMiddleware::class);
$app->post('/2fa/verify', [TwoFactorController::class, 'verify'])
->setName('2fa.verify.post')
->add(AuthMiddleware::class);
// 2FA Disable (requires full auth including 2FA)
$app->get('/2fa/disable', [TwoFactorController::class, 'showDisable'])
->setName('2fa.disable.show')
->add(TwoFactorMiddleware::class)
->add(AuthMiddleware::class);
$app->post('/2fa/disable', [TwoFactorController::class, 'disable'])
->setName('2fa.disable')
->add(TwoFactorMiddleware::class)
->add(AuthMiddleware::class);

Also update the dashboard route to include TwoFactorMiddleware:

app/Routes/web-routes.php
$app->get('/dashboard', [AuthController::class, 'dashboard'])
->setName('dashboard')
->add(TwoFactorMiddleware::class) // Add this line
->add(AuthMiddleware::class);

Objective: Modify the login process to check for 2FA status.

First, add the required import at the top of AuthController.php:

app/Controllers/AuthController.php
use App\Domain\Models\TwoFactorAuthModel;

Then update AuthController::Authenticate() - after setting session data (around line 152), update to:

app/Controllers/AuthController.php
// Check if user has 2FA enabled
$has2FA = $this->twoFactorModel->isEnabled($user['id']);
// Set session data using SessionManager (same pattern as Auth Part 2)
SessionManager::set('user_id', $user['id']);
SessionManager::set('user_email', $user['email']);
SessionManager::set('user_first_name', $user['first_name']);
SessionManager::set('user_last_name', $user['last_name']);
SessionManager::set('is_authenticated', true);
SessionManager::set('requires_2fa', $has2FA);
SessionManager::set('two_factor_verified', !$has2FA); // Auto-verified if no 2FA

Also update the dashboard method to pass has2FA dynamically:

app/Controllers/AuthController.php
public function dashboard(Request $request, Response $response): Response
{
$userId = SessionManager::get('user_id');
$has2FA = $this->twoFactorModel->isEnabled($userId);
return $this->render($response, 'auth/dashboard.php', [
'title' => 'Dashboard',
'has2FA' => $has2FA
]);
}

Objective: Verify that the basic 2FA functionality works correctly.

Testing Checklist:

  1. Login with a test account
  2. Access /2fa/setup - QR code displays
  3. Scan QR code with Google Authenticator/Authy
  4. Enter wrong code - shows error
  5. Enter correct code - enables 2FA successfully
  1. Logout and login again
  2. Redirected to /2fa/verify after password
  3. Enter correct code - access dashboard
  1. Login and reach 2FA verification
  2. Enter wrong code 5 times
  3. Should be logged out and session destroyed
  1. Navigate to disable 2FA page
  2. Enter incorrect password - shows error
  3. Enter correct password - 2FA disabled
  4. Next login doesn’t require 2FA

Objective: Create a model to manage trusted devices for the “Remember this device” feature.

This feature allows 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;
/**
* Model for managing trusted devices.
*/
class TrustedDeviceModel extends BaseModel
{
/**
* Create a new trusted device record.
*
* @param int $userId
* @param string $deviceToken
* @param array $deviceInfo ['device_name', 'user_agent', 'ip_address', 'expires_at']
* @return int
*/
public function create(int $userId, string $deviceToken, array $deviceInfo): int
{
// TODO: Insert a new record into trusted_devices table
// HINT: Use $this->execute() for INSERT
// Fields: user_id, device_token, device_name, user_agent, ip_address, expires_at
return 0; // Replace with your implementation
}
/**
* Check if a device token is valid for a user.
*
* @param string $deviceToken
* @param int $userId
* @return bool True if token is valid and not expired
*/
public function isValid(string $deviceToken, int $userId): bool
{
// TODO: Query to check if token exists, belongs to user, and hasn't expired
// HINT: Use $this->selectOne()
// SQL: SELECT * FROM trusted_devices WHERE device_token = ? AND user_id = ? AND expires_at > NOW()
return false; // Replace with your implementation
}
/**
* Update the last_used_at timestamp for a device.
*
* @param string $deviceToken
* @return bool
*/
public function updateLastUsed(string $deviceToken): bool
{
// TODO: Update last_used_at = NOW() for the device
return false; // Replace with your implementation
}
/**
* Get all trusted devices for a user.
*
* @param int $userId
* @return array
*/
public function getAllByUserId(int $userId): array
{
// TODO: Select all non-expired devices for the user
// HINT: Use $this->selectAll()
return []; // Replace with your implementation
}
/**
* Revoke (delete) a specific device.
*
* @param int $deviceId
* @param int $userId
* @return bool
*/
public function revoke(int $deviceId, int $userId): bool
{
// TODO: Delete the device record
// IMPORTANT: Include user_id in WHERE clause for security
return false; // Replace with your implementation
}
/**
* Revoke all devices for a user.
*
* @param int $userId
* @return bool
*/
public function revokeAll(int $userId): bool
{
// TODO: Delete all trusted devices for the user
return false; // Replace with your implementation
}
}

Step 11: Update TwoFactorController for Device Trust

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

Objective: Add device trust functionality to the 2FA verification process.

First, 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
// Check if user wants to trust this device
$trustDevice = $data['trust_device'] ?? false;
if ($trustDevice) {
// TODO: Generate a unique device token (64-character hex string)
// HINT: Use bin2hex(random_bytes(32)) to generate a secure random token
$deviceToken = ''; // Replace with your implementation
// TODO: Calculate the expiration date (30 days from now)
// HINT: Use DateTime class: (new \DateTime())->modify('+30 days')->format('Y-m-d H:i:s')
$expiresAt = ''; // Replace with your implementation
// TODO: Build the device information array with the following keys:
// - 'device_name': Use $this->getDeviceName($request) helper method
// - 'user_agent': Use $request->getHeaderLine('User-Agent')
// - 'ip_address': Use $this->getClientIp($request) helper method
// - 'expires_at': Use the expiration date calculated above
$deviceInfo = []; // Replace with your implementation
// TODO: Save the trusted device to the database
// HINT: Call $this->trustedDeviceModel->create($userId, $deviceToken, $deviceInfo)
// TODO: Set a secure cookie to remember this device
// HINT: Use setcookie() with an options array containing:
// - 'expires': strtotime('+30 days')
// - 'path': '/' . APP_ROOT_DIR_NAME
// - 'secure': false (set to true in production with HTTPS)
// - 'httponly': true (prevents JavaScript access)
// - 'samesite': 'Lax' (CSRF protection)
}

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 12: Update TwoFactorMiddleware for Device Trust

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

Objective: Check for trusted device cookie before requiring 2FA verification.

First, 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
) {
}

Then add trusted device check in the process() method, after checking if 2FA is enabled but before redirecting to verify:

app/Middleware/TwoFactorMiddleware.php
// Check for trusted device cookie
$cookies = $request->getCookieParams();
$deviceToken = $cookies['trusted_device'] ?? null;
if ($deviceToken) {
if ($this->trustedDeviceModel->isValid($deviceToken, $userId)) {
// Device is trusted - mark 2FA as verified
SessionManager::set('two_factor_verified', true);
$this->trustedDeviceModel->updateLastUsed($deviceToken);
return $handler->handle($request);
} else {
// Token invalid/expired - delete cookie
setcookie('trusted_device', '', time() - 3600, '/' . APP_ROOT_DIR_NAME);
}
}
// Continue to redirect to 2FA verification...

Objective: Verify the trusted device functionality works correctly.

Testing Checklist:

  1. Login with 2FA enabled
  2. Check “Trust this device” checkbox
  3. Verify 2FA code successfully
  4. Check browser cookies - trusted_device should be set
  1. Logout and login again
  2. Should skip 2FA verification automatically
  3. Direct access to dashboard

Test Case 3: Different Browser Still Requires 2FA

Section titled “Test Case 3: Different Browser Still Requires 2FA”
  1. Open a different browser (or incognito mode)
  2. Login with same account
  3. Should still require 2FA (no cookie in new browser)
  1. Wait for token to expire (or manually modify database)
  2. Login again
  3. Should require 2FA verification

TestExpected Result
Enable 2FAQR code scanned, code verified, 2FA enabled
Login with 2FAAfter password, redirected to /2fa/verify
Correct 2FA codeAccess granted to dashboard
Wrong 2FA codeError message, retry allowed
5 failed codesLogged out, session destroyed
Disable 2FAPassword required, 2FA disabled
Trust deviceCookie set, skips 2FA on next login
Different browserStill requires 2FA (no cookie)

Symptom: QR code area is blank or shows broken image

Possible Causes:

  • BaconQrCodeProvider not installed correctly
  • Secret not being generated

Solution:

  1. Verify bacon/bacon-qr-code is installed: composer show bacon/bacon-qr-code
  2. Check that $tfa->createSecret() is being called
  3. Verify $tfa->getQRCodeImageAsDataUri() returns a valid data URI

Symptom: Even correct codes from authenticator app are rejected

Possible Causes:

  • Time sync issue between server and phone
  • Wrong secret being used for verification

Solution:

  1. Ensure server time is accurate (use NTP)
  2. Verify the same secret is used for QR generation and verification
  3. Check that the secret from session matches during setup

Symptom: “Setup session expired” error during 2FA setup

Possible Causes:

  • Session not persisting between requests
  • Session cookie issues

Solution:

  1. Verify SessionMiddleware is running
  2. Check session configuration in PHP
  3. Ensure cookies are enabled in browser

Symptom: Still prompted for 2FA even after trusting device

Possible Causes:

  • Cookie not being set correctly
  • TwoFactorMiddleware not checking cookie

Solution:

  1. Check browser cookies for trusted_device
  2. Verify cookie path matches your app root
  3. Check database for trusted device record

Objective: Integrate 2FA protection into your admin panel from Assignment 2.

Admin panels have access to sensitive operations:

  • View and modify customer data
  • Manage products and pricing
  • Process orders and refunds
  • Change user permissions

If an admin account is compromised, an attacker gains full control of your e-commerce site. 2FA provides a critical second layer of protection - even if an attacker steals an admin’s password, they still can’t access the admin panel without the authenticator app.


Adding TwoFactorMiddleware to Admin Routes

Section titled “Adding TwoFactorMiddleware to Admin Routes”

In Assignment 2, you created an admin route group with AdminAuthMiddleware. Now you’ll add TwoFactorMiddleware to require 2FA verification for all admin routes.

Update your admin route group in app/Routes/web-routes.php:

app/Routes/web-routes.php
use App\Middleware\TwoFactorMiddleware;
use App\Middleware\AdminAuthMiddleware;
use App\Middleware\AuthMiddleware;
$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) // Step 3: Check 2FA verified
->add(AdminAuthMiddleware::class) // Step 2: Check user is admin
->add(AuthMiddleware::class); // Step 1: Check user is logged in

Middleware Execution Order:

Remember that middleware executes in reverse order of the .add() calls:

Request → AuthMiddleware → AdminAuthMiddleware → TwoFactorMiddleware → Controller
↓ ↓ ↓
"Is logged in?" "Is user admin?" "Is 2FA verified?"
  1. AuthMiddleware runs first - checks if user is logged in
  2. AdminAuthMiddleware runs second - checks if user has admin role
  3. TwoFactorMiddleware runs third - checks if 2FA has been verified
  4. Controller finally handles the request

For maximum security, you can require all admin users to enable 2FA before accessing the admin panel. This ensures no admin account exists without 2FA protection.

Update your AdminAuthMiddleware to inject TwoFactorAuthModel and check if the admin has 2FA enabled:

app/Middleware/AdminAuthMiddleware.php
<?php
declare(strict_types=1);
namespace App\Middleware;
use App\Domain\Models\TwoFactorAuthModel;
use App\Helpers\FlashMessage;
use App\Helpers\SessionManager;
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 AdminAuthMiddleware implements MiddlewareInterface
{
public function __construct(
private TwoFactorAuthModel $twoFactorModel
) {
}
public function process(Request $request, RequestHandler $handler): ResponseInterface
{
// ... existing admin role check code ...
// After verifying the user is an admin, check if 2FA is enabled
$userId = SessionManager::get('user_id');
if (!$this->twoFactorModel->isEnabled($userId)) {
// Admin must enable 2FA before accessing admin panel
FlashMessage::warning('Admin accounts require Two-Factor Authentication. Please enable 2FA to continue.');
$routeParser = RouteContext::fromRequest($request)->getRouteParser();
$setupUrl = $routeParser->urlFor('2fa.setup');
$response = new \Nyholm\Psr7\Response();
return $response->withStatus(302)->withHeader('Location', $setupUrl);
}
return $handler->handle($request);
}
}

Flow for Admin Without 2FA:

  1. Admin logs in with password
  2. Tries to access /admin/dashboard
  3. AdminAuthMiddleware detects 2FA is not enabled
  4. Redirected to /2fa/setup with a warning message
  5. After enabling 2FA, admin can access the admin panel

Test CaseExpected Result
Admin without 2FA tries to access /admin/dashboardRedirected to /2fa/setup with warning
Admin with 2FA enabled but not verified this sessionRedirected to /2fa/verify
Admin with 2FA verifiedAccess granted to admin panel
Regular user tries to access admin routeRedirected to login or access denied
Admin with trusted device cookieSkips 2FA verification, accesses admin panel

FileStatus
app/Domain/Models/TwoFactorAuthModel.phpCreate (skeleton)
app/Domain/Models/TrustedDeviceModel.phpCreate (skeleton)
app/Controllers/TwoFactorController.phpCreate (skeleton)
app/Middleware/TwoFactorMiddleware.phpCreate (skeleton)
app/Views/auth/2fa-setup.phpCreate
app/Views/auth/2fa-verify.phpCreate
app/Views/auth/2fa-disable.phpCreate
app/Routes/web-routes.phpModify (add routes)
config/container.phpModify (add dependencies)
app/Controllers/AuthController.phpModify (add 2FA check)
app/Middleware/AdminAuthMiddleware.phpModify (add 2FA requirement for admins - Step 14)