Files
alrahma_sunday_school/app/Controllers/AuthController.php
T
2026-05-16 13:44:12 -04:00

685 lines
24 KiB
PHP
Executable File

<?php
namespace App\Controllers;
use App\Models\LoginActivityModel;
use App\Models\UserModel;
use App\Models\UserRoleModel;
use CodeIgniter\Controller;
use CodeIgniter\Events\Events;
use App\Models\IpAttemptModel;
use App\Models\PasswordResetModel;
use CodeIgniter\I18n\Time;
use App\Models\RoleModel;
use App\Models\ConfigurationModel;
use App\Models\PreferencesModel;
require_once APPPATH . 'Helpers/pbkdf2_helper.php';
require_once APPPATH . 'Helpers/jwt_helper.php';
class AuthController extends Controller
{
protected $configModel;
protected $userModel;
protected $roleModel;
protected $schoolYear;
protected $semester;
protected $ipAttemptModel;
protected $loginActivityModel;
protected $preferencesModel;
public function __construct()
{
$this->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;
}
}