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.
This commit is contained in:
@@ -120,6 +120,34 @@ class AuthController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if 2FA is required
|
||||||
|
$twoFactorService = new \App\Services\TwoFactorService();
|
||||||
|
$policy = $twoFactorService->getTwoFactorPolicy();
|
||||||
|
|
||||||
|
if ($policy !== 'disabled' && $user['two_factor_enabled']) {
|
||||||
|
// User has 2FA enabled - require verification
|
||||||
|
$_SESSION['user_id'] = $user['id'];
|
||||||
|
$_SESSION['username'] = $user['username'];
|
||||||
|
$_SESSION['full_name'] = $user['full_name'];
|
||||||
|
$_SESSION['email'] = $user['email'];
|
||||||
|
$_SESSION['role'] = $user['role'];
|
||||||
|
$_SESSION['2fa_required'] = true;
|
||||||
|
|
||||||
|
// Clear any existing session messages before redirecting to 2FA
|
||||||
|
unset($_SESSION['error']);
|
||||||
|
unset($_SESSION['success']);
|
||||||
|
|
||||||
|
$this->redirect('/2fa/verify');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if 2FA is forced for this user
|
||||||
|
if ($twoFactorService->isTwoFactorRequired($user['id'])) {
|
||||||
|
$_SESSION['error'] = 'You must enable two-factor authentication to continue. Please contact an administrator.';
|
||||||
|
$this->redirect('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Login successful - create session
|
// Login successful - create session
|
||||||
$_SESSION['user_id'] = $user['id'];
|
$_SESSION['user_id'] = $user['id'];
|
||||||
$_SESSION['username'] = $user['username'];
|
$_SESSION['username'] = $user['username'];
|
||||||
@@ -342,10 +370,26 @@ class AuthController extends Controller
|
|||||||
private function verifyEmail($token)
|
private function verifyEmail($token)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
// Debug logging
|
||||||
|
$this->logger->info("Email verification attempt with token: " . substr($token, 0, 10) . "...");
|
||||||
|
|
||||||
// Find user by verification token using model
|
// Find user by verification token using model
|
||||||
$user = $this->userModel->findByVerificationToken($token);
|
$user = $this->userModel->findByVerificationToken($token);
|
||||||
|
|
||||||
if (!$user) {
|
if (!$user) {
|
||||||
|
$this->logger->warning("No user found with verification token: " . substr($token, 0, 10) . "...");
|
||||||
|
|
||||||
|
// Debug: Check if any user has this token (regardless of verification status)
|
||||||
|
$pdo = \Core\Database::getConnection();
|
||||||
|
$stmt = $pdo->prepare("SELECT id, email, email_verified, email_verification_token FROM users WHERE email_verification_token = ?");
|
||||||
|
$stmt->execute([$token]);
|
||||||
|
$debugUser = $stmt->fetch();
|
||||||
|
if ($debugUser) {
|
||||||
|
$this->logger->info("Debug: Found user with token - ID: {$debugUser['id']}, Email: {$debugUser['email']}, Verified: {$debugUser['email_verified']}");
|
||||||
|
} else {
|
||||||
|
$this->logger->warning("Debug: No user found with this token at all");
|
||||||
|
}
|
||||||
|
|
||||||
$this->view('auth/verify-email', [
|
$this->view('auth/verify-email', [
|
||||||
'title' => 'Verification Failed',
|
'title' => 'Verification Failed',
|
||||||
'error' => true,
|
'error' => true,
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ class InstallerController extends Controller
|
|||||||
'014_add_captcha_settings.sql',
|
'014_add_captcha_settings.sql',
|
||||||
'015_create_error_logs_table.sql',
|
'015_create_error_logs_table.sql',
|
||||||
'016_add_tags_to_domains.sql',
|
'016_add_tags_to_domains.sql',
|
||||||
|
'017_add_two_factor_authentication.sql',
|
||||||
];
|
];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -7,18 +7,21 @@ use Core\Auth;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\SessionManager;
|
use App\Models\SessionManager;
|
||||||
use App\Models\RememberToken;
|
use App\Models\RememberToken;
|
||||||
|
use App\Services\Logger;
|
||||||
|
|
||||||
class ProfileController extends Controller
|
class ProfileController extends Controller
|
||||||
{
|
{
|
||||||
private User $userModel;
|
private User $userModel;
|
||||||
private SessionManager $sessionModel;
|
private SessionManager $sessionModel;
|
||||||
private RememberToken $rememberTokenModel;
|
private RememberToken $rememberTokenModel;
|
||||||
|
private Logger $logger;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->userModel = new User();
|
$this->userModel = new User();
|
||||||
$this->sessionModel = new SessionManager();
|
$this->sessionModel = new SessionManager();
|
||||||
$this->rememberTokenModel = new RememberToken();
|
$this->rememberTokenModel = new RememberToken();
|
||||||
|
$this->logger = new Logger('profile');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,6 +69,7 @@ class ProfileController extends Controller
|
|||||||
$this->view('profile/index', [
|
$this->view('profile/index', [
|
||||||
'user' => $user,
|
'user' => $user,
|
||||||
'sessions' => $formattedSessions,
|
'sessions' => $formattedSessions,
|
||||||
|
'userModel' => $this->userModel,
|
||||||
'title' => 'My Profile'
|
'title' => 'My Profile'
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -108,6 +112,10 @@ class ProfileController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get current user data to check if email changed
|
||||||
|
$currentUser = $this->userModel->find($userId);
|
||||||
|
$emailChanged = $currentUser['email'] !== $email;
|
||||||
|
|
||||||
// Check if email is already taken by another user
|
// Check if email is already taken by another user
|
||||||
$existingUsers = $this->userModel->where('email', $email);
|
$existingUsers = $this->userModel->where('email', $email);
|
||||||
foreach ($existingUsers as $existingUser) {
|
foreach ($existingUsers as $existingUser) {
|
||||||
@@ -118,17 +126,45 @@ class ProfileController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update user
|
// Prepare update data
|
||||||
$this->userModel->update($userId, [
|
$updateData = [
|
||||||
'full_name' => $fullName,
|
'full_name' => $fullName,
|
||||||
'email' => $email,
|
'email' => $email,
|
||||||
]);
|
];
|
||||||
|
|
||||||
|
// If email changed, mark as unverified and send verification email
|
||||||
|
if ($emailChanged) {
|
||||||
|
$updateData['email_verified'] = null;
|
||||||
|
|
||||||
|
// Generate new verification token
|
||||||
|
$verificationToken = bin2hex(random_bytes(32));
|
||||||
|
$updateData['email_verification_token'] = $verificationToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user
|
||||||
|
$this->userModel->update($userId, $updateData);
|
||||||
|
|
||||||
// Update session
|
// Update session
|
||||||
$_SESSION['full_name'] = $fullName;
|
$_SESSION['full_name'] = $fullName;
|
||||||
$_SESSION['email'] = $email;
|
$_SESSION['email'] = $email;
|
||||||
|
|
||||||
|
// Send verification email if email changed
|
||||||
|
if ($emailChanged) {
|
||||||
|
try {
|
||||||
|
\App\Helpers\EmailHelper::sendVerificationEmail($email, $fullName, $verificationToken);
|
||||||
|
$_SESSION['success'] = 'Profile updated successfully. Please check your new email address for a verification link.';
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$_SESSION['success'] = 'Profile updated successfully, but verification email could not be sent. Please try resending verification.';
|
||||||
|
$this->logger->error("Failed to send verification email after profile update", [
|
||||||
|
'user_id' => $userId,
|
||||||
|
'email' => $email,
|
||||||
|
'error' => $e->getMessage()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
$_SESSION['success'] = 'Profile updated successfully';
|
$_SESSION['success'] = 'Profile updated successfully';
|
||||||
|
}
|
||||||
|
|
||||||
$this->redirect('/profile');
|
$this->redirect('/profile');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,12 +262,30 @@ class ProfileController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use AuthController logic
|
try {
|
||||||
$authController = new AuthController();
|
// Generate new verification token
|
||||||
|
$token = bin2hex(random_bytes(32));
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
$this->logger->info("Generated new verification token for user {$userId}: " . substr($token, 0, 10) . "...");
|
||||||
|
|
||||||
|
// Update verification token in database
|
||||||
|
$this->userModel->updateEmailVerificationToken($userId, $token);
|
||||||
|
|
||||||
|
// Send verification email
|
||||||
|
\App\Helpers\EmailHelper::sendVerificationEmail($user['email'], $user['full_name'], $token);
|
||||||
|
|
||||||
$_SESSION['pending_verification_email'] = $user['email'];
|
|
||||||
$_SESSION['success'] = 'Verification email sent! Please check your inbox.';
|
$_SESSION['success'] = 'Verification email sent! Please check your inbox.';
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$_SESSION['error'] = 'Failed to resend verification email. Please try again.';
|
||||||
|
$this->logger->error("Failed to resend verification email", [
|
||||||
|
'user_id' => $userId,
|
||||||
|
'email' => $user['email'],
|
||||||
|
'error' => $e->getMessage()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
$this->redirect('/profile');
|
$this->redirect('/profile');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class SettingsController extends Controller
|
|||||||
$appSettings = $this->settingModel->getAppSettings();
|
$appSettings = $this->settingModel->getAppSettings();
|
||||||
$emailSettings = $this->settingModel->getEmailSettings();
|
$emailSettings = $this->settingModel->getEmailSettings();
|
||||||
$captchaSettings = $this->settingModel->getCaptchaSettings();
|
$captchaSettings = $this->settingModel->getCaptchaSettings();
|
||||||
|
$twoFactorSettings = $this->settingModel->getTwoFactorSettings();
|
||||||
|
|
||||||
// Predefined notification day options
|
// Predefined notification day options
|
||||||
$notificationPresets = [
|
$notificationPresets = [
|
||||||
@@ -69,6 +70,7 @@ class SettingsController extends Controller
|
|||||||
'appSettings' => $appSettings,
|
'appSettings' => $appSettings,
|
||||||
'emailSettings' => $emailSettings,
|
'emailSettings' => $emailSettings,
|
||||||
'captchaSettings' => $captchaSettings,
|
'captchaSettings' => $captchaSettings,
|
||||||
|
'twoFactorSettings' => $twoFactorSettings,
|
||||||
'notificationPresets' => $notificationPresets,
|
'notificationPresets' => $notificationPresets,
|
||||||
'checkIntervalPresets' => $checkIntervalPresets,
|
'checkIntervalPresets' => $checkIntervalPresets,
|
||||||
'title' => 'Settings'
|
'title' => 'Settings'
|
||||||
@@ -435,5 +437,59 @@ class SettingsController extends Controller
|
|||||||
|
|
||||||
$this->redirect('/settings#email');
|
$this->redirect('/settings#email');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function updateTwoFactor()
|
||||||
|
{
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
$this->redirect('/settings');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
$this->verifyCsrf('/settings#security');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$twoFactorPolicy = trim($_POST['two_factor_policy'] ?? 'optional');
|
||||||
|
$rateLimitMinutes = (int)($_POST['two_factor_rate_limit_minutes'] ?? 15);
|
||||||
|
$emailCodeExpiryMinutes = (int)($_POST['two_factor_email_code_expiry_minutes'] ?? 10);
|
||||||
|
|
||||||
|
// Validate policy
|
||||||
|
$validPolicies = ['disabled', 'optional', 'forced'];
|
||||||
|
if (!in_array($twoFactorPolicy, $validPolicies)) {
|
||||||
|
$_SESSION['error'] = 'Invalid 2FA policy selected';
|
||||||
|
$this->redirect('/settings#security');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate rate limit (1-60 minutes)
|
||||||
|
if ($rateLimitMinutes < 1 || $rateLimitMinutes > 60) {
|
||||||
|
$_SESSION['error'] = 'Rate limit must be between 1 and 60 minutes';
|
||||||
|
$this->redirect('/settings#security');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate email code expiry (1-30 minutes)
|
||||||
|
if ($emailCodeExpiryMinutes < 1 || $emailCodeExpiryMinutes > 30) {
|
||||||
|
$_SESSION['error'] = 'Email code expiry must be between 1 and 30 minutes';
|
||||||
|
$this->redirect('/settings#security');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$twoFactorSettings = [
|
||||||
|
'two_factor_policy' => $twoFactorPolicy,
|
||||||
|
'two_factor_rate_limit_minutes' => $rateLimitMinutes,
|
||||||
|
'two_factor_email_code_expiry_minutes' => $emailCodeExpiryMinutes
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->settingModel->updateTwoFactorSettings($twoFactorSettings);
|
||||||
|
|
||||||
|
$_SESSION['success'] = 'Two-Factor Authentication settings updated successfully';
|
||||||
|
$this->redirect('/settings#security');
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$_SESSION['error'] = 'Failed to update 2FA settings: ' . $e->getMessage();
|
||||||
|
$this->redirect('/settings#security');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
489
app/Controllers/TwoFactorController.php
Normal file
489
app/Controllers/TwoFactorController.php
Normal file
@@ -0,0 +1,489 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use Core\Controller;
|
||||||
|
use Core\Auth;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\TwoFactorService;
|
||||||
|
use App\Services\Logger;
|
||||||
|
|
||||||
|
class TwoFactorController extends Controller
|
||||||
|
{
|
||||||
|
private User $userModel;
|
||||||
|
private TwoFactorService $twoFactorService;
|
||||||
|
private Logger $logger;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->userModel = new User();
|
||||||
|
$this->twoFactorService = new TwoFactorService();
|
||||||
|
$this->logger = new Logger('2fa');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show 2FA setup page
|
||||||
|
*/
|
||||||
|
public function setup()
|
||||||
|
{
|
||||||
|
Auth::require();
|
||||||
|
|
||||||
|
$userId = Auth::id();
|
||||||
|
$user = $this->userModel->find($userId);
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
$_SESSION['error'] = 'User not found';
|
||||||
|
$this->redirect('/profile');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if 2FA is disabled by admin
|
||||||
|
$policy = $this->twoFactorService->getTwoFactorPolicy();
|
||||||
|
if ($policy === 'disabled') {
|
||||||
|
$_SESSION['error'] = 'Two-factor authentication is disabled';
|
||||||
|
$this->redirect('/profile');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if email is verified
|
||||||
|
if (!$user['email_verified']) {
|
||||||
|
$_SESSION['error'] = 'You must verify your email address before enabling 2FA';
|
||||||
|
$this->redirect('/profile');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already enabled
|
||||||
|
if ($user['two_factor_enabled']) {
|
||||||
|
$_SESSION['info'] = 'Two-factor authentication is already enabled';
|
||||||
|
$this->redirect('/profile');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate or reuse existing secret for this setup session
|
||||||
|
if (!isset($_SESSION['2fa_setup_secret'])) {
|
||||||
|
$_SESSION['2fa_setup_secret'] = $this->twoFactorService->generateSecret();
|
||||||
|
}
|
||||||
|
|
||||||
|
$secret = $_SESSION['2fa_setup_secret'];
|
||||||
|
$qrCodeUrl = $this->twoFactorService->generateQrCodeDataUri($user['email'], $secret);
|
||||||
|
|
||||||
|
$this->view('2fa/setup', [
|
||||||
|
'user' => $user,
|
||||||
|
'secret' => $secret,
|
||||||
|
'qrCodeUrl' => $qrCodeUrl,
|
||||||
|
'title' => 'Setup Two-Factor Authentication'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify 2FA setup and enable it
|
||||||
|
*/
|
||||||
|
public function verifySetup()
|
||||||
|
{
|
||||||
|
Auth::require();
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
$this->redirect('/2fa/setup');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->verifyCsrf('/2fa/setup');
|
||||||
|
|
||||||
|
$userId = Auth::id();
|
||||||
|
$user = $this->userModel->find($userId);
|
||||||
|
$verificationCode = $_POST['verification_code'] ?? '';
|
||||||
|
|
||||||
|
if (!$user || !$user['email_verified'] || $user['two_factor_enabled']) {
|
||||||
|
$_SESSION['error'] = 'Invalid request';
|
||||||
|
$this->redirect('/2fa/setup');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the secret from session (should exist from setup page)
|
||||||
|
if (!isset($_SESSION['2fa_setup_secret'])) {
|
||||||
|
$_SESSION['error'] = 'Setup session expired. Please start over.';
|
||||||
|
$this->redirect('/2fa/setup');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$secret = $_SESSION['2fa_setup_secret'];
|
||||||
|
|
||||||
|
if (empty($verificationCode)) {
|
||||||
|
$_SESSION['error'] = 'Please enter the verification code';
|
||||||
|
$this->redirect('/2fa/setup');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the code
|
||||||
|
if (!$this->twoFactorService->verifyTotpCode($secret, $verificationCode)) {
|
||||||
|
$_SESSION['error'] = 'Invalid verification code. Please try again.';
|
||||||
|
$this->redirect('/2fa/setup');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate backup codes
|
||||||
|
$backupCodes = $this->twoFactorService->generateBackupCodes();
|
||||||
|
|
||||||
|
// Enable 2FA
|
||||||
|
if ($this->twoFactorService->enableTwoFactor($userId, $secret, $backupCodes)) {
|
||||||
|
$_SESSION['success'] = 'Two-factor authentication enabled successfully!';
|
||||||
|
|
||||||
|
// Clear the setup secret from session
|
||||||
|
unset($_SESSION['2fa_setup_secret']);
|
||||||
|
|
||||||
|
// Store backup codes in session for display
|
||||||
|
$_SESSION['backup_codes'] = $backupCodes;
|
||||||
|
|
||||||
|
$this->redirect('/2fa/backup-codes');
|
||||||
|
} else {
|
||||||
|
$_SESSION['error'] = 'Failed to enable two-factor authentication';
|
||||||
|
$this->redirect('/2fa/setup');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel 2FA setup (clear session secret)
|
||||||
|
*/
|
||||||
|
public function cancelSetup()
|
||||||
|
{
|
||||||
|
Auth::require();
|
||||||
|
|
||||||
|
// Clear the setup secret from session
|
||||||
|
unset($_SESSION['2fa_setup_secret']);
|
||||||
|
|
||||||
|
$_SESSION['info'] = '2FA setup cancelled';
|
||||||
|
$this->redirect('/profile');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show backup codes page
|
||||||
|
*/
|
||||||
|
public function backupCodes()
|
||||||
|
{
|
||||||
|
Auth::require();
|
||||||
|
|
||||||
|
$backupCodes = $_SESSION['backup_codes'] ?? null;
|
||||||
|
|
||||||
|
if (!$backupCodes) {
|
||||||
|
$_SESSION['error'] = 'No backup codes found';
|
||||||
|
$this->redirect('/profile');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear backup codes from session after display
|
||||||
|
unset($_SESSION['backup_codes']);
|
||||||
|
|
||||||
|
$userId = Auth::id();
|
||||||
|
$user = $this->userModel->find($userId);
|
||||||
|
|
||||||
|
$this->view('2fa/backup-codes', [
|
||||||
|
'user' => $user,
|
||||||
|
'backupCodes' => $backupCodes,
|
||||||
|
'title' => 'Backup Codes'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show 2FA verification page (during login)
|
||||||
|
*/
|
||||||
|
public function showVerify()
|
||||||
|
{
|
||||||
|
// Check if user is in 2FA verification state
|
||||||
|
if (!isset($_SESSION['2fa_required']) || !$_SESSION['2fa_required']) {
|
||||||
|
$this->redirect('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = Auth::id();
|
||||||
|
$user = $this->userModel->find($userId);
|
||||||
|
|
||||||
|
if (!$user || !$user['two_factor_enabled']) {
|
||||||
|
$_SESSION['error'] = 'Invalid request';
|
||||||
|
$this->redirect('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->view('2fa/verify', [
|
||||||
|
'user' => $user,
|
||||||
|
'title' => 'Two-Factor Authentication'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process 2FA verification
|
||||||
|
*/
|
||||||
|
public function verify()
|
||||||
|
{
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
$this->redirect('/2fa/verify');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->verifyCsrf('/2fa/verify');
|
||||||
|
|
||||||
|
// Check if user is in 2FA verification state
|
||||||
|
if (!isset($_SESSION['2fa_required']) || !$_SESSION['2fa_required']) {
|
||||||
|
$this->redirect('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = Auth::id();
|
||||||
|
$user = $this->userModel->find($userId);
|
||||||
|
$ipAddress = $_SERVER['REMOTE_ADDR'] ?? '';
|
||||||
|
|
||||||
|
if (!$user || !$user['two_factor_enabled']) {
|
||||||
|
$_SESSION['error'] = 'Invalid request';
|
||||||
|
$this->redirect('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check rate limiting
|
||||||
|
if (!$this->twoFactorService->checkRateLimit($ipAddress, $userId)) {
|
||||||
|
$_SESSION['error'] = 'Too many failed attempts. Please try again later.';
|
||||||
|
$this->redirect('/2fa/verify');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$verificationCode = trim($_POST['verification_code'] ?? '');
|
||||||
|
$verified = false;
|
||||||
|
|
||||||
|
if (!empty($verificationCode)) {
|
||||||
|
// Try TOTP code first (6 digits)
|
||||||
|
if (strlen($verificationCode) === 6 && is_numeric($verificationCode)) {
|
||||||
|
if ($this->twoFactorService->verifyTotpCode($user['two_factor_secret'], $verificationCode)) {
|
||||||
|
$verified = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try email code if TOTP failed (6 digits)
|
||||||
|
if (!$verified && strlen($verificationCode) === 6 && is_numeric($verificationCode)) {
|
||||||
|
if ($this->twoFactorService->verifyEmailCode($userId, $verificationCode)) {
|
||||||
|
$verified = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try backup code (8 characters)
|
||||||
|
if (!$verified && strlen($verificationCode) === 8) {
|
||||||
|
if ($this->twoFactorService->verifyBackupCode($userId, $verificationCode)) {
|
||||||
|
$verified = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record attempt
|
||||||
|
$this->twoFactorService->recordAttempt($userId, $ipAddress, $verified);
|
||||||
|
|
||||||
|
if ($verified) {
|
||||||
|
// Clear 2FA requirement and complete login
|
||||||
|
unset($_SESSION['2fa_required']);
|
||||||
|
|
||||||
|
// Determine which method was used
|
||||||
|
$method = 'unknown';
|
||||||
|
if (strlen($verificationCode) === 6 && is_numeric($verificationCode)) {
|
||||||
|
// Try to determine if it was TOTP or email by checking which one succeeded
|
||||||
|
if ($this->twoFactorService->verifyTotpCode($user['two_factor_secret'], $verificationCode)) {
|
||||||
|
$method = 'totp';
|
||||||
|
} else {
|
||||||
|
$method = 'email';
|
||||||
|
}
|
||||||
|
} elseif (strlen($verificationCode) === 8) {
|
||||||
|
$method = 'backup';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logger->info('2FA verification successful', [
|
||||||
|
'user_id' => $userId,
|
||||||
|
'method' => $method
|
||||||
|
]);
|
||||||
|
|
||||||
|
$_SESSION['success'] = 'Login successful!';
|
||||||
|
$this->redirect('/');
|
||||||
|
} else {
|
||||||
|
$_SESSION['error'] = 'Invalid verification code. Please try again.';
|
||||||
|
$this->redirect('/2fa/verify');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send email verification code
|
||||||
|
*/
|
||||||
|
public function sendEmailCode()
|
||||||
|
{
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
$this->redirect('/2fa/verify');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if user is in 2FA verification state
|
||||||
|
if (!isset($_SESSION['2fa_required']) || !$_SESSION['2fa_required']) {
|
||||||
|
$this->jsonResponse(['success' => false, 'error' => 'Invalid request']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = Auth::id();
|
||||||
|
if (!$userId) {
|
||||||
|
$this->jsonResponse(['success' => false, 'error' => 'User not authenticated']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->userModel->find($userId);
|
||||||
|
if (!$user) {
|
||||||
|
$this->jsonResponse(['success' => false, 'error' => 'User not found']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$user['two_factor_enabled']) {
|
||||||
|
$this->jsonResponse(['success' => false, 'error' => 'Two-factor authentication not enabled']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$user['email_verified']) {
|
||||||
|
$this->jsonResponse(['success' => false, 'error' => 'Email not verified']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check rate limit
|
||||||
|
if (!$this->twoFactorService->checkRateLimit($_SERVER['REMOTE_ADDR'] ?? '', $userId)) {
|
||||||
|
$this->jsonResponse(['success' => false, 'error' => 'Rate limit exceeded. Please try again later.']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->twoFactorService->generateEmailCode($userId);
|
||||||
|
$this->jsonResponse($result);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->logger->error('Error sending 2FA email code', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'trace' => $e->getTraceAsString()
|
||||||
|
]);
|
||||||
|
$this->jsonResponse(['success' => false, 'error' => 'Failed to send email code']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable 2FA
|
||||||
|
*/
|
||||||
|
public function disable()
|
||||||
|
{
|
||||||
|
Auth::require();
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
$this->redirect('/profile');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->verifyCsrf('/profile');
|
||||||
|
|
||||||
|
$userId = Auth::id();
|
||||||
|
$user = $this->userModel->find($userId);
|
||||||
|
|
||||||
|
if (!$user || !$user['two_factor_enabled']) {
|
||||||
|
$_SESSION['error'] = 'Two-factor authentication is not enabled';
|
||||||
|
$this->redirect('/profile');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require 2FA verification to disable 2FA
|
||||||
|
$verificationCode = trim($_POST['verification_code'] ?? '');
|
||||||
|
if (empty($verificationCode)) {
|
||||||
|
$_SESSION['error'] = 'Please enter your 2FA verification code to disable two-factor authentication';
|
||||||
|
$this->redirect('/profile');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the code using any available method
|
||||||
|
$verified = false;
|
||||||
|
|
||||||
|
// Try TOTP code first
|
||||||
|
if ($this->twoFactorService->verifyTotpCode($user['two_factor_secret'], $verificationCode)) {
|
||||||
|
$verified = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try email code if TOTP failed
|
||||||
|
if (!$verified && $user['email_verified']) {
|
||||||
|
if ($this->twoFactorService->verifyEmailCode($userId, $verificationCode)) {
|
||||||
|
$verified = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try backup code if other methods failed
|
||||||
|
if (!$verified) {
|
||||||
|
if ($this->twoFactorService->verifyBackupCode($userId, $verificationCode)) {
|
||||||
|
$verified = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$verified) {
|
||||||
|
$_SESSION['error'] = 'Invalid verification code. Please enter a valid 2FA code to disable two-factor authentication';
|
||||||
|
$this->redirect('/profile');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if 2FA is forced
|
||||||
|
if ($this->twoFactorService->isTwoFactorRequired($userId)) {
|
||||||
|
$_SESSION['error'] = 'Two-factor authentication is required and cannot be disabled';
|
||||||
|
$this->redirect('/profile');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->twoFactorService->disableTwoFactor($userId)) {
|
||||||
|
$_SESSION['success'] = 'Two-factor authentication has been disabled';
|
||||||
|
} else {
|
||||||
|
$_SESSION['error'] = 'Failed to disable two-factor authentication';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->redirect('/profile#twofactor');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerate backup codes
|
||||||
|
*/
|
||||||
|
public function regenerateBackupCodes()
|
||||||
|
{
|
||||||
|
Auth::require();
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
$this->redirect('/profile');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->verifyCsrf('/profile');
|
||||||
|
|
||||||
|
$userId = Auth::id();
|
||||||
|
$user = $this->userModel->find($userId);
|
||||||
|
|
||||||
|
if (!$user || !$user['two_factor_enabled']) {
|
||||||
|
$_SESSION['error'] = 'Two-factor authentication is not enabled';
|
||||||
|
$this->redirect('/profile');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new backup codes
|
||||||
|
$backupCodes = $this->twoFactorService->generateBackupCodes();
|
||||||
|
|
||||||
|
// Update user with new backup codes
|
||||||
|
if ($this->userModel->update($userId, [
|
||||||
|
'two_factor_backup_codes' => json_encode($backupCodes)
|
||||||
|
])) {
|
||||||
|
$_SESSION['success'] = 'New backup codes generated successfully!';
|
||||||
|
|
||||||
|
// Store backup codes in session for display
|
||||||
|
$_SESSION['backup_codes'] = $backupCodes;
|
||||||
|
|
||||||
|
$this->redirect('/2fa/backup-codes');
|
||||||
|
} else {
|
||||||
|
$_SESSION['error'] = 'Failed to generate new backup codes';
|
||||||
|
$this->redirect('/profile#twofactor');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send JSON response
|
||||||
|
*/
|
||||||
|
private function jsonResponse(array $data): void
|
||||||
|
{
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode($data);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -231,7 +231,7 @@ class EmailHelper
|
|||||||
/**
|
/**
|
||||||
* Send a notification email
|
* Send a notification email
|
||||||
*/
|
*/
|
||||||
public static function sendNotificationEmail(string $toEmail, string $subject, string $message, array $data = []): array
|
public static function sendNotificationEmail(string $toEmail, string $subject, string $message, array $data = [], bool $isHtml = false): array
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$emailSettings = self::getEmailSettings();
|
$emailSettings = self::getEmailSettings();
|
||||||
@@ -245,8 +245,16 @@ class EmailHelper
|
|||||||
// Content
|
// Content
|
||||||
$mail->isHTML(true);
|
$mail->isHTML(true);
|
||||||
$mail->Subject = $subject;
|
$mail->Subject = $subject;
|
||||||
|
|
||||||
|
if ($isHtml) {
|
||||||
|
// Message is already formatted HTML, use it directly
|
||||||
|
$mail->Body = $message;
|
||||||
|
$mail->AltBody = strip_tags($message);
|
||||||
|
} else {
|
||||||
|
// Message is plain text, format it with template
|
||||||
$mail->Body = self::formatHtmlBody($message, $data, $appSettings);
|
$mail->Body = self::formatHtmlBody($message, $data, $appSettings);
|
||||||
$mail->AltBody = strip_tags($message);
|
$mail->AltBody = strip_tags($message);
|
||||||
|
}
|
||||||
|
|
||||||
$mail->send();
|
$mail->send();
|
||||||
|
|
||||||
@@ -384,7 +392,7 @@ class EmailHelper
|
|||||||
</div>
|
</div>
|
||||||
";
|
";
|
||||||
|
|
||||||
return self::sendNotificationEmail($email, $subject, $htmlContent);
|
return self::sendNotificationEmail($email, $subject, $htmlContent, [], true);
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$errorMessage = "Failed to send verification email: " . $e->getMessage();
|
$errorMessage = "Failed to send verification email: " . $e->getMessage();
|
||||||
@@ -467,7 +475,7 @@ class EmailHelper
|
|||||||
</div>
|
</div>
|
||||||
";
|
";
|
||||||
|
|
||||||
return self::sendNotificationEmail($email, $subject, $htmlContent);
|
return self::sendNotificationEmail($email, $subject, $htmlContent, [], true);
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$errorMessage = "Failed to send password reset email: " . $e->getMessage();
|
$errorMessage = "Failed to send password reset email: " . $e->getMessage();
|
||||||
@@ -506,4 +514,73 @@ class EmailHelper
|
|||||||
|
|
||||||
return "Domain Monitor Alert";
|
return "Domain Monitor Alert";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send 2FA verification code email
|
||||||
|
*/
|
||||||
|
public static function sendTwoFactorCode(string $email, string $fullName, string $code): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$appSettings = self::getAppSettings();
|
||||||
|
$subject = 'Your Two-Factor Authentication Code';
|
||||||
|
|
||||||
|
// Create a properly formatted HTML email
|
||||||
|
$htmlContent = "
|
||||||
|
<div style='max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;'>
|
||||||
|
<div style='background: #28a745; color: white; padding: 20px; border-radius: 5px 5px 0 0; text-align: center;'>
|
||||||
|
<h2 style='margin: 0; font-size: 24px;'>🔐 Two-Factor Authentication</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style='background: #f9f9f9; padding: 30px; border: 1px solid #ddd;'>
|
||||||
|
<h3 style='color: #333; margin-top: 0;'>Your Verification Code</h3>
|
||||||
|
|
||||||
|
<p style='color: #555; line-height: 1.6; margin-bottom: 20px;'>Hello {$fullName},</p>
|
||||||
|
|
||||||
|
<p style='color: #555; line-height: 1.6; margin-bottom: 20px;'>
|
||||||
|
Use this code to complete your two-factor authentication:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style='background: white; border: 2px solid #28a745; border-radius: 5px; padding: 25px; text-align: center; margin: 25px 0;'>
|
||||||
|
<div style='font-size: 32px; font-weight: bold; color: #28a745; letter-spacing: 8px; font-family: monospace;'>{$code}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style='background: #fff3cd; border: 1px solid #ffeaa7; border-radius: 5px; padding: 15px; margin: 20px 0;'>
|
||||||
|
<p style='margin: 0; color: #856404; font-size: 14px;'>
|
||||||
|
<strong>⏰ Important:</strong> This code expires in 10 minutes for security reasons.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style='color: #666; font-size: 14px; line-height: 1.6;'>
|
||||||
|
If you did not request this code, please ignore this email.
|
||||||
|
No further action is required.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style='background: #333; color: white; padding: 15px; text-align: center; font-size: 12px; border-radius: 0 0 5px 5px;'>
|
||||||
|
<p style='margin: 0 0 10px 0;'>This is an automated message from {$appSettings['app_name']}</p>
|
||||||
|
<a href='{$appSettings['app_url']}' style='color: #4A90E2; text-decoration: none;'>Visit Dashboard</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
";
|
||||||
|
|
||||||
|
return self::sendNotificationEmail($email, $subject, $htmlContent, [], true);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$errorMessage = "Failed to send 2FA verification email: " . $e->getMessage();
|
||||||
|
|
||||||
|
// Log the error using the application's logger
|
||||||
|
self::getLogger()->error($errorMessage, [
|
||||||
|
'email' => $email,
|
||||||
|
'full_name' => $fullName,
|
||||||
|
'exception' => $e->getMessage(),
|
||||||
|
'trace' => $e->getTraceAsString()
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'message' => $errorMessage,
|
||||||
|
'error' => $e->getMessage()
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -256,5 +256,33 @@ class Setting extends Model
|
|||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get 2FA settings
|
||||||
|
*/
|
||||||
|
public function getTwoFactorSettings(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'policy' => $this->getValue('two_factor_policy', 'optional'),
|
||||||
|
'rate_limit_minutes' => (int)$this->getValue('two_factor_rate_limit_minutes', 15),
|
||||||
|
'email_code_expiry_minutes' => (int)$this->getValue('two_factor_email_code_expiry_minutes', 10)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update 2FA settings
|
||||||
|
*/
|
||||||
|
public function updateTwoFactorSettings(array $settings): bool
|
||||||
|
{
|
||||||
|
$result = true;
|
||||||
|
|
||||||
|
foreach ($settings as $key => $value) {
|
||||||
|
if (!$this->setValue($key, $value)) {
|
||||||
|
$result = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ class User extends Model
|
|||||||
public function findByVerificationToken(string $token): ?array
|
public function findByVerificationToken(string $token): ?array
|
||||||
{
|
{
|
||||||
$stmt = $this->db->prepare(
|
$stmt = $this->db->prepare(
|
||||||
"SELECT * FROM users WHERE email_verification_token = ? AND email_verified = 0"
|
"SELECT * FROM users WHERE email_verification_token = ? AND (email_verified IS NULL OR email_verified = 0)"
|
||||||
);
|
);
|
||||||
$stmt->execute([$token]);
|
$stmt->execute([$token]);
|
||||||
$result = $stmt->fetch();
|
$result = $stmt->fetch();
|
||||||
@@ -254,5 +254,35 @@ class User extends Model
|
|||||||
$stmt = $this->db->prepare("DELETE FROM remember_tokens WHERE token = ?");
|
$stmt = $this->db->prepare("DELETE FROM remember_tokens WHERE token = ?");
|
||||||
return $stmt->execute([$token]);
|
return $stmt->execute([$token]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user's 2FA status
|
||||||
|
*/
|
||||||
|
public function getTwoFactorStatus(int $userId): array
|
||||||
|
{
|
||||||
|
$user = $this->find($userId);
|
||||||
|
if (!$user) {
|
||||||
|
return ['enabled' => false, 'can_enable' => false, 'required' => false];
|
||||||
|
}
|
||||||
|
|
||||||
|
$twoFactorService = new \App\Services\TwoFactorService();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'enabled' => (bool)$user['two_factor_enabled'],
|
||||||
|
'can_enable' => $twoFactorService->canEnableTwoFactor($userId),
|
||||||
|
'required' => $twoFactorService->isTwoFactorRequired($userId),
|
||||||
|
'setup_at' => $user['two_factor_setup_at'],
|
||||||
|
'backup_codes_count' => $user['two_factor_backup_codes'] ? count(json_decode($user['two_factor_backup_codes'], true)) : 0
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user has verified email (required for 2FA)
|
||||||
|
*/
|
||||||
|
public function hasVerifiedEmail(int $userId): bool
|
||||||
|
{
|
||||||
|
$user = $this->find($userId);
|
||||||
|
return $user && $user['email_verified'];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
329
app/Services/TwoFactorService.php
Normal file
329
app/Services/TwoFactorService.php
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
<?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]);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
209
app/Views/2fa/backup-codes.php
Normal file
209
app/Views/2fa/backup-codes.php
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
<?php
|
||||||
|
$title = '2FA Backup Codes';
|
||||||
|
$pageTitle = '2FA Backup Codes';
|
||||||
|
$pageDescription = 'Save these backup codes in a safe place';
|
||||||
|
$pageIcon = 'fas fa-key';
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="max-w-2xl mx-auto">
|
||||||
|
<div class="bg-white shadow rounded-lg">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900">2FA Backup Codes</h3>
|
||||||
|
<p class="text-sm text-gray-600 mt-1">Save these codes in a safe place - they can be used to access your account if you lose your authenticator device</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6">
|
||||||
|
<!-- Warning -->
|
||||||
|
<div class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<i class="fas fa-exclamation-triangle text-red-400"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<h4 class="text-sm font-medium text-red-800">Important Security Notice</h4>
|
||||||
|
<p class="text-sm text-red-700 mt-1">
|
||||||
|
These backup codes are shown only once. Each code can only be used once. Store them securely and never share them with anyone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Backup Codes -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h4 class="text-md font-semibold text-gray-900">Your Backup Codes</h4>
|
||||||
|
<button onclick="printCodes()" class="text-sm text-primary hover:text-primary-dark">
|
||||||
|
<i class="fas fa-print mr-1"></i>
|
||||||
|
Print Codes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3" id="backup-codes">
|
||||||
|
<?php foreach ($backupCodes as $index => $code): ?>
|
||||||
|
<div class="flex items-center justify-between bg-gray-50 p-3 rounded-lg border">
|
||||||
|
<code class="font-mono text-sm text-gray-900"><?= htmlspecialchars($code) ?></code>
|
||||||
|
<button onclick="copyCode('<?= htmlspecialchars($code) ?>', this)"
|
||||||
|
class="ml-2 px-2 py-1 text-xs bg-gray-200 text-gray-700 rounded hover:bg-gray-300">
|
||||||
|
<i class="fas fa-copy"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Instructions -->
|
||||||
|
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||||
|
<h4 class="text-sm font-medium text-blue-800 mb-2">How to use backup codes:</h4>
|
||||||
|
<ul class="text-sm text-blue-700 space-y-1">
|
||||||
|
<li>• When logging in, enter a backup code instead of your 2FA code</li>
|
||||||
|
<li>• Each backup code can only be used once</li>
|
||||||
|
<li>• After using a code, it will be automatically removed from your account</li>
|
||||||
|
<li>• If you run out of backup codes, you'll need to disable and re-enable 2FA</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex items-center justify-between pt-4 border-t border-gray-200">
|
||||||
|
<a href="/profile" class="text-sm text-gray-600 hover:text-gray-500">
|
||||||
|
<i class="fas fa-arrow-left mr-1"></i>
|
||||||
|
Back to Profile
|
||||||
|
</a>
|
||||||
|
<div class="flex space-x-3">
|
||||||
|
<button onclick="downloadCodes()"
|
||||||
|
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary">
|
||||||
|
<i class="fas fa-download mr-2"></i>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
<a href="/"
|
||||||
|
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary">
|
||||||
|
<i class="fas fa-check mr-2"></i>
|
||||||
|
I've Saved These Codes
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function copyCode(code, button) {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
navigator.clipboard.writeText(code).then(() => {
|
||||||
|
showCopySuccess(button);
|
||||||
|
}).catch(() => {
|
||||||
|
fallbackCopyTextToClipboard(code);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
fallbackCopyTextToClipboard(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCopySuccess(button) {
|
||||||
|
const originalHTML = button.innerHTML;
|
||||||
|
button.innerHTML = '<i class="fas fa-check"></i>';
|
||||||
|
button.classList.remove('bg-gray-200', 'hover:bg-gray-300', 'text-gray-700');
|
||||||
|
button.classList.add('bg-green-500', 'text-white');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
button.innerHTML = originalHTML;
|
||||||
|
button.classList.remove('bg-green-500', 'text-white');
|
||||||
|
button.classList.add('bg-gray-200', 'hover:bg-gray-300', 'text-gray-700');
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackCopyTextToClipboard(text) {
|
||||||
|
const textArea = document.createElement('textarea');
|
||||||
|
textArea.value = text;
|
||||||
|
textArea.style.position = 'fixed';
|
||||||
|
textArea.style.top = '0';
|
||||||
|
textArea.style.left = '0';
|
||||||
|
textArea.style.width = '2em';
|
||||||
|
textArea.style.height = '2em';
|
||||||
|
textArea.style.padding = '0';
|
||||||
|
textArea.style.border = 'none';
|
||||||
|
textArea.style.outline = 'none';
|
||||||
|
textArea.style.boxShadow = 'none';
|
||||||
|
textArea.style.background = 'transparent';
|
||||||
|
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.focus();
|
||||||
|
textArea.select();
|
||||||
|
|
||||||
|
try {
|
||||||
|
document.execCommand('copy');
|
||||||
|
alert('Code copied to clipboard!');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Copy failed:', err);
|
||||||
|
alert('Failed to copy code');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
}
|
||||||
|
|
||||||
|
function printCodes() {
|
||||||
|
const printWindow = window.open('', '_blank');
|
||||||
|
const codes = <?= json_encode($backupCodes) ?>;
|
||||||
|
|
||||||
|
printWindow.document.write(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>2FA Backup Codes - <?= htmlspecialchars($user['full_name'] ?? $user['username']) ?></title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; padding: 20px; line-height: 1.6; }
|
||||||
|
h1 { color: #333; margin-bottom: 20px; }
|
||||||
|
.codes { background: #f5f5f5; padding: 15px; border: 1px solid #ddd; margin: 20px 0; }
|
||||||
|
.code { margin: 5px 0; font-family: monospace; }
|
||||||
|
.warning { background: #fee; border: 1px solid #fcc; padding: 10px; margin: 10px 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>2FA Backup Codes</h1>
|
||||||
|
<p><strong>Account:</strong> <?= htmlspecialchars($user['full_name'] ?? $user['username']) ?> (<?= htmlspecialchars($user['email']) ?>)</p>
|
||||||
|
<p><strong>Generated:</strong> ${new Date().toLocaleString()}</p>
|
||||||
|
|
||||||
|
<div class="warning">
|
||||||
|
<strong>Important:</strong> Store these codes in a safe place. Each code can only be used once.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="codes">
|
||||||
|
${codes.map((code, index) => `<div class="code">${index + 1}. ${code}</div>`).join('')}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
|
||||||
|
printWindow.document.close();
|
||||||
|
printWindow.print();
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadCodes() {
|
||||||
|
const codes = <?= json_encode($backupCodes) ?>;
|
||||||
|
const content = `2FA Backup Codes
|
||||||
|
Account: <?= htmlspecialchars($user['full_name'] ?? $user['username']) ?> (<?= htmlspecialchars($user['email']) ?>)
|
||||||
|
Generated: ${new Date().toLocaleString()}
|
||||||
|
|
||||||
|
IMPORTANT: Store these codes in a safe place. Each code can only be used once.
|
||||||
|
|
||||||
|
${codes.map((code, index) => `${index + 1}. ${code}`).join('\n')}
|
||||||
|
|
||||||
|
If you lose access to your authenticator app, you can use these codes to log in.
|
||||||
|
Generate new codes if you run out or if you suspect they've been compromised.`;
|
||||||
|
|
||||||
|
const blob = new Blob([content], { type: 'text/plain' });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = '2fa-backup-codes.txt';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
$content = ob_get_clean();
|
||||||
|
require __DIR__ . '/../layout/base.php';
|
||||||
|
?>
|
||||||
162
app/Views/2fa/setup.php
Normal file
162
app/Views/2fa/setup.php
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<?php
|
||||||
|
$title = 'Setup Two-Factor Authentication';
|
||||||
|
$pageTitle = 'Setup 2FA';
|
||||||
|
$pageDescription = 'Configure two-factor authentication for your account';
|
||||||
|
$pageIcon = 'fas fa-shield-alt';
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="max-w-3xl mx-auto">
|
||||||
|
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 flex items-center">
|
||||||
|
<i class="fas fa-shield-alt text-gray-400 mr-2 text-sm"></i>
|
||||||
|
Setup Two-Factor Authentication
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6 space-y-5">
|
||||||
|
<!-- Step 1: Download Authenticator App -->
|
||||||
|
<div class="border-l-4 border-blue-500 pl-4">
|
||||||
|
<h4 class="text-base font-semibold text-gray-900 mb-2">Step 1: Install an Authenticator App</h4>
|
||||||
|
<p class="text-sm text-gray-600 mb-3">Download one of these apps on your mobile device:</p>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
|
<div class="bg-gray-50 rounded-lg p-3 text-center">
|
||||||
|
<i class="fab fa-google text-2xl text-blue-600 mb-2"></i>
|
||||||
|
<p class="text-sm font-medium text-gray-900">Google Authenticator</p>
|
||||||
|
<p class="text-xs text-gray-500">iOS & Android</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 rounded-lg p-3 text-center">
|
||||||
|
<i class="fas fa-mobile-alt text-2xl text-blue-600 mb-2"></i>
|
||||||
|
<p class="text-sm font-medium text-gray-900">Authy</p>
|
||||||
|
<p class="text-xs text-gray-500">iOS & Android</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 rounded-lg p-3 text-center">
|
||||||
|
<i class="fab fa-microsoft text-2xl text-blue-600 mb-2"></i>
|
||||||
|
<p class="text-sm font-medium text-gray-900">Microsoft Authenticator</p>
|
||||||
|
<p class="text-xs text-gray-500">iOS & Android</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2: Scan QR Code -->
|
||||||
|
<div class="border-l-4 border-green-500 pl-4">
|
||||||
|
<h4 class="text-base font-semibold text-gray-900 mb-2">Step 2: Scan QR Code</h4>
|
||||||
|
<p class="text-sm text-gray-600 mb-4">Open your authenticator app and scan this QR code:</p>
|
||||||
|
|
||||||
|
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3 mb-4">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-info-circle text-blue-600 mr-2"></i>
|
||||||
|
<p class="text-sm text-blue-800">
|
||||||
|
<strong>Note:</strong> This QR code will remain the same even if you refresh the page.
|
||||||
|
Once you scan it, you can enter the verification code below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center space-y-4">
|
||||||
|
<div class="bg-white border-2 border-gray-200 rounded-lg p-4">
|
||||||
|
<img src="<?= htmlspecialchars($qrCodeUrl) ?>" alt="QR Code for 2FA setup" class="w-48 h-48">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-xs text-gray-500 mb-2">Can't scan? Enter this code manually:</p>
|
||||||
|
<div class="bg-gray-100 rounded-lg p-3 font-mono text-sm">
|
||||||
|
<code class="text-gray-800"><?= htmlspecialchars($secret) ?></code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3: Verify Code -->
|
||||||
|
<div class="border-l-4 border-yellow-500 pl-4">
|
||||||
|
<h4 class="text-base font-semibold text-gray-900 mb-2">Step 3: Verify Setup</h4>
|
||||||
|
<p class="text-sm text-gray-600 mb-4">Enter the 6-digit code from your authenticator app:</p>
|
||||||
|
|
||||||
|
<form method="POST" action="/2fa/verify-setup" id="verifyForm">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
|
||||||
|
<div class="max-w-xs mx-auto">
|
||||||
|
<input type="text"
|
||||||
|
name="verification_code"
|
||||||
|
id="verification_code"
|
||||||
|
maxlength="6"
|
||||||
|
pattern="[0-9]{6}"
|
||||||
|
placeholder="123456"
|
||||||
|
class="w-full px-4 py-3 text-center text-2xl font-mono border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
|
||||||
|
required>
|
||||||
|
<p class="text-xs text-gray-500 mt-2 text-center">Enter 6-digit code</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col sm:flex-row gap-3 pt-3">
|
||||||
|
<button type="submit"
|
||||||
|
class="inline-flex items-center justify-center px-5 py-2.5 bg-primary hover:bg-primary-dark text-white rounded-lg font-medium transition-colors text-sm">
|
||||||
|
<i class="fas fa-check mr-2"></i>
|
||||||
|
Verify & Enable 2FA
|
||||||
|
</button>
|
||||||
|
<a href="/2fa/cancel-setup"
|
||||||
|
class="inline-flex items-center justify-center px-5 py-2.5 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50 transition-colors text-sm">
|
||||||
|
<i class="fas fa-times mr-2"></i>
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Security Notice -->
|
||||||
|
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||||
|
<div class="flex">
|
||||||
|
<i class="fas fa-exclamation-triangle text-yellow-600 mt-0.5 mr-3"></i>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-yellow-900">Important Security Notice</p>
|
||||||
|
<p class="text-sm text-yellow-700 mt-1">
|
||||||
|
Once 2FA is enabled, you'll need your authenticator app to log in.
|
||||||
|
Make sure to save your backup codes in a secure location.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const codeInput = document.getElementById('verification_code');
|
||||||
|
|
||||||
|
// Auto-focus on code input
|
||||||
|
codeInput.focus();
|
||||||
|
|
||||||
|
// Only allow digits
|
||||||
|
codeInput.addEventListener('input', function(e) {
|
||||||
|
this.value = this.value.replace(/[^0-9]/g, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-submit when 6 digits are entered
|
||||||
|
codeInput.addEventListener('input', function(e) {
|
||||||
|
if (this.value.length === 6) {
|
||||||
|
// Small delay to let user see the complete code
|
||||||
|
setTimeout(() => {
|
||||||
|
document.getElementById('verifyForm').submit();
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle form submission
|
||||||
|
document.getElementById('verifyForm').addEventListener('submit', function(e) {
|
||||||
|
const code = codeInput.value.trim();
|
||||||
|
if (code.length !== 6 || !/^\d{6}$/.test(code)) {
|
||||||
|
e.preventDefault();
|
||||||
|
alert('Please enter a valid 6-digit code');
|
||||||
|
codeInput.focus();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
$content = ob_get_clean();
|
||||||
|
require __DIR__ . '/../layout/base.php';
|
||||||
|
?>
|
||||||
206
app/Views/2fa/verify.php
Normal file
206
app/Views/2fa/verify.php
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
<?php
|
||||||
|
$title = '2FA Verification';
|
||||||
|
ob_start();
|
||||||
|
|
||||||
|
$twoFactorService = new \App\Services\TwoFactorService();
|
||||||
|
$canSendEmailCode = $user['email_verified'] && $twoFactorService->checkRateLimit($_SERVER['REMOTE_ADDR'] ?? '', $user['id']);
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="text-center mb-6">
|
||||||
|
<div class="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-primary bg-opacity-10 mb-4">
|
||||||
|
<i class="fas fa-shield-alt text-primary text-xl"></i>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 mb-2">
|
||||||
|
2FA Verification
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
Hello, <strong><?= htmlspecialchars($user['full_name'] ?? $user['username']) ?></strong>!<br>
|
||||||
|
Please enter your 2FA code to complete login.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (isset($_SESSION['error'])): ?>
|
||||||
|
<div class="bg-red-50 border border-red-200 rounded-lg p-3 mb-4">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-exclamation-circle text-red-500 mr-2"></i>
|
||||||
|
<span class="text-sm text-red-700"><?= htmlspecialchars($_SESSION['error']) ?></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php unset($_SESSION['error']); ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (isset($_SESSION['success'])): ?>
|
||||||
|
<div class="bg-green-50 border border-green-200 rounded-lg p-3 mb-4">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-check-circle text-green-500 mr-2"></i>
|
||||||
|
<span class="text-sm text-green-700"><?= htmlspecialchars($_SESSION['success']) ?></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php unset($_SESSION['success']); ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<form class="space-y-4" method="POST" action="/2fa/verify" id="verifyForm">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
|
||||||
|
<!-- Security verification completed during login -->
|
||||||
|
<div class="bg-green-50 border border-green-200 rounded-lg p-3 mb-4">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-check-circle text-green-500 mr-2"></i>
|
||||||
|
<span class="text-sm text-green-700">Security verification completed during login</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="code" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
2FA Code
|
||||||
|
</label>
|
||||||
|
<input id="code" name="verification_code" type="text" required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-center text-lg font-mono tracking-widest focus:ring-2 focus:ring-primary focus:border-primary transition-colors"
|
||||||
|
placeholder="000000" maxlength="8" autocomplete="one-time-code" autofocus>
|
||||||
|
<p class="text-xs text-gray-500 mt-1 text-center">Enter 6-digit code from your authenticator app, email code, or 8-character backup code</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit"
|
||||||
|
class="w-full bg-primary text-white py-2 px-4 rounded-lg hover:bg-primary-dark focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-colors">
|
||||||
|
<i class="fas fa-check mr-2"></i>
|
||||||
|
Verify Code
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<?php if ($canSendEmailCode): ?>
|
||||||
|
<button type="button" onclick="sendEmailCode()"
|
||||||
|
class="text-primary hover:text-primary-dark transition-colors">
|
||||||
|
<i class="fas fa-envelope mr-1"></i>
|
||||||
|
Send Email Code
|
||||||
|
</button>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="text-gray-400">
|
||||||
|
<i class="fas fa-envelope mr-1"></i>
|
||||||
|
Email code unavailable
|
||||||
|
</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<a href="/logout" class="text-gray-600 hover:text-gray-500 transition-colors">
|
||||||
|
<i class="fas fa-sign-out-alt mr-1"></i>
|
||||||
|
Sign out instead
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 pt-4 border-t border-gray-200">
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
Having trouble? You can also use a backup code or contact your administrator for help.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function sendEmailCode() {
|
||||||
|
const btn = event.target;
|
||||||
|
const originalText = btn.innerHTML;
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i>Sending...';
|
||||||
|
|
||||||
|
fetch('/2fa/send-email-code', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'X-CSRF-Token': document.querySelector('input[name="csrf_token"]').value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
btn.innerHTML = '<i class="fas fa-check mr-1"></i>Code Sent';
|
||||||
|
btn.classList.remove('text-primary', 'hover:text-primary-dark');
|
||||||
|
btn.classList.add('text-green-600');
|
||||||
|
|
||||||
|
// Reset button after 30 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
btn.classList.remove('text-green-600');
|
||||||
|
btn.classList.add('text-primary', 'hover:text-primary-dark');
|
||||||
|
}, 30000);
|
||||||
|
} else {
|
||||||
|
alert('Failed to send email code: ' + (data.error || 'Unknown error'));
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
alert('Failed to send email code');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const codeInput = document.getElementById('code');
|
||||||
|
const form = document.getElementById('verifyForm');
|
||||||
|
|
||||||
|
// Auto-focus on code input
|
||||||
|
codeInput.focus();
|
||||||
|
|
||||||
|
// Handle input validation and auto-submit
|
||||||
|
codeInput.addEventListener('input', function(e) {
|
||||||
|
// Allow digits, letters for backup codes
|
||||||
|
this.value = this.value.replace(/[^A-Za-z0-9]/g, '');
|
||||||
|
|
||||||
|
// Auto-submit when 6 digits are entered (TOTP/email codes)
|
||||||
|
if (this.value.length === 6 && /^\d{6}$/.test(this.value)) {
|
||||||
|
setTimeout(() => {
|
||||||
|
form.submit();
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-submit when 8 characters are entered (backup codes)
|
||||||
|
if (this.value.length === 8 && /^[A-Z0-9]{8}$/i.test(this.value)) {
|
||||||
|
setTimeout(() => {
|
||||||
|
form.submit();
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Form validation
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
const code = codeInput.value.trim();
|
||||||
|
|
||||||
|
// Check if code is entered
|
||||||
|
if (!code) {
|
||||||
|
e.preventDefault();
|
||||||
|
alert('Please enter a verification code');
|
||||||
|
codeInput.focus();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate code format
|
||||||
|
if (code.length === 6 && !/^\d{6}$/.test(code)) {
|
||||||
|
e.preventDefault();
|
||||||
|
alert('Please enter a valid 6-digit code');
|
||||||
|
codeInput.focus();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code.length === 8 && !/^[A-Z0-9]{8}$/i.test(code)) {
|
||||||
|
e.preventDefault();
|
||||||
|
alert('Please enter a valid 8-character backup code');
|
||||||
|
codeInput.focus();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code.length < 6 || code.length > 8) {
|
||||||
|
e.preventDefault();
|
||||||
|
alert('Please enter a valid verification code (6 digits or 8 characters)');
|
||||||
|
codeInput.focus();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
$content = ob_get_clean();
|
||||||
|
require __DIR__ . '/../auth/base-auth.php';
|
||||||
|
?>
|
||||||
@@ -4,6 +4,11 @@ $pageTitle = 'My Profile';
|
|||||||
$pageDescription = 'Manage your account settings and preferences';
|
$pageDescription = 'Manage your account settings and preferences';
|
||||||
$pageIcon = 'fas fa-user-circle';
|
$pageIcon = 'fas fa-user-circle';
|
||||||
ob_start();
|
ob_start();
|
||||||
|
|
||||||
|
// Get 2FA status
|
||||||
|
$twoFactorStatus = $userModel->getTwoFactorStatus($user['id']);
|
||||||
|
$twoFactorService = new \App\Services\TwoFactorService();
|
||||||
|
$twoFactorPolicy = $twoFactorService->getTwoFactorPolicy();
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<!-- Main Profile Layout -->
|
<!-- Main Profile Layout -->
|
||||||
@@ -54,6 +59,10 @@ ob_start();
|
|||||||
<i class="fas fa-shield-alt w-5 mr-3 text-sm"></i>
|
<i class="fas fa-shield-alt w-5 mr-3 text-sm"></i>
|
||||||
<span>Security</span>
|
<span>Security</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button onclick="showSection('twofactor')" id="nav-twofactor" class="nav-item w-full flex items-center px-4 py-2.5 text-sm font-medium rounded-lg transition-colors mb-1">
|
||||||
|
<i class="fas fa-key w-5 mr-3 text-sm"></i>
|
||||||
|
<span>Two-Factor Auth</span>
|
||||||
|
</button>
|
||||||
<button onclick="showSection('sessions')" id="nav-sessions" class="nav-item w-full flex items-center px-4 py-2.5 text-sm font-medium rounded-lg transition-colors mb-1">
|
<button onclick="showSection('sessions')" id="nav-sessions" class="nav-item w-full flex items-center px-4 py-2.5 text-sm font-medium rounded-lg transition-colors mb-1">
|
||||||
<i class="fas fa-laptop w-5 mr-3 text-sm"></i>
|
<i class="fas fa-laptop w-5 mr-3 text-sm"></i>
|
||||||
<span>Active Sessions</span>
|
<span>Active Sessions</span>
|
||||||
@@ -172,6 +181,148 @@ ob_start();
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Two-Factor Authentication Section -->
|
||||||
|
<div id="section-twofactor" class="content-section hidden">
|
||||||
|
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900">Two-Factor Authentication</h3>
|
||||||
|
<p class="text-sm text-gray-600 mt-1">Add an extra layer of security to your account</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6">
|
||||||
|
<?php if ($twoFactorPolicy === 'disabled'): ?>
|
||||||
|
<!-- 2FA Disabled by Admin -->
|
||||||
|
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-ban text-gray-400 text-xl mr-3"></i>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900">Two-Factor Authentication Disabled</p>
|
||||||
|
<p class="text-sm text-gray-600 mt-1">2FA has been disabled by the administrator.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php elseif (!$user['email_verified']): ?>
|
||||||
|
<!-- Email Not Verified -->
|
||||||
|
<div class="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-exclamation-triangle text-amber-600 text-xl mr-3"></i>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-amber-900">Email Verification Required</p>
|
||||||
|
<p class="text-sm text-amber-700 mt-1">You must verify your email address before enabling 2FA.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php elseif ($twoFactorStatus['enabled']): ?>
|
||||||
|
<!-- 2FA Enabled -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-shield-alt text-green-600 text-xl mr-3"></i>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-green-900">Two-Factor Authentication Enabled</p>
|
||||||
|
<p class="text-sm text-green-700 mt-1">
|
||||||
|
Your account is protected with 2FA since
|
||||||
|
<?= date('M j, Y', strtotime($twoFactorStatus['setup_at'])) ?>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="bg-gray-50 rounded-lg p-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900">Backup Codes</p>
|
||||||
|
<p class="text-sm text-gray-600"><?= $twoFactorStatus['backup_codes_count'] ?> remaining</p>
|
||||||
|
</div>
|
||||||
|
<i class="fas fa-key text-gray-400"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 rounded-lg p-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900">Authenticator App</p>
|
||||||
|
<p class="text-sm text-gray-600">Active</p>
|
||||||
|
</div>
|
||||||
|
<i class="fas fa-mobile-alt text-gray-400"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col sm:flex-row gap-3">
|
||||||
|
<?php if ($twoFactorStatus['backup_codes_count'] < 3): ?>
|
||||||
|
<form method="POST" action="/2fa/regenerate-backup-codes" onsubmit="return confirm('Generate new backup codes? Your current codes will stop working.')">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<button type="submit" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors font-medium">
|
||||||
|
<i class="fas fa-refresh mr-2"></i>
|
||||||
|
Generate New Backup Codes
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($twoFactorPolicy !== 'forced'): ?>
|
||||||
|
<button type="button" onclick="showDisable2FAModal()" class="inline-flex items-center px-4 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors font-medium">
|
||||||
|
<i class="fas fa-ban mr-2"></i>
|
||||||
|
Disable 2FA
|
||||||
|
</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php elseif ($twoFactorStatus['required']): ?>
|
||||||
|
<!-- 2FA Required -->
|
||||||
|
<div class="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-exclamation-circle text-red-600 text-xl mr-3"></i>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-red-900">Two-Factor Authentication Required</p>
|
||||||
|
<p class="text-sm text-red-700 mt-1">You must enable 2FA to continue using your account.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<a href="/2fa/setup" class="inline-flex items-center px-6 py-3 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
|
||||||
|
<i class="fas fa-shield-alt mr-2"></i>
|
||||||
|
Enable Two-Factor Authentication
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<!-- 2FA Optional -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-info-circle text-blue-600 text-xl mr-3"></i>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-blue-900">Enhanced Security Available</p>
|
||||||
|
<p class="text-sm text-blue-700 mt-1">
|
||||||
|
Enable two-factor authentication to add an extra layer of security to your account.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<a href="/2fa/setup" class="inline-flex items-center px-6 py-3 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
|
||||||
|
<i class="fas fa-shield-alt mr-2"></i>
|
||||||
|
Enable Two-Factor Authentication
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||||
|
<h4 class="text-sm font-medium text-gray-900 mb-2">How 2FA Works</h4>
|
||||||
|
<ul class="text-sm text-gray-700 space-y-1">
|
||||||
|
<li>• Generate time-based codes using an authenticator app</li>
|
||||||
|
<li>• Use backup codes if you lose access to your device</li>
|
||||||
|
<li>• Receive email codes as an alternative method</li>
|
||||||
|
<li>• Enhanced protection against unauthorized access</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Security Section -->
|
<!-- Security Section -->
|
||||||
<div id="section-security" class="content-section hidden">
|
<div id="section-security" class="content-section hidden">
|
||||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||||
@@ -460,7 +611,7 @@ function showSection(section) {
|
|||||||
// On page load, check URL hash and show that section
|
// On page load, check URL hash and show that section
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
const hash = window.location.hash.substring(1); // Remove #
|
const hash = window.location.hash.substring(1); // Remove #
|
||||||
const validSections = ['profile', 'security', 'sessions'<?php if ($user['role'] !== 'admin'): ?>, 'danger'<?php endif; ?>];
|
const validSections = ['profile', 'security', 'twofactor', 'sessions'<?php if ($user['role'] !== 'admin'): ?>, 'danger'<?php endif; ?>];
|
||||||
|
|
||||||
if (hash && validSections.includes(hash)) {
|
if (hash && validSections.includes(hash)) {
|
||||||
showSection(hash);
|
showSection(hash);
|
||||||
@@ -477,8 +628,63 @@ function confirmDelete() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showDisable2FAModal() {
|
||||||
|
document.getElementById('disable2FAModal').classList.remove('hidden');
|
||||||
|
document.getElementById('disable2FACode').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideDisable2FAModal() {
|
||||||
|
document.getElementById('disable2FAModal').classList.add('hidden');
|
||||||
|
document.getElementById('disable2FAForm').reset();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- Disable 2FA Modal -->
|
||||||
|
<div id="disable2FAModal" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||||
|
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
||||||
|
<div class="mt-3">
|
||||||
|
<div class="flex items-center justify-center w-12 h-12 mx-auto bg-red-100 rounded-full mb-4">
|
||||||
|
<i class="fas fa-ban text-red-600 text-xl"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 text-center mb-2">Disable Two-Factor Authentication</h3>
|
||||||
|
<p class="text-sm text-gray-500 text-center mb-6">
|
||||||
|
This will make your account less secure. Enter your 2FA code to confirm.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form id="disable2FAForm" method="POST" action="/2fa/disable" class="space-y-4">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<div>
|
||||||
|
<label for="disable2FACode" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||||
|
Verification Code
|
||||||
|
</label>
|
||||||
|
<input type="text"
|
||||||
|
id="disable2FACode"
|
||||||
|
name="verification_code"
|
||||||
|
maxlength="8"
|
||||||
|
placeholder="Enter 2FA code"
|
||||||
|
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500 transition-colors text-sm"
|
||||||
|
required>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Enter your authenticator code, email code, or backup code</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex space-x-3 pt-4">
|
||||||
|
<button type="submit"
|
||||||
|
class="flex-1 bg-red-600 hover:bg-red-700 text-white py-2.5 rounded-lg font-medium transition-colors text-sm">
|
||||||
|
Disable 2FA
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
onclick="hideDisable2FAModal()"
|
||||||
|
class="flex-1 bg-gray-300 hover:bg-gray-400 text-gray-700 py-2.5 rounded-lg font-medium transition-colors text-sm">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
$content = ob_get_clean();
|
$content = ob_get_clean();
|
||||||
require __DIR__ . '/../layout/base.php';
|
require __DIR__ . '/../layout/base.php';
|
||||||
|
|||||||
@@ -431,6 +431,7 @@ foreach ($notificationPresets as $key => $preset) {
|
|||||||
<p class="text-sm text-gray-600 mt-1">Configure CAPTCHA protection for authentication forms</p>
|
<p class="text-sm text-gray-600 mt-1">Configure CAPTCHA protection for authentication forms</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- CAPTCHA Settings -->
|
||||||
<form method="POST" action="/settings/update-captcha" class="p-6">
|
<form method="POST" action="/settings/update-captcha" class="p-6">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@@ -537,6 +538,86 @@ foreach ($notificationPresets as $key => $preset) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Two-Factor Authentication Settings -->
|
||||||
|
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden mt-6">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900">Two-Factor Authentication</h3>
|
||||||
|
<p class="text-sm text-gray-600 mt-1">Configure 2FA policy and security settings</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="/settings/update-two-factor" class="p-6">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="two_factor_policy" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
2FA Policy
|
||||||
|
</label>
|
||||||
|
<select id="two_factor_policy" name="two_factor_policy"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary">
|
||||||
|
<option value="disabled" <?= ($twoFactorSettings['policy'] ?? 'optional') === 'disabled' ? 'selected' : '' ?>>
|
||||||
|
Disabled - No 2FA features available
|
||||||
|
</option>
|
||||||
|
<option value="optional" <?= ($twoFactorSettings['policy'] ?? 'optional') === 'optional' ? 'selected' : '' ?>>
|
||||||
|
Optional - Users can choose to enable 2FA
|
||||||
|
</option>
|
||||||
|
<option value="forced" <?= ($twoFactorSettings['policy'] ?? 'optional') === 'forced' ? 'selected' : '' ?>>
|
||||||
|
Forced - All users must enable 2FA (email verification required)
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
|
<i class="fas fa-info-circle text-blue-500 mr-1"></i>
|
||||||
|
Users must have verified email addresses to enable 2FA
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="two_factor_rate_limit_minutes" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Rate Limit (minutes)
|
||||||
|
</label>
|
||||||
|
<input type="number" id="two_factor_rate_limit_minutes" name="two_factor_rate_limit_minutes"
|
||||||
|
value="<?= htmlspecialchars($twoFactorSettings['rate_limit_minutes'] ?? 15) ?>"
|
||||||
|
min="1" max="60"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Maximum failed attempts per IP address</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="two_factor_email_code_expiry_minutes" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Email Code Expiry (minutes)
|
||||||
|
</label>
|
||||||
|
<input type="number" id="two_factor_email_code_expiry_minutes" name="two_factor_email_code_expiry_minutes"
|
||||||
|
value="<?= htmlspecialchars($twoFactorSettings['email_code_expiry_minutes'] ?? 10) ?>"
|
||||||
|
min="1" max="30"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">How long email backup codes remain valid</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2FA Info Box -->
|
||||||
|
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
|
<p class="text-sm font-medium text-gray-900 mb-2">
|
||||||
|
<i class="fas fa-info-circle text-blue-500 mr-1"></i>
|
||||||
|
Two-Factor Authentication Features
|
||||||
|
</p>
|
||||||
|
<ul class="text-sm text-gray-700 space-y-1">
|
||||||
|
<li>• <strong>TOTP Authenticator Apps:</strong> Google Authenticator, Authy, Microsoft Authenticator</li>
|
||||||
|
<li>• <strong>Email Backup Codes:</strong> One-time codes sent to verified email addresses</li>
|
||||||
|
<li>• <strong>Backup Recovery Codes:</strong> 8 single-use codes generated during setup</li>
|
||||||
|
<li>• <strong>Rate Limiting:</strong> Prevents brute force attacks on verification codes</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between pt-6 mt-6 border-t border-gray-200">
|
||||||
|
<button type="submit" class="inline-flex items-center px-4 py-2.5 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 transition-colors font-medium">
|
||||||
|
<i class="fas fa-shield-alt mr-2"></i>
|
||||||
|
Save 2FA Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Tab Content: System Information -->
|
<!-- Tab Content: System Information -->
|
||||||
<div id="content-system" class="tab-content hidden">
|
<div id="content-system" class="tab-content hidden">
|
||||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||||
|
|||||||
@@ -24,7 +24,9 @@
|
|||||||
"ext-pdo": "*",
|
"ext-pdo": "*",
|
||||||
"vlucas/phpdotenv": "^5.5",
|
"vlucas/phpdotenv": "^5.5",
|
||||||
"phpmailer/phpmailer": "^6.8",
|
"phpmailer/phpmailer": "^6.8",
|
||||||
"guzzlehttp/guzzle": "^7.8"
|
"guzzlehttp/guzzle": "^7.8",
|
||||||
|
"pragmarx/google2fa": "^8.0",
|
||||||
|
"endroid/qr-code": "^4.8"
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class Auth
|
|||||||
*/
|
*/
|
||||||
public static function check(): bool
|
public static function check(): bool
|
||||||
{
|
{
|
||||||
return isset($_SESSION['user_id']);
|
return isset($_SESSION['user_id']) && !isset($_SESSION['2fa_required']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,7 +53,9 @@ class Auth
|
|||||||
'/reset-password',
|
'/reset-password',
|
||||||
'/verify-email',
|
'/verify-email',
|
||||||
'/resend-verification',
|
'/resend-verification',
|
||||||
'/install'
|
'/install',
|
||||||
|
'/2fa/verify',
|
||||||
|
'/2fa/send-email-code'
|
||||||
];
|
];
|
||||||
|
|
||||||
// Don't redirect if on a public path
|
// Don't redirect if on a public path
|
||||||
@@ -64,8 +66,13 @@ class Auth
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!self::check()) {
|
if (!self::check()) {
|
||||||
|
if (isset($_SESSION['user_id']) && self::requiresTwoFactor()) {
|
||||||
|
$_SESSION['error'] = 'Please complete two-factor authentication';
|
||||||
|
header('Location: /2fa/verify');
|
||||||
|
} else {
|
||||||
$_SESSION['error'] = 'Please login to continue';
|
$_SESSION['error'] = 'Please login to continue';
|
||||||
header('Location: /login');
|
header('Location: /login');
|
||||||
|
}
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,5 +108,13 @@ class Auth
|
|||||||
{
|
{
|
||||||
return $_SESSION['role'] ?? null;
|
return $_SESSION['role'] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if 2FA verification is required
|
||||||
|
*/
|
||||||
|
public static function requiresTwoFactor(): bool
|
||||||
|
{
|
||||||
|
return isset($_SESSION['2fa_required']) && $_SESSION['2fa_required'];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
44
database/migrations/017_add_two_factor_authentication.sql
Normal file
44
database/migrations/017_add_two_factor_authentication.sql
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
-- Add Two-Factor Authentication (2FA) support
|
||||||
|
-- TOTP (Time-based One-Time Password) implementation
|
||||||
|
|
||||||
|
-- Add 2FA fields to users table
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN two_factor_enabled BOOLEAN DEFAULT FALSE AFTER email_verified,
|
||||||
|
ADD COLUMN two_factor_secret VARCHAR(32) NULL AFTER two_factor_enabled,
|
||||||
|
ADD COLUMN two_factor_backup_codes TEXT NULL AFTER two_factor_secret,
|
||||||
|
ADD COLUMN two_factor_setup_at TIMESTAMP NULL AFTER two_factor_backup_codes;
|
||||||
|
|
||||||
|
-- Create table for 2FA verification attempts (for rate limiting and security)
|
||||||
|
CREATE TABLE IF NOT EXISTS two_factor_verification_attempts (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
ip_address VARCHAR(45) NOT NULL,
|
||||||
|
success BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_user_id (user_id),
|
||||||
|
INDEX idx_ip_address (ip_address),
|
||||||
|
INDEX idx_created_at (created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Create table for 2FA email codes (backup method)
|
||||||
|
CREATE TABLE IF NOT EXISTS two_factor_email_codes (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
code VARCHAR(6) NOT NULL,
|
||||||
|
expires_at TIMESTAMP NOT NULL,
|
||||||
|
used BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_user_id (user_id),
|
||||||
|
INDEX idx_code (code),
|
||||||
|
INDEX idx_expires_at (expires_at),
|
||||||
|
INDEX idx_used (used)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Add 2FA policy setting
|
||||||
|
INSERT INTO settings (setting_key, setting_value) VALUES
|
||||||
|
('two_factor_policy', 'optional'),
|
||||||
|
('two_factor_rate_limit_minutes', '15'),
|
||||||
|
('two_factor_email_code_expiry_minutes', '10')
|
||||||
|
ON DUPLICATE KEY UPDATE setting_key=setting_key;
|
||||||
@@ -15,6 +15,7 @@ use App\Controllers\UserController;
|
|||||||
use App\Controllers\InstallerController;
|
use App\Controllers\InstallerController;
|
||||||
use App\Controllers\NotificationController;
|
use App\Controllers\NotificationController;
|
||||||
use App\Controllers\ErrorLogController;
|
use App\Controllers\ErrorLogController;
|
||||||
|
use App\Controllers\TwoFactorController;
|
||||||
|
|
||||||
$router = Application::$router;
|
$router = Application::$router;
|
||||||
|
|
||||||
@@ -39,6 +40,11 @@ $router->post('/forgot-password', [AuthController::class, 'forgotPassword']);
|
|||||||
$router->get('/reset-password', [AuthController::class, 'showResetPassword']);
|
$router->get('/reset-password', [AuthController::class, 'showResetPassword']);
|
||||||
$router->post('/reset-password', [AuthController::class, 'resetPassword']);
|
$router->post('/reset-password', [AuthController::class, 'resetPassword']);
|
||||||
|
|
||||||
|
// Two-Factor Authentication routes (public during verification)
|
||||||
|
$router->get('/2fa/verify', [TwoFactorController::class, 'showVerify']);
|
||||||
|
$router->post('/2fa/verify', [TwoFactorController::class, 'verify']);
|
||||||
|
$router->post('/2fa/send-email-code', [TwoFactorController::class, 'sendEmailCode']);
|
||||||
|
|
||||||
// Debug route (public - remove in production!)
|
// Debug route (public - remove in production!)
|
||||||
$router->get('/debug/whois', [DebugController::class, 'whois']);
|
$router->get('/debug/whois', [DebugController::class, 'whois']);
|
||||||
|
|
||||||
@@ -109,6 +115,7 @@ $router->post('/settings/update', [SettingsController::class, 'update']);
|
|||||||
$router->post('/settings/update-app', [SettingsController::class, 'updateApp']);
|
$router->post('/settings/update-app', [SettingsController::class, 'updateApp']);
|
||||||
$router->post('/settings/update-email', [SettingsController::class, 'updateEmail']);
|
$router->post('/settings/update-email', [SettingsController::class, 'updateEmail']);
|
||||||
$router->post('/settings/update-captcha', [SettingsController::class, 'updateCaptcha']);
|
$router->post('/settings/update-captcha', [SettingsController::class, 'updateCaptcha']);
|
||||||
|
$router->post('/settings/update-two-factor', [SettingsController::class, 'updateTwoFactor']);
|
||||||
$router->post('/settings/test-email', [SettingsController::class, 'testEmail']);
|
$router->post('/settings/test-email', [SettingsController::class, 'testEmail']);
|
||||||
$router->post('/settings/test-cron', [SettingsController::class, 'testCron']);
|
$router->post('/settings/test-cron', [SettingsController::class, 'testCron']);
|
||||||
$router->post('/settings/clear-logs', [SettingsController::class, 'clearLogs']);
|
$router->post('/settings/clear-logs', [SettingsController::class, 'clearLogs']);
|
||||||
@@ -122,6 +129,14 @@ $router->get('/profile/resend-verification', [ProfileController::class, 'resendV
|
|||||||
$router->post('/profile/logout-other-sessions', [ProfileController::class, 'logoutOtherSessions']);
|
$router->post('/profile/logout-other-sessions', [ProfileController::class, 'logoutOtherSessions']);
|
||||||
$router->post('/profile/logout-session/{sessionId}', [ProfileController::class, 'logoutSession']);
|
$router->post('/profile/logout-session/{sessionId}', [ProfileController::class, 'logoutSession']);
|
||||||
|
|
||||||
|
// Two-Factor Authentication management (protected)
|
||||||
|
$router->get('/2fa/setup', [TwoFactorController::class, 'setup']);
|
||||||
|
$router->post('/2fa/verify-setup', [TwoFactorController::class, 'verifySetup']);
|
||||||
|
$router->get('/2fa/cancel-setup', [TwoFactorController::class, 'cancelSetup']);
|
||||||
|
$router->get('/2fa/backup-codes', [TwoFactorController::class, 'backupCodes']);
|
||||||
|
$router->post('/2fa/disable', [TwoFactorController::class, 'disable']);
|
||||||
|
$router->post('/2fa/regenerate-backup-codes', [TwoFactorController::class, 'regenerateBackupCodes']);
|
||||||
|
|
||||||
// Notifications
|
// Notifications
|
||||||
$router->get('/notifications', [NotificationController::class, 'index']);
|
$router->get('/notifications', [NotificationController::class, 'index']);
|
||||||
$router->get('/notifications/{id}/mark-read', [NotificationController::class, 'markAsRead']);
|
$router->get('/notifications/{id}/mark-read', [NotificationController::class, 'markAsRead']);
|
||||||
|
|||||||
Reference in New Issue
Block a user