PHP项目TOTP与扫码绑定

wen PHP项目 3

本文目录导读:

PHP项目TOTP与扫码绑定

  1. TOTP 基本原理
  2. 所需库安装
  3. 后端PHP实现
  4. 前端实现
  5. 数据库迁移
  6. 路由配置
  7. 安全建议

我来为您详细介绍如何在PHP项目中实现TOTP(基于时间的一次性密码)与扫码绑定功能。

TOTP 基本原理

TOTP基于HMAC-SHA1算法,使用时间和共享密钥生成一次性密码。

所需库安装

使用Composer安装相关库:

composer require spomky-labs/otphp
composer require endroid/qr-code

或者使用更轻量的库:

composer require pragmarx/google2fa
composer require simplesoftwareio/simple-qrcode

后端PHP实现

1 TOTP服务类

<?php
// app/Services/TotpService.php
namespace App\Services;
use OTPHP\TOTP;
use Endroid\QrCode\QrCode;
use Endroid\QrCode\Writer\PngWriter;
use Endroid\QrCode\Encoding\Encoding;
use Endroid\QrCode\ErrorCorrectionLevel;
class TotpService
{
    private $issuer;
    private $digits;
    private $period;
    public function __construct()
    {
        $this->issuer = 'YourAppName'; // 应用名称
        $this->digits = 6; // 验证码位数
        $this->period = 30; // 有效期(秒)
    }
    /**
     * 生成TOTP密钥和二维码数据
     */
    public function generateSecret(string $username): array
    {
        // 创建TOTP对象
        $totp = TOTP::create();
        $totp->setLabel($username);
        $totp->setIssuer($this->issuer);
        $totp->setDigits($this->digits);
        $totp->setPeriod($this->period);
        // 生成密钥
        $secret = $totp->getSecret();
        // 生成OTP Auth URI(用于二维码)
        $otpAuthUri = $totp->getProvisioningUri();
        return [
            'secret' => $secret,
            'uri' => $otpAuthUri,
            'qr_code' => $this->generateQrCode($otpAuthUri)
        ];
    }
    /**
     * 生成二维码
     */
    private function generateQrCode(string $data): string
    {
        $qrCode = QrCode::create($data)
            ->setEncoding(new Encoding('UTF-8'))
            ->setErrorCorrectionLevel(ErrorCorrectionLevel::Medium)
            ->setSize(300)
            ->setMargin(10);
        $writer = new PngWriter();
        $result = $writer->write($qrCode);
        // 返回Base64编码的图片
        return base64_encode($result->getString());
    }
    /**
     * 验证TOTP码
     */
    public function verifyTotp(string $secret, string $code): bool
    {
        try {
            $totp = TOTP::createFromSecret($secret);
            $totp->setDigits($this->digits);
            $totp->setPeriod($this->period);
            return $totp->verify($code);
        } catch (\Exception $e) {
            return false;
        }
    }
    /**
     * 获取当前有效的TOTP码(用于测试)
     */
    public function getCurrentCode(string $secret): string
    {
        $totp = TOTP::createFromSecret($secret);
        $totp->setDigits($this->digits);
        $totp->setPeriod($this->period);
        return $totp->now();
    }
}

2 用户控制器

