本文目录导读:

我来为您详细介绍如何在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');
安全建议
- HTTPS支持:确保所有请求都在HTTPS下进行
- 速率限制:对验证码输入进行速率限制
- 会话管理:验证成功后清除临时会话数据
- 恢复码存储:建议用户下载并安全保存恢复码
- 密钥轮换:支持用户重置TOTP密钥
- 日志记录:记录所有验证尝试
这个实现提供了完整的TOTP认证流程,包括扫码绑定、验证码验证和恢复码功能。