Реализация восстановления пароля в PHP

Раздел: Веб-разработка -> Безопасность и авторизация

Основное решение: сброс пароля через одноразовый токен

Как организовать безопасный сброс пароля с помощью одноразового токена?

Наиболее эффективный способ восстановления доступа к учётной записи – генерация временного токена, который отправляется пользователю по электронной почте. Токен хранится в базе данных вместе с меткой времени жизни и флагом использования. После перехода по ссылке пользователь вводит новый пароль, и токен становится недействительным.

Пошаговая реализация

Шаг 1. Создание таблицы для токенов


CREATE TABLE password_reset_tokens (
    id INT AUTO_INCREMENT PRIMARY KEY,
    user_id INT NOT NULL,
    token VARCHAR(64) NOT NULL,
    expires_at DATETIME NOT NULL,
    used TINYINT(1) DEFAULT 0,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

Шаг 2. Генерация токена и отправка письма


<?php
// Функция для создания токена
function generateResetToken($userId) {
    $token = bin2hex(random_bytes(32)); // 64 символа
    $expires = date('Y-m-d H:i:s', strtotime('+1 hour'));
    
    $pdo = getDbConnection();
    $stmt = $pdo->prepare('INSERT INTO password_reset_tokens (user_id, token, expires_at) VALUES (?, ?, ?)');
    $stmt->execute([$userId, $token, $expires]);
    
    return $token;
}
?>

После генерации токен включается в ссылку, которая отправляется пользователю:


$resetLink = "https://example.com/reset_password.php?token=" . urlencode($token);
// Использование mail() или PHPMailer для отправки

Шаг 3. Обработка запроса с токеном


<?php
// reset_password.php
if (isset($_GET['token'])) {
    $token = $_GET['token'];
    $pdo = getDbConnection();
    $stmt = $pdo->prepare('SELECT user_id, expires_at, used FROM password_reset_tokens WHERE token = ?');
    $stmt->execute([$token]);
    $row = $stmt->fetch();
    
    if (!$row) {
        die('Неверный токен.');
    }
    if ($row['used']) {
        die('Токен уже был использован.');
    }
    if (strtotime($row['expires_at']) < time()) {
        die('Срок действия токена истёк.');
    }
    
    // Показать форму для нового пароля
    echo '<form method="POST">
            <input type="hidden" name="token" value="' . htmlspecialchars($token) . '">
            Новый пароль: <input type="password" name="new_password" required>
            <button type="submit">Сменить пароль</button>
          </form>';
}

if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['token'], $_POST['new_password'])) {
    $token = $_POST['token'];
    $newPassword = password_hash($_POST['new_password'], PASSWORD_DEFAULT);
    
    $pdo->beginTransaction();
    $stmt = $pdo->prepare('SELECT user_id FROM password_reset_tokens WHERE token = ? AND used = 0 AND expires_at > NOW()');
    $stmt->execute([$token]);
    $user = $stmt->fetch();
    
    if ($user) {
        $stmt = $pdo->prepare('UPDATE users SET password = ? WHERE id = ?');
        $stmt->execute([$newPassword, $user['user_id']]);
        
        $stmt = $pdo->prepare('UPDATE password_reset_tokens SET used = 1 WHERE token = ?');
        $stmt->execute([$token]);
        $pdo->commit();
        echo 'Пароль успешно изменён.';
    } else {
        $pdo->rollBack();
        echo 'Ошибка: токен недействителен или просрочен.';
    }
}
?>

Типичные ошибки и их решение

  • Токен не найден в БД. Возможная причина: неверное хеширование (используйте random_bytes и bin2hex). Проверьте длину и кодировку при вставке.
  • Истечение срока игнорируется. Убедитесь, что в запросе используется условие expires_at > NOW().
  • Уязвимость к повторному использованию токена. Всегда устанавливайте флаг used = 1 в транзакции.
  • SQL-инъекции. Используйте подготовленные запросы (PDO).

Какие альтернативные методы восстановления пароля существуют?

В зависимости от контекста можно применять другие подходы, каждый со своими особенностями и ограничениями.

Как реализовать восстановление через временный пароль?

Вместо токена генерируется одноразовый пароль (OTP), который передаётся пользователю и действует ограниченное время. Пользователь вводит этот пароль в специальную форму.


