108 lines
3.2 KiB
PHP
108 lines
3.2 KiB
PHP
<?php
|
|
namespace App\Services;
|
|
|
|
/**
|
|
* Class TwoFactorService
|
|
* Enterprise 2FA TOTP RFC 6238 Service (Google Authenticator Compatible)
|
|
*
|
|
* @package App\Services
|
|
*/
|
|
class TwoFactorService
|
|
{
|
|
private static array $base32Chars = [
|
|
'A'=>0,'B'=>1,'C'=>2,'D'=>3,'E'=>4,'F'=>5,'G'=>6,'H'=>7,
|
|
'I'=>8,'J'=>9,'K'=>10,'L'=>11,'M'=>12,'N'=>13,'O'=>14,'P'=>15,
|
|
'Q'=>16,'R'=>17,'S'=>18,'T'=>19,'U'=>20,'V'=>21,'W'=>22,'X'=>23,
|
|
'Y'=>24,'Z'=>25,'2'=>26,'3'=>27,'4'=>28,'5'=>29,'6'=>30,'7'=>31
|
|
];
|
|
|
|
/**
|
|
* Generate 16-character Base32 secret key
|
|
*/
|
|
public static function generateSecret(): string
|
|
{
|
|
$chars = array_keys(self::$base32Chars);
|
|
$secret = '';
|
|
for ($i = 0; $i < 16; $i++) {
|
|
$secret .= $chars[random_int(0, 31)];
|
|
}
|
|
return $secret;
|
|
}
|
|
|
|
/**
|
|
* Generate otpauth:// URI for Google Authenticator QR Code
|
|
*/
|
|
public static function getQrCodeUrl(string $issuer, string $accountName, string $secret): string
|
|
{
|
|
$encodedIssuer = urlencode($issuer);
|
|
$encodedAccount = urlencode($accountName);
|
|
return "otpauth://totp/{$encodedIssuer}:{$encodedAccount}?secret={$secret}&issuer={$encodedIssuer}&algorithm=SHA1&digits=6&period=30";
|
|
}
|
|
|
|
/**
|
|
* Verify 6-digit TOTP Code against Secret (RFC 6238)
|
|
*/
|
|
public static function verifyCode(string $secret, string $code, int $window = 1): bool
|
|
{
|
|
if (strlen($code) !== 6 || !is_numeric($code)) return false;
|
|
|
|
$timestamp = time();
|
|
$timeSlice = (int)floor($timestamp / 30);
|
|
|
|
for ($i = -$window; $i <= $window; $i++) {
|
|
$calculated = self::calculateCode($secret, $timeSlice + $i);
|
|
if (hash_equals($calculated, str_pad($code, 6, '0', STR_PAD_LEFT))) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Calculate 6-digit TOTP for a specific time slice
|
|
*/
|
|
private static function calculateCode(string $secret, int $timeSlice): string
|
|
{
|
|
$secretKey = self::base32Decode(strtoupper($secret));
|
|
|
|
// Pack time slice into 64-bit big-endian binary string
|
|
$timeBytes = pack('N2', 0, $timeSlice);
|
|
|
|
$hash = hash_hmac('sha1', $timeBytes, $secretKey, true);
|
|
$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);
|
|
}
|
|
|
|
private static function base32Decode(string $secret): string
|
|
{
|
|
$secret = strtoupper($secret);
|
|
$buffer = 0;
|
|
$bitsLeft = 0;
|
|
$result = '';
|
|
|
|
for ($i = 0; $i < strlen($secret); $i++) {
|
|
$char = $secret[$i];
|
|
if (!isset(self::$base32Chars[$char])) continue;
|
|
|
|
$buffer <<= 5;
|
|
$buffer |= self::$base32Chars[$char];
|
|
$bitsLeft += 5;
|
|
|
|
if ($bitsLeft >= 8) {
|
|
$bitsLeft -= 8;
|
|
$result .= chr(($buffer >> $bitsLeft) & 0xFF);
|
|
}
|
|
}
|
|
return $result;
|
|
}
|
|
}
|