<?php
// app/Http/Controllers/TotpController.php
namespace App\Http\Controllers;
use App\Services\TotpService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
class TotpController extends Controller
{
    protected $totpService;
    public function __construct(TotpService $totpService)
    {
        $this->totpService = $totpService;
    }
    /**
     * 显示开启TOTP页面
     */
    public function showSetupForm()
    {
        return view('totp.setup');
    }
    /**
     * 获取TOTP配置信息
     */
    public function getSetupInfo(Request $request)
    {
        $user = Auth::user();
        // 生成TOTP密钥和二维码
        $totpData = $this->totpService->generateSecret($user->email);
        // 临时存储密钥,待验证后正式保存
        Cache::put('totp_secret_' . $user->id, $totpData['secret'], now()->addMinutes(10));
        return response()->json([
            'secret' => $totpData['secret'],
            'qr_code' => 'data:image/png;base64,' . $totpData['qr_code'],
            'uri' => $totpData['uri']
        ]);
    }
    /**
     * 验证并开启TOTP
     */
    public function enableTotp(Request $request)
    {
        $request->validate([
            'code' => 'required|string|size:6'
        ]);
        $user = Auth::user();
        $tempSecret = Cache::get('totp_secret_' . $user->id);
        if (!$tempSecret) {
            return response()->json([
                'success' => false,
                'message' => '密钥已过期,请重新获取'
            ], 400);
        }
        // 验证TOTP码
        if (!$this->totpService->verifyTotp($tempSecret, $request->code)) {
            return response()->json([
                'success' => false,
                'message' => '验证码错误'
            ], 400);
        }
        // 保存TOTP密钥到数据库
        $user->totp_secret = $tempSecret;
        $user->totp_enabled = true;
        $user->save();
        // 清除临时密钥
        Cache::forget('totp_secret_' . $user->id);
        // 记录恢复码
        $recoveryCodes = $this->generateRecoveryCodes();
        $user->recovery_codes = json_encode($recoveryCodes);
        $user->save();
        return response()->json([
            'success' => true,
            'message' => 'TOTP验证已开启',
            'recovery_codes' => $recoveryCodes
        ]);
    }
    /**
     * 验证TOTP登录
     */
    public function verifyTotpLogin(Request $request)
    {
        $request->validate([
            'code' => 'required|string|size:6',
            'user_id' => 'required|integer'
        ]);
        $user = User::find($request->user_id);
        if (!$user || !$user->totp_enabled) {
            return response()->json([
                'success' => false,
                'message' => '用户未开启TOTP验证'
            ], 400);
        }
        // 先检查是否是恢复码
        if ($this->isRecoveryCode($user, $request->code)) {
            // 处理恢复码逻辑
            $this->consumeRecoveryCode($user, $request->code);
            return response()->json(['success' => true]);
        }
        // 验证TOTP码
        if ($this->totpService->verifyTotp($user->totp_secret, $request->code)) {
            return response()->json(['success' => true]);
        }
        return response()->json([
            'success' => false,
            'message' => '验证码错误'
        ], 400);
    }
    /**
     * 生成恢复码
     */
    private function generateRecoveryCodes(): array
    {
        $codes = [];
        for ($i = 0; $i < 8; $i++) {
            $codes[] = [
                'code' => strtoupper(bin2hex(random_bytes(4))),
                'used' => false
            ];
        }
        return $codes;
    }
    /**
     * 检查是否是恢复码
     */
    private function isRecoveryCode($user, string $code): bool
    {
        $recoveryCodes = json_decode($user->recovery_codes, true);
        foreach ($recoveryCodes as $recoveryCode) {
            if (!$recoveryCode['used'] && $recoveryCode['code'] === strtoupper($code)) {
                return true;
            }
        }
        return false;
    }
    /**
     * 使用恢复码
     */
    private function consumeRecoveryCode($user, string $code): void
    {
        $recoveryCodes = json_decode($user->recovery_codes, true);
        foreach ($recoveryCodes as &$recoveryCode) {
            if ($recoveryCode['code'] === strtoupper($code)) {
                $recoveryCode['used'] = true;
                break;
            }
        }
        $user->recovery_codes = json_encode($recoveryCodes);
        $user->save();
    }
}

前端实现

1 Blade模板

