Add two-factor authentication (2FA) support
Introduces two-factor authentication (2FA) with TOTP, backup codes, and email codes. Adds controllers, services, views, and migration for 2FA setup, verification, and management. Updates user and settings models, email helper, and relevant controllers to support 2FA policy enforcement, configuration, and user flows. Enhances security by allowing admins to require or disable 2FA, and provides backup code generation and management for account recovery.
2025-10-16 17:25:06 +03:00
|
|
|
<?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
|
|
|
|
|
{
|
Switch PHP views to Twig and add 2FA/UI enhancements
Migrate many view templates from raw PHP to Twig and modernize UI/UX for 2FA and settings. Controllers updated to provide avatar data and two-factor info (ProfileController, UserController) and SettingsController now includes timezone lists, notification preset selection, cron path, cached update state and rollback availability. ErrorHandler now attempts to render error pages via a new Core\TwigService with a safe fallback to raw PHP views. TwoFactorService generation silences deprecated warnings during QR code creation. Numerous .php view files were removed and replaced with .twig equivalents (2fa setup/verify/backup-codes and many auth, dashboard, domains, errors, layout, users, tags, tld-registry, etc.), and core/TwigService was added. These changes move the app toward a Twig-based templating system, improve 2FA flows, surface avatar images in lists/profiles, and make error rendering more robust.
2026-03-03 18:21:32 +02:00
|
|
|
$previousLevel = error_reporting(error_reporting() & ~E_DEPRECATED);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
$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());
|
|
|
|
|
} finally {
|
|
|
|
|
error_reporting($previousLevel);
|
|
|
|
|
}
|
Add two-factor authentication (2FA) support
Introduces two-factor authentication (2FA) with TOTP, backup codes, and email codes. Adds controllers, services, views, and migration for 2FA setup, verification, and management. Updates user and settings models, email helper, and relevant controllers to support 2FA policy enforcement, configuration, and user flows. Enhances security by allowing admins to require or disable 2FA, and provides backup code generation and management for account recovery.
2025-10-16 17:25:06 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
*/
|
2025-10-29 12:45:01 +02:00
|
|
|
public function checkRateLimit(string $ipAddress, ?int $userId = null): bool
|
Add two-factor authentication (2FA) support
Introduces two-factor authentication (2FA) with TOTP, backup codes, and email codes. Adds controllers, services, views, and migration for 2FA setup, verification, and management. Updates user and settings models, email helper, and relevant controllers to support 2FA policy enforcement, configuration, and user flows. Enhances security by allowing admins to require or disable 2FA, and provides backup code generation and management for account recovery.
2025-10-16 17:25:06 +03:00
|
|
|
{
|
|
|
|
|
$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]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|