From 8d7ee3b9fc1bf2e7789d424e659cf811ca76da05 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 30 Jul 2026 01:15:53 -0400 Subject: [PATCH] add autologout session --- app/Config/Filters.php | 6 ++ app/Config/Routes.php | 2 + app/Config/SessionTimeout.php | 10 +-- app/Controllers/AuthController.php | 1 + .../View/SessionTimeoutController.php | 23 ++--- app/Filters/AuthFilter.php | 14 ++- public/assets/js/session_timeout.js | 4 +- public/js/session_timeout.js | 8 +- .../Api/SessionTimeoutControllerTest.php | 87 +++++++++++-------- .../Filters/AuthFilterSessionTimeoutTest.php | 41 +++++++++ 10 files changed, 138 insertions(+), 58 deletions(-) create mode 100644 tests/app/Filters/AuthFilterSessionTimeoutTest.php diff --git a/app/Config/Filters.php b/app/Config/Filters.php index e16a14f..0a34179 100644 --- a/app/Config/Filters.php +++ b/app/Config/Filters.php @@ -80,6 +80,12 @@ class Filters extends BaseConfig // Badge PDF generation posts (handled by auth; allow multiple submits without reload) 'badge', 'index.php/badge', + + // Session keep-alive endpoint updates only the current browser session. + 'session/ping', + 'index.php/session/ping', + 'session/ping-activity', + 'index.php/session/ping-activity', ]], ], 'after' => [ diff --git a/app/Config/Routes.php b/app/Config/Routes.php index cfa5a55..4b87c04 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -288,7 +288,9 @@ $routes->get('ui/style', 'View\UiController::style'); //Timeout page after timeout +$routes->get('session/get-timeout-config', 'View\SessionTimeoutController::getTimeoutConfig'); $routes->get('session/check-timeout', 'View\SessionTimeoutController::checkTimeout'); +$routes->post('session/ping-activity', 'View\SessionTimeoutController::pingActivity'); $routes->post('session/ping', 'View\SessionTimeoutController::pingActivity'); diff --git a/app/Config/SessionTimeout.php b/app/Config/SessionTimeout.php index 64eab3b..c98212d 100644 --- a/app/Config/SessionTimeout.php +++ b/app/Config/SessionTimeout.php @@ -4,14 +4,14 @@ namespace Config; class SessionTimeout { - // Session timeout in seconds (12 hours) - public const TIMEOUT_DURATION = 43200; + // Session timeout in seconds (30 minutes) + public const TIMEOUT_DURATION = 1800; - // Warning threshold in seconds (5 minutes before timeout) - public const WARNING_THRESHOLD = 42900; + // Show warning after 25 minutes of inactivity (5 minutes before timeout) + public const WARNING_THRESHOLD = 1500; // Server-side check interval (in seconds) - public const CHECK_INTERVAL = 300; // 5 minutes + public const CHECK_INTERVAL = 60; // 1 minute // Client-side check interval (in milliseconds) public const CLIENT_CHECK_INTERVAL = 60000; // 1 minute diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php index 91a0a3f..6247fc3 100644 --- a/app/Controllers/AuthController.php +++ b/app/Controllers/AuthController.php @@ -559,6 +559,7 @@ class AuthController extends BaseController 'user_type' => $user['user_type'], 'is_logged_in' => true, 'login_time' => time(), + 'last_activity' => time(), 'roles' => $roleNames, 'semester' => $this->semester, 'school_year' => $this->schoolYear, diff --git a/app/Controllers/View/SessionTimeoutController.php b/app/Controllers/View/SessionTimeoutController.php index 2c0970a..1244dc0 100644 --- a/app/Controllers/View/SessionTimeoutController.php +++ b/app/Controllers/View/SessionTimeoutController.php @@ -15,11 +15,11 @@ class SessionTimeoutController extends BaseController return $this->response->setJSON([ 'success' => true, 'timeout' => SessionTimeout::TIMEOUT_DURATION, - 'warning_time' => SessionTimeout::WARNING_THRESHOLD, - 'check_interval' => SessionTimeout::CHECK_INTERVAL, - 'logout_url' => site_url('auth/logout'), - 'keep_alive_url' => site_url('session/pingActivity'), - 'check_url' => site_url('session/checkTimeout') + 'warning_time' => SessionTimeout::TIMEOUT_DURATION - SessionTimeout::WARNING_THRESHOLD, + 'check_interval' => SessionTimeout::CLIENT_CHECK_INTERVAL, + 'logout_url' => site_url('logout'), + 'keep_alive_url' => site_url('session/ping-activity'), + 'check_url' => site_url('session/check-timeout') ]); } @@ -35,9 +35,9 @@ class SessionTimeoutController extends BaseController $lastActivity = $session->get('last_activity'); $elapsed = time() - $lastActivity; - if ($elapsed > SessionTimeout::TIMEOUT_DURATION) { + if ($elapsed >= SessionTimeout::TIMEOUT_DURATION) { return $this->expireSession(); - } elseif ($elapsed > SessionTimeout::WARNING_THRESHOLD) { + } elseif ($elapsed >= SessionTimeout::WARNING_THRESHOLD) { return $this->response->setJSON([ 'status' => 'warning', 'time_remaining' => SessionTimeout::TIMEOUT_DURATION - $elapsed @@ -56,7 +56,7 @@ class SessionTimeoutController extends BaseController // Only update if session is still valid if (!$session->has('last_activity') || - (time() - $session->get('last_activity') > SessionTimeout::TIMEOUT_DURATION)) { + (time() - $session->get('last_activity') >= SessionTimeout::TIMEOUT_DURATION)) { return $this->expireSession(); } @@ -86,7 +86,8 @@ class SessionTimeoutController extends BaseController $session->remove('last_activity'); $session->destroy(); - // Regenerate session ID for security - session_regenerate_id(true); + if (session_status() === PHP_SESSION_ACTIVE) { + session_regenerate_id(true); + } } -} \ No newline at end of file +} diff --git a/app/Filters/AuthFilter.php b/app/Filters/AuthFilter.php index d69386f..50dcfd5 100644 --- a/app/Filters/AuthFilter.php +++ b/app/Filters/AuthFilter.php @@ -5,6 +5,7 @@ namespace App\Filters; use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\ResponseInterface; use CodeIgniter\Filters\FilterInterface; +use Config\SessionTimeout; class AuthFilter implements FilterInterface { @@ -38,6 +39,7 @@ class AuthFilter implements FilterInterface $userRoles = $session->get('roles'); $userId = $session->get('user_id'); $loginTime = (int) $session->get('login_time'); + $lastActivity = (int) $session->get('last_activity'); // Must be logged in if (empty($userRoles) || empty($userId)) { @@ -55,6 +57,12 @@ class AuthFilter implements FilterInterface return $this->handleExpiredSession($request); } + if ($lastActivity <= 0 || (time() - $lastActivity) >= SessionTimeout::TIMEOUT_DURATION) { + return $this->handleExpiredSession($request, 'Your session has expired due to inactivity. Please log in again.'); + } + + $session->set('last_activity', time()); + // Load role IDs for this user $roleIdRows = $this->db->table('user_roles') ->select('role_id') @@ -83,7 +91,7 @@ class AuthFilter implements FilterInterface return $this->deny($request, "You don't have permission to use this feature."); } - private function handleExpiredSession(RequestInterface $request) + private function handleExpiredSession(RequestInterface $request, string $message = 'Your session has expired. Please log in again.') { session()->destroy(); @@ -92,7 +100,7 @@ class AuthFilter implements FilterInterface ->setStatusCode(401) ->setJSON([ 'status' => 'expired', - 'message' => 'Your session has expired. Please log in again.', + 'message' => $message, 'redirect' => '/login', ]); } @@ -104,7 +112,7 @@ class AuthFilter implements FilterInterface } return redirect()->to('/login?redirect_to=' . rawurlencode('/' . ltrim($target, '/'))) - ->with('error', 'Your session has expired. Please log in again.'); + ->with('error', $message); } private function parseRequirements(?array $arguments): array diff --git a/public/assets/js/session_timeout.js b/public/assets/js/session_timeout.js index 47c0d88..9df6e8d 100644 --- a/public/assets/js/session_timeout.js +++ b/public/assets/js/session_timeout.js @@ -4,7 +4,7 @@ class SessionTimeoutManager { timeout: 1800, // default 30 min warning_time: 1500, // default 5 min before timeout check_interval: 60000, // 1 minute - logout_url: '/auth/logout', + logout_url: '/logout', keep_alive_url: '/session/ping-activity', check_url: '/session/check-timeout' }; @@ -244,4 +244,4 @@ if (document.readyState === 'loading') { } // Make available globally -window.sessionTimeout = sessionTimeout; \ No newline at end of file +window.sessionTimeout = sessionTimeout; diff --git a/public/js/session_timeout.js b/public/js/session_timeout.js index 70e2f26..6a2b758 100644 --- a/public/js/session_timeout.js +++ b/public/js/session_timeout.js @@ -4,7 +4,7 @@ class SessionTimeoutManager { timeout: 1800, // 30 minutes warning_time: 300, // 5 minutes before timeout check_interval: 60000, // 1 minute - logout_url: '/auth/logout', + logout_url: '/logout', keep_alive_url: '/session/ping-activity', check_url: '/session/check-timeout' }; @@ -26,7 +26,9 @@ class SessionTimeoutManager { } // Try to get CSRF token from form input (fallback) - const csrfInput = document.querySelector('input[name="' + csrf_token + '"]'); + const csrfInput = typeof csrf_token !== 'undefined' + ? document.querySelector('input[name="' + csrf_token + '"]') + : null; if (csrfInput) { return csrfInput.value; } @@ -241,4 +243,4 @@ if (document.readyState === 'loading') { } // Make available globally -window.sessionTimeout = sessionTimeout; \ No newline at end of file +window.sessionTimeout = sessionTimeout; diff --git a/tests/app/Controllers/Api/SessionTimeoutControllerTest.php b/tests/app/Controllers/Api/SessionTimeoutControllerTest.php index 6d02e18..68e695a 100644 --- a/tests/app/Controllers/Api/SessionTimeoutControllerTest.php +++ b/tests/app/Controllers/Api/SessionTimeoutControllerTest.php @@ -2,22 +2,13 @@ namespace Tests\App\Controllers\Api; -use App\Controllers\Api\SessionTimeoutController; -use CodeIgniter\HTTP\ResponseInterface; +use App\Controllers\View\SessionTimeoutController; +use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\URI; +use CodeIgniter\HTTP\UserAgent; use CodeIgniter\Test\CIUnitTestCase; - -class TestableSessionTimeoutController extends SessionTimeoutController -{ - protected function success($data = null, string $message = 'Success', int $code = ResponseInterface::HTTP_OK) - { - return ['data' => $data, 'message' => $message, 'code' => $code]; - } - - protected function error(string $message = 'An error occurred', int $code = ResponseInterface::HTTP_BAD_REQUEST, ?array $errors = null) - { - return ['message' => $message, 'code' => $code, 'errors' => $errors]; - } -} +use Config\App; +use Config\Services; class SessionTimeoutControllerTest extends CIUnitTestCase { @@ -25,37 +16,65 @@ class SessionTimeoutControllerTest extends CIUnitTestCase { parent::tearDown(); session()->destroy(); + Services::resetSingle('response'); } - public function testGetTimeoutConfigIncludesKeys() + public function testGetTimeoutConfigIncludesThirtyMinuteSettings(): void { - $controller = new TestableSessionTimeoutController(); - $response = $controller->getTimeoutConfig(); + $this->expectOutputRegex('/^\s*$/'); - $this->assertSame(ResponseInterface::HTTP_OK, $response['code']); - $this->assertArrayHasKey('timeout', $response['data']); - $this->assertArrayHasKey('check_url', $response['data']); + $response = $this->controller()->getTimeoutConfig(); + $body = json_decode((string) $response->getBody(), true); + + $this->assertTrue($body['success']); + $this->assertSame(1800, $body['timeout']); + $this->assertSame(300, $body['warning_time']); + $this->assertStringContainsString('session/check-timeout', $body['check_url']); + $this->assertStringContainsString('session/ping-activity', $body['keep_alive_url']); } - public function testCheckTimeoutReturnsActiveWhenFresh() + public function testCheckTimeoutReturnsActiveWhenFresh(): void { session()->set('last_activity', time()); - $controller = new TestableSessionTimeoutController(); - $response = $controller->checkTimeout(); - $this->assertSame(ResponseInterface::HTTP_OK, $response['code']); - $this->assertSame('Session active', $response['message']); - $this->assertSame('active', $response['data']['status']); + $response = $this->controller()->checkTimeout(); + $body = json_decode((string) $response->getBody(), true); + + $this->assertSame('active', $body['status']); + $this->assertGreaterThan(0, $body['time_remaining']); } - public function testPingActivityRefreshesSession() + public function testCheckTimeoutExpiresAfterThirtyMinutesOfInactivity(): void { - $controller = new TestableSessionTimeoutController(); - session()->set('last_activity', time()); + session()->set('last_activity', time() - 1800); - $response = $controller->pingActivity(); - $this->assertSame(ResponseInterface::HTTP_OK, $response['code']); - $this->assertSame('Session activity refreshed', $response['message']); - $this->assertSame('active', $response['data']['status']); + $response = $this->controller()->checkTimeout(); + $body = json_decode((string) $response->getBody(), true); + + $this->assertSame('expired', $body['status']); + $this->assertStringContainsString('login', $body['redirect']); + } + + public function testPingActivityRefreshesSession(): void + { + session()->set('last_activity', time() - 60); + + $response = $this->controller()->pingActivity(); + $body = json_decode((string) $response->getBody(), true); + + $this->assertSame('active', $body['status']); + $this->assertSame(1800, $body['time_remaining']); + $this->assertGreaterThanOrEqual(time() - 1, session()->get('last_activity')); + } + + private function controller(): SessionTimeoutController + { + Services::resetSingle('response'); + + $controller = new SessionTimeoutController(); + $request = new IncomingRequest(config(App::class), new URI('https://example.test/session/check-timeout'), 'php://input', new UserAgent()); + $controller->initController($request, Services::response(), Services::logger()); + + return $controller; } } diff --git a/tests/app/Filters/AuthFilterSessionTimeoutTest.php b/tests/app/Filters/AuthFilterSessionTimeoutTest.php new file mode 100644 index 0000000..6bc3b4c --- /dev/null +++ b/tests/app/Filters/AuthFilterSessionTimeoutTest.php @@ -0,0 +1,41 @@ +destroy(); + } + + public function testAjaxRequestExpiresAfterThirtyMinutesOfInactivity(): void + { + session()->set([ + 'roles' => ['admin'], + 'user_id' => 123, + 'login_time' => time() - 120, + 'last_activity' => time() - 1800, + ]); + + $request = new IncomingRequest(config(App::class), new URI('https://example.test/administrator'), 'php://input', new UserAgent()); + $request->setHeader('X-Requested-With', 'XMLHttpRequest'); + + $result = (new AuthFilter())->before($request); + $body = json_decode((string) $result->getBody(), true); + + $this->assertInstanceOf(Response::class, $result); + $this->assertSame(401, $result->getStatusCode()); + $this->assertSame('expired', $body['status']); + $this->assertStringContainsString('inactivity', $body['message']); + } +}