<!-- resources/views/totp/setup.blade.php -->
<!DOCTYPE html>
<html>
<head>设置双重验证</title>
    <meta name="csrf-token" content="{{ csrf_token() }}">
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
    <div class="container mt-5">
        <div class="row justify-content-center">
            <div class="col-md-8">
                <div class="card">
                    <div class="card-header">
                        <h3>设置双重验证(TOTP)</h3>
                    </div>
                    <div class="card-body">
                        <!-- 步骤1:显示二维码 -->
                        <div id="step1" class="text-center">
                            <h5>步骤1:使用认证器App扫描二维码</h5>
                            <p class="text-muted">推荐使用 Google Authenticator 或 Authy</p>
                            <div id="qrCodeContainer" class="mb-3">
                                <div class="spinner-border" role="status">
                                    <span class="visually-hidden">加载中...</span>
                                </div>
                            </div>
                            <div id="manualSetup" class="d-none mb-3">
                                <p>或者手动输入密钥:</p>
                                <code id="secretKey" class="p-2 bg-light rounded"></code>
                            </div>
                            <button class="btn btn-link" onclick="showManualSetup()">
                                无法扫码?手动设置
                            </button>
                        </div>
                        <!-- 步骤2:验证代码 -->
                        <div id="step2" class="d-none">
                            <h5>步骤2:输入验证码确认</h5>
                            <p class="text-muted">请输入认证器App显示的6位数字验证码</p>
                            <div class="form-group mb-3">
                                <input type="text" 
                                       id="verificationCode" 
                                       class="form-control form-control-lg text-center" 
                                       maxlength="6" 
                                       placeholder="000000"
                                       oninput="this.value=this.value.replace(/[^0-9]/g,'')">
                            </div>
                            <button class="btn btn-primary btn-lg w-100" onclick="verifyCode()">
                                验证并开启
                            </button>
                            <div id="errorMessage" class="alert alert-danger d-none mt-3"></div>
                        </div>
                        <!-- 步骤3:完成 -->
                        <div id="step3" class="d-none text-center">
                            <div class="mb-4">
                                <i class="bi bi-check-circle-fill text-success" style="font-size: 4rem;"></i>
                            </div>
                            <h4>TOTP验证已成功开启!</h4>
                            <p class="text-warning">请妥善保管您的恢复码</p>
                            <div class="recovery-codes bg-light p-3 rounded mb-3">
                                <h6>恢复码(每个只能使用一次)</h6>
                                <div id="recoveryCodes" class="text-monospace"></div>
                            </div>
                            <button class="btn btn-secondary" onclick="downloadRecoveryCodes()">
                                下载恢复码
                            </button>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>
    <script>
        let setupData = null;
        // 获取TOTP配置信息
        async function getSetupInfo() {
            try {
                const response = await fetch('/totp/setup/info', {
                    method: 'POST',
                    headers: {
                        'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
                    }
                });
                setupData = await response.json();
                // 显示二维码
                document.getElementById('qrCodeContainer').innerHTML = 
                    `<img src="${setupData.qr_code}" alt="TOTP QR Code" class="img-fluid">`;
                // 显示密钥
                document.getElementById('secretKey').textContent = setupData.secret;
                // 显示步骤2
                document.getElementById('step2').classList.remove('d-none');
            } catch (error) {
                console.error('获取配置失败:', error);
                alert('获取配置失败,请刷新页面重试');
            }
        }
        // 显示手动设置方式
        function showManualSetup() {
            document.getElementById('manualSetup').classList.remove('d-none');
        }
        // 验证TOTP码
        async function verifyCode() {
            const code = document.getElementById('verificationCode').value;
            const errorDiv = document.getElementById('errorMessage');
            if (code.length !== 6) {
                errorDiv.textContent = '请输入6位验证码';
                errorDiv.classList.remove('d-none');
                return;
            }
            try {
                const response = await fetch('/totp/enable', {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                        'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
                    },
                    body: JSON.stringify({ code: code })
                });
                const result = await response.json();
                if (result.success) {
                    // 显示完成页面
                    document.getElementById('step2').classList.add('d-none');
                    document.getElementById('step3').classList.remove('d-none');
                    // 显示恢复码
                    const codesContainer = document.getElementById('recoveryCodes');
                    result.recovery_codes.forEach((item) => {
                        const p = document.createElement('p');
                        p.className = 'mb-1';
                        p.innerHTML = `<code>${item.code}</code>`;
                        codesContainer.appendChild(p);
                    });
                } else {
                    errorDiv.textContent = result.message;
                    errorDiv.classList.remove('d-none');
                }
            } catch (error) {
                console.error('验证失败:', error);
                alert('验证失败,请重试');
            }
        }
        // 下载恢复码
        function downloadRecoveryCodes() {
            const codes = document.querySelectorAll('#recoveryCodes code');
            let text = '将用恢复码\n\n';
            codes.forEach(code => {
                text += code.textContent + '\n';
            });
            const blob = new Blob([text], { type: 'text/plain' });
            const url = window.URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            a.download = 'recovery-codes.txt';
            a.click();
            window.URL.revokeObjectURL(url);
        }
        // 页面加载时获取配置
        document.addEventListener('DOMContentLoaded', getSetupInfo);
    </script>
