Adds class and SQL for two-factor auth

main
Yasen Pramatarov 2025-04-07 12:44:22 +03:00
parent b4fabb6d59
commit 90688016e8
2 changed files with 388 additions and 0 deletions

View File

@ -0,0 +1,371 @@
<?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 = 32;
private $period = 30; // Time step in seconds
private $digits = 6; // Number of digits in TOTP code
private $algorithm = 'sha1';
private $issuer = 'Jilo';
/**
* 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
* @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;
}
}
}

View File

@ -46,6 +46,23 @@ CREATE TABLE platforms (
jitsi_url TEXT NOT NULL, jitsi_url TEXT NOT NULL,
jilo_database 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 ( CREATE TABLE logs (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,