Files
alrahma_sunday_school/app/Controllers/AuthController.php
T
root 0f8ad86b4f
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 47s
Tests / PHPUnit (push) Successful in 1m20s
Add canonical user access profile table
Create user_access_profiles as a denormalized access index over the existing roles and user_roles tables. Store primary_category plus is_admin, is_teacher, and is_parent flags so permission checks can use a single canonical source for broad user groups.

Classify teacher and teacher_assistant/TA roles as teacher access, parent as parent access, and every other active staff role as admin access. Backfill all existing users during migration and keep profiles synchronized when role assignments are inserted, updated, or deleted.

Expose the computed access profile in web sessions and auth API responses while preserving the existing detailed roles array and route-filter behavior. Add unit coverage for TA, staff-admin, and multi-role parent/teacher classification.
2026-08-29 23:20:40 -04:00

795 lines
28 KiB
PHP

<?php
namespace App\Controllers;
use App\Models\LoginActivityModel;
use App\Models\UserModel;
use App\Models\UserRoleModel;
use App\Models\UserAccessProfileModel;
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 BaseController
{
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 = getSemester();
}
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') ?? ''));
$validator = \Config\Services::validation();
$requestData = sanitize_request_value($this->request->getPost(['email', 'password']));
$validator->setRules([
'email' => 'required|valid_email|max_length[254]',
'password' => 'required|max_length[255]',
]);
if (! $validator->run($requestData)) {
return redirect()
->back()
->with('error', 'Please enter a valid email and password.')
->withInput();
}
// Step 1: Get email, password, and IP from the request
$email = strtolower((string) $requestData['email']);
$password = (string) $requestData['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 = sanitize_request_value($this->request->getJSON(true) ?: [
'email' => $this->request->getPost('email'),
'password' => $this->request->getPost('password'),
]);
$validation = \Config\Services::validation();
$validation->setRules([
'email' => 'required|valid_email|max_length[254]',
'password' => 'required|max_length[255]',
]);
if (! $validation->run($requestData)) {
return $this->response->setStatusCode(422)->setJSON([
'status' => false,
'message' => 'Validation failed.',
'errors' => $validation->getErrors(),
]);
}
$email = strtolower((string) ($requestData['email'] ?? ''));
$password = (string) ($requestData['password'] ?? '');
$ip = $this->request->getIPAddress();
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
$roleNames = $this->getUserRoleNames((int) $user['id']);
$accessProfile = $this->accessProfileForUser((int) $user['id'], $roleNames);
// 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,
];
try {
$secret = require_env('JWT_SECRET');
} catch (\RuntimeException $e) {
return $this->response->setStatusCode(500)->setJSON([
'status' => false,
'message' => 'JWT configuration is missing.',
]);
}
$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,
'primary_category' => $accessProfile['primary_category'],
'is_admin' => $accessProfile['is_admin'],
'is_teacher' => $accessProfile['is_teacher'],
'is_parent' => $accessProfile['is_parent'],
],
]);
}
public function adminAuthMe()
{
if (! session()->get('is_logged_in')) {
return $this->response->setStatusCode(401)->setJSON([
'status' => false,
'message' => 'Unauthenticated.',
]);
}
$userId = (int) session()->get('user_id');
$roles = array_values(array_filter((array) (session()->get('roles') ?? [])));
$activeRole = session()->get('role');
return $this->response->setJSON([
'status' => true,
'user' => [
'id' => $userId,
'email' => session()->get('user_email'),
'name' => session()->get('user_name'),
'type' => session()->get('user_type'),
'roles' => $roles,
'role' => $activeRole,
'primary_category' => session()->get('primary_category'),
'is_admin' => (bool) session()->get('is_admin'),
'is_teacher' => (bool) session()->get('is_teacher'),
'is_parent' => (bool) session()->get('is_parent'),
],
]);
}
/**
* API Registration endpoint
* POST /api/v1/register
*/
public function apiRegister()
{
$requestData = sanitize_request_value($this->request->getJSON(true) ?: $this->request->getPost());
// Basic validation
$rules = [
'firstname' => "required|regex_match[/^[\\p{L}\\s'\\-]+$/u]|min_length[2]|max_length[30]",
'lastname' => "required|regex_match[/^[\\p{L}\\s'\\-]+$/u]|min_length[2]|max_length[30]",
'email' => 'required|valid_email|max_length[254]|is_unique[users.email]',
'password' => 'required|min_length[8]|max_length[255]',
'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' => getSemester(),
'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,
];
$accessProfile = $this->accessProfileForUser((int) $userId, $payload['roles']);
$secret = require_env('JWT_SECRET');
$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'],
'primary_category' => $accessProfile['primary_category'],
'is_admin' => $accessProfile['is_admin'],
'is_teacher' => $accessProfile['is_teacher'],
'is_parent' => $accessProfile['is_parent'],
],
]);
} 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]);
}
protected function getUserRoleNames(int $userId): array
{
$userRoleModel = new UserRoleModel();
$db = \Config\Database::connect();
$builder = $userRoleModel->select('roles.name')
->join('roles', 'roles.id = user_roles.role_id')
->where('user_roles.user_id', $userId)
->where('COALESCE(roles.is_active, 1) = 1', null, false);
if ($db->fieldExists('deleted_at', 'user_roles')) {
$builder->where('user_roles.deleted_at', null);
}
$rolesRows = $builder->get()->getResultArray();
return array_column($rolesRows, 'name');
}
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();
$db = \Config\Database::connect();
$rolesBuilder = $userRoleModel->select('roles.name')
->join('roles', 'roles.id = user_roles.role_id')
->where('user_roles.user_id', $user['id'])
->where('COALESCE(roles.is_active, 1) = 1', null, false);
if ($db->fieldExists('deleted_at', 'user_roles')) {
$rolesBuilder->where('user_roles.deleted_at', null);
}
$roles = $rolesBuilder->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');
$accessProfile = $this->accessProfileForUser((int) $user['id'], $roleNames);
session()->regenerate(true);
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(),
'last_activity' => time(),
'roles' => $roleNames,
'primary_category' => $accessProfile['primary_category'],
'is_admin' => $accessProfile['is_admin'],
'is_teacher' => $accessProfile['is_teacher'],
'is_parent' => $accessProfile['is_parent'],
'semester' => $this->semester,
'school_year' => $this->schoolYear,
]);
$this->applyStylePreferences((int) $user['id']);
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 accessProfileForUser(int $userId, array $roleNames): array
{
try {
$profile = model(UserAccessProfileModel::class)->getForUser($userId);
if ($profile !== null) {
return [
'primary_category' => (string) ($profile['primary_category'] ?? UserAccessProfileModel::CATEGORY_GUEST),
'is_admin' => (bool) ($profile['is_admin'] ?? false),
'is_teacher' => (bool) ($profile['is_teacher'] ?? false),
'is_parent' => (bool) ($profile['is_parent'] ?? false),
];
}
} catch (\Throwable $e) {
log_message('warning', 'Unable to load user access profile: ' . $e->getMessage());
}
$flags = UserAccessProfileModel::flagsForRoles($roleNames);
return [
'primary_category' => UserAccessProfileModel::primaryCategory($flags),
'is_admin' => (bool) $flags['is_admin'],
'is_teacher' => (bool) $flags['is_teacher'],
'is_parent' => (bool) $flags['is_parent'],
];
}
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;
}
}