</body>
</html>

2 登录页面添加TOTP验证

<!-- resources/views/auth/totp-verify.blade.php -->
<!DOCTYPE html>
<html>
<head>双重验证</title>
    <meta name="csrf-token" content="{{ csrf_token() }}">
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
    <div class="container mt-5">
        <div class="row justify-content-center">
            <div class="col-md-6">
                <div class="card">
                    <div class="card-header">
                        <h4>双重验证</h4>
                    </div>
                    <div class="card-body">
                        <p>请输入您的认证器App上的6位验证码</p>
                        <form onsubmit="verifyLoginTotp(event)">
                            <div class="form-group mb-3">
                                <input type="text" 
                                       id="code" 
                                       class="form-control form-control-lg text-center" 
                                       maxlength="6" 
                                       placeholder="000000"
                                       autofocus>
                            </div>
                            <button type="submit" class="btn btn-primary btn-lg w-100">
                                验证
                            </button>
                        </form>
                        <div class="mt-3">
                            <a href="#" onclick="useRecoveryCode()">使用恢复码</a>
                        </div>
                        <div id="error" class="alert alert-danger d-none mt-3"></div>
                    </div>
                </div>
            </div>
        </div>
    </div>
    <script>
        const userId = {{ session('2fa:user_id') }};
        async function verifyLoginTotp(event) {
            event.preventDefault();
            const code = document.getElementById('code').value;
            const errorDiv = document.getElementById('error');
            if (code.length !== 6) {
                errorDiv.textContent = '请输入6位验证码';
                errorDiv.classList.remove('d-none');
                return;
            }
            try {
                const response = await fetch('/totp/verify', {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                        'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
                    },
                    body: JSON.stringify({
                        code: code,
                        user_id: userId
                    })
                });
                const result = await response.json();
                if (result.success) {
                    window.location.href = '/dashboard';
                } else {
                    errorDiv.textContent = result.message;
                    errorDiv.classList.remove('d-none');
                }
            } catch (error) {
                console.error('验证失败:', error);
                alert('验证失败,请重试');
            }
        }
        function useRecoveryCode() {
            alert('请输入恢复码代替TOTP验证码即可');
        }
    </script>
</body>
</html>

数据库迁移

<?php
// database/migrations/xxxx_xx_xx_add_totp_to_users_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddTotpToUsersTable extends Migration
{
    public function up()
    {
        Schema::table('users', function (Blueprint $table) {
            $table->string('totp_secret')->nullable()->after('password');
            $table->boolean('totp_enabled')->default(false)->after('totp_secret');
            $table->text('recovery_codes')->nullable()->after('totp_enabled');
            $table->timestamp('totp_activated_at')->nullable()->after('recovery_codes');
        });
    }
    public function down()
    {
        Schema::table('users', function (Blueprint $table) {
            $table->dropColumn([
                'totp_secret',
                'totp_enabled',
                'recovery_codes',
                'totp_activated_at'
            ]);
        });
    }
}

路由配置

// routes/web.php
Route::middleware(['auth'])->group(function () {
    Route::get('/totp/setup', 'TotpController@showSetupForm')->name('totp.setup');
    Route::post('/totp/setup/info', 'TotpController@getSetupInfo');
    Route::post('/totp/enable', 'TotpController@enableTotp');
});
// 登录后的TOTP验证
Route::post('/totp/verify', 'TotpController@verifyTotpLogin');

安全建议

  1. HTTPS支持:确保所有请求都在HTTPS下进行
  2. 速率限制:对验证码输入进行速率限制
  3. 会话管理:验证成功后清除临时会话数据
  4. 恢复码存储:建议用户下载并安全保存恢复码
  5. 密钥轮换:支持用户重置TOTP密钥
  6. 日志记录:记录所有验证尝试

这个实现提供了完整的TOTP认证流程,包括扫码绑定、验证码验证和恢复码功能。

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