194 lines
5.5 KiB
PHP
194 lines
5.5 KiB
PHP
<?php
|
|
defined('BASEPATH') OR exit('No direct script access allowed');
|
|
|
|
/**
|
|
* Two-Factor Authentication Library
|
|
* Implements TOTP (Time-based One-Time Password) for Google Authenticator
|
|
* Based on RFC 6238
|
|
*/
|
|
class TwoFactorAuth
|
|
{
|
|
protected $ci;
|
|
protected $codeLength = 6;
|
|
protected $period = 30; // 30 seconds validity window
|
|
|
|
public function __construct()
|
|
{
|
|
$this->ci =& get_instance();
|
|
}
|
|
|
|
/**
|
|
* Generate a new secret key for TOTP
|
|
* @return string Base32 encoded secret
|
|
*/
|
|
public function generateSecret($length = 16)
|
|
{
|
|
$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($secret, $username, $issuer = 'DBOIS')
|
|
{
|
|
$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($secret, $code, $discrepancy = 1)
|
|
{
|
|
$currentTimeSlice = floor(time() / $this->period);
|
|
|
|
for ($i = -$discrepancy; $i <= $discrepancy; $i++) {
|
|
$calculatedCode = $this->getCode($secret, $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 $timeSlice The time slice (defaults to current)
|
|
* @return string The generated code
|
|
*/
|
|
public function getCode($secret, $timeSlice = null)
|
|
{
|
|
if ($timeSlice === null) {
|
|
$timeSlice = 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($secret)
|
|
{
|
|
if (empty($secret)) {
|
|
return '';
|
|
}
|
|
|
|
$base32chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
$base32charsFlipped = array_flip(str_split($base32chars));
|
|
|
|
$paddingCharCount = substr_count($secret, '=');
|
|
$allowedValues = array(6, 4, 3, 1, 0);
|
|
|
|
if (!in_array($paddingCharCount, $allowedValues)) {
|
|
return false;
|
|
}
|
|
|
|
for ($i = 0; $i < 4; $i++) {
|
|
if ($paddingCharCount == $allowedValues[$i] &&
|
|
substr($secret, -($allowedValues[$i])) != str_repeat('=', $allowedValues[$i])) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
$secret = str_replace('=', '', $secret);
|
|
$secret = str_split($secret);
|
|
$binaryString = '';
|
|
|
|
for ($i = 0; $i < count($secret); $i = $i + 8) {
|
|
$x = '';
|
|
if (!in_array($secret[$i], $base32charsFlipped)) {
|
|
return false;
|
|
}
|
|
|
|
for ($j = 0; $j < 8; $j++) {
|
|
$x .= str_pad(base_convert(@$base32charsFlipped[@$secret[$i + $j]], 10, 2), 5, '0', STR_PAD_LEFT);
|
|
}
|
|
|
|
$eightBits = str_split($x, 8);
|
|
|
|
for ($z = 0; $z < count($eightBits); $z++) {
|
|
$binaryString .= (($y = chr(base_convert($eightBits[$z], 2, 10))) || 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($safeString, $userString)
|
|
{
|
|
if (function_exists('hash_equals')) {
|
|
return hash_equals($safeString, $userString);
|
|
}
|
|
|
|
$safeLen = strlen($safeString);
|
|
$userLen = strlen($userString);
|
|
|
|
if ($userLen != $safeLen) {
|
|
return false;
|
|
}
|
|
|
|
$result = 0;
|
|
|
|
for ($i = 0; $i < $userLen; $i++) {
|
|
$result |= (ord($safeString[$i]) ^ ord($userString[$i]));
|
|
}
|
|
|
|
return $result === 0;
|
|
}
|
|
}
|