$otp = random_int(100000, 999999); // 6-значный код
$expires = date('Y-m-d H:i:s', strtotime('+10 minutes'));
$stmt = $pdo->prepare('INSERT INTO reset_otp (user_id, otp, expires_at) VALUES (?, ?, ?)');
$stmt->execute([$userId, password_hash($otp, PASSWORD_DEFAULT), $expires]);

Проблемы: OTP может быть перехвачен при передаче (используйте HTTPS). Необходимо ограничить количество попыток ввода.

Как использовать контрольные вопросы для восстановления?

Пользователь заранее задаёт вопросы и ответы. При восстановлении он должен верно ответить на один или несколько вопросов. Этот метод считается менее безопасным, так как ответы часто легко угадать или получить через социальную инженерию.


// При регистрации
$stmt = $pdo->prepare('INSERT INTO security_questions (user_id, question_hash, answer_hash) VALUES (?, ?, ?)');
$stmt->execute([$userId, hash('sha256', $question), hash('sha256', strtolower($answer))]);

Недостатки: Хранить ответы в хешированном виде необходимо, но даже так можно использовать радужные таблицы. Рекомендуется добавлять соль. Этот метод не рекомендуется в качестве основного.

Как интегрировать восстановление через двухфакторную аутентификацию (2FA)?

Если у пользователя включена 2FA, восстановление может потребовать подтверждения через резервные коды или другой фактор. Например, отправка временного кода на привязанный телефон.


// Генерация резервных кодов при настройке 2FA
$codes = [];
for ($i = 0; $i < 5; $i++) {
    $codes[] = bin2hex(random_bytes(4)); // 8 символов
}
$stmt = $pdo->prepare('INSERT INTO backup_codes (user_id, code_hash) VALUES (?, ?)');
foreach ($codes as $code) {
    $stmt->execute([$userId, password_hash($code, PASSWORD_DEFAULT)]);
}

Сложности: Необходимо информировать пользователя о резервных кодах. Один код может быть использован только один раз.

Как реализовать восстановление через ссылку с подписью (HMAC)?

Вместо хранения токена в БД можно создать подписанную ссылку, содержащую идентификатор пользователя и метку времени, подписанную секретным ключом. При проверке подпись восстанавливается и сравнивается.


$secretKey = 'ваш_секретный_ключ';
$userId = 42;
$expires = time() + 3600;
$data = $userId . '|' . $expires;
$signature = hash_hmac('sha256', $data, $secretKey);
$link = "https://example.com/reset.php?uid=$userId&exp=$expires&sig=$signature";

Проверка на стороне сервера:


$expected = hash_hmac('sha256', $uid . '|' . $exp, $secretKey);
if (hash_equals($expected, $sig) && $exp > time()) {
    // Разрешить смену пароля
}

Предостережения: Нельзя отозвать такую ссылку до истечения срока. Необходимо использовать безопасное сравнение через hash_equals для предотвращения timing attack.

Расширенные и нестандартные примеры кода

Полная реализация класса для восстановления пароля

Пример

<?php
class PasswordResetManager {
    private PDO $pdo;
    private string $secretKey;
    private int $tokenLifetime = 3600; // 1 час

    public function __construct(PDO $pdo, string $secretKey) {
        $this->pdo = $pdo;
        $this->secretKey = $secretKey;
    }

    public function generateToken(int $userId): string {
        $token = bin2hex(random_bytes(32));
        $expiresAt = date('Y-m-d H:i:s', time() + $this->tokenLifetime);
        
        $stmt = $this->pdo->prepare('INSERT INTO password_reset (user_id, token, expires_at) VALUES (?, ?, ?)');
        $stmt->execute([$userId, $token, $expiresAt]);
        
        return $token;
    }

    public function resetPassword(string $token, string $newPassword): bool {
        $this->pdo->beginTransaction();
        
        $stmt = $this->pdo->prepare('SELECT user_id FROM password_reset WHERE token = ? AND expires_at > NOW() AND used = 0 FOR UPDATE');
        $stmt->execute([$token]);
        $user = $stmt->fetch();
        
        if (!$user) {
            $this->pdo->rollBack();
            return false;
        }
        
        $hash = password_hash($newPassword, PASSWORD_ARGON2ID);
        $stmt = $this->pdo->prepare('UPDATE users SET password = ? WHERE id = ?');
        $stmt->execute([$hash, $user['user_id']]);
        
        $stmt = $this->pdo->prepare('UPDATE password_reset SET used = 1 WHERE token = ?');
        $stmt->execute([$token]);
        
        $this->pdo->commit();
        return true;
    }

