Files
dbois/app/Libraries/TwoFactorAuth.php
T
2026-01-09 11:41:28 +01:00

182 lines
5.3 KiB
PHP

<?php
namespace App\Libraries;
/**
* Two-Factor Authentication Library
* Implements TOTP (Time-based One-Time Password) for Google Authenticator
* Based on RFC 6238
*/
class TwoFactorAuth
{
protected int $codeLength = 6;
protected int $period = 30; // 30 seconds validity window
public function __construct()
{
// No CI instance needed
}
/**
* Generate a new secret key for TOTP
* @return string Base32 encoded secret
*/
public function generateSecret(int $length = 16): string
{
$secret = '';
$validChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; // Base32 alphabet
for ($i = 0; $i < $length; $i++) {
$secret .= $validChars[random_int(0, 31)];
}
return $secret;
}
/**
* Generate QR code URL for Google Authenticator
* @param string $secret The user's TOTP secret
* @param string $username The username
* @param string $issuer The application name
* @return string QR code URL
*/
public function getQRCodeUrl(string $secret, string $username, string $issuer = 'DBOIS'): string
{
$encodedIssuer = rawurlencode($issuer);
$encodedUsername = rawurlencode($username);
$otpauthUrl = "otpauth://totp/{$encodedIssuer}:{$encodedUsername}?secret={$secret}&issuer={$encodedIssuer}";
// Using Google Charts API for QR code generation
return "https://chart.googleapis.com/chart?chs=200x200&chld=M|0&cht=qr&chl=" . urlencode($otpauthUrl);
}
/**
* Verify a TOTP code
* @param string $secret The user's secret
* @param string $code The code to verify
* @param int $discrepancy Time window (default 1 = +/- 30 seconds)
* @return bool True if code is valid
*/
public function verifyCode(string $secret, string $code, int $discrepancy = 1): bool
{
$currentTimeSlice = floor(time() / $this->period);
for ($i = -$discrepancy; $i <= $discrepancy; $i++) {
$calculatedCode = $this->getCode($secret, (int)($currentTimeSlice + $i));
if ($this->timingSafeEquals($calculatedCode, $code)) {
return true;
}
}
return false;
}
/**
* Generate TOTP code for a given time slice
* @param string $secret The secret key
* @param int|null $timeSlice The time slice (defaults to current)
* @return string The generated code
*/
public function getCode(string $secret, ?int $timeSlice = null): string
{
if ($timeSlice === null) {
$timeSlice = (int)floor(time() / $this->period);
}
$secretKey = $this->base32Decode($secret);
// Pack time into binary string
$time = chr(0) . chr(0) . chr(0) . chr(0) . pack('N*', $timeSlice);
// Hash with secret
$hash = hash_hmac('sha1', $time, $secretKey, true);
// Use last 4 bits as offset
$offset = ord(substr($hash, -1)) & 0x0F;
// Get 4 bytes from offset
$hashPart = substr($hash, $offset, 4);
// Unpack binary value
$value = unpack('N', $hashPart);
$value = $value[1];
// Only 32 bits
$value = $value & 0x7FFFFFFF;
// Get the required digits
$modulo = pow(10, $this->codeLength);
return str_pad($value % $modulo, $this->codeLength, '0', STR_PAD_LEFT);
}
/**
* Decode Base32 string
* @param string $secret Base32 encoded string
* @return string Binary string
*/
protected function base32Decode(string $secret): string
{
if (empty($secret)) {
return '';
}
$base32chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
$base32charsFlipped = array_flip(str_split($base32chars));
$paddingCharCount = substr_count($secret, '=');
$allowedValues = [6, 4, 3, 1, 0];
if (!in_array($paddingCharCount, $allowedValues)) {
return '';
}
for ($i = 0; $i < 4; $i++) {
if (
$paddingCharCount == $allowedValues[$i] &&
substr($secret, -($allowedValues[$i])) != str_repeat('=', $allowedValues[$i])
) {
return '';
}
}
$secret = str_replace('=', '', $secret);
$secret = str_split($secret);
$binaryString = '';
for ($i = 0; $i < count($secret); $i = $i + 8) {
$x = '';
if (!isset($base32charsFlipped[$secret[$i]])) {
return '';
}
for ($j = 0; $j < 8; $j++) {
$char = $secret[$i + $j] ?? '';
$charValue = $base32charsFlipped[$char] ?? 0;
$x .= str_pad(base_convert((string)$charValue, 10, 2), 5, '0', STR_PAD_LEFT);
}
$eightBits = str_split($x, 8);
for ($z = 0; $z < count($eightBits); $z++) {
$y = chr((int)base_convert($eightBits[$z], 2, 10));
$binaryString .= ($y || ord($y) == 48) ? $y : '';
}
}
return $binaryString;
}
/**
* Timing-safe string comparison
* @param string $safeString Known string
* @param string $userString User provided string
* @return bool True if strings match
*/
protected function timingSafeEquals(string $safeString, string $userString): bool
{
return hash_equals($safeString, $userString);
}
}