Реализация восстановления пароля в 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).