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:
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';
|
||||
$pageIcon = 'fas fa-user-circle';
|
||||
ob_start();
|
||||
|
||||
// Get 2FA status
|
||||
$twoFactorStatus = $userModel->getTwoFactorStatus($user['id']);
|
||||
$twoFactorService = new \App\Services\TwoFactorService();
|
||||
$twoFactorPolicy = $twoFactorService->getTwoFactorPolicy();
|
||||
?>
|
||||
|
||||
<!-- Main Profile Layout -->
|
||||
@@ -54,6 +59,10 @@ ob_start();
|
||||
<i class="fas fa-shield-alt w-5 mr-3 text-sm"></i>
|
||||
<span>Security</span>
|
||||
</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">
|
||||
<i class="fas fa-laptop w-5 mr-3 text-sm"></i>
|
||||
<span>Active Sessions</span>
|
||||
@@ -172,6 +181,148 @@ ob_start();
|
||||
</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 -->
|
||||
<div id="section-security" class="content-section 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
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
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)) {
|
||||
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>
|
||||
|
||||
<!-- 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
|
||||
$content = ob_get_clean();
|
||||
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>
|
||||
</div>
|
||||
|
||||
<!-- CAPTCHA Settings -->
|
||||
<form method="POST" action="/settings/update-captcha" class="p-6">
|
||||
<?= csrf_field() ?>
|
||||
<div class="space-y-4">
|
||||
@@ -537,6 +538,86 @@ foreach ($notificationPresets as $key => $preset) {
|
||||
</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 -->
|
||||
<div id="content-system" class="tab-content hidden">
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
|
||||
Reference in New Issue
Block a user