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.
330 lines
9.5 KiB
PHP
330 lines
9.5 KiB
PHP
<?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]);
|
|
}
|
|
|
|
}
|