Lab: Two-Factor Authentication (2FA)
Resources
Section titled “Resources”- TOTP: Time-Based One-Time Password Algorithm (RFC 6238) ↗
- robthree/twofactorauth Library Documentation ↗
- OWASP Multi-Factor Authentication Cheat Sheet ↗
- Google Authenticator ↗
Overview
Section titled “Overview”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:
- User enables 2FA by scanning a QR code with their authenticator app
- The QR code contains a secret key that both the server and app will use
- When logging in, after entering their password, users must also enter the 6-digit code from their app
- 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
Learning Objectives
Section titled “Learning Objectives”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
Prerequisites
Section titled “Prerequisites”- Session Middleware lab completed - You will use
SessionManagerfor 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
Step 1: Install Required Packages
Section titled “Step 1: Install Required Packages”Objective: Add the TOTP and QR code generation libraries to your project.
Open a terminal in VS Code and run the following commands:
composer require robthree/twofactorauthcomposer require bacon/bacon-qr-codeWhat Gets Installed:
robthree/twofactorauth- TOTP generation and validation librarybacon/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.
Step 2: Import the Database Schema
Section titled “Step 2: Import the Database Schema”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:
-- Two-factor authentication tableCREATE 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" featureCREATE 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 userstrusted_devices- Stores trusted device tokens for “Remember this device” featurelogin_attempts- (Optional) For implementing login rate limiting in future enhancements
Step 3: Create TwoFactorAuthModel
Section titled “Step 3: Create TwoFactorAuthModel”Objective: Create a model to manage 2FA data in the database.
Create 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 }}Step 4: Create TwoFactorController
Section titled “Step 4: Create TwoFactorController”Objective: Create a controller to handle 2FA setup, verification, and disabling.
Create 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' ]); }}Step 5: Create TwoFactorMiddleware
Section titled “Step 5: Create TwoFactorMiddleware”Objective: Create middleware to enforce 2FA verification for authenticated users who have it enabled.
Note: This middleware follows the same pattern as
AuthMiddlewarefrom 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:
- Is the user authenticated?
- Does the user have 2FA enabled?
- 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:
<?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); }}Step 6: Create Views
Section titled “Step 6: Create Views”Objective: Create the view templates for 2FA setup, verification, and disabling.
6.1: Create 2FA Setup View
Section titled “6.1: Create 2FA Setup View”Objective: Create the 2FA setup view to display the QR code and secret key to the user.
Create 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'; ?>6.2: Create 2FA Verification View
Section titled “6.2: Create 2FA Verification View”Objective: Create the 2FA verification view to verify the user’s code.
Create 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'; ?>6.3: Create 2FA Disable View
Section titled “6.3: Create 2FA Disable View”Objective: Create the disable 2FA view to confirm the user’s password before disabling 2FA.
Create 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'; ?>6.4: Create the Dashboard View
Section titled “6.4: Create the Dashboard View”Objective: Create the dashboard view to display the user’s information and security settings.
<?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'; ?>Step 7: Register Routes
Section titled “Step 7: Register Routes”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:
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->get('/dashboard', [AuthController::class, 'dashboard']) ->setName('dashboard') ->add(TwoFactorMiddleware::class) // Add this line ->add(AuthMiddleware::class);Step 8: Update Login Flow
Section titled “Step 8: Update Login Flow”Objective: Modify the login process to check for 2FA status.
First, add the required import at the top of AuthController.php:
use App\Domain\Models\TwoFactorAuthModel;Then update AuthController::Authenticate() - after setting session data (around line 152), update to:
// 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 2FAAlso update the dashboard method to pass has2FA dynamically:
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 ]);}Step 9: Test 2FA
Section titled “Step 9: Test 2FA”Objective: Verify that the basic 2FA functionality works correctly.
Testing Checklist:
Test Case 1: Enable 2FA
Section titled “Test Case 1: Enable 2FA”- Login with a test account
- Access
/2fa/setup- QR code displays - Scan QR code with Google Authenticator/Authy
- Enter wrong code - shows error
- Enter correct code - enables 2FA successfully
Test Case 2: Login with 2FA
Section titled “Test Case 2: Login with 2FA”- Logout and login again
- Redirected to
/2fa/verifyafter password - Enter correct code - access dashboard
Test Case 3: Failed Attempts
Section titled “Test Case 3: Failed Attempts”- Login and reach 2FA verification
- Enter wrong code 5 times
- Should be logged out and session destroyed
Test Case 4: Disable 2FA
Section titled “Test Case 4: Disable 2FA”- Navigate to disable 2FA page
- Enter incorrect password - shows error
- Enter correct password - 2FA disabled
- Next login doesn’t require 2FA
Step 10: Create TrustedDeviceModel
Section titled “Step 10: Create TrustedDeviceModel”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:
<?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:
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);:
// 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:
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:
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:
// 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...Step 13: Test Remember Device Feature
Section titled “Step 13: Test Remember Device Feature”Objective: Verify the trusted device functionality works correctly.
Testing Checklist:
Test Case 1: Trust a Device
Section titled “Test Case 1: Trust a Device”- Login with 2FA enabled
- Check “Trust this device” checkbox
- Verify 2FA code successfully
- Check browser cookies -
trusted_deviceshould be set
Test Case 2: Skip 2FA on Trusted Device
Section titled “Test Case 2: Skip 2FA on Trusted Device”- Logout and login again
- Should skip 2FA verification automatically
- Direct access to dashboard
Test Case 3: Different Browser Still Requires 2FA
Section titled “Test Case 3: Different Browser Still Requires 2FA”- Open a different browser (or incognito mode)
- Login with same account
- Should still require 2FA (no cookie in new browser)
Test Case 4: Expired Token
Section titled “Test Case 4: Expired Token”- Wait for token to expire (or manually modify database)
- Login again
- Should require 2FA verification
Testing Your Implementation
Section titled “Testing Your Implementation”Functional Tests Summary
Section titled “Functional Tests Summary”| Test | Expected Result |
|---|---|
| Enable 2FA | QR code scanned, code verified, 2FA enabled |
| Login with 2FA | After password, redirected to /2fa/verify |
| Correct 2FA code | Access granted to dashboard |
| Wrong 2FA code | Error message, retry allowed |
| 5 failed codes | Logged out, session destroyed |
| Disable 2FA | Password required, 2FA disabled |
| Trust device | Cookie set, skips 2FA on next login |
| Different browser | Still requires 2FA (no cookie) |
Common Issues and Solutions
Section titled “Common Issues and Solutions”Issue 1: QR Code Not Displaying
Section titled “Issue 1: QR Code Not Displaying”Symptom: QR code area is blank or shows broken image
Possible Causes:
- BaconQrCodeProvider not installed correctly
- Secret not being generated
Solution:
- Verify
bacon/bacon-qr-codeis installed:composer show bacon/bacon-qr-code - Check that
$tfa->createSecret()is being called - Verify
$tfa->getQRCodeImageAsDataUri()returns a valid data URI
Issue 2: Code Always Invalid
Section titled “Issue 2: Code Always Invalid”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:
- Ensure server time is accurate (use NTP)
- Verify the same secret is used for QR generation and verification
- Check that the secret from session matches during setup
Issue 3: Session Secret Lost
Section titled “Issue 3: Session Secret Lost”Symptom: “Setup session expired” error during 2FA setup
Possible Causes:
- Session not persisting between requests
- Session cookie issues
Solution:
- Verify SessionMiddleware is running
- Check session configuration in PHP
- Ensure cookies are enabled in browser
Issue 4: Trusted Device Not Working
Section titled “Issue 4: Trusted Device Not Working”Symptom: Still prompted for 2FA even after trusting device
Possible Causes:
- Cookie not being set correctly
- TwoFactorMiddleware not checking cookie
Solution:
- Check browser cookies for
trusted_device - Verify cookie path matches your app root
- Check database for trusted device record
Step 14: Applying 2FA to Your Admin Panel
Section titled “Step 14: Applying 2FA to Your Admin Panel”Objective: Integrate 2FA protection into your admin panel from Assignment 2.
Why Admin Panels Need 2FA
Section titled “Why Admin Panels Need 2FA”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:
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 inMiddleware 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?"- AuthMiddleware runs first - checks if user is logged in
- AdminAuthMiddleware runs second - checks if user has admin role
- TwoFactorMiddleware runs third - checks if 2FA has been verified
- Controller finally handles the request
Requiring 2FA for Admin Accounts
Section titled “Requiring 2FA for Admin Accounts”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:
<?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:
- Admin logs in with password
- Tries to access
/admin/dashboard AdminAuthMiddlewaredetects 2FA is not enabled- Redirected to
/2fa/setupwith a warning message - After enabling 2FA, admin can access the admin panel
Testing Checklist
Section titled “Testing Checklist”| Test Case | Expected Result |
|---|---|
Admin without 2FA tries to access /admin/dashboard | Redirected to /2fa/setup with warning |
| Admin with 2FA enabled but not verified this session | Redirected to /2fa/verify |
| Admin with 2FA verified | Access granted to admin panel |
| Regular user tries to access admin route | Redirected to login or access denied |
| Admin with trusted device cookie | Skips 2FA verification, accesses admin panel |
Files Summary
Section titled “Files Summary”| File | Status |
|---|---|
app/Domain/Models/TwoFactorAuthModel.php | Create (skeleton) |
app/Domain/Models/TrustedDeviceModel.php | Create (skeleton) |
app/Controllers/TwoFactorController.php | Create (skeleton) |
app/Middleware/TwoFactorMiddleware.php | Create (skeleton) |
app/Views/auth/2fa-setup.php | Create |
app/Views/auth/2fa-verify.php | Create |
app/Views/auth/2fa-disable.php | Create |
app/Routes/web-routes.php | Modify (add routes) |
config/container.php | Modify (add dependencies) |
app/Controllers/AuthController.php | Modify (add 2FA check) |
app/Middleware/AdminAuthMiddleware.php | Modify (add 2FA requirement for admins - Step 14) |