怎样在PHP项目中实现多因素认证?

wen java案例 4

本文目录导读:

怎样在PHP项目中实现多因素认证?

  1. 基础架构设计
  2. 接口定义
  3. TOTP (基于时间的一次性密码) 实现
  4. Email验证器实现
  5. 主MFA管理器
  6. 数据库迁移
  7. 使用示例
  8. 安全注意事项

我来详细介绍在PHP项目中实现多因素认证(MFA)的方法和最佳实践。

基础架构设计

目录结构

project/
├── app/
│   ├── Auth/
│   │   ├── MultiFactorAuth.php
│   │   ├── Authenticators/
│   │   │   ├── AuthenticatorInterface.php
│   │   │   ├── TOTPAuthenticator.php
│   │   │   ├── EmailAuthenticator.php
│   │   │   └── SMSAuthenticator.php
│   │   └── Exceptions/
│   │       └── MFAException.php
│   └── Models/
│       └── User.php
├── config/
│   └── mfa.php
└── database/
    └── migrations/

接口定义

<?php
// app/Auth/Authenticators/AuthenticatorInterface.php
interface AuthenticatorInterface
{
    public function generateSecret(): string;
    public function generateCode(string $secret): string;
    public function validateCode(string $secret, string $code): bool;
    public function getType(): string;
}

TOTP (基于时间的一次性密码) 实现

<?php
// app/Auth/Authenticators/TOTPAuthenticator.php
class TOTPAuthenticator implements AuthenticatorInterface
{
    private int $timeStep = 30; // 30秒有效期
    private int $codeLength = 6;
    public function generateSecret(): string
    {
        // 生成16位base32编码的秘密
        $random = random_bytes(10);
        return $this->base32Encode($random);
    }
    public function generateCode(string $secret): string
    {
        $counter = floor(time() / $this->timeStep);
        return $this->generateHOTP($secret, $counter);
    }
    public function validateCode(string $secret, string $code): bool
    {
        // 验证当前时间步和前一个时间步(允许误差)
        $counter = floor(time() / $this->timeStep);
        for ($i = -1; $i <= 1; $i++) {
            $validCode = $this->generateHOTP($secret, $counter + $i);
            if (hash_equals($validCode, $code)) {
                return true;
            }
        }
        return false;
    }
    public function getType(): string
    {
        return 'totp';
    }
    private function generateHOTP(string $secret, int $counter): string
    {
        $secret = $this->base32Decode($secret);
        $counter = pack('N*', 0, $counter);
        $hash = hash_hmac('sha1', $counter, $secret, true);
        $offset = ord($hash[19]) & 0xf;
        $binary = (
            (ord($hash[$offset]) & 0x7f) << 24 |
            (ord($hash[$offset + 1]) & 0xff) << 16 |
            (ord($hash[$offset + 2]) & 0xff) << 8 |
            (ord($hash[$offset + 3]) & 0xff)
        );
        $otp = $binary % pow(10, $this->codeLength);
        return str_pad($otp, $this->codeLength, '0', STR_PAD_LEFT);
    }
    private function base32Encode(string $data): string
    {
        $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
        $base32 = '';
        $bits = '';
        foreach (str_split($data) as $byte) {
            $bits .= sprintf('%08b', ord($byte));
        }
        for ($i = 0; $i < strlen($bits); $i += 5) {
            $chunk = substr($bits, $i, 5);
            $chunk = str_pad($chunk, 5, '0', STR_PAD_RIGHT);
            $base32 .= $alphabet[bindec($chunk)];
        }
        return $base32;
    }
    private function base32Decode(string $data): string
    {
        $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
        $data = strtoupper($data);
        $bits = '';
        foreach (str_split($data) as $char) {
            $position = strpos($alphabet, $char);
            if ($position !== false) {
                $bits .= sprintf('%05b', $position);
            }
        }
        $bytes = '';
        for ($i = 0; $i + 8 <= strlen($bits); $i += 8) {
            $chunk = substr($bits, $i, 8);
            $bytes .= chr(bindec($chunk));
        }
        return $bytes;
    }
    public function getProvisioningUri(string $username, string $secret, string $issuer = 'MyApp'): string
    {
        $params = http_build_query([
            'secret' => $secret,
            'issuer' => $issuer,
            'algorithm' => 'SHA1',
            'digits' => $this->codeLength,
            'period' => $this->timeStep
        ]);
        return "otpauth://totp/{$issuer}:{$username}?{$params}";
    }
}

