Files
domnitor/app/Services/TwoFactorService.php

330 lines
9.5 KiB
PHP
Raw Normal View History

<?php
namespace App\Services;
use App\Models\User;
use App\Models\Setting;
use App\Helpers\EmailHelper;
use App\Services\Logger;
use PragmaRX\Google2FA\Google2FA;
use Endroid\QrCode\QrCode;
use Endroid\QrCode\Writer\PngWriter;
class TwoFactorService
{
private User $userModel;
private Setting $settingModel;
private Logger $logger;
private Google2FA $google2fa;
public function __construct()
{
$this->userModel = new User();
$this->settingModel = new Setting();
$this->logger = new Logger('2fa');
$this->google2fa = new Google2FA();
}
/**
* Generate a new TOTP secret for user
*/
public function generateSecret(): string
{
return $this->google2fa->generateSecretKey();
}
/**
* Generate QR code data URI for authenticator app
*/
public function generateQrCodeDataUri(string $email, string $secret, string $appName = 'Domain Monitor'): string
{
$qrCode = new QrCode($this->google2fa->getQRCodeUrl($appName, $email, $secret));
$qrCode->setSize(200);
$qrCode->setMargin(10);
$writer = new PngWriter();
$result = $writer->write($qrCode);
return 'data:image/png;base64,' . base64_encode($result->getString());
}
/**
* Verify TOTP code
*/
public function verifyTotpCode(string $secret, string $code, int $window = 1): bool
{
if (strlen($code) !== 6 || !ctype_digit($code)) {
return false;
}
return $this->google2fa->verifyKey($secret, $code, $window);
}
/**
* Generate backup codes for user
*/
public function generateBackupCodes(int $count = 8): array
{
$codes = [];
for ($i = 0; $i < $count; $i++) {
$codes[] = strtoupper(substr(md5(random_bytes(16)), 0, 8));
}
return $codes;
}
/**
* Verify backup code
*/
public function verifyBackupCode(int $userId, string $code): bool
{
$user = $this->userModel->find($userId);
if (!$user || !$user['two_factor_enabled'] || !$user['two_factor_backup_codes']) {
return false;
}
$backupCodes = json_decode($user['two_factor_backup_codes'], true);
if (!is_array($backupCodes)) {
return false;
}
$codeIndex = array_search(strtoupper($code), $backupCodes);
if ($codeIndex === false) {
return false;
}
// Remove used backup code
unset($backupCodes[$codeIndex]);
$backupCodes = array_values($backupCodes); // Re-index array
// Update user with remaining backup codes
$this->userModel->update($userId, [
'two_factor_backup_codes' => json_encode($backupCodes)
]);
$this->logger->info('Backup code used successfully', [
'user_id' => $userId,
'remaining_codes' => count($backupCodes)
]);
return true;
}
/**
* Generate and send email verification code
*/
public function generateEmailCode(int $userId): array
{
$user = $this->userModel->find($userId);
if (!$user || !$user['email_verified']) {
return ['success' => false, 'error' => 'User email not verified'];
}
// Clean up expired codes
$this->cleanExpiredEmailCodes($userId);
// Generate 6-digit code
$code = str_pad(random_int(100000, 999999), 6, '0', STR_PAD_LEFT);
// Calculate expiry time
$expiryMinutes = (int)$this->settingModel->getValue('two_factor_email_code_expiry_minutes', 10);
$expiresAt = date('Y-m-d H:i:s', time() + ($expiryMinutes * 60));
// Store code in database
$pdo = \Core\Database::getConnection();
$stmt = $pdo->prepare(
"INSERT INTO two_factor_email_codes (user_id, code, expires_at) VALUES (?, ?, ?)"
);
$stmt->execute([$userId, $code, $expiresAt]);
// Send email
$result = EmailHelper::sendTwoFactorCode($user['email'], $user['full_name'], $code);
if ($result['success']) {
$this->logger->info('2FA email code sent', [
'user_id' => $userId,
'email' => $user['email']
]);
return ['success' => true, 'expires_at' => $expiresAt];
} else {
$this->logger->error('Failed to send 2FA email code', [
'user_id' => $userId,
'email' => $user['email'],
'error' => $result['error'] ?? 'Unknown error'
]);
return ['success' => false, 'error' => 'Failed to send email code'];
}
}
/**
* Verify email code
*/
public function verifyEmailCode(int $userId, string $code): bool
{
$pdo = \Core\Database::getConnection();
$stmt = $pdo->prepare(
"SELECT * FROM two_factor_email_codes
WHERE user_id = ? AND code = ? AND used = 0 AND expires_at > NOW()
ORDER BY created_at DESC LIMIT 1"
);
$stmt->execute([$userId, $code]);
$emailCode = $stmt->fetch();
if (!$emailCode) {
return false;
}
// Mark code as used
$pdo = \Core\Database::getConnection();
$stmt = $pdo->prepare(
"UPDATE two_factor_email_codes SET used = 1 WHERE id = ?"
);
$stmt->execute([$emailCode['id']]);
$this->logger->info('2FA email code verified successfully', [
'user_id' => $userId
]);
return true;
}
/**
* Check if user can enable 2FA (email must be verified)
*/
public function canEnableTwoFactor(int $userId): bool
{
$user = $this->userModel->find($userId);
return $user && $user['email_verified'] && !$user['two_factor_enabled'];
}
/**
* Enable 2FA for user
*/
public function enableTwoFactor(int $userId, string $secret, array $backupCodes): bool
{
$user = $this->userModel->find($userId);
if (!$this->canEnableTwoFactor($userId)) {
return false;
}
$result = $this->userModel->update($userId, [
'two_factor_enabled' => 1,
'two_factor_secret' => $secret,
'two_factor_backup_codes' => json_encode($backupCodes),
'two_factor_setup_at' => date('Y-m-d H:i:s')
]);
if ($result) {
$this->logger->info('2FA enabled successfully', [
'user_id' => $userId,
'backup_codes_count' => count($backupCodes)
]);
}
return $result;
}
/**
* Disable 2FA for user
*/
public function disableTwoFactor(int $userId): bool
{
$result = $this->userModel->update($userId, [
'two_factor_enabled' => 0,
'two_factor_secret' => null,
'two_factor_backup_codes' => null,
'two_factor_setup_at' => null
]);
if ($result) {
// Clean up email codes
$this->cleanExpiredEmailCodes($userId);
$this->logger->info('2FA disabled successfully', [
'user_id' => $userId
]);
}
return $result;
}
/**
* Get 2FA policy setting
*/
public function getTwoFactorPolicy(): string
{
return $this->settingModel->getValue('two_factor_policy', 'optional');
}
/**
* Check if 2FA is required for user based on policy
*/
public function isTwoFactorRequired(int $userId): bool
{
$policy = $this->getTwoFactorPolicy();
if ($policy === 'disabled') {
return false;
}
if ($policy === 'forced') {
$user = $this->userModel->find($userId);
return $user && $user['email_verified'] && !$user['two_factor_enabled'];
}
return false; // Optional policy
}
/**
* Check rate limiting for 2FA attempts
*/
public function checkRateLimit(string $ipAddress, int $userId = null): bool
{
$rateLimitMinutes = (int)$this->settingModel->getValue('two_factor_rate_limit_minutes', 15);
$since = date('Y-m-d H:i:s', time() - ($rateLimitMinutes * 60));
$query = "SELECT COUNT(*) as attempts FROM two_factor_verification_attempts
WHERE ip_address = ? AND created_at > ?";
$params = [$ipAddress, $since];
if ($userId) {
$query .= " AND user_id = ?";
$params[] = $userId;
}
$pdo = \Core\Database::getConnection();
$stmt = $pdo->prepare($query);
$stmt->execute($params);
$result = $stmt->fetch();
// Allow max 5 attempts per IP, 3 per user per IP
$maxAttempts = $userId ? 3 : 5;
return $result['attempts'] < $maxAttempts;
}
/**
* Record 2FA verification attempt
*/
public function recordAttempt(int $userId, string $ipAddress, bool $success): void
{
$pdo = \Core\Database::getConnection();
$stmt = $pdo->prepare(
"INSERT INTO two_factor_verification_attempts (user_id, ip_address, success) VALUES (?, ?, ?)"
);
$stmt->execute([$userId, $ipAddress, $success ? 1 : 0]);
}
/**
* Clean up expired email codes
*/
private function cleanExpiredEmailCodes(int $userId): void
{
$pdo = \Core\Database::getConnection();
$stmt = $pdo->prepare(
"DELETE FROM two_factor_email_codes WHERE user_id = ? AND expires_at < NOW()"
);
$stmt->execute([$userId]);
}
}