diff --git a/app/classes/twoFactorAuth.php b/app/classes/twoFactorAuth.php new file mode 100644 index 0000000..34a5749 --- /dev/null +++ b/app/classes/twoFactorAuth.php @@ -0,0 +1,371 @@ +db = $database; + } else { + $this->db = $database->getConnection(); + } + } + + /** + * Enable 2FA for a user + * + * @param int $userId User ID + * @return array Array containing success status and data (secret, QR code URL) + */ + public function enable($userId) { + 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 ['success' => false, 'message' => '2FA is already enabled']; + } + + // Generate secret key + $secret = $this->generateSecret(); + + // Get user's username for the QR code + $stmt = $this->db->prepare('SELECT username FROM users WHERE id = ?'); + $stmt->execute([$userId]); + $user = $stmt->fetch(PDO::FETCH_ASSOC); + + // Generate backup codes + $backupCodes = $this->generateBackupCodes(); + + // Store in database + $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 + ] + ]; + + } catch (Exception $e) { + if ($this->db->inTransaction()) { + $this->db->rollBack(); + } + return ['success' => false, 'message' => $e->getMessage()]; + } + } + + /** + * 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 + $stmt = $this->db->prepare(' + SELECT secret_key, backup_codes, enabled + FROM user_2fa + WHERE user_id = ? + '); + $stmt->execute([$userId]); + $tfa = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$tfa || !$tfa['enabled']) { + return false; + } + + // Check if it's a backup code + if ($this->verifyBackupCode($userId, $code)) { + return true; + } + + // Verify TOTP code + $currentTime = time(); + + // Check current and adjacent time steps + for ($timeStep = -1; $timeStep <= 1; $timeStep++) { + $checkTime = $currentTime + ($timeStep * $this->period); + if ($this->generateCode($tfa['secret_key'], $checkTime) === $code) { + // Update last used timestamp + $stmt = $this->db->prepare(' + UPDATE user_2fa + SET last_used = NOW() + WHERE user_id = ? + '); + $stmt->execute([$userId]); + return true; + } + } + + return false; + + } catch (Exception $e) { + error_log('2FA verification error: ' . $e->getMessage()); + return false; + } + } + + /** + * Generate a TOTP code for a given secret and time + * + * @param string $secret The secret key + * @param int $time Current Unix timestamp + * @return string Generated code + */ + private function generateCode($secret, $time) { + $timeStep = floor($time / $this->period); + $timeHex = str_pad(dechex($timeStep), 16, '0', STR_PAD_LEFT); + + // Convert hex time to binary + $timeBin = ''; + for ($i = 0; $i < strlen($timeHex); $i += 2) { + $timeBin .= chr(hexdec(substr($timeHex, $i, 2))); + } + + // Get binary secret + $secretBin = $this->base32Decode($secret); + + // Calculate HMAC + $hash = hash_hmac($this->algorithm, $timeBin, $secretBin, true); + + // Get offset + $offset = ord($hash[strlen($hash) - 1]) & 0xF; + + // Generate 4-byte code + $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); + + return str_pad($code, $this->digits, '0', STR_PAD_LEFT); + } + + /** + * Generate a random secret key + * + * @return string Base32 encoded secret + */ + private function generateSecret() { + $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 . '0000', $i, 5); + $encoded .= $alphabet[bindec($chunk)]; + } + + return $encoded; + } + + /** + * Base32 decode data + * + * @param string $data Base32 encoded string + * @return string Decoded data + */ + private function base32Decode($data) { + $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + $binary = ''; + $decoded = ''; + + // 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); + } + + // 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 otpauth URL for QR codes + * + * @param string $username Username + * @param string $secret Secret key + * @return string otpauth URL + */ + private function generateOtpauthUrl($username, $secret) { + return sprintf( + 'otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=%s&digits=%d&period=%d', + urlencode($this->issuer), + urlencode($username), + $secret, + urlencode($this->issuer), + strtoupper($this->algorithm), + $this->digits, + $this->period + ); + } + + /** + * 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 { + $stmt = $this->db->prepare(' + UPDATE user_2fa + SET enabled = 0, + secret_key = NULL, + backup_codes = NULL + 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; + } + } +} diff --git a/doc/jilo-web.schema b/doc/jilo-web.schema index 981cce1..83825e0 100644 --- a/doc/jilo-web.schema +++ b/doc/jilo-web.schema @@ -46,6 +46,23 @@ CREATE TABLE platforms ( jitsi_url TEXT NOT NULL, jilo_database TEXT NOT NULL ); +CREATE TABLE user_2fa ( + user_id INTEGER NOT NULL PRIMARY KEY, + secret_key TEXT NOT NULL, + backup_codes TEXT, + enabled INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + last_used TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) +); +CREATE TABLE user_2fa_temp ( + user_id INTEGER NOT NULL, + code TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + PRIMARY KEY (user_id, code), + FOREIGN KEY (user_id) REFERENCES users(id) +); CREATE TABLE logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL,