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:
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';
|
||||
?>
|
||||
Reference in New Issue
Block a user