@@ -80,6 +80,12 @@ class Filters extends BaseConfig
|
|||||||
// Badge PDF generation posts (handled by auth; allow multiple submits without reload)
|
// Badge PDF generation posts (handled by auth; allow multiple submits without reload)
|
||||||
'badge',
|
'badge',
|
||||||
'index.php/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' => [
|
'after' => [
|
||||||
|
|||||||
@@ -288,7 +288,9 @@ $routes->get('ui/style', 'View\UiController::style');
|
|||||||
|
|
||||||
|
|
||||||
//Timeout page after timeout
|
//Timeout page after timeout
|
||||||
|
$routes->get('session/get-timeout-config', 'View\SessionTimeoutController::getTimeoutConfig');
|
||||||
$routes->get('session/check-timeout', 'View\SessionTimeoutController::checkTimeout');
|
$routes->get('session/check-timeout', 'View\SessionTimeoutController::checkTimeout');
|
||||||
|
$routes->post('session/ping-activity', 'View\SessionTimeoutController::pingActivity');
|
||||||
$routes->post('session/ping', 'View\SessionTimeoutController::pingActivity');
|
$routes->post('session/ping', 'View\SessionTimeoutController::pingActivity');
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ namespace Config;
|
|||||||
|
|
||||||
class SessionTimeout
|
class SessionTimeout
|
||||||
{
|
{
|
||||||
// Session timeout in seconds (12 hours)
|
// Session timeout in seconds (30 minutes)
|
||||||
public const TIMEOUT_DURATION = 43200;
|
public const TIMEOUT_DURATION = 1800;
|
||||||
|
|
||||||
// Warning threshold in seconds (5 minutes before timeout)
|
// Show warning after 25 minutes of inactivity (5 minutes before timeout)
|
||||||
public const WARNING_THRESHOLD = 42900;
|
public const WARNING_THRESHOLD = 1500;
|
||||||
|
|
||||||
// Server-side check interval (in seconds)
|
// 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)
|
// Client-side check interval (in milliseconds)
|
||||||
public const CLIENT_CHECK_INTERVAL = 60000; // 1 minute
|
public const CLIENT_CHECK_INTERVAL = 60000; // 1 minute
|
||||||
|
|||||||
@@ -559,6 +559,7 @@ class AuthController extends BaseController
|
|||||||
'user_type' => $user['user_type'],
|
'user_type' => $user['user_type'],
|
||||||
'is_logged_in' => true,
|
'is_logged_in' => true,
|
||||||
'login_time' => time(),
|
'login_time' => time(),
|
||||||
|
'last_activity' => time(),
|
||||||
'roles' => $roleNames,
|
'roles' => $roleNames,
|
||||||
'semester' => $this->semester,
|
'semester' => $this->semester,
|
||||||
'school_year' => $this->schoolYear,
|
'school_year' => $this->schoolYear,
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ class SessionTimeoutController extends BaseController
|
|||||||
return $this->response->setJSON([
|
return $this->response->setJSON([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'timeout' => SessionTimeout::TIMEOUT_DURATION,
|
'timeout' => SessionTimeout::TIMEOUT_DURATION,
|
||||||
'warning_time' => SessionTimeout::WARNING_THRESHOLD,
|
'warning_time' => SessionTimeout::TIMEOUT_DURATION - SessionTimeout::WARNING_THRESHOLD,
|
||||||
'check_interval' => SessionTimeout::CHECK_INTERVAL,
|
'check_interval' => SessionTimeout::CLIENT_CHECK_INTERVAL,
|
||||||
'logout_url' => site_url('auth/logout'),
|
'logout_url' => site_url('logout'),
|
||||||
'keep_alive_url' => site_url('session/pingActivity'),
|
'keep_alive_url' => site_url('session/ping-activity'),
|
||||||
'check_url' => site_url('session/checkTimeout')
|
'check_url' => site_url('session/check-timeout')
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,9 +35,9 @@ class SessionTimeoutController extends BaseController
|
|||||||
$lastActivity = $session->get('last_activity');
|
$lastActivity = $session->get('last_activity');
|
||||||
$elapsed = time() - $lastActivity;
|
$elapsed = time() - $lastActivity;
|
||||||
|
|
||||||
if ($elapsed > SessionTimeout::TIMEOUT_DURATION) {
|
if ($elapsed >= SessionTimeout::TIMEOUT_DURATION) {
|
||||||
return $this->expireSession();
|
return $this->expireSession();
|
||||||
} elseif ($elapsed > SessionTimeout::WARNING_THRESHOLD) {
|
} elseif ($elapsed >= SessionTimeout::WARNING_THRESHOLD) {
|
||||||
return $this->response->setJSON([
|
return $this->response->setJSON([
|
||||||
'status' => 'warning',
|
'status' => 'warning',
|
||||||
'time_remaining' => SessionTimeout::TIMEOUT_DURATION - $elapsed
|
'time_remaining' => SessionTimeout::TIMEOUT_DURATION - $elapsed
|
||||||
@@ -56,7 +56,7 @@ class SessionTimeoutController extends BaseController
|
|||||||
|
|
||||||
// Only update if session is still valid
|
// Only update if session is still valid
|
||||||
if (!$session->has('last_activity') ||
|
if (!$session->has('last_activity') ||
|
||||||
(time() - $session->get('last_activity') > SessionTimeout::TIMEOUT_DURATION)) {
|
(time() - $session->get('last_activity') >= SessionTimeout::TIMEOUT_DURATION)) {
|
||||||
return $this->expireSession();
|
return $this->expireSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +86,8 @@ class SessionTimeoutController extends BaseController
|
|||||||
$session->remove('last_activity');
|
$session->remove('last_activity');
|
||||||
$session->destroy();
|
$session->destroy();
|
||||||
|
|
||||||
// Regenerate session ID for security
|
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||||
session_regenerate_id(true);
|
session_regenerate_id(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Filters;
|
|||||||
use CodeIgniter\HTTP\RequestInterface;
|
use CodeIgniter\HTTP\RequestInterface;
|
||||||
use CodeIgniter\HTTP\ResponseInterface;
|
use CodeIgniter\HTTP\ResponseInterface;
|
||||||
use CodeIgniter\Filters\FilterInterface;
|
use CodeIgniter\Filters\FilterInterface;
|
||||||
|
use Config\SessionTimeout;
|
||||||
|
|
||||||
class AuthFilter implements FilterInterface
|
class AuthFilter implements FilterInterface
|
||||||
{
|
{
|
||||||
@@ -38,6 +39,7 @@ class AuthFilter implements FilterInterface
|
|||||||
$userRoles = $session->get('roles');
|
$userRoles = $session->get('roles');
|
||||||
$userId = $session->get('user_id');
|
$userId = $session->get('user_id');
|
||||||
$loginTime = (int) $session->get('login_time');
|
$loginTime = (int) $session->get('login_time');
|
||||||
|
$lastActivity = (int) $session->get('last_activity');
|
||||||
|
|
||||||
// Must be logged in
|
// Must be logged in
|
||||||
if (empty($userRoles) || empty($userId)) {
|
if (empty($userRoles) || empty($userId)) {
|
||||||
@@ -55,6 +57,12 @@ class AuthFilter implements FilterInterface
|
|||||||
return $this->handleExpiredSession($request);
|
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
|
// Load role IDs for this user
|
||||||
$roleIdRows = $this->db->table('user_roles')
|
$roleIdRows = $this->db->table('user_roles')
|
||||||
->select('role_id')
|
->select('role_id')
|
||||||
@@ -83,7 +91,7 @@ class AuthFilter implements FilterInterface
|
|||||||
return $this->deny($request, "You don't have permission to use this feature.");
|
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();
|
session()->destroy();
|
||||||
|
|
||||||
@@ -92,7 +100,7 @@ class AuthFilter implements FilterInterface
|
|||||||
->setStatusCode(401)
|
->setStatusCode(401)
|
||||||
->setJSON([
|
->setJSON([
|
||||||
'status' => 'expired',
|
'status' => 'expired',
|
||||||
'message' => 'Your session has expired. Please log in again.',
|
'message' => $message,
|
||||||
'redirect' => '/login',
|
'redirect' => '/login',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -104,7 +112,7 @@ class AuthFilter implements FilterInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
return redirect()->to('/login?redirect_to=' . rawurlencode('/' . ltrim($target, '/')))
|
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
|
private function parseRequirements(?array $arguments): array
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ class SessionTimeoutManager {
|
|||||||
timeout: 1800, // default 30 min
|
timeout: 1800, // default 30 min
|
||||||
warning_time: 1500, // default 5 min before timeout
|
warning_time: 1500, // default 5 min before timeout
|
||||||
check_interval: 60000, // 1 minute
|
check_interval: 60000, // 1 minute
|
||||||
logout_url: '/auth/logout',
|
logout_url: '/logout',
|
||||||
keep_alive_url: '/session/ping-activity',
|
keep_alive_url: '/session/ping-activity',
|
||||||
check_url: '/session/check-timeout'
|
check_url: '/session/check-timeout'
|
||||||
};
|
};
|
||||||
@@ -244,4 +244,4 @@ if (document.readyState === 'loading') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Make available globally
|
// Make available globally
|
||||||
window.sessionTimeout = sessionTimeout;
|
window.sessionTimeout = sessionTimeout;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ class SessionTimeoutManager {
|
|||||||
timeout: 1800, // 30 minutes
|
timeout: 1800, // 30 minutes
|
||||||
warning_time: 300, // 5 minutes before timeout
|
warning_time: 300, // 5 minutes before timeout
|
||||||
check_interval: 60000, // 1 minute
|
check_interval: 60000, // 1 minute
|
||||||
logout_url: '/auth/logout',
|
logout_url: '/logout',
|
||||||
keep_alive_url: '/session/ping-activity',
|
keep_alive_url: '/session/ping-activity',
|
||||||
check_url: '/session/check-timeout'
|
check_url: '/session/check-timeout'
|
||||||
};
|
};
|
||||||
@@ -26,7 +26,9 @@ class SessionTimeoutManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try to get CSRF token from form input (fallback)
|
// 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) {
|
if (csrfInput) {
|
||||||
return csrfInput.value;
|
return csrfInput.value;
|
||||||
}
|
}
|
||||||
@@ -241,4 +243,4 @@ if (document.readyState === 'loading') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Make available globally
|
// Make available globally
|
||||||
window.sessionTimeout = sessionTimeout;
|
window.sessionTimeout = sessionTimeout;
|
||||||
|
|||||||
@@ -2,22 +2,13 @@
|
|||||||
|
|
||||||
namespace Tests\App\Controllers\Api;
|
namespace Tests\App\Controllers\Api;
|
||||||
|
|
||||||
use App\Controllers\Api\SessionTimeoutController;
|
use App\Controllers\View\SessionTimeoutController;
|
||||||
use CodeIgniter\HTTP\ResponseInterface;
|
use CodeIgniter\HTTP\IncomingRequest;
|
||||||
|
use CodeIgniter\HTTP\URI;
|
||||||
|
use CodeIgniter\HTTP\UserAgent;
|
||||||
use CodeIgniter\Test\CIUnitTestCase;
|
use CodeIgniter\Test\CIUnitTestCase;
|
||||||
|
use Config\App;
|
||||||
class TestableSessionTimeoutController extends SessionTimeoutController
|
use Config\Services;
|
||||||
{
|
|
||||||
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];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class SessionTimeoutControllerTest extends CIUnitTestCase
|
class SessionTimeoutControllerTest extends CIUnitTestCase
|
||||||
{
|
{
|
||||||
@@ -25,37 +16,65 @@ class SessionTimeoutControllerTest extends CIUnitTestCase
|
|||||||
{
|
{
|
||||||
parent::tearDown();
|
parent::tearDown();
|
||||||
session()->destroy();
|
session()->destroy();
|
||||||
|
Services::resetSingle('response');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testGetTimeoutConfigIncludesKeys()
|
public function testGetTimeoutConfigIncludesThirtyMinuteSettings(): void
|
||||||
{
|
{
|
||||||
$controller = new TestableSessionTimeoutController();
|
$this->expectOutputRegex('/^\s*$/');
|
||||||
$response = $controller->getTimeoutConfig();
|
|
||||||
|
|
||||||
$this->assertSame(ResponseInterface::HTTP_OK, $response['code']);
|
$response = $this->controller()->getTimeoutConfig();
|
||||||
$this->assertArrayHasKey('timeout', $response['data']);
|
$body = json_decode((string) $response->getBody(), true);
|
||||||
$this->assertArrayHasKey('check_url', $response['data']);
|
|
||||||
|
$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());
|
session()->set('last_activity', time());
|
||||||
$controller = new TestableSessionTimeoutController();
|
|
||||||
$response = $controller->checkTimeout();
|
|
||||||
|
|
||||||
$this->assertSame(ResponseInterface::HTTP_OK, $response['code']);
|
$response = $this->controller()->checkTimeout();
|
||||||
$this->assertSame('Session active', $response['message']);
|
$body = json_decode((string) $response->getBody(), true);
|
||||||
$this->assertSame('active', $response['data']['status']);
|
|
||||||
|
$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() - 1800);
|
||||||
session()->set('last_activity', time());
|
|
||||||
|
|
||||||
$response = $controller->pingActivity();
|
$response = $this->controller()->checkTimeout();
|
||||||
$this->assertSame(ResponseInterface::HTTP_OK, $response['code']);
|
$body = json_decode((string) $response->getBody(), true);
|
||||||
$this->assertSame('Session activity refreshed', $response['message']);
|
|
||||||
$this->assertSame('active', $response['data']['status']);
|
$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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\App\Filters;
|
||||||
|
|
||||||
|
use App\Filters\AuthFilter;
|
||||||
|
use CodeIgniter\HTTP\IncomingRequest;
|
||||||
|
use CodeIgniter\HTTP\Response;
|
||||||
|
use CodeIgniter\HTTP\URI;
|
||||||
|
use CodeIgniter\HTTP\UserAgent;
|
||||||
|
use CodeIgniter\Test\CIUnitTestCase;
|
||||||
|
use Config\App;
|
||||||
|
|
||||||
|
class AuthFilterSessionTimeoutTest extends CIUnitTestCase
|
||||||
|
{
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
parent::tearDown();
|
||||||
|
session()->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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user