PHP 找回密码流程设计

wen PHP项目 3

本文目录导读:

PHP 找回密码流程设计

  1. 整体流程设计
  2. 数据库设计
  3. 完整PHP代码实现
  4. 安全措施清单
  5. 常见问题处理

我来设计一个完整的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
  • 设置全局加密密钥

常见问题处理

  1. 邮件发送失败:记录错误并显示通用提示
  2. 令牌过期:提示重新申请
  3. 重复使用令牌:立即作废所有相关令牌
  4. 密码重用:可以记录密码哈希历史,防止重复使用旧密码

这个设计提供了完整的找回密码流程,兼具安全性和用户体验,可以根据实际需求调整邮件服务(如使用PHPMailer发送更专业的邮件)、增加验证码等。

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