base32_chars[random_int(0, 31)]; } return $secret; } /** * Get the TOTP code * @param string $secret * @param int|null $time * @return string * @throws Exception */ public function getCode(string $secret, int $time = null): string { if ($time === null) { $time = floor(time() / 30); } $base32_secret = $this->base32Decode($secret); // Pack time into binary string $time_binary = pack('N*', 0) . pack('N*', $time); // HMAC-SHA1 $hash = hash_hmac('sha1', $time_binary, $base32_secret, true); // Dynamic truncation $offset = ord($hash[19]) & 0xf; $otp = ( ((ord($hash[$offset + 0]) & 0x7f) << 24) | ((ord($hash[$offset + 1]) & 0xff) << 16) | ((ord($hash[$offset + 2]) & 0xff) << 8) | (ord($hash[$offset + 3]) & 0xff) ) % 1000000; return str_pad((string)$otp, 6, '0', STR_PAD_LEFT); } /** * Verify the TOTP code * @param string $secret * @param string $code * @param int $discrepancy * @return bool * @throws Exception */ public function verifyCode(string $secret, string $code, int $discrepancy = 1): bool { $current_time = floor(time() / 30); for ($i = -$discrepancy; $i <= $discrepancy; $i++) { if ($this->getCode($secret, $current_time + $i) === $code) { return true; } } return false; } /** * Base32 decode * @param string $base32 * @return string */ private function base32Decode(string $base32): string { $base32 = strtoupper($base32); if (!preg_match('/^[A-Z2-7]+$/', $base32)) { return ''; } $binary = ''; foreach (str_split($base32) as $char) { $binary .= str_pad(decbin(strpos($this->base32_chars, $char)), 5, '0', STR_PAD_LEFT); } $binary_chunks = str_split($binary, 8); $result = ''; foreach ($binary_chunks as $chunk) { if (strlen($chunk) === 8) { $result .= chr(bindec($chunk)); } } return $result; } /** * Generate a QR code URL * @param string $secret * @param string $name * @param string $issuer * @return string */ public function getQrCodeUrl(string $secret, string $name, string $issuer): string { return 'otpauth://totp/' . rawurlencode($issuer) . ':' . rawurlencode($name) . '?secret=' . $secret . '&issuer=' . rawurlencode($issuer); } }