Email验证器实现

<?php
// app/Auth/Authenticators/EmailAuthenticator.php
class EmailAuthenticator implements AuthenticatorInterface
{
    private \PDO $db;
    private \PHPMailer\PHPMailer\PHPMailer $mailer;
    private int $codeLength = 6;
    private int $expiryMinutes = 10;
    public function __construct(\PDO $db, \PHPMailer\PHPMailer\PHPMailer $mailer)
    {
        $this->db = $db;
        $this->mailer = $mailer;
    }
    public function generateSecret(): string
    {
        // Email验证器不需要secret,Code是临时的
        return '';
    }
    public function generateCode(string $email): string
    {
        // 生成随机验证码
        $code = '';
        for ($i = 0; $i < $this->codeLength; $i++) {
            $code .= random_int(0, 9);
        }
        // 存储验证码到数据库
        $stmt = $this->db->prepare(
            'INSERT INTO mfa_codes (identifier, code, type, expires_at, used) 
             VALUES (:identifier, :code, :type, :expires_at, 0)'
        );
        $stmt->execute([
            ':identifier' => $email,
            ':code' => password_hash($code, PASSWORD_DEFAULT),
            ':type' => 'email',
            ':expires_at' => date('Y-m-d H:i:s', strtotime("+{$this->expiryMinutes} minutes"))
        ]);
        // 发送邮件
        $this->sendEmail($email, $code);
        return $code;
    }
    public function validateCode(string $email, string $code): bool
    {
        // 查找未使用的验证码
        $stmt = $this->db->prepare(
            'SELECT * FROM mfa_codes 
             WHERE identifier = :identifier 
             AND type = :type 
             AND used = 0 
             AND expires_at > NOW() 
             ORDER BY created_at DESC 
             LIMIT 1'
        );
        $stmt->execute([
            ':identifier' => $email,
            ':type' => 'email'
        ]);
        $record = $stmt->fetch();
        if (!$record) {
            return false;
        }
        // 验证密码
        if (password_verify($code, $record['code'])) {
            // 标记为已使用
            $stmt = $this->db->prepare('UPDATE mfa_codes SET used = 1 WHERE id = :id');
            $stmt->execute([':id' => $record['id']]);
            return true;
        }
        return false;
    }
    public function getType(): string
    {
        return 'email';
    }
    private function sendEmail(string $email, string $code): void
    {
        try {
            $this->mailer->addAddress($email);
            $this->mailer->Subject = '您的验证码';
            $this->mailer->Body = "您的验证码是: {$code}\n有效期: {$this->expiryMinutes}分钟";
            $this->mailer->send();
        } catch (Exception $e) {
            throw new MFAException("邮件发送失败: " . $e->getMessage());
        }
    }
}

主MFA管理器

