Adds class and SQL for two-factor auth
parent
b4fabb6d59
commit
90688016e8
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
|
Loading…
Reference in New Issue