490 lines
15 KiB
PHP
490 lines
15 KiB
PHP
|
|
<?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;
|
||
|
|
}
|
||
|
|
}
|