    public function createSignedLink(int $userId): string {
        $expires = time() + $this->tokenLifetime;
        $data = $userId . '|' . $expires;
        $signature = hash_hmac('sha256', $data, $this->secretKey);
        return "https://example.com/reset.php?uid=$userId&exp=$expires&sig=$signature";
    }

    public function verifySignedLink(int $uid, int $exp, string $sig): bool {
        $expected = hash_hmac('sha256', $uid . '|' . $exp, $this->secretKey);
        return hash_equals($expected, $sig) && $exp > time();
    }
}
?>

Результат использования класса: Упрощается работа с токенами, увеличивается надёжность за счёт использования FOR UPDATE для блокировки строки и предупреждения гонок. Однако требуется настройка транзакций на уровне InnoDB.

Очистка просроченных токенов с помощью cron

Пример

#!/usr/bin/env php
<?php
// cleanup_tokens.php
require 'config.php';
$pdo = getDbConnection();

// Удаление токенов, срок которых истёк более 2 дней назад
$stmt = $pdo->prepare('DELETE FROM password_reset WHERE expires_at < DATE_SUB(NOW(), INTERVAL 2 DAY)');
$stmt->execute();
echo "Удалено " . $stmt->rowCount() . " просроченных токенов.\n";
?>
# Пример вывода после выполнения:
Удалено 15 просроченных токенов.

Настройка cron: * * * * * php /path/to/cleanup_tokens.php

Использование хранимой процедуры для атомарной смены пароля

Пример

DELIMITER //
CREATE PROCEDURE ResetPassword(IN p_token VARCHAR(64), IN p_new_password VARCHAR(255))
BEGIN
    DECLARE v_user_id INT;
    DECLARE EXIT HANDLER FOR SQLEXCEPTION ROLLBACK;
    
    START TRANSACTION;
    
    SELECT user_id INTO v_user_id
    FROM password_reset_tokens
    WHERE token = p_token AND used = 0 AND expires_at > NOW()
    FOR UPDATE;
    
    IF v_user_id IS NOT NULL THEN
        UPDATE users SET password = p_new_password WHERE id = v_user_id;
        UPDATE password_reset_tokens SET used = 1 WHERE token = p_token;
        COMMIT;
    ELSE
        ROLLBACK;
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Invalid or expired token';
    END IF;
END //
DELIMITER ;

Вызов из PHP:

Пример

$stmt = $pdo->prepare('CALL ResetPassword(?, ?)');
$stmt->execute([$token, password_hash($newPassword, PASSWORD_DEFAULT)]);

Результат: Логика перенесена на сторону БД, что снижает нагрузку на PHP и гарантирует атомарность. Однако усложняется отладка и переносимость кода.

Генерация QR-кода для восстановления через приложение

Нестандартный сценарий: вместо ссылки пользователю показывается QR-код, который можно отсканировать мобильным приложением. Приложение открывает браузер с зашифрованным токеном.

Пример

// Использование библиотеки phpqrcode (требуется установка)
require_once 'phpqrcode/qrlib.php';

$token = generateResetToken($userId);
$encryptedToken = base64_encode(openssl_encrypt($token, 'aes-256-cbc', $encryptionKey, 0, $iv));
$url = "https://example.com/reset?data=" . urlencode($encryptedToken);

QRcode::png($url, 'qr_reset.png', QR_ECLEVEL_L, 10);
echo '<img src="qr_reset.png" alt="QR-код для восстановления">';
Вывод: изображение QR-кода, которое пользователь сканирует. После сканирования браузер переходит по ссылке с зашифрованным токеном. На сервере токен расшифровывается и проверяется.

Проблемы: Необходимо управлять ключами шифрования. Уязвимость при использовании устаревших алгоритмов. Требуется установка расширений PHP (openssl, gd).

Забыли пароль PHP - comments

En
Forgot php (php)