<?php
// app/Auth/MultiFactorAuth.php
class MultiFactorAuth
{
    private array $authenticators = [];
    private \PDO $db;
    private array $config;
    public function __construct(\PDO $db, array $config = [])
    {
        $this->db = $db;
        $this->config = array_merge([
            'enabled_methods' => ['totp', 'email', 'sms'],
            'default_method' => 'totp',
            'backup_codes_count' => 10
        ], $config);
    }
    public function registerAuthenticator(AuthenticatorInterface $authenticator): void
    {
        $this->authenticators[$authenticator->getType()] = $authenticator;
    }
    public function setupMFA(int $userId, string $method = 'totp'): array
    {
        if (!isset($this->authenticators[$method])) {
            throw new MFAException("不支持的MFA方法: {$method}");
        }
        $authenticator = $this->authenticators[$method];
        $secret = $authenticator->generateSecret();
        // 保存设置到数据库
        $stmt = $this->db->prepare(
            'INSERT INTO user_mfa_settings (user_id, method, secret, enabled, created_at) 
             VALUES (:user_id, :method, :secret, 0, NOW())'
        );
        $stmt->execute([
            ':user_id' => $userId,
            ':method' => $method,
            ':secret' => $secret
        ]);
        return [
            'setting_id' => $this->db->lastInsertId(),
            'secret' => $secret,
            'provisioning_uri' => $method === 'totp' ? 
                $authenticator->getProvisioningUri("user_{$userId}", $secret) : null
        ];
    }
    public function enableMFA(int $settingId, string $code): bool
    {
        $stmt = $this->db->prepare(
            'SELECT * FROM user_mfa_settings WHERE id = :id'
        );
        $stmt->execute([':id' => $settingId]);
        $setting = $stmt->fetch();
        if (!$setting) {
            throw new MFAException("MFA设置不存在");
        }
        $authenticator = $this->authenticators[$setting['method']];
        if ($authenticator->validateCode($setting['secret'], $code)) {
            // 启用MFA
            $stmt = $this->db->prepare(
                'UPDATE user_mfa_settings SET enabled = 1 WHERE id = :id'
            );
            $stmt->execute([':id' => $settingId]);
            // 生成备份码
            $this->generateBackupCodes($setting['user_id']);
            return true;
        }
        return false;
    }
    public function verifyMFA(int $userId, string $code, string $method = null): bool
    {
        $settings = $this->getUserMFASettings($userId, true);
        if (empty($settings)) {
            return true; // 未启用MFA
        }
        // 优先检查备份码
        if ($this->verifyBackupCode($userId, $code)) {
            return true;
        }
        foreach ($settings as $setting) {
            if ($method && $setting['method'] !== $method) {
                continue;
            }
            $authenticator = $this->authenticators[$setting['method']];
            if ($authenticator->validateCode($setting['secret'], $code)) {
                return true;
            }
        }
        return false;
    }
    public function generateMFAChallenge(int $userId): array
    {
        $settings = $this->getUserMFASettings($userId, true);
        $challenges = [];
        foreach ($settings as $setting) {
            $authenticator = $this->authenticators[$setting['method']];
            if ($setting['method'] === 'email') {
                // 发送验证码邮件
                $userEmail = $this->getUserEmail($userId);
                $authenticator->generateCode($userEmail);
                $challenges[] = [
                    'method' => 'email',
                    'message' => '验证码已发送到您的邮箱'
                ];
            } elseif ($setting['method'] === 'sms') {
                // 发送短信验证码
                $userPhone = $this->getUserPhone($userId);
                $authenticator->generateCode($userPhone);
                $challenges[] = [
                    'method' => 'sms',
                    'message' => '验证码已发送到您的手机'
                ];
            }
        }
        return $challenges;
    }
    private function generateBackupCodes(int $userId): void
    {
        $backupCodes = [];
        for ($i = 0; $i < $this->config['backup_codes_count']; $i++) {
            $code = bin2hex(random_bytes(4)); // 8字符的16进制码
            $hashedCode = password_hash($code, PASSWORD_DEFAULT);
            $stmt = $this->db->prepare(
                'INSERT INTO mfa_backup_codes (user_id, code_hash, used, created_at) 
                 VALUES (:user_id, :code_hash, 0, NOW())'
            );
            $stmt->execute([
                ':user_id' => $userId,
                ':code_hash' => $hashedCode
            ]);
            $backupCodes[] = $code;
        }
        // 返回备份码(应只显示一次)
        $_SESSION['backup_codes'] = $backupCodes;
    }
    private function verifyBackupCode(int $userId, string $code): bool
    {
        $stmt = $this->db->prepare(
            'SELECT * FROM mfa_backup_codes 
             WHERE user_id = :user_id AND used = 0 
             ORDER BY created_at ASC LIMIT 1'
        );
        $stmt->execute([':user_id' => $userId]);
        $record = $stmt->fetch();
        if (!$record) {
            return false;
        }
        if (password_verify($code, $record['code_hash'])) {
            // 标记为已使用
            $stmt = $this->db->prepare(
                'UPDATE mfa_backup_codes SET used = 1 WHERE id = :id'
            );
            $stmt->execute([':id' => $record['id']]);
            return true;
        }
        return false;
    }
    private function getUserMFASettings(int $userId, bool $enabledOnly = false): array
    {
        $sql = 'SELECT * FROM user_mfa_settings WHERE user_id = :user_id';
        if ($enabledOnly) {
            $sql .= ' AND enabled = 1';
        }
        $stmt = $this->db->prepare($sql);
        $stmt->execute([':user_id' => $userId]);
        return $stmt->fetchAll();
    }
    private function getUserEmail(int $userId): string
    {
        // 从用户表获取邮箱
        $stmt = $this->db->prepare('SELECT email FROM users WHERE id = :id');
        $stmt->execute([':id' => $userId]);
        return $stmt->fetchColumn();
    }
    private function getUserPhone(int $userId): string
    {
        // 从用户表获取手机号
        $stmt = $this->db->prepare('SELECT phone FROM users WHERE id = :id');
        $stmt->execute([':id' => $userId]);
        return $stmt->fetchColumn();
    }
}

