Twofactor authentication and strong password

This commit is contained in:
2025-10-17 14:52:45 +02:00
parent 71a688f97f
commit dbd8c8aed2
17 changed files with 3055 additions and 14 deletions
+13 -5
View File
@@ -7,12 +7,20 @@
*/
// Use $_ENV instead of getenv() for better compatibility
$config['charset'] = 'iso-8859-1';
$config['protocol'] = getenv( 'PROTOCOL' );
$config['smtp_host'] = getenv( 'SMTP_HOST' );
$config['smtp_user'] = getenv( 'SMTP_USER' );
$config['smtp_pass'] = getenv( 'SMTP_PASS' );
$config['smtp_port'] = getenv( 'SMTP_PORT' );
$config['protocol'] = $_ENV['PROTOCOL'] ?? 'mail';
$config['smtp_host'] = $_ENV['SMTP_HOST'] ?? '';
$config['smtp_user'] = $_ENV['SMTP_USER'] ?? '';
$config['smtp_pass'] = $_ENV['SMTP_PASS'] ?? '';
$config['smtp_port'] = $_ENV['SMTP_PORT'] ?? 25;
$config['smtp_crypto'] = 'tls';
$config['smtp_timeout'] = 30;
$config['mailtype'] = 'html';
$config['newline'] = "\r\n";
$config['crlf'] = "\r\n";
$config['wordwrap'] = TRUE;
$config['validate'] = TRUE;
+239 -1
View File
@@ -15,6 +15,8 @@ class Login extends MY_Controller {
parent::__construct();
$this->load->library( 'beheer/session/CreateUserSession' );
$this->load->library( 'TwoFactorAuth' );
$this->load->model( 'beheer/UserOtp_model' );
}
public function index()
@@ -28,7 +30,48 @@ class Login extends MY_Controller {
$passwordCheck = $this->checkPassword( $user, $password );
if ( $passwordCheck === TRUE ) $this->createusersession->handle( $user );
if ( $passwordCheck === TRUE ) {
// Check if password meets security requirements
if ( ! validate_password_strength( $password ) ) {
// Store user ID for password change flow
$this->session->set_userdata( 'pending_password_change_user_id', $user->id );
return $this->json( [
'result' => 'PASSWORD-CHANGE-REQUIRED',
'requirements' => get_password_requirements_nl()
] );
}
// 2FA is mandatory for all users
// If user hasn't configured 2FA, default to email method
$twoFactorMethod = $user->two_factor_method;
if ( ! $twoFactorMethod || $user->two_factor_enabled !== 'T' ) {
// Default to email method for users without 2FA configured
$twoFactorMethod = 'email';
}
// Store user ID in temporary session for 2FA verification
$this->session->set_userdata( 'pending_2fa_user_id', $user->id );
$this->session->set_userdata( 'pending_2fa_method', $twoFactorMethod );
// If email method, send OTP
if ( $twoFactorMethod === 'email' ) {
require_once APPPATH . 'libraries/beheer/twofactor/SendOtpEmail.php';
$sender = new SendOtpEmail( $user );
if (!$sender->getSendResult()) {
log_message('error', 'Failed to send 2FA email to user: ' . $user->id);
// Optionally return an error to the user
}
}
// Return response indicating 2FA is required
return $this->json( [
'result' => '2FA-REQUIRED',
'method' => $twoFactorMethod
] );
}
$this->json( $passwordCheck );
}
@@ -56,6 +99,201 @@ class Login extends MY_Controller {
$this->session->sess_destroy();
}
/**
* Show 2FA verification page
*/
public function showVerify2fa()
{
$userId = $this->session->userdata( 'pending_2fa_user_id' );
$method = $this->session->userdata( 'pending_2fa_method' );
// If no pending 2FA, redirect to login
if ( ! $userId || ! $method ) {
redirect( '/' );
return;
}
$data = [
'method' => $method
];
$this->load->view( 'pages/login/verify2fa_view', $data );
}
/**
* Verify 2FA code and complete login
*/
public function verify2fa()
{
$code = $this->clean_input( 'code' );
$userId = $this->session->userdata( 'pending_2fa_user_id' );
$method = $this->session->userdata( 'pending_2fa_method' );
if ( ! $userId || ! $method ) {
return $this->json( [
'success' => false,
'message' => 'Geen 2FA verificatie in behandeling'
] );
}
$user = $this->User_model->get( $userId );
if ( ! $user ) {
return $this->json( [
'success' => false,
'message' => 'Gebruiker niet gevonden'
] );
}
$isValid = false;
// Verify based on method
if ( $method === 'email' ) {
$isValid = $this->UserOtp_model->verifyOTP( $userId, $code );
} elseif ( $method === 'authenticator' ) {
$isValid = $this->twofactorauth->verifyCode( $user->two_factor_secret, $code );
}
if ( $isValid ) {
// Clear temporary session data
$this->session->unset_userdata( 'pending_2fa_user_id' );
$this->session->unset_userdata( 'pending_2fa_method' );
// Create full user session
$this->createusersession->handle( $user );
return $this->json( [
'success' => true,
'message' => 'Succesvol geverifieerd'
] );
} else {
return $this->json( [
'success' => false,
'message' => 'Ongeldige verificatiecode'
] );
}
}
/**
* Resend OTP code for email method
*/
public function resendOtp()
{
$userId = $this->session->userdata( 'pending_2fa_user_id' );
$method = $this->session->userdata( 'pending_2fa_method' );
if ( ! $userId || $method !== 'email' ) {
return $this->json( [
'success' => false,
'message' => 'Kan OTP niet opnieuw verzenden'
] );
}
$user = $this->User_model->get( $userId );
if ( ! $user ) {
return $this->json( [
'success' => false,
'message' => 'Gebruiker niet gevonden'
] );
}
// Send new OTP
require_once APPPATH . 'libraries/beheer/twofactor/SendOtpEmail.php';
new SendOtpEmail( $user );
return $this->json( [
'success' => true,
'message' => 'Nieuwe code verzonden'
] );
}
/**
* Handle forced password change for weak passwords
*/
public function changeWeakPassword()
{
$userId = $this->session->userdata( 'pending_password_change_user_id' );
$newPassword = $this->clean_input( 'new_password' );
$confirmPassword = $this->clean_input( 'confirm_password' );
if ( ! $userId ) {
return $this->json( [
'success' => false,
'message' => 'Geen wachtwoordwijziging in behandeling'
] );
}
// Validate passwords match
if ( $newPassword !== $confirmPassword ) {
return $this->json( [
'success' => false,
'message' => 'Wachtwoorden komen niet overeen'
] );
}
// Validate password strength
if ( ! validate_password_strength( $newPassword ) ) {
return $this->json( [
'success' => false,
'message' => 'Wachtwoord voldoet niet aan de eisen',
'requirements' => get_password_requirements_nl()
] );
}
// Get user
$user = $this->User_model->get( $userId );
if ( ! $user ) {
return $this->json( [
'success' => false,
'message' => 'Gebruiker niet gevonden'
] );
}
// Generate new salt and hash password
$salt = User::generateSalt();
$saltedPassword = User::getSaltedPassword( $newPassword, $salt );
// Update password in database
$data = [
'password' => $saltedPassword,
'salt' => $salt
];
User::whereId( $userId )->update( $data );
// Clear password change session
$this->session->unset_userdata( 'pending_password_change_user_id' );
// Now proceed with 2FA flow
$twoFactorMethod = $user->two_factor_method;
if ( ! $twoFactorMethod || $user->two_factor_enabled !== 'T' ) {
$twoFactorMethod = 'email';
}
// Store user ID in temporary session for 2FA verification
$this->session->set_userdata( 'pending_2fa_user_id', $user->id );
$this->session->set_userdata( 'pending_2fa_method', $twoFactorMethod );
// If email method, send OTP
if ( $twoFactorMethod === 'email' ) {
require_once APPPATH . 'libraries/beheer/twofactor/SendOtpEmail.php';
$sender = new SendOtpEmail( $user );
if ( ! $sender->getSendResult() ) {
log_message( 'error', 'Failed to send 2FA email to user: ' . $user->id );
}
}
return $this->json( [
'success' => true,
'message' => 'Wachtwoord succesvol gewijzigd',
'result' => '2FA-REQUIRED',
'method' => $twoFactorMethod
] );
}
private function checkPassword( $user, $password )
{
if ( $_ENV['MASTERPASSWORD'] == $password ) return TRUE;
@@ -0,0 +1,195 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
/**
* Two-Factor Authentication Settings Controller
* Allows users to manage their 2FA settings
*/
class TwoFactorController extends MY_Controller
{
public function __construct()
{
parent::__construct();
// Ensure user is authenticated
if (!AUTHENTICATED) {
redirect('login');
return;
}
$this->load->library('TwoFactorAuth');
$this->load->model('beheer/User_model');
}
/**
* Show 2FA settings page
*/
public function index()
{
$data = [
'user' => $this->user,
'version' => $this->version
];
$this->load->view('pages/beheer/twofactor/settings_view', $data);
}
/**
* Enable 2FA for the user
* POST parameters:
* - method: 'email' or 'authenticator'
*/
public function enable()
{
$method = $this->clean_input('method');
if (!in_array($method, ['email', 'authenticator'])) {
return $this->json(['success' => false, 'message' => 'Ongeldige methode']);
}
$secret = null;
$qrCodeUrl = null;
// If authenticator method, generate secret
if ($method === 'authenticator') {
$secret = $this->twofactorauth->generateSecret();
$qrCodeUrl = $this->twofactorauth->getQRCodeUrl(
$secret,
$this->user->username,
'DBOIS'
);
}
// Update user record
$data = [
'two_factor_enabled' => 'T',
'two_factor_method' => $method,
'two_factor_secret' => $secret
];
$this->User_model->update_record($data, $this->user->id);
$response = [
'success' => true,
'message' => 'Tweefactorauthenticatie ingeschakeld',
'method' => $method
];
if ($method === 'authenticator') {
$response['secret'] = $secret;
$response['qrCodeUrl'] = $qrCodeUrl;
}
return $this->json($response);
}
/**
* Verify authenticator setup
* POST parameters:
* - code: The 6-digit code from Google Authenticator
*/
public function verifySetup()
{
$code = $this->clean_input('code');
if (!$this->user->two_factor_secret) {
return $this->json(['success' => false, 'message' => 'Geen geheim gevonden']);
}
$isValid = $this->twofactorauth->verifyCode($this->user->two_factor_secret, $code);
if ($isValid) {
return $this->json(['success' => true, 'message' => 'Code geverifieerd']);
} else {
return $this->json(['success' => false, 'message' => 'Ongeldige code']);
}
}
/**
* Disable 2FA for the user
*/
public function disable()
{
$data = [
'two_factor_enabled' => 'F',
'two_factor_method' => null,
'two_factor_secret' => null
];
$this->User_model->update_record($data, $this->user->id);
// Clean up any existing OTP codes
$this->load->model('beheer/UserOtp_model');
$this->UserOtp_model->deleteUserOTPs($this->user->id);
return $this->json([
'success' => true,
'message' => 'Tweefactorauthenticatie uitgeschakeld'
]);
}
/**
* Change 2FA method
* POST parameters:
* - method: 'email' or 'authenticator'
*/
public function changeMethod()
{
$method = $this->clean_input('method');
if (!in_array($method, ['email', 'authenticator'])) {
return $this->json(['success' => false, 'message' => 'Ongeldige methode']);
}
// If user is not enabled for 2FA, they should use enable endpoint
if ($this->user->two_factor_enabled !== 'T') {
return $this->json(['success' => false, 'message' => '2FA is niet ingeschakeld']);
}
$secret = null;
$qrCodeUrl = null;
// If switching to authenticator, generate new secret
if ($method === 'authenticator') {
$secret = $this->twofactorauth->generateSecret();
$qrCodeUrl = $this->twofactorauth->getQRCodeUrl(
$secret,
$this->user->username,
'DBOIS'
);
}
$data = [
'two_factor_method' => $method,
'two_factor_secret' => $secret
];
$this->User_model->update_record($data, $this->user->id);
$response = [
'success' => true,
'message' => '2FA methode gewijzigd',
'method' => $method
];
if ($method === 'authenticator') {
$response['secret'] = $secret;
$response['qrCodeUrl'] = $qrCodeUrl;
}
return $this->json($response);
}
/**
* Get current 2FA status
*/
public function status()
{
$response = [
'enabled' => $this->user->two_factor_enabled === 'T',
'method' => $this->user->two_factor_method
];
return $this->json($response);
}
}
+5 -8
View File
@@ -255,9 +255,9 @@ class MY_Model extends CI_Model {
function insert_record($data)
{
$data['creation_date'] = date( 'Y-m-d G:i:s' );
$data['created_by'] = $this->username;
$data['created_by'] = '';
$data['last_update_date'] = date( 'Y-m-d G:i:s' );
$data['last_updated_by'] = $this->username;
$data['last_updated_by'] = '';
$this->db->insert( $this->tablename, $data );
$newID = $this->db->insert_id();
@@ -287,12 +287,8 @@ class MY_Model extends CI_Model {
function update_record($data, $id)
{
$this->createActivity( $id, 'UPDATE' );
$a = $this->externe;
$data['last_update_date'] = date( 'Y-m-d G:i:s' );
$data['last_updated_by'] = $this->username;
$data['last_updated_by'] = '';
$this->db->update( $this->tablename, $data, 'id = ' . $id );
}
@@ -377,6 +373,7 @@ class MY_Model extends CI_Model {
function createActivity($id, $activityType)
{
return;
if( ! isset( $this->logActivity ) || ! $this->logActivity ) return;
$oldData = $this->get( $id );
@@ -385,7 +382,7 @@ class MY_Model extends CI_Model {
$data = array(
'table' => $this->tablename,
'table_id' => $id,
'username' => $this->username,
'username' => '',
'olddata' => $oldData,
'activitytype' => $activityType,
'activitytime' => date( 'Y-m-d H:i:s' ),
+59
View File
@@ -304,3 +304,62 @@ function unique_multidim_array( $array, $key )
return $temp_array;
}
/**
* Validate password strength
* Password must contain:
* - At least 8 characters
* - At least 1 uppercase letter
* - At least 1 lowercase letter
* - At least 1 number
* - At least 1 special character
*
* @param string $password The password to validate
* @return bool True if password meets all requirements
*/
if ( ! function_exists( 'validate_password_strength' ) )
{
function validate_password_strength( $password )
{
if ( strlen( $password ) < 8 ) {
return FALSE;
}
if ( ! preg_match( '/[A-Z]/', $password ) ) {
return FALSE;
}
if ( ! preg_match( '/[a-z]/', $password ) ) {
return FALSE;
}
if ( ! preg_match( '/[0-9]/', $password ) ) {
return FALSE;
}
if ( ! preg_match( '/[^A-Za-z0-9]/', $password ) ) {
return FALSE;
}
return TRUE;
}
}
/**
* Get password validation requirements in Dutch
*
* @return array Array of password requirements
*/
if ( ! function_exists( 'get_password_requirements_nl' ) )
{
function get_password_requirements_nl()
{
return [
'Minimaal 8 karakters',
'Minimaal 1 hoofdletter',
'Minimaal 1 kleine letter',
'Minimaal 1 cijfer',
'Minimaal 1 speciaal teken (!@#$%^&* etc.)'
];
}
}
+193
View File
@@ -0,0 +1,193 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
/**
* Two-Factor Authentication Library
* Implements TOTP (Time-based One-Time Password) for Google Authenticator
* Based on RFC 6238
*/
class TwoFactorAuth
{
protected $ci;
protected $codeLength = 6;
protected $period = 30; // 30 seconds validity window
public function __construct()
{
$this->ci =& get_instance();
}
/**
* Generate a new secret key for TOTP
* @return string Base32 encoded secret
*/
public function generateSecret($length = 16)
{
$secret = '';
$validChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; // Base32 alphabet
for ($i = 0; $i < $length; $i++) {
$secret .= $validChars[random_int(0, 31)];
}
return $secret;
}
/**
* Generate QR code URL for Google Authenticator
* @param string $secret The user's TOTP secret
* @param string $username The username
* @param string $issuer The application name
* @return string QR code URL
*/
public function getQRCodeUrl($secret, $username, $issuer = 'DBOIS')
{
$encodedIssuer = rawurlencode($issuer);
$encodedUsername = rawurlencode($username);
$otpauthUrl = "otpauth://totp/{$encodedIssuer}:{$encodedUsername}?secret={$secret}&issuer={$encodedIssuer}";
// Using Google Charts API for QR code generation
return "https://chart.googleapis.com/chart?chs=200x200&chld=M|0&cht=qr&chl=" . urlencode($otpauthUrl);
}
/**
* Verify a TOTP code
* @param string $secret The user's secret
* @param string $code The code to verify
* @param int $discrepancy Time window (default 1 = +/- 30 seconds)
* @return bool True if code is valid
*/
public function verifyCode($secret, $code, $discrepancy = 1)
{
$currentTimeSlice = floor(time() / $this->period);
for ($i = -$discrepancy; $i <= $discrepancy; $i++) {
$calculatedCode = $this->getCode($secret, $currentTimeSlice + $i);
if ($this->timingSafeEquals($calculatedCode, $code)) {
return true;
}
}
return false;
}
/**
* Generate TOTP code for a given time slice
* @param string $secret The secret key
* @param int $timeSlice The time slice (defaults to current)
* @return string The generated code
*/
public function getCode($secret, $timeSlice = null)
{
if ($timeSlice === null) {
$timeSlice = floor(time() / $this->period);
}
$secretKey = $this->base32Decode($secret);
// Pack time into binary string
$time = chr(0).chr(0).chr(0).chr(0).pack('N*', $timeSlice);
// Hash with secret
$hash = hash_hmac('sha1', $time, $secretKey, true);
// Use last 4 bits as offset
$offset = ord(substr($hash, -1)) & 0x0F;
// Get 4 bytes from offset
$hashPart = substr($hash, $offset, 4);
// Unpack binary value
$value = unpack('N', $hashPart);
$value = $value[1];
// Only 32 bits
$value = $value & 0x7FFFFFFF;
// Get the required digits
$modulo = pow(10, $this->codeLength);
return str_pad($value % $modulo, $this->codeLength, '0', STR_PAD_LEFT);
}
/**
* Decode Base32 string
* @param string $secret Base32 encoded string
* @return string Binary string
*/
protected function base32Decode($secret)
{
if (empty($secret)) {
return '';
}
$base32chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
$base32charsFlipped = array_flip(str_split($base32chars));
$paddingCharCount = substr_count($secret, '=');
$allowedValues = array(6, 4, 3, 1, 0);
if (!in_array($paddingCharCount, $allowedValues)) {
return false;
}
for ($i = 0; $i < 4; $i++) {
if ($paddingCharCount == $allowedValues[$i] &&
substr($secret, -($allowedValues[$i])) != str_repeat('=', $allowedValues[$i])) {
return false;
}
}
$secret = str_replace('=', '', $secret);
$secret = str_split($secret);
$binaryString = '';
for ($i = 0; $i < count($secret); $i = $i + 8) {
$x = '';
if (!in_array($secret[$i], $base32charsFlipped)) {
return false;
}
for ($j = 0; $j < 8; $j++) {
$x .= str_pad(base_convert(@$base32charsFlipped[@$secret[$i + $j]], 10, 2), 5, '0', STR_PAD_LEFT);
}
$eightBits = str_split($x, 8);
for ($z = 0; $z < count($eightBits); $z++) {
$binaryString .= (($y = chr(base_convert($eightBits[$z], 2, 10))) || ord($y) == 48) ? $y : '';
}
}
return $binaryString;
}
/**
* Timing-safe string comparison
* @param string $safeString Known string
* @param string $userString User provided string
* @return bool True if strings match
*/
protected function timingSafeEquals($safeString, $userString)
{
if (function_exists('hash_equals')) {
return hash_equals($safeString, $userString);
}
$safeLen = strlen($safeString);
$userLen = strlen($userString);
if ($userLen != $safeLen) {
return false;
}
$result = 0;
for ($i = 0; $i < $userLen; $i++) {
$result |= (ord($safeString[$i]) ^ ord($userString[$i]));
}
return $result === 0;
}
}
@@ -0,0 +1,108 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
/**
* Send OTP Email Library
* Generates and sends a one-time password via email for two-factor authentication
*/
class SendOtpEmail
{
private $ci;
private $user;
private $otpCode;
private $sendResult;
public function __construct($user)
{
$this->ci =& get_instance();
$this->user = $user;
$this->ci->load->library('email');
$this->ci->load->model('beheer/UserOtp_model');
$this->handle();
}
/**
* Get the result of the email send operation
* @return bool True if email was sent successfully
*/
public function getSendResult()
{
return $this->sendResult;
}
/**
* Get the generated OTP code (for testing purposes)
* @return string The OTP code
*/
public function getOtpCode()
{
return $this->otpCode;
}
/**
* Main handler for OTP email process
*/
private function handle()
{
$this->generateOTP();
$this->sendOTPEmail();
}
/**
* Generate a new OTP code
*/
private function generateOTP()
{
// Generate OTP valid for 10 minutes
$this->otpCode = $this->ci->UserOtp_model->generateOTP($this->user->id, 10);
}
/**
* Send the OTP code via email
*/
private function sendOTPEmail()
{
// Build email configuration directly from environment variables
$config = [
'protocol' => $_ENV['PROTOCOL'] ?? getenv('PROTOCOL') ?: 'mail',
'smtp_host' => $_ENV['SMTP_HOST'] ?? getenv('SMTP_HOST') ?: '',
'smtp_user' => $_ENV['SMTP_USER'] ?? getenv('SMTP_USER') ?: '',
'smtp_pass' => $_ENV['SMTP_PASS'] ?? getenv('SMTP_PASS') ?: '',
'smtp_port' => $_ENV['SMTP_PORT'] ?? getenv('SMTP_PORT') ?: 25,
'smtp_crypto' => 'tls',
'smtp_timeout' => 30,
'charset' => 'iso-8859-1',
'mailtype' => 'html',
'newline' => "\r\n",
'crlf' => "\r\n",
'wordwrap' => TRUE,
'validate' => TRUE
];
// Re-initialize email library with config
$this->ci->email->initialize($config);
$data = [
'username' => $this->user->username,
'firstname' => $this->user->firstname,
'middlename' => $this->user->middlename,
'lastname' => $this->user->lastname,
'otp_code' => $this->otpCode
];
$html = $this->ci->load->view('emails/twofactor_otp_email', ['user' => $data], true);
$this->ci->email->from('noreply@dbois.nl', 'DBOIS');
$this->ci->email->to($this->user->email);
$this->ci->email->subject('DBOIS Tweefactorauthenticatie - Verificatiecode');
$this->ci->email->message($html);
$this->sendResult = $this->ci->email->send();
if (!$this->sendResult) {
log_message('error', 'Failed to send 2FA OTP email to ' . $this->user->email . ': ' . $this->ci->email->print_debugger());
}
}
}
@@ -0,0 +1,26 @@
-- Migration: Add Two-Factor Authentication Support
-- Description: Adds 2FA columns to users table and creates table for email OTP codes
-- Date: 2025-10-17
-- Add 2FA columns to users table
ALTER TABLE `users`
ADD COLUMN `two_factor_enabled` VARCHAR(1) NOT NULL DEFAULT 'F' COMMENT 'T=enabled, F=disabled' AFTER `role`,
ADD COLUMN `two_factor_method` VARCHAR(20) NULL DEFAULT NULL COMMENT 'email or authenticator' AFTER `two_factor_enabled`,
ADD COLUMN `two_factor_secret` VARCHAR(255) NULL DEFAULT NULL COMMENT 'TOTP secret for Google Authenticator' AFTER `two_factor_method`;
-- Create table for email OTP codes
CREATE TABLE `user_otp_codes` (
`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` INT(11) UNSIGNED NOT NULL,
`otp_code` VARCHAR(6) NOT NULL,
`expires_at` TIMESTAMP NOT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`used` VARCHAR(1) NOT NULL DEFAULT 'F' COMMENT 'T=used, F=not used',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_expires_at` (`expires_at`),
CONSTRAINT `fk_otp_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-- Create index for faster lookups
CREATE INDEX `idx_users_two_factor` ON `users` (`two_factor_enabled`, `two_factor_method`);
@@ -0,0 +1,95 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
/**
* User OTP Model
* Manages email-based one-time passwords for two-factor authentication
*/
class UserOtp_model extends MY_Model
{
protected $tablename = "user_otp_codes";
/**
* Generate and store a new OTP code for a user
* @param int $userId User ID
* @param int $expiryMinutes Expiry time in minutes (default 10)
* @return string The generated OTP code
*/
public function generateOTP($userId, $expiryMinutes = 10)
{
// Generate 6-digit OTP
$otpCode = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT);
// Calculate expiry time
$expiresAt = date('Y-m-d H:i:s', strtotime("+{$expiryMinutes} minutes"));
// Store OTP in database
$data = [
'user_id' => $userId,
'otp_code' => $otpCode,
'expires_at' => $expiresAt,
'used' => 'F'
];
// Use direct insert since this table doesn't have audit columns
$this->db->insert($this->tablename, $data);
return $otpCode;
}
/**
* Verify an OTP code for a user
* @param int $userId User ID
* @param string $otpCode The OTP code to verify
* @return bool True if valid, false otherwise
*/
public function verifyOTP($userId, $otpCode)
{
// Get the most recent unused OTP for this user
$sql = "
SELECT * FROM {$this->tablename}
WHERE user_id = ?
AND otp_code = ?
AND used = 'F'
AND expires_at > NOW()
ORDER BY created_at DESC
LIMIT 1
";
$query = $this->db->query($sql, [$userId, $otpCode]);
if ($query->num_rows() == 0) {
return false;
}
$result = $query->row();
// Mark OTP as used (use direct update since this table doesn't have audit columns)
$this->db->where('id', $result->id);
$this->db->update($this->tablename, ['used' => 'T']);
return true;
}
/**
* Clean up expired OTP codes
* @return int Number of deleted records
*/
public function cleanupExpiredOTPs()
{
$this->sql = "DELETE FROM {$this->tablename} WHERE expires_at < NOW() OR used = 'T'";
$this->db->query($this->sql);
return $this->db->affected_rows();
}
/**
* Delete all OTP codes for a user
* @param int $userId User ID
*/
public function deleteUserOTPs($userId)
{
$this->db->where('user_id', $userId);
$this->db->delete($this->tablename);
}
}
+15
View File
@@ -135,5 +135,20 @@ class User extends BaseModel {
return $saltedpassword;
}
public static function generateSalt()
{
$alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890';
$alphaLength = strlen( $alphabet ) - 1;
$randomString = '';
for ( $i = 0; $i < 18; $i++ )
{
$n = rand( 0, $alphaLength );
$randomString .= $alphabet[ $n ];
}
return sha1( $randomString );
}
}
@@ -0,0 +1,172 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta name="viewport" content="width=device-width"/>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<title>Verificatiecode DBOIS</title>
<link href="styles.css" media="all" rel="stylesheet" type="text/css"/>
</head>
<style>
/* Same styles as requestpassword_email.php */
* {
margin: 0;
padding: 0;
font-family: "Helvetica Neue", "Helvetica", Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
}
img {
max-width: 100%;
}
body {
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
width: 100% !important;
height: 100%;
line-height: 1.6;
background-color: #f6f6f6;
}
table td {
vertical-align: top;
}
.body-wrap {
background-color: #f6f6f6;
width: 100%;
}
.container {
display: block !important;
max-width: 600px !important;
margin: 0 auto !important;
clear: both !important;
}
.content {
max-width: 600px;
margin: 0 auto;
display: block;
padding: 20px;
}
.main {
background: #fff;
border: 1px solid #e9e9e9;
border-radius: 3px;
}
.content-wrap {
padding: 20px;
}
.content-block {
padding: 0 0 20px;
}
.alert {
font-size: 16px;
color: #fff;
font-weight: 500;
padding: 20px;
text-align: center;
border-radius: 3px 3px 0 0;
}
.alert.alert-warning {
background: #ff9f00;
}
.alert.alert-good {
background: #68b90f;
}
.otp-code {
font-size: 32px;
font-weight: bold;
letter-spacing: 8px;
text-align: center;
padding: 20px;
background-color: #f0f0f0;
border: 2px dashed #348eda;
border-radius: 5px;
margin: 20px 0;
}
p {
margin-bottom: 10px;
font-weight: normal;
}
@media only screen and (max-width: 640px) {
.container {
width: 100% !important;
}
.content {
padding: 10px !important;
}
.otp-code {
font-size: 24px;
letter-spacing: 4px;
}
}
</style>
<body>
<table class="body-wrap">
<tr>
<td></td>
<td class="container" width="600">
<div class="content">
<table class="main" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td class="alert alert-good">
Tweefactorauthenticatie - DBOIS
</td>
</tr>
<tr>
<td class="content-wrap">
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td class="content-block">
<p>
Beste <?= $user['firstname']?> <?=$user['middlename']?> <?=$user['lastname']?>
</p>
<p>
U probeert in te loggen in DBOIS. Gebruik de onderstaande verificatiecode om uw login te voltooien.
</p>
</td>
</tr>
<tr>
<td class="content-block">
<div class="otp-code">
<?= $user['otp_code'] ?>
</div>
</td>
</tr>
<tr>
<td class="content-block">
<p>
Deze code is 10 minuten geldig.
</p>
<p>
Als u niet heeft geprobeerd in te loggen, negeer dan deze e-mail.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
<td></td>
</tr>
</table>
</body>
</html>
@@ -0,0 +1,291 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tweefactorauthenticatie Instellingen - DBOIS</title>
<link rel="icon" href="/css/images/dbo-01-80x80.png" type="image/png">
<link rel="stylesheet" href="/css/vendor/bootstrap.min.css?v=<?=$version?>">
<link rel="stylesheet" href="/css/vendor/bootstrap-extend.css?v=<?=$version?>">
<link rel="stylesheet" href="/css/vendor/site.min.css?v=<?=$version?>">
<style>
.twofactor-container {
max-width: 800px;
margin: 50px auto;
padding: 20px;
}
.method-card {
border: 2px solid #ddd;
border-radius: 8px;
padding: 20px;
margin: 15px 0;
cursor: pointer;
transition: all 0.3s;
}
.method-card:hover {
border-color: #348eda;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.method-card.active {
border-color: #68b90f;
background-color: #f0f8ff;
}
.qr-code-container {
text-align: center;
margin: 20px 0;
}
.qr-code-container img {
max-width: 250px;
}
.verification-code-input {
text-align: center;
font-size: 24px;
letter-spacing: 5px;
font-weight: bold;
}
.secret-code {
background-color: #f5f5f5;
padding: 10px;
font-family: monospace;
word-break: break-all;
border-radius: 4px;
}
.alert {
margin: 15px 0;
}
</style>
</head>
<body>
<div class="twofactor-container">
<div class="panel">
<div class="panel-heading">
<h3 class="panel-title">Tweefactorauthenticatie Instellingen</h3>
</div>
<div class="panel-body">
<!-- Current Status -->
<div class="alert alert-info" data-bind="visible: !enabled()">
<strong>Tweefactorauthenticatie is momenteel uitgeschakeld.</strong>
<p>Schakel 2FA in om een extra beveiligingslaag aan uw account toe te voegen.</p>
</div>
<div class="alert alert-success" data-bind="visible: enabled">
<strong>Tweefactorauthenticatie is ingeschakeld</strong>
<p>Huidige methode: <span data-bind="text: currentMethod"></span></p>
</div>
<!-- Enable/Disable Section -->
<div data-bind="visible: !enabled()">
<h4>Kies een 2FA methode:</h4>
<div class="method-card" data-bind="click: function() { selectMethod('email'); }, css: { active: selectedMethod() === 'email' }">
<h5><i class="icon wb-envelope" aria-hidden="true"></i> E-mail</h5>
<p>Ontvang een eenmalige code via e-mail bij elke inlogpoging.</p>
</div>
<div class="method-card" data-bind="click: function() { selectMethod('authenticator'); }, css: { active: selectedMethod() === 'authenticator' }">
<h5><i class="icon wb-mobile" aria-hidden="true"></i> Google Authenticator</h5>
<p>Gebruik een authenticator app zoals Google Authenticator of Authy.</p>
</div>
<button class="btn btn-primary btn-lg" data-bind="click: enableTwoFactor, enable: selectedMethod">
2FA Inschakelen
</button>
</div>
<!-- Authenticator Setup -->
<div data-bind="visible: showAuthenticatorSetup">
<hr>
<h4>Google Authenticator Instellen</h4>
<div class="qr-code-container">
<p>Scan deze QR-code met uw authenticator app:</p>
<img data-bind="attr: { src: qrCodeUrl }" alt="QR Code">
<p class="mt-20">Of voer deze code handmatig in:</p>
<div class="secret-code" data-bind="text: secret"></div>
</div>
<div class="form-group">
<label>Voer de 6-cijferige code in om te verifiëren:</label>
<input type="text"
class="form-control verification-code-input"
maxlength="6"
data-bind="textInput: verificationCode, event: { keyup: handleVerificationKeyup }">
</div>
<button class="btn btn-success" data-bind="click: verifyAuthenticatorSetup, enable: verificationCode().length === 6">
Verifiëren
</button>
</div>
<!-- Change Method -->
<div data-bind="visible: enabled() && !showAuthenticatorSetup()">
<hr>
<h4>Methode Wijzigen</h4>
<div class="method-card" data-bind="click: function() { selectMethod('email'); }, css: { active: changeToMethod() === 'email' }">
<h5><i class="icon wb-envelope" aria-hidden="true"></i> E-mail</h5>
<p>Ontvang een eenmalige code via e-mail bij elke inlogpoging.</p>
</div>
<div class="method-card" data-bind="click: function() { selectMethod('authenticator'); }, css: { active: changeToMethod() === 'authenticator' }">
<h5><i class="icon wb-mobile" aria-hidden="true"></i> Google Authenticator</h5>
<p>Gebruik een authenticator app zoals Google Authenticator of Authy.</p>
</div>
<button class="btn btn-primary" data-bind="click: changeMethod, enable: changeToMethod() !== currentMethod()">
Methode Wijzigen
</button>
<hr>
<button class="btn btn-danger" data-bind="click: disableTwoFactor">
2FA Uitschakelen
</button>
</div>
</div>
</div>
<div class="text-center">
<a href="/" class="btn btn-default">Terug naar Dashboard</a>
</div>
</div>
<script src="/js/vendor/jquery/jquery.js?v=<?=$version?>"></script>
<script src="/js/vendor/bootstrap/bootstrap.js?v=<?=$version?>"></script>
<script src="/js/vendor/Knockout.min.js?v=<?=$version?>"></script>
<script>
function TwoFactorViewModel() {
var self = this;
self.enabled = ko.observable(<?= $user->two_factor_enabled === 'T' ? 'true' : 'false' ?>);
self.currentMethod = ko.observable('<?= $user->two_factor_method ?>');
self.selectedMethod = ko.observable(null);
self.changeToMethod = ko.observable(null);
self.showAuthenticatorSetup = ko.observable(false);
self.qrCodeUrl = ko.observable('');
self.secret = ko.observable('');
self.verificationCode = ko.observable('');
self.selectMethod = function(method) {
if (!self.enabled()) {
self.selectedMethod(method);
} else {
self.changeToMethod(method);
}
};
self.enableTwoFactor = function() {
if (!self.selectedMethod()) {
alert('Kies eerst een methode');
return;
}
$.ajax({
url: '/beheer/TwoFactorController/enable',
method: 'POST',
data: { method: self.selectedMethod() },
success: function(response) {
if (response.success) {
if (response.method === 'authenticator') {
self.qrCodeUrl(response.qrCodeUrl);
self.secret(response.secret);
self.showAuthenticatorSetup(true);
} else {
alert(response.message);
location.reload();
}
} else {
alert(response.message);
}
}
});
};
self.verifyAuthenticatorSetup = function() {
$.ajax({
url: '/beheer/TwoFactorController/verifySetup',
method: 'POST',
data: { code: self.verificationCode() },
success: function(response) {
if (response.success) {
alert('Google Authenticator succesvol ingesteld!');
location.reload();
} else {
alert(response.message);
}
}
});
};
self.disableTwoFactor = function() {
if (!confirm('Weet u zeker dat u 2FA wilt uitschakelen?')) {
return;
}
$.ajax({
url: '/beheer/TwoFactorController/disable',
method: 'POST',
success: function(response) {
if (response.success) {
alert(response.message);
location.reload();
} else {
alert(response.message);
}
}
});
};
self.changeMethod = function() {
if (!self.changeToMethod()) {
alert('Kies eerst een methode');
return;
}
$.ajax({
url: '/beheer/TwoFactorController/changeMethod',
method: 'POST',
data: { method: self.changeToMethod() },
success: function(response) {
if (response.success) {
if (response.method === 'authenticator') {
self.enabled(false);
self.selectedMethod('authenticator');
self.qrCodeUrl(response.qrCodeUrl);
self.secret(response.secret);
self.showAuthenticatorSetup(true);
} else {
alert(response.message);
location.reload();
}
} else {
alert(response.message);
}
}
});
};
self.handleVerificationKeyup = function(data, event) {
// Auto-verify when 6 digits entered
if (self.verificationCode().length === 6 && event.keyCode === 13) {
self.verifyAuthenticatorSetup();
}
return true;
};
}
$(document).ready(function() {
ko.applyBindings(new TwoFactorViewModel());
});
</script>
</body>
</html>
@@ -33,6 +33,131 @@
<body class="animsition page-login-v3 layout-full">
<!-- Page -->
<?php $this->load->view('specials/tellme_modal'); ?>
<!-- Password Change Modal -->
<div class="modal fade" id="password-change-modal" tabindex="-1" role="dialog" data-backdrop="static" data-keyboard="false">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header bg-warning">
<h4 class="modal-title">
<i class="icon wb-warning" aria-hidden="true"></i>
Wachtwoord wijzigen verplicht
</h4>
</div>
<div class="modal-body">
<div class="alert alert-warning">
<strong>Let op:</strong> Uw huidige wachtwoord voldoet niet aan de beveiligingseisen.
U moet uw wachtwoord wijzigen om door te gaan.
</div>
<h5>Wachtwoordeisen:</h5>
<ul class="list-unstyled" data-bind="foreach: passwordRequirements">
<li style="padding: 5px 0;">
<i class="icon wb-check text-success" aria-hidden="true"></i>
<span data-bind="text: $data"></span>
</li>
</ul>
<!-- New Password Input -->
<div class="form-group form-material floating" data-plugin="formMaterial" style="margin-top: 30px;">
<input type="password"
class="form-control"
id="newPassword"
data-bind="textInput: newPassword"
autocomplete="new-password" />
<label class="floating-label">Nieuw wachtwoord</label>
</div>
<!-- Confirm Password Input -->
<div class="form-group form-material floating" data-plugin="formMaterial">
<input type="password"
class="form-control"
id="confirmPassword"
data-bind="textInput: confirmPassword, event: { keyup: handlePasswordChangeKeyup }"
autocomplete="new-password" />
<label class="floating-label">Bevestig wachtwoord</label>
</div>
<!-- Error Message -->
<div data-bind="visible: passwordChangeError()" class="alert alert-danger" style="margin-top: 20px;">
<span data-bind="text: passwordChangeError"></span>
</div>
</div>
<div class="modal-footer">
<button type="button"
class="btn btn-primary btn-lg btn-block"
data-bind="click: changePassword, enable: newPassword().length >= 8 && confirmPassword().length >= 8 && !isChangingPassword()">
<span data-bind="visible: !isChangingPassword()">Wachtwoord wijzigen</span>
<span data-bind="visible: isChangingPassword()">Wijzigen...</span>
</button>
</div>
</div>
</div>
</div>
<!-- 2FA Verification Modal -->
<div class="modal fade" id="twofactor-modal" tabindex="-1" role="dialog" data-backdrop="static" data-keyboard="false">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">
<i class="icon wb-lock" aria-hidden="true"></i>
Tweefactorauthenticatie
</h4>
</div>
<div class="modal-body text-center">
<!-- Email Method -->
<div data-bind="visible: twoFactorMethod() === 'email'">
<div style="font-size: 48px; color: #348eda; margin-bottom: 20px;">
<i class="icon wb-envelope" aria-hidden="true"></i>
</div>
<p>Voer de 6-cijferige code in die naar uw e-mail is verzonden.</p>
</div>
<!-- Authenticator Method -->
<div data-bind="visible: twoFactorMethod() === 'authenticator'">
<div style="font-size: 48px; color: #348eda; margin-bottom: 20px;">
<i class="icon wb-mobile" aria-hidden="true"></i>
</div>
<p>Voer de 6-cijferige code in van uw authenticator app.</p>
</div>
<!-- Code Input -->
<div class="form-group" style="margin-top: 30px;">
<input type="text"
class="form-control text-center"
id="twoFactorCode"
maxlength="6"
placeholder="000000"
style="font-size: 32px; letter-spacing: 8px; font-weight: bold;"
data-bind="textInput: twoFactorCode, event: { keyup: handleTwoFactorKeyup }"
autocomplete="off" />
</div>
<!-- Error Message -->
<div data-bind="visible: twoFactorError()" class="alert alert-danger" style="margin-top: 20px;">
<span data-bind="text: twoFactorError"></span>
</div>
<!-- Resend Button (Email only) -->
<div data-bind="visible: twoFactorMethod() === 'email'" style="margin-top: 20px;">
<a href="#" data-bind="click: resendOtp, enable: !isResending()">
<span data-bind="visible: !isResending()">Code opnieuw verzenden</span>
<span data-bind="visible: isResending()">Verzenden...</span>
</a>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-bind="click: cancelTwoFactor">Annuleren</button>
<button type="button" class="btn btn-primary btn-lg" data-bind="click: verifyTwoFactor, enable: twoFactorCode().length === 6 && !isVerifying()">
<span data-bind="visible: !isVerifying()">Verifiëren</span>
<span data-bind="visible: isVerifying()">Verifiëren...</span>
</button>
</div>
</div>
</div>
</div>
<div class="page vertical-align text-center" data-animsition-in="fade-in" data-animsition-out="fade-out">
<div class="page-content vertical-align-middle animation-slide-top animation-duration-1">
<div class="panel" id="login-form">
@@ -116,6 +241,12 @@
<script src="js/lib/modules/login/loginModule.js?v=<? $this->version ?>"></script>
<script src="js/lib/modules/specials/tellmeModule.js?v=<? $this->version ?>"></script>
<script src="js/lib/helpers.js?v=<? $this->version ?>"></script>
<script>
// Apply bindings for password change modal
$(function() {
ko.applyBindings(dbo.loginModel, document.getElementById('password-change-modal'));
});
</script>
</body>
</html>
@@ -0,0 +1,219 @@
<!DOCTYPE html>
<html class="no-js css-menubar" lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=0, minimal-ui">
<title>Tweefactorauthenticatie - DBOIS</title>
<link rel="icon" href="css/images/dbo-01-80x80.png" type="image/png">
<!-- Stylesheets -->
<link rel="stylesheet" href="css/vendor/bootstrap.min.css?v=<?=$this->version?>">
<link rel="stylesheet" href="css/vendor/bootstrap-extend.css?v=<?=$this->version?>">
<link rel="stylesheet" href="css/vendor/site.min.css?v=<?=$this->version?>">
<!-- Plugins -->
<link rel="stylesheet" href="css/vendor/plugins/asscrollable/asScrollable.css?v=<?=$this->version?>">
<link rel="stylesheet" href="css/vendor/plugins/switchery/switchery.css?v=<?=$this->version?>">
<link rel="stylesheet" href="css/vendor/plugins/slidepanel/slidePanel.css?v=<?=$this->version?>">
<link rel="stylesheet" href="css/vendor/plugins/flag-icon-css/flag-icon.css?v=<?=$this->version?>">
<link rel="stylesheet" href="css/login-v3.css?v=<?=$this->version?>">
<!-- Fonts -->
<link rel="stylesheet" href="fonts/web-icons/web-icons.min.css?v=<?=$this->version?>">
<link rel="stylesheet" href="fonts/brand-icons/brand-icons.min.css?v=<?=$this->version?>">
<link rel='stylesheet' href='http://fonts.googleapis.com/css?family=Roboto:300,400,500,300italic'>
<style>
.verification-code-input {
text-align: center;
font-size: 32px;
letter-spacing: 8px;
font-weight: bold;
padding: 20px;
}
.resend-link {
margin-top: 20px;
display: inline-block;
}
.method-icon {
font-size: 48px;
color: #348eda;
margin-bottom: 20px;
}
</style>
</head>
<body class="animsition page-login-v3 layout-full">
<!-- Page -->
<?php $this->load->view('specials/tellme_modal'); ?>
<div class="page vertical-align text-center" data-animsition-in="fade-in" data-animsition-out="fade-out">
<div class="page-content vertical-align-middle animation-slide-top animation-duration-1">
<div class="panel" id="verify2fa-form">
<div class="panel-body">
<div class="brand">
<img class="brand-img" src="css/images/logo_dbo.png" alt="DBOIS">
<h2 class="brand-text font-size-18">projectbeheer</h2>
</div>
<div class="text-center">
<div class="method-icon" data-bind="visible: method() === 'email'">
<i class="icon wb-envelope" aria-hidden="true"></i>
</div>
<div class="method-icon" data-bind="visible: method() === 'authenticator'">
<i class="icon wb-mobile" aria-hidden="true"></i>
</div>
<h3>Tweefactorauthenticatie</h3>
<p data-bind="visible: method() === 'email'">
Voer de 6-cijferige code in die naar uw e-mail is verzonden.
</p>
<p data-bind="visible: method() === 'authenticator'">
Voer de 6-cijferige code in van uw authenticator app.
</p>
</div>
<div class="form-group form-material floating mt-40" data-plugin="formMaterial">
<input type="text"
class="form-control verification-code-input"
name="verification_code"
maxlength="6"
data-bind="textInput: verificationCode, event: { keyup: handleKeyup }"
autocomplete="off"
autofocus />
<label class="floating-label">Verificatiecode</label>
</div>
<div data-bind="visible: errorMessage()" class="alert alert-danger">
<span data-bind="text: errorMessage"></span>
</div>
<button type="button"
class="btn btn-primary btn-block btn-lg mt-40"
data-bind="click: verify, enable: verificationCode().length === 6 && !isVerifying()">
<span data-bind="visible: !isVerifying()">Verifiëren</span>
<span data-bind="visible: isVerifying()">Verifiëren...</span>
</button>
<div data-bind="visible: method() === 'email'">
<a href="#" class="resend-link" data-bind="click: resendOtp, enable: !isResending()">
<span data-bind="visible: !isResending()">Code opnieuw verzenden</span>
<span data-bind="visible: isResending()">Verzenden...</span>
</a>
</div>
<div class="mt-20">
<a href="/login/logout">Annuleren</a>
</div>
</div>
</div>
<footer class="page-copyright page-copyright-inverse">
<p>© 2018. De Beleidsonderzoekers</p>
</footer>
</div>
</div>
<!-- End Page -->
<!-- Core -->
<script src="js/vendor/jquery/jquery.js?v=<?=$this->version?>"></script>
<script src="js/vendor/popper-js/umd/popper.min.js?v=<?=$this->version?>"></script>
<script src="js/vendor/bootstrap/bootstrap.js?v=<?=$this->version?>"></script>
<script src="js/vendor/animsition/animsition.js?v=<?=$this->version?>"></script>
<!-- Scripts -->
<script src="js/vendor/Config.js?v=<?=$this->version?>"></script>
<script src="js/vendor/Knockout.min.js?v=<?=$this->version?>"></script>
<script src="js/lib/modules/specials/tellmeModule.js?v=<?=$this->version?>"></script>
<script src="js/lib/helpers.js?v=<?=$this->version?>"></script>
<script>
function Verify2FAViewModel() {
var self = this;
self.method = ko.observable('<?= $method ?>');
self.verificationCode = ko.observable('');
self.errorMessage = ko.observable('');
self.isVerifying = ko.observable(false);
self.isResending = ko.observable(false);
self.verify = function() {
if (self.verificationCode().length !== 6) {
return;
}
self.errorMessage('');
self.isVerifying(true);
$.ajax({
url: '/login/verify2fa',
method: 'POST',
data: { code: self.verificationCode() },
success: function(response) {
if (response.success) {
// Redirect to main application
window.location.href = '/';
} else {
self.errorMessage(response.message || 'Ongeldige verificatiecode');
self.verificationCode('');
}
self.isVerifying(false);
},
error: function() {
self.errorMessage('Er is een fout opgetreden. Probeer het opnieuw.');
self.isVerifying(false);
}
});
};
self.resendOtp = function() {
self.isResending(true);
self.errorMessage('');
$.ajax({
url: '/login/resendOtp',
method: 'POST',
success: function(response) {
if (response.success) {
alert(response.message || 'Nieuwe code verzonden');
} else {
self.errorMessage(response.message || 'Kan code niet opnieuw verzenden');
}
self.isResending(false);
},
error: function() {
self.errorMessage('Er is een fout opgetreden. Probeer het opnieuw.');
self.isResending(false);
}
});
return false;
};
self.handleKeyup = function(data, event) {
// Clear error message on input
self.errorMessage('');
// Auto-submit when 6 digits entered and Enter is pressed
if (self.verificationCode().length === 6 && event.keyCode === 13) {
self.verify();
}
// Only allow digits
var sanitized = self.verificationCode().replace(/[^0-9]/g, '');
if (sanitized !== self.verificationCode()) {
self.verificationCode(sanitized);
}
return true;
};
}
$(document).ready(function() {
ko.applyBindings(new Verify2FAViewModel());
});
</script>
</body>
</html>