本文目录导读:

我来详细介绍在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 {
// 验证失败
}
安全注意事项
- 速率限制: 添加验证码尝试次数限制
- 安全存储: 使用bcrypt或argon2加密存储备份码
- 会话管理: MFA验证成功后使用安全的会话令牌
- 日志记录: 记录所有MFA尝试日志
- 加密传输: 使用HTTPS传输所有验证数据
这个实现提供了完整的MFA功能,包括TOTP、邮箱验证和备份码支持,可以根据项目需求进一步扩展。