数据库迁移

-- 用户MFA设置表
CREATE TABLE user_mfa_settings (
    id INT PRIMARY KEY AUTO_INCREMENT,
    user_id INT NOT NULL,
    method VARCHAR(20) NOT NULL,
    secret VARCHAR(255) NOT NULL,
    enabled TINYINT(1) DEFAULT 0,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES users(id),
    UNIQUE KEY unique_user_method (user_id, method)
);
-- 验证码表
CREATE TABLE mfa_codes (
    id INT PRIMARY KEY AUTO_INCREMENT,
    identifier VARCHAR(255) NOT NULL,
    code VARCHAR(255) NOT NULL,
    type VARCHAR(20) NOT NULL,
    used TINYINT(1) DEFAULT 0,
    expires_at DATETIME NOT NULL,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_identifier_type (identifier, type, used),
    INDEX idx_expires (expires_at)
);
-- 备份码表
CREATE TABLE mfa_backup_codes (
    id INT PRIMARY KEY AUTO_INCREMENT,
    user_id INT NOT NULL,
    code_hash VARCHAR(255) NOT NULL,
    used TINYINT(1) DEFAULT 0,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES users(id),
    INDEX idx_user_used (user_id, used)
);

使用示例

<?php
// 初始化
$pdo = new PDO('mysql:host=localhost;dbname=myapp', 'user', 'password');
$mailer = new PHPMailer\PHPMailer\PHPMailer();
// 创建MFA管理器
$mfaManager = new MultiFactorAuth($pdo, [
    'enabled_methods' => ['totp', 'email'],
    'default_method' => 'totp',
    'backup_codes_count' => 10
]);
// 注册认证器
$mfaManager->registerAuthenticator(new TOTPAuthenticator());
$mfaManager->registerAuthenticator(new EmailAuthenticator($pdo, $mailer));
// 设置MFA
$setupInfo = $mfaManager->setupMFA($userId, 'totp');
// 显示QR码供用户扫描
echo '<img src="' . $setupInfo['provisioning_uri'] . '">';
// 验证并启用
if ($_POST['code']) {
    $result = $mfaManager->enableMFA($setupInfo['setting_id'], $_POST['code']);
}
// 登录验证
session_start();
if ($mfaManager->verifyMFA($_SESSION['user_id'], $_POST['mfa_code'])) {
    $_SESSION['mfa_verified'] = true;
    // 登录成功
} else {
    // 验证失败
}

安全注意事项

  1. 速率限制: 添加验证码尝试次数限制
  2. 安全存储: 使用bcrypt或argon2加密存储备份码
  3. 会话管理: MFA验证成功后使用安全的会话令牌
  4. 日志记录: 记录所有MFA尝试日志
  5. 加密传输: 使用HTTPS传输所有验证数据

这个实现提供了完整的MFA功能,包括TOTP、邮箱验证和备份码支持,可以根据项目需求进一步扩展。

抱歉,评论功能暂时关闭!