userModel = new UserModel(); $this->roleModel = new RoleModel(); $this->configModel = new ConfigurationModel(); $this->ipAttemptModel = new IpAttemptModel(); $this->loginActivityModel = new LoginActivityModel(); $this->preferencesModel = new PreferencesModel(); $this->schoolYear = $this->configModel->getConfig('school_year'); $this->semester = $this->configModel->getConfig('semester'); } public function setModel($model) { $this->userModel = $model; } private function scheduleEvent($userId) { // Schedule the event to run after 2 minutes (120 seconds) Events::trigger('delete_unverified_user', $userId); } public function loginMask() { $redirectTo = $this->sanitizeRedirectTarget((string) ($this->request->getGet('redirect_to') ?? '')); if (session()->get('is_logged_in')) { if ($redirectTo !== null) { return redirect()->to($redirectTo); } $roles = session()->get('roles') ?? []; if (empty($roles) && session()->get('role')) { $roles = [session()->get('role')]; } if (!empty($roles)) { return $this->redirectToDashboard($roles); } } return view('user/login', [ 'redirect_to' => $redirectTo ?? '', ]); } public function login() { log_message('info', 'Processing login form submission.'); $redirectTo = $this->sanitizeRedirectTarget((string) ($this->request->getPost('redirect_to') ?? $this->request->getGet('redirect_to') ?? '')); // Step 1: Get email, password, and IP from the request $email = $this->request->getPost('email'); $password = $this->request->getPost('password'); $ip = $this->request->getIPAddress(); log_message('info', 'Login attempt from IP: ' . $ip . ' for email: ' . $email); // Step 2: Check if the IP is blocked (too many failed attempts) if ($this->isIpBlocked($ip)) { return redirect() ->back() ->with('error', 'Too many failed attempts from your IP. Please try again later.') ->withInput(); // ✅ preserve email input } // Step 3: Look up user by email $user = $this->getUserByEmail($email); if ($user) { // Step 4: Check if user is suspended if ($this->checkIfUserSuspended($user)) { return redirect() ->back() ->with('error', 'Account suspended. Please check your email to reset your password.') ->withInput(); // ✅ preserve email input } // Step 5: Verify password if ($this->verifyPassword($password, $user['password'])) { // Step 6: Login successful — reset failed attempts and log $this->resetFailedAttempts($user['id']); $this->logLoginAttempt($user['id'], $user['email'], $ip, $this->request->getUserAgent()); // ✅ Step 7: Call loginUser() to set session and redirect return $this->loginUser($user, $redirectTo); } else { // Step 8: Password mismatch — log failed attempt $this->handleFailedLogin($user['id'], $user['email'], $ip); return redirect() ->back() ->with('error', 'The email and password combination you entered is invalid. Please try again.') ->withInput(); // ✅ preserve email input } } else { // Step 9: No user found — log IP-level attempt $this->logIpAttempt($ip); return redirect() ->back() ->with('error', 'The email and password combination you entered is invalid. Please try again.') ->withInput(); // ✅ preserve email input } } // JSON API: POST /api/login public function apiLogin() { $requestData = $this->request->getJSON(true); if (!$requestData) { // fallback to form vars $requestData = [ 'email' => $this->request->getPost('email'), 'password' => $this->request->getPost('password'), ]; } $email = $requestData['email'] ?? ''; $password = $requestData['password'] ?? ''; $ip = $this->request->getIPAddress(); if (!$email || !$password) { return $this->response->setStatusCode(400)->setJSON([ 'status' => false, 'message' => 'Email and password are required.' ]); } if ($this->isIpBlocked($ip)) { return $this->response->setStatusCode(429)->setJSON([ 'status' => false, 'message' => 'Too many failed attempts from your IP. Please try again later.' ]); } $user = $this->getUserByEmail($email); if (!$user) { $this->logIpAttempt($ip); return $this->response->setStatusCode(401)->setJSON([ 'status' => false, 'message' => 'Invalid email or password.' ]); } if ($this->checkIfUserSuspended($user)) { return $this->response->setStatusCode(403)->setJSON([ 'status' => false, 'message' => 'Account suspended. Please reset your password.' ]); } if (!$this->verifyPassword($password, $user['password'])) { $this->handleFailedLogin($user['id'], $user['email'], $ip); return $this->response->setStatusCode(401)->setJSON([ 'status' => false, 'message' => 'Invalid email or password.' ]); } // Success: reset attempts + log $this->resetFailedAttempts($user['id']); $this->logLoginAttempt($user['id'], $user['email'], $ip, $this->request->getUserAgent()); // Fetch roles $userRoleModel = new UserRoleModel(); $rolesRows = $userRoleModel->select('roles.name') ->join('roles', 'roles.id = user_roles.role_id') ->where('user_roles.user_id', $user['id']) ->get() ->getResultArray(); $roleNames = array_column($rolesRows, 'name'); // Build roles map (object with keys per example) $rolesMap = []; foreach ($roleNames as $r) { $rolesMap[$r] = true; } // Build JWT token $now = time(); $exp = $now + 60 * 60 * 24; // 24h default $payload = [ 'sub' => (int) $user['id'], 'name' => trim(($user['firstname'] ?? '') . ' ' . ($user['lastname'] ?? '')), 'roles' => $roleNames, 'iat' => $now, 'exp' => $exp, ]; $secret = env('JWT_SECRET', 'change-me-in-env'); $token = jwt_encode($payload, $secret, 'HS256'); return $this->response->setJSON([ 'status' => true, 'token' => $token, 'user' => [ 'id' => (int) $user['id'], 'name' => $payload['name'], 'roles' => (object) $rolesMap, ], ]); } /** * API Registration endpoint * POST /api/v1/register */ public function apiRegister() { $requestData = $this->request->getJSON(true); if (!$requestData) { // fallback to form vars $requestData = $this->request->getPost(); } // Basic validation $rules = [ 'firstname' => 'required|min_length[2]|max_length[30]', 'lastname' => 'required|min_length[2]|max_length[30]', 'email' => 'required|valid_email|is_unique[users.email]', 'password' => 'required|min_length[8]', 'cellphone' => 'required|min_length[10]|max_length[20]', ]; $validation = \Config\Services::validation(); $validation->setRules($rules); if (!$validation->run($requestData)) { return $this->response->setStatusCode(422)->setJSON([ 'status' => false, 'message' => 'Validation failed', 'errors' => $validation->getErrors(), ]); } // Check if email already exists $existingUser = $this->userModel->where('email', $requestData['email'])->first(); if ($existingUser) { return $this->response->setStatusCode(422)->setJSON([ 'status' => false, 'message' => 'Email already registered', ]); } // Hash password require_once APPPATH . 'Helpers/pbkdf2_helper.php'; $hashedPassword = pbkdf2_hash($requestData['password']); // Prepare user data $userData = [ 'firstname' => $requestData['firstname'], 'lastname' => $requestData['lastname'], 'email' => $requestData['email'], 'password' => $hashedPassword, 'cellphone' => $requestData['cellphone'], 'gender' => $requestData['gender'] ?? null, 'address_street' => $requestData['address_street'] ?? null, 'city' => $requestData['city'] ?? null, 'state' => $requestData['state'] ?? null, 'zip' => $requestData['zip'] ?? null, 'school_year' => $this->configModel->getConfig('school_year'), 'semester' => $this->configModel->getConfig('semester'), 'status' => 'active', 'is_verified' => 0, // Require email verification ]; try { $userId = $this->userModel->insert($userData); if (!$userId) { return $this->response->setStatusCode(500)->setJSON([ 'status' => false, 'message' => 'Failed to create user account', 'errors' => $this->userModel->errors(), ]); } // Assign parent role if specified $roleId = null; if (isset($requestData['role']) && $requestData['role'] === 'parent') { $role = $this->roleModel->where('name', 'parent')->first(); if ($role) { $roleId = $role['id']; $userRoleModel = new \App\Models\UserRoleModel(); $userRoleModel->insert([ 'user_id' => $userId, 'role_id' => $roleId, ]); } } // Generate JWT token $now = time(); $exp = $now + 60 * 60 * 24; // 24h $payload = [ 'sub' => (int) $userId, 'name' => trim($userData['firstname'] . ' ' . $userData['lastname']), 'roles' => $roleId ? ['parent'] : [], 'iat' => $now, 'exp' => $exp, ]; $secret = env('JWT_SECRET', 'change-me-in-env'); $token = jwt_encode($payload, $secret, 'HS256'); return $this->response->setStatusCode(201)->setJSON([ 'status' => true, 'message' => 'Registration successful. Please verify your email.', 'token' => $token, 'user' => [ 'id' => (int) $userId, 'name' => $payload['name'], 'email' => $userData['email'], 'roles' => $payload['roles'], ], ]); } catch (\Exception $e) { log_message('error', 'Registration error: ' . $e->getMessage()); return $this->response->setStatusCode(500)->setJSON([ 'status' => false, 'message' => 'Registration failed. Please try again.', ]); } } private function verifyPassword($password, $storedHash) { if (!function_exists('pbkdf2_verify')) { die('pbkdf2_verify() is NOT loaded'); } return pbkdf2_verify($password, $storedHash); } private function handleFailedLogin($userId, $email, $ip) { // Get user data $user = $this->userModel->find($userId); $failedAttempts = $user['failed_attempts'] + 1; $data = ['failed_attempts' => $failedAttempts, 'last_failed_at' => utc_now()]; // Check if the failed attempts have reached 3 if ($failedAttempts >= 3) { // Suspend the account $data['is_suspended'] = true; // Set a warning message in session to inform the user session()->setFlashdata('warning_message', 'Your account has been suspended due to multiple failed login attempts. A reset password email has been sent to your registered email address.'); // Send the password reset email $this->sendPasswordResetEmail($email); } // Update the user record $this->userModel->update($userId, $data); // Log the IP attempt (track failed login attempts) $this->logIpAttempt($ip); } private function isIpBlocked($ip) { $attempt = $this->ipAttemptModel->where('ip_address', $ip)->first(); return $attempt && $attempt['blocked_until'] > utc_now(); } private function logIpAttempt($ip) { $now = utc_now(); $attempt = $this->ipAttemptModel->where('ip_address', $ip)->first(); if ($attempt) { $attempts = $attempt['attempts'] + 1; $blockedUntil = $attempts >= 10 ? date('Y-m-d H:i:s', strtotime('+24 hours')) : null; $this->ipAttemptModel->update($attempt['id'], [ 'attempts' => $attempts, 'last_attempt_at' => $now, 'blocked_until' => $blockedUntil ]); } else { $this->ipAttemptModel->insert([ 'ip_address' => $ip, 'attempts' => 1, 'last_attempt_at' => $now ]); } } private function logLoginAttempt($userId, $email, $ipAddress, $userAgent) { $this->loginActivityModel->insert([ 'user_id' => $userId, 'email' => $email, 'login_time' => utc_now(), 'ip_address' => $ipAddress, 'user_agent' => $userAgent ]); } private function resetFailedAttempts($userId) { $this->userModel->update($userId, ['failed_attempts' => 0, 'last_failed_at' => null]); } public function logout() { // Get user ID and email from session $userId = session()->get('user_id'); $email = session()->get('user_email'); // Log logout $this->logLogout($userId, $email); log_message('info', 'Session destroyed.'); // Destroy the session session()->destroy(); // Redirect to the main login page return redirect()->to(base_url('/')); } private function logLogout($userId, $email) { $this->loginActivityModel->where('user_id', $userId) ->where('logout_time', null) ->set('logout_time', utc_now()) ->set('email', $email) ->update(); } private function getUserByEmail($email) { return $this->userModel->where('email', $email)->first(); } private function checkIfUserSuspended($user) { return $user['is_suspended']; } // This function is used to send ResetPassword email to the userwith suspended account. private function sendPasswordResetEmail($email) { // Load the UserController $userController = new \App\Controllers\View\UserController(); // First, attempt to find the user by their email $userModel = new UserModel(); $user = $userModel->where('email', $email)->first(); if (!$user) { log_message('error', 'User with email ' . $email . ' not found for password reset.'); return false; } // Generate a secure token for the password reset helper('text'); $token = bin2hex(random_bytes(48)); $tokenHash = hash('sha256', $token); // Calculate the expiration time for the token (1 hour from now) $expires_at = Time::now()->addHours(1); // Store the token in the password_resets table $passwordResetModel = new PasswordResetModel(); $passwordResetModel->insert([ 'email' => $email, 'token' => $tokenHash, 'created_at' => Time::now(), 'expires_at' => $expires_at, ]); // Now, send the reset email using the generated token $userController->sendResetEmail($email, $token); // Calling the original helper function to send the email // Log and return success log_message('info', 'Password reset email sent to ' . $email); return true; } private function loginUser($user, ?string $redirectTo = null) { $userRoleModel = new UserRoleModel(); $roles = $userRoleModel->select('roles.name') ->join('roles', 'roles.id = user_roles.role_id') ->where('user_roles.user_id', $user['id']) ->get() ->getResultArray(); if (empty($roles)) { log_message('error', 'No roles found for user ID: ' . $user['id']); return redirect()->back()->with('error', 'Role not assigned. Please contact support.'); } $roleNames = array_column($roles, 'name'); session()->set([ 'user_id' => $user['id'], 'user_email' => $user['email'], 'user_name' => $user['firstname'] . ' ' . $user['lastname'], 'user_type' => $user['user_type'], 'is_logged_in' => true, 'login_time' => time(), 'roles' => $roleNames, 'semester' => $this->semester, 'school_year' => $this->schoolYear, ]); $this->applyStylePreferences((int) $user['id']); log_message('debug', 'Session after login: ' . print_r(session()->get(), true)); //dd('Login successful. Roles:', $roleNames, session()->get()); if (count($roleNames) === 1) { // One role → set and redirect directly session()->set('role', $roleNames[0]); if ($redirectTo !== null) { return redirect()->to($redirectTo); } return $this->redirectToDashboard([$roleNames[0]]); } else { // Multiple roles → redirect to role selection view if ($redirectTo !== null) { session()->set('post_login_redirect', $redirectTo); } else { session()->remove('post_login_redirect'); } return redirect()->to('/select-role'); } } private function applyStylePreferences(int $userId): void { if (!$userId) { return; } $prefs = $this->preferencesModel->where('user_id', $userId)->first(); if (!$prefs) { return; } $styleCfg = config('Style'); $styleColor = (string) ($prefs['style_color'] ?? ''); if ($styleColor !== '' && isset(($styleCfg->stylePalettes ?? [])[$styleColor])) { session()->set('style_color', $styleColor); } $menuColor = (string) ($prefs['menu_color'] ?? ''); if ($menuColor === 'custom') { $bg = (string) ($prefs['menu_custom_bg'] ?? '#0f172a'); $tx = (string) ($prefs['menu_custom_text'] ?? '#ffffff'); $mode = (string) ($prefs['menu_custom_mode'] ?? 'dark'); session()->set('menu_color', 'custom'); session()->set('menu_custom_bg', $bg); session()->set('menu_custom_text', $tx); session()->set('menu_custom_mode', $mode); } elseif ($menuColor !== '' && isset(($styleCfg->menuPalettes ?? [])[$menuColor])) { session()->set('menu_color', $menuColor); } } private function redirectToDashboard(array $roles) { if (empty($roles)) { log_message('error', 'Empty roles array passed to redirectToDashboard.'); return redirect()->to('/landing_page/guest_dashboard'); } $roleModel = new RoleModel(); // Resolve all candidate roles (by name or slug), ordered by priority ASC $rows = $roleModel->findByNamesOrSlugs($roles); if (!empty($rows)) { $route = $rows[0]['dashboard_route'] ?? '/landing_page/guest_dashboard'; log_message('debug', 'Redirecting user to: ' . $route); return redirect()->to($route); } log_message('warning', 'No matching role found. Redirecting to guest dashboard.'); return redirect()->to('/landing_page/guest_dashboard'); } public function setRole() { $selectedRole = $this->request->getPost('selected_role'); $availableRoles = session()->get('roles'); $redirectTo = $this->sanitizeRedirectTarget((string) ($this->request->getPost('redirect_to') ?? session()->get('post_login_redirect') ?? '')); if (!$selectedRole || !in_array($selectedRole, $availableRoles)) { return redirect()->to('/select-role')->with('error', 'Invalid role selected.'); } session()->set('role', $selectedRole); session()->remove('post_login_redirect'); if ($redirectTo !== null) { return redirect()->to($redirectTo); } return $this->redirectToDashboard([$selectedRole]); } public function selectRole() { $roles = session()->get('roles'); if (!$roles || !is_array($roles)) { return redirect()->to('/login')->with('error', 'No roles available.'); } return view('auth/select_role', [ 'roles' => $roles, 'redirect_to' => (string) (session()->get('post_login_redirect') ?? ''), ]); } private function sanitizeRedirectTarget(string $redirectTo): ?string { $redirectTo = trim($redirectTo); if ($redirectTo === '') { return null; } if (preg_match('#^https?://#i', $redirectTo)) { $appHost = (string) parse_url(base_url('/'), PHP_URL_HOST); $targetHost = (string) parse_url($redirectTo, PHP_URL_HOST); $targetPath = (string) parse_url($redirectTo, PHP_URL_PATH); $targetQuery = (string) parse_url($redirectTo, PHP_URL_QUERY); if ($appHost === '' || $targetHost === '' || strcasecmp($appHost, $targetHost) !== 0) { return null; } $redirectTo = $targetPath !== '' ? $targetPath : '/'; if ($targetQuery !== '') { $redirectTo .= '?' . $targetQuery; } } if (!str_starts_with($redirectTo, '/')) { $redirectTo = '/' . ltrim($redirectTo, '/'); } if (str_starts_with($redirectTo, '//')) { return null; } return $redirectTo; } }