TOTP (Time-based One-Time Password) — это алгоритм одноразовых паролей, которые обновляются каждые 30 секунд и используются для двухфакторной аутентификации (2FA).
Это 6-значный код, который вы видите в приложениях вроде:
- Google Authenticator
- Microsoft Authenticator
- Authy
- 1Password
- FreeOTP и др.
Он зависит от текущего времени и секретного ключа, который вы получаете при настройке 2FA.
Точное время на сервере — критическое требование!
- При включении 2FA, сайт или сервис показывает вам QR-код или секретный ключ.
- Вы добавляете его в TOTP-приложение.
- Приложение и сервер используют одинаковый алгоритм и время, чтобы генерировать коды.
- При входе вы вводите этот 6-значный код, и сервер проверяет, совпадает ли он с ожидаемым значением.
<?php
readonly class TOTP
{
private const string BASE32CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; // RFC 4648 Base32
private const int DEFAULT_DIGITS = 6; // Количество цифр в OTP по умолчанию
private const int DEFAULT_TIME_STEP = 30; // Временной шаг в секундах по умолчанию
private const int DEFAULT_SECRET_LENGTH = 20; // Длина секретного ключа по умолчанию
private string $decodedSecret; // Кэшированный декодированный секрет
/**
* Конструктор для инициализации объекта TOTP с секретным ключом.
*
* @param string $secret Секретный ключ в кодировке Base32.
*/
public function __construct(private string $secret)
{
$this->decodedSecret = $this->base32decode($secret); // Декодируем секрет один раз при создании объекта
}
/**
* Генерация случайного секретного ключа OTP заданной длины.
*
* @param string $uniqueString Уникальная строка для создания сида.
* @param int $length Длина секретного ключа (по умолчанию: 20).
* @return string Сгенерированный секретный ключ OTP.
*/
public static function generateSecret(string $uniqueString, int $length = self::DEFAULT_SECRET_LENGTH): string
{
// Уникальная строка + текущее время в микросекундах
$seed = $uniqueString . microtime(true);
// Хэшируем сид для более случайного распределения
$seed = hash('sha256', $seed);
$secret = '';
$max = strlen(self::BASE32CHARS) - 1;
// Генерируем случайную строку нужной длины
for ($i = 0; $i < $length; $i++) {
$index = hexdec(substr($seed, $i, 2)) % $max; // Получаем индекс символа из хэшированного сида
$secret .= self::BASE32CHARS[$index];
}
return $secret;
}
/**
* Генерация одноразового пароля (OTP) на основе времени с использованием секретного ключа.
*
* @param int $digits Количество цифр в OTP (по умолчанию: 6).
* @param int $timeStep Временной шаг в секундах (по умолчанию: 30).
* @return string Сгенерированный OTP.
*/
public function generate(int $digits = self::DEFAULT_DIGITS, int $timeStep = self::DEFAULT_TIME_STEP): string
{
$timeCounter = floor(time() / $timeStep); // Текущее время, разделенное на временной шаг
return $this->computeOtp($timeCounter, $digits); // Вычисляем OTP
}
/**
* Проверка одноразового пароля (OTP) с учётом временного окна.
*
* @param string $otp Введённый пользователем OTP.
* @param int $digits Количество цифр в OTP (по умолчанию: 6).
* @param int $timeStep Временной шаг в секундах (по умолчанию: 30).
* @param int $window Количество интервалов до и после текущего для проверки (по умолчанию: 1).
* @return bool Возвращает true, если OTP валиден в указанном временном окне.
*/
public function verify(string $otp, int $digits = self::DEFAULT_DIGITS, int $timeStep = self::DEFAULT_TIME_STEP, int $window = 1): bool
{
$currentTimeCounter = floor(time() / $timeStep); // Текущий временной счётчик
// Проверяем OTP для текущего времени и соседних интервалов (±window)
for ($i = -$window; $i <= $window; $i++) {
$timeCounter = $currentTimeCounter + $i;
$generatedOtp = $this->computeOtp($timeCounter, $digits); // Вычисляем OTP
if ($otp === $generatedOtp) {
return true; // OTP совпал
}
}
return false; // OTP не совпал ни в одном интервале
}
/**
* Вычисление OTP для заданного временного счётчика.
*
* @param int $timeCounter Временной счётчик (время, разделённое на timeStep).
* @param int $digits Количество цифр в OTP.
* @return string Сгенерированный OTP.
*/
private function computeOtp(int $timeCounter, int $digits): string
{
$binaryTime = pack('N*', 0) . pack('N*', $timeCounter); // Упаковываем время в бинарный формат (big endian)
$hash = hash_hmac('sha1', $binaryTime, $this->decodedSecret, true); // Вычисляем HMAC-SHA1 хеш
$offset = ord($hash[strlen($hash) - 1]) & 0x0F; // Получаем смещение из последнего полубайта хеша
$otp = (
((ord($hash[$offset]) & 0x7F) << 24) |
((ord($hash[$offset + 1]) & 0xFF) << 16) |
((ord($hash[$offset + 2]) & 0xFF) << 8) |
(ord($hash[$offset + 3]) & 0xFF)
) % 10 ** $digits; // Вычисляем значение OTP
return str_pad((string)$otp, $digits, '0', STR_PAD_LEFT); // Форматируем OTP до указанной длины
}
/**
* Декодирование строки, закодированной в Base32.
*
* @param string $input Входная строка, закодированная в Base32.
* @return string Декодированная бинарная строка.
*/
private function base32decode(string $input): string
{
$base32charsFlipped = array_flip(str_split(self::BASE32CHARS));
$output = '';
$v = 0;
$vbits = 0;
for ($i = 0, $j = strlen($input); $i < $j; $i++) {
$v <<= 5;
if ($input[$i] === '=') {continue;} // Пропускаем символы заполнения
$v += $base32charsFlipped[$input[$i]]; // Декодируем символ Base32
$vbits += 5;
if ($vbits >= 8) {
$vbits -= 8;
$output .= chr(($v & (0xFF << $vbits)) >> $vbits); // Добавляем декодированный байт
}
}
return $output;
}
}
Принцип работы:
- Генерируем секретный ключ при помощи
TOTP::generateSecret(), записываем его в БД и показываем пользователю, чтобы он добавил его в своё приложение. - Когда нужно проверить:
$userInput = $_POST['otp'];
$totp = new TOTP($userSecret);
if ($totp->verify($userInput)) {
echo "Успешная аутентификация!";
} else {
echo "Неверный код.";
}
Метод generate() используется для случаев, когда актуальный код нужно показать, например для визуального сравнения с кодом в приложении.
Минимальная версия языка:
- С
readonly classи типами уconst→ PHP 8.3+ - С
readonly class, но без типов уconst→ PHP 8.2+ - Без
readonly, без типизированныхconst→ PHP 7.4+
Ещё больше понижать, надо уже переписывать.