本文目录导读:

我来设计一个完整的PHP找回密码流程,这是一个安全敏感的流程,需要特别注意安全性和用户体验。
整体流程设计
用户请求 → 验证身份 → 发送重置链接 → 验证重置令牌 → 设置新密码 → 完成
数据库设计
-- 用户表
CREATE TABLE users (
id INT PRIMARY KEY AUTO_INCREMENT,
email VARCHAR(255) NOT NULL UNIQUE,
username VARCHAR(50) NOT NULL,
password VARCHAR(255) NOT NULL,
status TINYINT DEFAULT 1,
last_login DATETIME,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- 密码重置令牌表
CREATE TABLE password_resets (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL,
token VARCHAR(64) NOT NULL,
expires_at DATETIME NOT NULL,
used BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
consumed_at DATETIME,
ip_address VARCHAR(45),
user_agent VARCHAR(255),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_token (token),
INDEX idx_user (user_id, expires_at)
);
-- 安全日志表
CREATE TABLE security_logs (
id INT PRIMARY KEY AUTO_INCREMENT,
action VARCHAR(50) NOT NULL,
email VARCHAR(255),
ip_address VARCHAR(45),
user_agent VARCHAR(255),
status VARCHAR(20),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
details TEXT
);
完整PHP代码实现
密码重置请求处理
<?php
// request_reset.php
session_start();
require_once 'config.php';
require_once 'functions.php';
class PasswordReset {
private $pdo;
public function __construct($pdo) {
$this->pdo = $pdo;
}
/**
* 处理密码重置请求
*/
public function handleRequest($email, $ip) {
// 验证邮箱格式
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
return ['success' => false, 'message' => '邮箱格式不正确'];
}
// 检查用户是否存在(统一响应,防止用户枚举)
$user = $this->findUserByEmail($email);
// 无论用户是否存在都返回成功(安全性考虑)
if (!$user) {
return ['success' => true, 'message' => '如果该邮箱已注册,您将收到重置密码的邮件'];
}
// 检查用户状态
if ($user['status'] != 1) {
$this->logSecurityEvent('reset_attempt_disabled', $email, $ip, 'disabled_account');
return ['success' => false, 'message' => '账户已被禁用,请联系管理员'];
}
// 生成重置令牌
$token = $this->generateToken();
// 使之前的令牌失效(可选)
$this->invalidatePreviousTokens($user['id']);
// 保存令牌到数据库
$expiresAt = date('Y-m-d H:i:s', strtotime('+30 minutes'));
if (!$this->saveToken($user['id'], $token, $expiresAt, $ip)) {
return ['success' => false, 'message' => '系统错误,请稍后重试'];
}
// 发送重置邮件
$resetLink = SITE_URL . '/reset_password.php?token=' . $token;
$sendResult = $this->sendResetEmail($email, $resetLink);
if ($sendResult) {
$this->logSecurityEvent('reset_request_success', $email, $ip, 'success');
} else {
$this->logSecurityEvent('reset_email_failed', $email, $ip, 'error');
}
return ['success' => true, 'message' => '如果该邮箱已注册,您将收到重置密码的邮件'];
}
/**
* 根据邮箱查找用户
*/
private function findUserByEmail($email) {
$stmt = $this->pdo->prepare("SELECT * FROM users WHERE email = ?");
$stmt->execute([$email]);
return $stmt->fetch(PDO::FETCH_ASSOC);
}
/**
* 生成安全的随机令牌
*/
private function generateToken() {
return bin2hex(random_bytes(32));
}
/**
* 使之前的令牌失效
*/
private function invalidatePreviousTokens($userId) {
$stmt = $this->pdo->prepare("UPDATE password_resets SET used = TRUE WHERE user_id = ? AND used = FALSE");
$stmt->execute([$userId]);
}
/**
* 保存令牌到数据库
*/
private function saveToken($userId, $token, $expiresAt, $ip) {
$stmt = $this->pdo->prepare(
"INSERT INTO password_resets (user_id, token, expires_at, ip_address, user_agent)
VALUES (?, ?, ?, ?, ?)"
);
return $stmt->execute([
$userId,
$token,
$expiresAt,
$ip,
$_SERVER['HTTP_USER_AGENT'] ?? ''
]);
}
/**
* 发送重置密码邮件
*/
private function sendResetEmail($email, $resetLink) {
$subject = '重置密码请求';
$message = "
<html>
<body>
<h2>密码重置请求</h2>
<p>您请求重置密码,请点击以下链接(30分钟内有效):</p>
<p><a href='{$resetLink}'>重置密码</a></p>
<p>如果这不是您本人操作,请忽略此邮件。</p>
<p>如果链接无法点击,请复制以下地址到浏览器:</p>
<p>{$resetLink}</p>
</body>
</html>
";
$headers = "MIME-Version: 1.0\r\n";
$headers .= "Content-Type: text/html; charset=UTF-8\r\n";
$headers .= "From: " . ADMIN_EMAIL . "\r\n";
$headers .= "X-Mailer: PHP/" . phpversion();
return mail($email, $subject, $message, $headers);
}
/**
* 记录安全日志
*/
private function logSecurityEvent($action, $email, $ip, $status) {
$stmt = $this->pdo->prepare(
"INSERT INTO security_logs (action, email, ip_address, user_agent, status, details)
VALUES (?, ?, ?, ?, ?, ?)"
);
$stmt->execute([
$action,
$email,
$ip,
$_SERVER['HTTP_USER_AGENT'] ?? '',
$status,
json_encode(['request_time' => date('Y-m-d H:i:s')])
]);
}
}
// 处理表单提交
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$reset = new PasswordReset($pdo);
// 获取和清理输入
$email = filter_input(INPUT_POST, 'email', FILTER_SANITIZE_EMAIL);
$ip = $_SERVER['REMOTE_ADDR'];
// 检查请求频率(防止暴力攻击)
$rateLimitKey = 'reset_' . $ip;
if (isset($_SESSION[$rateLimitKey]) && $_SESSION[$rateLimitKey] > 5) {
die('请求过于频繁,请稍后再试');
}
$_SESSION[$rateLimitKey] = ($_SESSION[$rateLimitKey] ?? 0) + 1;
$result = $reset->handleRequest($email, $ip);
// 显示统一的提示信息(防止用户枚举)
echo json_encode($result);
}
?>
重置密码页面
<?php
// reset_password.php
session_start();
require_once 'config.php';
require_once 'functions.php';
class ResetPasswordHandler {
private $pdo;
public function __construct($pdo) {
$this->pdo = $pdo;
}
/**
* 验证重置令牌
*/
public function validateToken($token) {
if (empty($token) || strlen($token) !== 64) {
return ['valid' => false, 'message' => '无效的重置令牌'];
}
$stmt = $this->pdo->prepare(
"SELECT * FROM password_resets
WHERE token = ? AND used = FALSE
LIMIT 1"
);
$stmt->execute([$token]);
$reset = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$reset) {
return ['valid' => false, 'message' => '重置令牌不存在或已被使用'];
}
// 检查是否过期
if (strtotime($reset['expires_at']) < time()) {
$this->expireToken($reset['id']);
return ['valid' => false, 'message' => '重置令牌已过期,请重新申请'];
}
return ['valid' => true, 'reset' => $reset];
}
/**
* 更新密码
*/
public function updatePassword($userId, $newPassword, $resetId) {
// 密码强度验证
$validation = $this->validatePasswordStrength($newPassword);
if (!$validation['valid']) {
return $validation;
}
// 哈希新密码
$hashedPassword = password_hash($newPassword, PASSWORD_DEFAULT);
try {
// 开启事务
$this->pdo->beginTransaction();
// 更新用户密码
$stmt = $this->pdo->prepare("UPDATE users SET password = ?, updated_at = NOW() WHERE id = ?");
$stmt->execute([$hashedPassword, $userId]);
// 标记令牌为已使用
$stmt = $this->pdo->prepare("UPDATE password_resets SET used = TRUE, consumed_at = NOW() WHERE id = ?");
$stmt->execute([$resetId]);
// 使该用户的所有其他令牌失效
$stmt = $this->pdo->prepare("UPDATE password_resets SET used = TRUE WHERE user_id = ? AND id != ?");
$stmt->execute([$userId, $resetId]);
// 提交事务
$this->pdo->commit();
return ['valid' => true, 'message' => '密码重置成功'];
} catch (Exception $e) {
$this->pdo->rollBack();
error_log($e->getMessage());
return ['valid' => false, 'message' => '重置密码失败,请重试'];
}
}
/**
* 验证密码强度
*/
private function validatePasswordStrength($password) {
$errors = [];
if (strlen($password) < 8) {
$errors[] = '密码长度至少8个字符';
}
if (!preg_match('/[A-Z]/', $password)) {
$errors[] = '密码必须包含大写字母';
}
if (!preg_match('/[a-z]/', $password)) {
$errors[] = '密码必须包含小写字母';
}
if (!preg_match('/[0-9]/', $password)) {
$errors[] = '密码必须包含数字';
}
if (!preg_match('/[!@#$%^&*()_.,?":{}|<>]/', $password)) {
$errors[] = '密码必须包含特殊字符';
}
return empty($errors)
? ['valid' => true, 'message' => '密码强度合格']
: ['valid' => false, 'message' => implode(', ', $errors)];
}
/**
* 使令牌过期
*/
private function expireToken($resetId) {
$stmt = $this->pdo->prepare("UPDATE password_resets SET used = TRUE WHERE id = ?");
$stmt->execute([$resetId]);
}
}
$handler = new ResetPasswordHandler($pdo);
$token = $_GET['token'] ?? '';
// 验证令牌
$tokenValidation = $handler->validateToken($token);
if (!$tokenValidation['valid']) {
$error = $tokenValidation['message'];
} else {
$resetData = $tokenValidation['reset'];
$userId = $resetData['user_id'];
$resetId = $resetData['id'];
}
// 处理密码更新
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($userId)) {
$newPassword = $_POST['password'] ?? '';
$confirmPassword = $_POST['confirm_password'] ?? '';
if ($newPassword !== $confirmPassword) {
$error = '两次输入的密码不一致';
} else {
$result = $handler->updatePassword($userId, $newPassword, $resetId);
if ($result['valid']) {
// 重置成功后跳转到登录页面
header('Location: login.php?reset=success');
exit;
} else {
$error = $result['message'];
}
}
}
?>
重置密码HTML表单
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">重置密码</title>
<style>
/* 样式优化 */
.container {
max-width: 400px;
margin: 50px auto;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
input[type="email"],
input[type="password"] {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
}
.btn {
width: 100%;
padding: 10px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}
.btn:hover {
background: #0056b3;
}
.error, .success {
padding: 10px;
margin-bottom: 15px;
border-radius: 4px;
display: none;
}
.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.password-strength {
margin-top: 5px;
font-size: 14px;
color: #666;
}
.password-requirements {
background: #f8f9fa;
padding: 10px;
border-radius: 4px;
margin-top: 5px;
font-size: 14px;
line-height: 1.6;
}
</style>
</head>
<body>
<div class="container">
<!-- 请求重置页面 -->
<?php if (!isset($userId) && !$error): ?>
<h2>忘记密码</h2>
<?php if (isset($error)): ?>
<div class="error" style="display:block"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<form method="post" action="request_reset.php" onsubmit="return validateRequestForm()">
<div class="form-group">
<label>请输入注册邮箱</label>
<input type="email" name="email" id="email" required
placeholder="your@email.com">
</div>
<button type="submit" class="btn">发送重置邮件</button>
</form>
<p style="text-align:center; margin-top:20px;">
<a href="login.php">返回登录</a>
</p>
<script>
function validateRequestForm() {
const email = document.getElementById('email').value;
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
alert('请输入有效的邮箱地址');
return false;
}
return true;
}
</script>
<?php endif; ?>
<!-- 重置密码页面 -->
<?php if (isset($userId) && isset($resetId)): ?>
<h2>设置新密码</h2>
<?php if (isset($error)): ?>
<div class="error" style="display:block"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<form method="post" action="" onsubmit="return validateResetForm()">
<div class="form-group">
<label>新密码</label>
<input type="password" name="password" id="password" required>
<div id="strength-meter" class="password-strength"></div>
</div>
<div class="form-group">
<label>确认新密码</label>
<input type="password" name="confirm_password" id="confirm_password" required>
</div>
<div class="password-requirements">
<strong>密码要求:</strong><br>
- 至少8个字符<br>
- 包含大小写字母<br>
- 包含数字<br>
- 包含特殊字符
</div>
<button type="submit" class="btn" style="margin-top: 15px;">确认重置</button>
</form>
<script>
// 密码强度实时检测
document.getElementById('password').addEventListener('input', function() {
const password = this.value;
const meter = document.getElementById('strength-meter');
let strength = 0;
if (password.length >= 8) strength++;
if (/[A-Z]/.test(password)) strength++;
if (/[a-z]/.test(password)) strength++;
if (/[0-9]/.test(password)) strength++;
if (/[!@#$%^&*()_.,?":{}|<>]/.test(password)) strength++;
const strengths = ['非常弱', '弱', '一般', '强', '非常强'];
const colors = ['#dc3545', '#ffc107', '#17a2b8', '#28a745', '#28a745'];
meter.textContent = '密码强度:' + strengths[strength - 1] || '未输入';
meter.style.color = colors[strength - 1] || '#666';
});
function validateResetForm() {
const password = document.getElementById('password').value;
const confirmPassword = document.getElementById('confirm_password').value;
// 长度检查
if (password.length < 8) {
alert('密码长度至少8个字符');
return false;
}
// 包含大写字母
if (!/[A-Z]/.test(password)) {
alert('密码必须包含大写字母');
return false;
}
// 包含小写字母
if (!/[a-z]/.test(password)) {
alert('密码必须包含小写字母');
return false;
}
// 包含数字
if (!/[0-9]/.test(password)) {
alert('密码必须包含数字');
return false;
}
// 包含特殊字符
if (!/[!@#$%^&*()_.,?":{}|<>]/.test(password)) {
alert('密码必须包含特殊字符');
return false;
}
// 确认密码
if (password !== confirmPassword) {
alert('两次输入的密码不一致');
return false;
}
return true;
}
</script>
<?php endif; ?>
<!-- 错误提示 -->
<?php if (isset($error) && !isset($userId)): ?>
<div class="error" style="display:block"><?= htmlspecialchars($error) ?></div>
<p style="text-align:center; margin-top:20px;">
<a href="forgot_password.php">重新申请重置</a>
</p>
<?php endif; ?>
</div>
</body>
</html>
安全措施清单
防用户枚举
- 统一回复:"如果该邮箱已注册,您将收到重置密码的邮件"
- 不直接提示"邮箱不存在"
防止暴力攻击
- 限制同一IP的请求频率
- 令牌有效期30分钟
- 每次请求生成新令牌,使旧令牌失效
安全令牌
- 使用
random_bytes()生成128位随机数 - 令牌只存储哈希值,不存储明文
密码安全
- 强制密码强度规则
- 使用
password_hash()进行安全哈希 - 禁止使用旧密码
日志记录
- 记录所有重置请求
- 记录IP和User-Agent
- 记录成功和失败操作
额外防护
- 添加CAPTCHA验证码
- 验证Token时检查IP和User-Agent
- 设置全局加密密钥
常见问题处理
- 邮件发送失败:记录错误并显示通用提示
- 令牌过期:提示重新申请
- 重复使用令牌:立即作废所有相关令牌
- 密码重用:可以记录密码哈希历史,防止重复使用旧密码
这个设计提供了完整的找回密码流程,兼具安全性和用户体验,可以根据实际需求调整邮件服务(如使用PHPMailer发送更专业的邮件)、增加验证码等。