421 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			PHP
		
	
			
		
		
	
	
			421 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			PHP
		
	
| <?php
 | |
| 
 | |
| /**
 | |
|  * Class TwoFactorAuthentication
 | |
|  *
 | |
|  * Handles two-factor authentication functionality using TOTP (Time-based One-Time Password).
 | |
|  * Internal implementation without external dependencies.
 | |
|  */
 | |
| class TwoFactorAuthentication {
 | |
|     private $db;
 | |
|     private $secretLength = 20; // 160 bits for SHA1
 | |
|     private $period = 30;       // Time step in seconds (T0)
 | |
|     private $digits = 6;        // Number of digits in TOTP code
 | |
|     private $algorithm = 'sha1'; // HMAC algorithm
 | |
|     private $issuer = 'TotalMeet';
 | |
|     private $window = 1;        // Time window of 1 step before/after
 | |
| 
 | |
|     /**
 | |
|      * Constructor
 | |
|      *
 | |
|      * @param PDO $database Database connection
 | |
|      */
 | |
|     public function __construct($database) {
 | |
|         if ($database instanceof PDO) {
 | |
|             $this->db = $database;
 | |
|         } else {
 | |
|             $this->db = $database->getConnection();
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Enable 2FA for a user
 | |
|      *
 | |
|      * @param int $userId User ID
 | |
|      * @param string $secret Secret key (base32 encoded)
 | |
|      * @param string $code Verification code
 | |
|      * @return bool True if enabled successfully
 | |
|      */
 | |
|     public function enable($userId, $secret = null, $code = null) {
 | |
|         try {
 | |
|             // Check if 2FA is already enabled
 | |
|             $stmt = $this->db->prepare('SELECT enabled FROM user_2fa WHERE user_id = ?');
 | |
|             $stmt->execute([$userId]);
 | |
|             $existing = $stmt->fetch(PDO::FETCH_ASSOC);
 | |
| 
 | |
|             if ($existing && $existing['enabled']) {
 | |
|                 return false;
 | |
|             }
 | |
| 
 | |
|             // If no secret provided, generate one and return setup data
 | |
|             if ($secret === null) {
 | |
|                 // Generate secret key
 | |
|                 $secret = $this->generateSecret();
 | |
| 
 | |
|                 // Get user's username for the QR code
 | |
|                 $stmt = $this->db->prepare('SELECT username FROM user WHERE id = ?');
 | |
|                 $stmt->execute([$userId]);
 | |
|                 $user = $stmt->fetch(PDO::FETCH_ASSOC);
 | |
| 
 | |
|                 // Generate backup codes
 | |
|                 $backupCodes = $this->generateBackupCodes();
 | |
| 
 | |
|                 // Store in database without enabling yet
 | |
|                 $this->db->beginTransaction();
 | |
| 
 | |
|                 $stmt = $this->db->prepare('
 | |
|                     INSERT INTO user_2fa (user_id, secret_key, backup_codes, enabled, created_at)
 | |
|                     VALUES (?, ?, ?, 0, NOW())
 | |
|                     ON DUPLICATE KEY UPDATE
 | |
|                         secret_key = VALUES(secret_key),
 | |
|                         backup_codes = VALUES(backup_codes),
 | |
|                         enabled = VALUES(enabled),
 | |
|                         created_at = VALUES(created_at)
 | |
|                 ');
 | |
| 
 | |
|                 $stmt->execute([
 | |
|                     $userId,
 | |
|                     $secret,
 | |
|                     json_encode($backupCodes)
 | |
|                 ]);
 | |
| 
 | |
|                 $this->db->commit();
 | |
| 
 | |
|                 // Generate otpauth URL for QR code
 | |
|                 $otpauthUrl = $this->generateOtpauthUrl($user['username'], $secret);
 | |
| 
 | |
|                 return [
 | |
|                     'success' => true,
 | |
|                     'data' => [
 | |
|                         'secret' => $secret,
 | |
|                         'otpauthUrl' => $otpauthUrl,
 | |
|                         'backupCodes' => $backupCodes
 | |
|                     ]
 | |
|                 ];
 | |
|             }
 | |
| 
 | |
|             // If secret and code provided, verify the code and enable 2FA
 | |
|             if ($code !== null) {
 | |
|                 // Verify the setup code
 | |
|                 if (!$this->verify($userId, $code)) {
 | |
|                     error_log("Code verification failed");
 | |
|                     return false;
 | |
|                 }
 | |
| 
 | |
|                 // Enable 2FA
 | |
|                 $stmt = $this->db->prepare('
 | |
|                     UPDATE user_2fa
 | |
|                     SET enabled = 1
 | |
|                     WHERE user_id = ? AND secret_key = ?
 | |
|                 ');
 | |
|                 return $stmt->execute([$userId, $secret]);
 | |
|             }
 | |
| 
 | |
|             return false;
 | |
| 
 | |
|         } catch (Exception $e) {
 | |
|             if ($this->db->inTransaction()) {
 | |
|                 $this->db->rollBack();
 | |
|             }
 | |
|             error_log('2FA enable error: ' . $e->getMessage());
 | |
|             return false;
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Verify a 2FA code
 | |
|      *
 | |
|      * @param int $userId User ID
 | |
|      * @param string $code The verification code
 | |
|      * @return bool True if verified, false otherwise
 | |
|      */
 | |
|     public function verify($userId, $code) {
 | |
|         try {
 | |
|             // Get user's 2FA settings
 | |
|             $settings = $this->getUserSettings($userId);
 | |
|             if (!$settings) {
 | |
|                 return false;
 | |
|             }
 | |
| 
 | |
|             // Check if code matches a backup code
 | |
|             if ($this->verifyBackupCode($userId, $code)) {
 | |
|                 return true;
 | |
|             }
 | |
| 
 | |
|             // Get current Unix timestamp
 | |
|             $currentTime = time();
 | |
| 
 | |
|             // Check time window
 | |
|             for ($timeSlot = -$this->window; $timeSlot <= $this->window; $timeSlot++) {
 | |
|                 $checkTime = $currentTime + ($timeSlot * $this->period);
 | |
|                 $generatedCode = $this->generateCode($settings['secret_key'], $checkTime);
 | |
|                 if (hash_equals($generatedCode, $code)) {
 | |
|                     return true;
 | |
|                 }
 | |
|             }
 | |
| 
 | |
|             return false;
 | |
| 
 | |
|         } catch (Exception $e) {
 | |
|             error_log('2FA verification error: ' . $e->getMessage());
 | |
|             return false;
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Generate a random secret key
 | |
|      *
 | |
|      * @return string Base32 encoded secret
 | |
|      */
 | |
|     private function generateSecret() {
 | |
|         // Generate random bytes (160 bits for SHA1)
 | |
|         $random = random_bytes($this->secretLength);
 | |
|         return $this->base32Encode($random);
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Base32 encode data
 | |
|      *
 | |
|      * @param string $data Data to encode
 | |
|      * @return string Base32 encoded string
 | |
|      */
 | |
|     private function base32Encode($data) {
 | |
|         $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
 | |
|         $binary = '';
 | |
|         $encoded = '';
 | |
| 
 | |
|         // Convert to binary
 | |
|         for ($i = 0; $i < strlen($data); $i++) {
 | |
|             $binary .= str_pad(decbin(ord($data[$i])), 8, '0', STR_PAD_LEFT);
 | |
|         }
 | |
| 
 | |
|         // Process 5 bits at a time
 | |
|         for ($i = 0; $i < strlen($binary); $i += 5) {
 | |
|             $chunk = substr($binary, $i, 5);
 | |
|             if (strlen($chunk) < 5) {
 | |
|                 $chunk = str_pad($chunk, 5, '0', STR_PAD_RIGHT);
 | |
|             }
 | |
|             $encoded .= $alphabet[bindec($chunk)];
 | |
|         }
 | |
| 
 | |
|         // Add padding
 | |
|         $padding = strlen($encoded) % 8;
 | |
|         if ($padding > 0) {
 | |
|             $encoded .= str_repeat('=', 8 - $padding);
 | |
|         }
 | |
| 
 | |
|         return $encoded;
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Base32 decode data
 | |
|      *
 | |
|      * @param string $data Base32 encoded string
 | |
|      * @return string Decoded data
 | |
|      */
 | |
|     private function base32Decode($data) {
 | |
|         $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
 | |
| 
 | |
|         // Remove padding and uppercase
 | |
|         $data = rtrim(strtoupper($data), '=');
 | |
| 
 | |
|         $binary = '';
 | |
| 
 | |
|         // Convert to binary
 | |
|         for ($i = 0; $i < strlen($data); $i++) {
 | |
|             $position = strpos($alphabet, $data[$i]);
 | |
|             if ($position === false) {
 | |
|                 continue;
 | |
|             }
 | |
|             $binary .= str_pad(decbin($position), 5, '0', STR_PAD_LEFT);
 | |
|         }
 | |
| 
 | |
|         $decoded = '';
 | |
|         // Process 8 bits at a time
 | |
|         for ($i = 0; $i + 7 < strlen($binary); $i += 8) {
 | |
|             $chunk = substr($binary, $i, 8);
 | |
|             $decoded .= chr(bindec($chunk));
 | |
|         }
 | |
| 
 | |
|         return $decoded;
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Generate a TOTP code for a given secret and time
 | |
|      * RFC 6238 compliant implementation
 | |
|      */
 | |
|     private function generateCode($secret, $time) {
 | |
|         // Calculate number of time steps since Unix epoch
 | |
|         $timeStep = (int)floor($time / $this->period);
 | |
| 
 | |
|         // Pack time into 8 bytes (64-bit big-endian)
 | |
|         $timeBin = pack('J', $timeStep);
 | |
| 
 | |
|         // Clean secret of any padding
 | |
|         $secret = rtrim($secret, '=');
 | |
| 
 | |
|         // Get binary secret
 | |
|         $secretBin = $this->base32Decode($secret);
 | |
| 
 | |
|         // Calculate HMAC
 | |
|         $hash = hash_hmac($this->algorithm, $timeBin, $secretBin, true);
 | |
| 
 | |
|         // Get dynamic truncation offset
 | |
|         $offset = ord($hash[strlen($hash) - 1]) & 0xF;
 | |
| 
 | |
|         // Generate 31-bit number
 | |
|         $code = (
 | |
|             ((ord($hash[$offset]) & 0x7F) << 24) |
 | |
|             ((ord($hash[$offset + 1]) & 0xFF) << 16) |
 | |
|             ((ord($hash[$offset + 2]) & 0xFF) << 8) |
 | |
|             (ord($hash[$offset + 3]) & 0xFF)
 | |
|         ) % pow(10, $this->digits);
 | |
| 
 | |
|         $code = str_pad($code, $this->digits, '0', STR_PAD_LEFT);
 | |
| 
 | |
|         return $code;
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Generate otpauth URL for QR codes
 | |
|      * Format: otpauth://totp/ISSUER:ACCOUNT?secret=SECRET&issuer=ISSUER&algorithm=ALGORITHM&digits=DIGITS&period=PERIOD
 | |
|      */
 | |
|     private function generateOtpauthUrl($username, $secret) {
 | |
|         $params = [
 | |
|             'secret' => $secret,
 | |
|             'issuer' => $this->issuer,
 | |
|             'algorithm' => strtoupper($this->algorithm),
 | |
|             'digits' => $this->digits,
 | |
|             'period' => $this->period
 | |
|         ];
 | |
| 
 | |
|         return sprintf(
 | |
|             'otpauth://totp/%s:%s?%s',
 | |
|             rawurlencode($this->issuer),
 | |
|             rawurlencode($username),
 | |
|             http_build_query($params)
 | |
|         );
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Generate backup codes
 | |
|      *
 | |
|      * @param int $count Number of backup codes to generate
 | |
|      * @return array Array of backup codes
 | |
|      */
 | |
|     private function generateBackupCodes($count = 8) {
 | |
|         $codes = [];
 | |
|         for ($i = 0; $i < $count; $i++) {
 | |
|             $codes[] = bin2hex(random_bytes(4));
 | |
|         }
 | |
|         return $codes;
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Verify a backup code
 | |
|      *
 | |
|      * @param int $userId User ID
 | |
|      * @param string $code The backup code to verify
 | |
|      * @return bool True if verified, false otherwise
 | |
|      */
 | |
|     private function verifyBackupCode($userId, $code) {
 | |
|         try {
 | |
|             $stmt = $this->db->prepare('SELECT backup_codes FROM user_2fa WHERE user_id = ?');
 | |
|             $stmt->execute([$userId]);
 | |
|             $result = $stmt->fetch(PDO::FETCH_ASSOC);
 | |
| 
 | |
|             if (!$result) {
 | |
|                 return false;
 | |
|             }
 | |
| 
 | |
|             $backupCodes = json_decode($result['backup_codes'], true);
 | |
| 
 | |
|             // Check if the code exists and hasn't been used
 | |
|             $codeIndex = array_search($code, $backupCodes);
 | |
|             if ($codeIndex !== false) {
 | |
|                 // Remove the used code
 | |
|                 unset($backupCodes[$codeIndex]);
 | |
|                 $backupCodes = array_values($backupCodes);
 | |
| 
 | |
|                 // Update backup codes in database
 | |
|                 $stmt = $this->db->prepare('
 | |
|                     UPDATE user_2fa
 | |
|                     SET backup_codes = ?
 | |
|                     WHERE user_id = ?
 | |
|                 ');
 | |
|                 $stmt->execute([json_encode($backupCodes), $userId]);
 | |
| 
 | |
|                 return true;
 | |
|             }
 | |
| 
 | |
|             return false;
 | |
| 
 | |
|         } catch (Exception $e) {
 | |
|             error_log('Backup code verification error: ' . $e->getMessage());
 | |
|             return false;
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Disable 2FA for a user
 | |
|      *
 | |
|      * @param int $userId User ID
 | |
|      * @return bool True if disabled successfully
 | |
|      */
 | |
|     public function disable($userId) {
 | |
|         try {
 | |
|             // First check if user has 2FA settings
 | |
|             $settings = $this->getUserSettings($userId);
 | |
|             if (!$settings) {
 | |
|                 return false;
 | |
|             }
 | |
| 
 | |
|             // Delete the 2FA settings entirely instead of just disabling
 | |
|             $stmt = $this->db->prepare('
 | |
|                 DELETE FROM user_2fa
 | |
|                 WHERE user_id = ?
 | |
|             ');
 | |
|             return $stmt->execute([$userId]);
 | |
| 
 | |
|         } catch (Exception $e) {
 | |
|             error_log('2FA disable error: ' . $e->getMessage());
 | |
|             return false;
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Check if 2FA is enabled for a user
 | |
|      *
 | |
|      * @param int $userId User ID
 | |
|      * @return bool True if enabled
 | |
|      */
 | |
|     public function isEnabled($userId) {
 | |
|         try {
 | |
|             $stmt = $this->db->prepare('SELECT enabled FROM user_2fa WHERE user_id = ?');
 | |
|             $stmt->execute([$userId]);
 | |
|             $result = $stmt->fetch(PDO::FETCH_ASSOC);
 | |
|             return $result && $result['enabled'];
 | |
| 
 | |
|         } catch (Exception $e) {
 | |
|             error_log('2FA status check error: ' . $e->getMessage());
 | |
|             return false;
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     private function getUserSettings($userId) {
 | |
|         try {
 | |
|             $stmt = $this->db->prepare('
 | |
|                 SELECT secret_key, backup_codes, enabled
 | |
|                 FROM user_2fa
 | |
|                 WHERE user_id = ?
 | |
|             ');
 | |
|             $stmt->execute([$userId]);
 | |
|             return $stmt->fetch(PDO::FETCH_ASSOC);
 | |
| 
 | |
|         } catch (Exception $e) {
 | |
|             error_log('Failed to get user 2FA settings: ' . $e->getMessage());
 | |
|             return null;
 | |
|         }
 | |
|     }
 | |
| }
 |