Session and ACL Login Class update

Session:
regenerate session id after some time or random.
Default is 'never', can be 'interval' form 0 to 1h and random from always to 1 in 100
Session also checks that strict session settings are enabled

Login class:
Automatic re-read of acl settings after some time (default 5min, can be chnaged via option).
Default set strict headers, can be turned off via option
Moved various parts into their own methods and cleaned up double call logic.
Login is now recorded in the last login entry
no more debug flags are read from the database anymore
All options are set via array and not with a single option (was auto login)
This commit is contained in:
Clemens Schwaighofer
2024-12-11 21:02:21 +09:00
parent 2b0434e36b
commit 8d3882a6fe
9 changed files with 944 additions and 527 deletions

View File

@@ -37,6 +37,8 @@ CREATE TABLE edit_user (
protected SMALLINT NOT NULL DEFAULT 0,
-- is admin user
admin SMALLINT NOT NULL DEFAULT 0,
-- force lgout counter
force_logout INT DEFAULT 0,
-- last login log
last_login TIMESTAMP WITHOUT TIME ZONE,
-- login error
@@ -74,6 +76,7 @@ COMMENT ON COLUMN edit_user.strict IS 'If too many failed logins user will be lo
COMMENT ON COLUMN edit_user.locked IS 'Locked from too many wrong password logins';
COMMENT ON COLUMN edit_user.protected IS 'User can only be chnaged by admin user';
COMMENT ON COLUMN edit_user.admin IS 'If set, this user is SUPER admin';
COMMENT ON COLUMN edit_user.force_logout IS 'Counter for forced log out, if this one is higher than the session set one the session gets terminated';
COMMENT ON COLUMN edit_user.last_login IS 'Last succesfull login tiemstamp';
COMMENT ON COLUMN edit_user.login_error_count IS 'Number of failed logins, reset on successful login';
COMMENT ON COLUMN edit_user.login_error_date_last IS 'Last login error date';

View File

@@ -1185,7 +1185,6 @@ final class CoreLibsACLLoginTest extends TestCase
foreach ($session as $session_var => $session_value) {
$_SESSION[$session_var] = $session_value;
}
/** @var \CoreLibs\ACL\Login&MockObject */
$login_mock = $this->getMockBuilder(\CoreLibs\ACL\Login::class)
->setConstructorArgs([
@@ -1204,7 +1203,7 @@ final class CoreLibsACLLoginTest extends TestCase
. 'locale' . DIRECTORY_SEPARATOR,
]
])
->onlyMethods(['loginTerminate', 'loginReadPageName', 'loginPrintLogin'])
->onlyMethods(['loginTerminate', 'loginReadPageName', 'loginPrintLogin', 'loginEnhanceHttpSecurity'])
->getMock();
$login_mock->expects($this->any())
->method('loginTerminate')
@@ -1222,6 +1221,10 @@ final class CoreLibsACLLoginTest extends TestCase
->method('loginPrintLogin')
->willReturnCallback(function () {
});
$login_mock->expects($this->any())
->method('loginEnhanceHttpSecurity')
->willReturnCallback(function () {
});
// if mock_settings: enabled OFF
// run DB update and set off

View File

@@ -581,6 +581,8 @@ CREATE TABLE edit_user (
protected SMALLINT NOT NULL DEFAULT 0,
-- is admin user
admin SMALLINT NOT NULL DEFAULT 0,
-- forced logout counter
force_logout INT DEFAULT 0,
-- last login log
last_login TIMESTAMP WITHOUT TIME ZONE,
-- login error
@@ -697,6 +699,7 @@ CREATE TABLE edit_log (
action_value VARCHAR, -- in action_data
action_type VARCHAR, -- in action_data
action_error VARCHAR -- in action_data
) INHERITS (edit_generic) WITHOUT OIDS;
-- END: table/edit_log.sql
-- START: table/edit_log_overflow.sql
-- AUTHOR: Clemens Schwaighofer

View File

@@ -54,7 +54,9 @@ final class CoreLibsCreateSessionTest extends TestCase
'getSessionId' => '1234abcd4567'
],
'sessionNameGlobals',
false,
[
'auto_write_close' => false,
],
],
'auto write close' => [
'sessionNameAutoWriteClose',
@@ -66,7 +68,9 @@ final class CoreLibsCreateSessionTest extends TestCase
'getSessionId' => '1234abcd4567'
],
'sessionNameAutoWriteClose',
true,
[
'auto_write_close' => true,
],
],
];
}
@@ -81,13 +85,14 @@ final class CoreLibsCreateSessionTest extends TestCase
* @param string $input
* @param array<mixed> $mock_data
* @param string $expected
* @param array<string,mixed> $options
* @return void
*/
public function testStartSession(
string $input,
array $mock_data,
string $expected,
?bool $auto_write_close,
?array $options,
): void {
/** @var \CoreLibs\Create\Session&MockObject $session_mock */
$session_mock = $this->createPartialMock(
@@ -174,9 +179,14 @@ final class CoreLibsCreateSessionTest extends TestCase
4,
'/^\[SESSION\] Failed to activate session/'
],
'expired session' => [
\RuntimeException::class,
5,
'/^\[SESSION\] Expired session found/'
],
'not a valid session id returned' => [
\UnexpectedValueException::class,
5,
6,
'/^\[SESSION\] getSessionId did not return a session id/'
], */
];
@@ -206,7 +216,8 @@ final class CoreLibsCreateSessionTest extends TestCase
$this->expectException($exception);
$this->expectExceptionCode($exception_code);
$this->expectExceptionMessageMatches($expected_error);
new \CoreLibs\Create\Session($session_name);
// cannot set ini after header sent, plus we are on command line there are no headers
new \CoreLibs\Create\Session($session_name, ['session_strict' => false]);
}
/**

View File

@@ -7,6 +7,10 @@ ALTER TABLE edit_log ADD http_data JSONB;
ALTER TABLE edit_log ADD ip_address JSONB;
ALTER TABLE edit_log ADD action_data JSONB;
ALTER TABLE edit_log ADD request_scheme VARCHAR;
ALTER TABLE edit_user ADD force_logout INT DEFAULT 0;
COMMENT ON COLUMN edit_user.force_logout IS 'Counter for forced log out, if this one is higher than the session set one the session gets terminated';
ALTER TABLE edit_user ADD last_login TIMESTAMP WITHOUT TIME ZONE;
COMMENT ON COLUMN edit_user.last_login IS 'Last succesfull login tiemstamp';
-- update set_edit_gneric
-- adds the created or updated date tags

View File

@@ -21,7 +21,10 @@ $SET_SESSION_NAME = EDIT_SESSION_NAME;
use CoreLibs\Debug\Support;
// init login & backend class
$session = new CoreLibs\Create\Session($SET_SESSION_NAME);
$session = new CoreLibs\Create\Session($SET_SESSION_NAME, [
'regenerate' => 'interval',
'regenerate_interval' => 10, // every 10 seconds
]);
$log = new CoreLibs\Logging\Logging([
'log_folder' => BASE . LOG,
'log_file_id' => $LOG_FILE_ID,
@@ -90,6 +93,8 @@ print <<<HTML
</div>
HTML;
echo "SESSION ID: " . $session->getSessionIdCall() . "<br>";
echo "CHECK PERMISSION: " . ($login->loginCheckPermissions() ? 'OK' : 'BAD') . "<br>";
echo "IS ADMIN: " . ($login->loginIsAdmin() ? 'OK' : 'BAD') . "<br>";
echo "MIN ACCESS BASE: " . ($login->loginCheckAccessBase('admin') ? 'OK' : 'BAD') . "<br>";
@@ -118,8 +123,7 @@ if (isset($login->loginGetAcl()['unit'])) {
print "Something went wrong with the login<br>";
}
echo "<hr>";
// echo "<hr>";
// IP check: 'REMOTE_ADDR', 'HTTP_X_FORWARDED_FOR', 'CLIENT_IP' in _SERVER
// Agent check: 'HTTP_USER_AGENT'

View File

@@ -146,7 +146,7 @@ $_SESSION['this_will_be_written'] = 'not empty';
// open again with same name
$session_name = 'class-test-session';
try {
$session_alt = new Session($session_name, auto_write_close:true);
$session_alt = new Session($session_name, ['auto_write_close' => true]);
print "[4 SET] Current session id: " . $session_alt->getSessionId() . "<br>";
print "[4 SET] Current session auto write close: " . ($session_alt->checkAutoWriteClose() ? 'Yes' : 'No') . "<br>";
print "[START AGAIN] Current session id: " . $session_alt->getSessionId() . "<br>";

File diff suppressed because it is too large Load Diff

View File

@@ -21,21 +21,107 @@ class Session
private string $session_id = '';
/** @var bool flag auto write close */
private bool $auto_write_close = false;
/** @var string regenerate option, default never */
private string $regenerate = 'never';
/** @var int regenerate interval either 1 to 100 for random or 0 to 3600 for interval */
private int $regenerate_interval = 0;
/** @var array<string> allowed session id regenerate (rotate) options */
private const ALLOWED_REGENERATE_OPTIONS = ['none', 'random', 'interval'];
/** @var int default random interval */
public const DEFAULT_REGENERATE_RANDOM = 100;
/** @var int default rotate internval in minutes */
public const DEFAULT_REGENERATE_INTERVAL = 5 * 60;
/** @var int maximum time for regenerate interval is one hour */
public const MAX_REGENERATE_INTERAL = 60 * 60;
/**
* init a session, if array is empty or array does not have session_name set
* then no auto init is run
*
* @param string $session_name if set and not empty, will start session
* @param array<string,bool> $options
*/
public function __construct(string $session_name, bool $auto_write_close = false)
{
public function __construct(
string $session_name,
array $options = []
) {
$this->setOptions($options);
$this->initSession($session_name);
$this->auto_write_close = $auto_write_close;
}
// MARK: private methods
/**
* set session class options
*
* @param array<string,bool> $options
* @return void
*/
private function setOptions(array $options): void
{
if (
!isset($options['auto_write_close']) ||
!is_bool($options['auto_write_close'])
) {
$options['auto_write_close'] = false;
}
$this->auto_write_close = $options['auto_write_close'];
if (
!isset($options['session_strict']) ||
!is_bool($options['session_strict'])
) {
$options['session_strict'] = true;
}
// set strict options, on not started sessiononly
if (
$options['session_strict'] &&
$this->getSessionStatus() === PHP_SESSION_NONE
) {
// use cookies to store session IDs
ini_set('session.use_cookies', 1);
// use cookies only (do not send session IDs in URLs)
ini_set('session.use_only_cookies', 1);
// do not send session IDs in URLs
ini_set('session.use_trans_sid', 0);
}
// session regenerate id options
if (
empty($options['regenerate']) ||
!in_array($options['regenerate'], self::ALLOWED_REGENERATE_OPTIONS)
) {
$options['regenerate'] = 'never';
}
$this->regenerate = (string)$options['regenerate'];
// for regenerate: 'random' (default 100)
// regenerate_interval must be between (1 = always) and 100 (1 in 100)
// for regenerate: 'interval' (default 5min)
// regenerate_interval must be 0 = always, to 3600 (every hour)
if (
$options['regenerate'] == 'random' &&
(
!isset($options['regenerate_interval']) ||
!is_numeric($options['regenerate_interval']) ||
$options['regenerate_interval'] < 0 ||
$options['regenerate_interval'] > 100
)
) {
$options['regenerate_interval'] = self::DEFAULT_REGENERATE_RANDOM;
}
if (
$options['regenerate'] == 'interval' &&
(
!isset($options['regenerate_interval']) ||
!is_numeric($options['regenerate_interval']) ||
$options['regenerate_interval'] < 1 ||
$options['regenerate_interval'] > self::MAX_REGENERATE_INTERAL
)
) {
$options['regenerate_interval'] = self::DEFAULT_REGENERATE_INTERVAL;
}
$this->regenerate_interval = (int)($options['regenerate_interval'] ?? 0);
}
/**
* Start session
* startSession should be called for complete check
@@ -72,6 +158,72 @@ class Session
return false;
}
// MARK: regenerate session
/**
* auto rotate session id
*
* @return void
* @throws \RuntimeException failure to regenerate session id
* @throws \UnexpectedValueException failed to get new session id
* @throws \RuntimeException failed to set new sesson id
* @throws \UnexpectedValueException new session id generated does not match the new set one
*/
private function sessionRegenerateSessionId()
{
// never
if ($this->regenerate == 'never') {
return;
}
// regenerate
if (
!(
// is not session obsolete
empty($_SESSION['SESSION_REGENERATE_OBSOLETE']) &&
(
(
// random
$this->regenerate == 'random' &&
mt_rand(1, $this->regenerate_interval) == 1
) || (
// interval type
$this->regenerate == 'interval' &&
($_SESSION['SESSION_REGENERATE_TIMESTAMP'] ?? 0) + $this->regenerate_interval < time()
)
)
)
) {
return;
}
// Set current session to expire in 1 minute
$_SESSION['SESSION_REGENERATE_OBSOLETE'] = true;
$_SESSION['SESSION_REGENERATE_EXPIRES'] = time() + 60;
$_SESSION['SESSION_REGENERATE_TIMESTAMP'] = time();
// Create new session without destroying the old one
if (session_regenerate_id(false) === false) {
throw new \RuntimeException('[SESSION] Session id regeneration failed', 1);
}
// Grab current session ID and close both sessions to allow other scripts to use them
if (false === ($new_session_id = $this->getSessionIdCall())) {
throw new \UnexpectedValueException('[SESSION] getSessionIdCall did not return a session id', 2);
}
$this->writeClose();
// Set session ID to the new one, and start it back up again
if (($get_new_session_id = session_id($new_session_id)) === false) {
throw new \RuntimeException('[SESSION] set session_id failed', 3);
}
if ($get_new_session_id != $new_session_id) {
throw new \UnexpectedValueException('[SESSION] new session id does not match the new set one', 4);
}
$this->session_id = $new_session_id;
$this->startSessionCall();
// Don't want this one to expire
unset($_SESSION['SESSION_REGENERATE_OBSOLETE']);
unset($_SESSION['SESSION_REGENERATE_EXPIRES']);
}
// MARK: session validation
/**
* check if session name is valid
*
@@ -151,6 +303,13 @@ class Session
if (!$this->checkActiveSession()) {
throw new \RuntimeException('[SESSION] Failed to activate session', 5);
}
if (
!empty($_SESSION['SESSION_REGENERATE_OBSOLETE']) &&
!empty($_SESSION['SESSION_REGENERATE_EXPIRES']) && $_SESSION['SESSION_REGENERATE_EXPIRES'] < time()
) {
$this->sessionDestroy();
throw new \RuntimeException('[SESSION] Expired session found', 6);
}
} elseif ($session_name != $this->getSessionName()) {
throw new \UnexpectedValueException(
'[SESSION] Another session exists with a different name: ' . $this->getSessionName(),
@@ -159,10 +318,12 @@ class Session
}
// check session id
if (false === ($session_id = $this->getSessionIdCall())) {
throw new \UnexpectedValueException('[SESSION] getSessionId did not return a session id', 6);
throw new \UnexpectedValueException('[SESSION] getSessionIdCall did not return a session id', 7);
}
// set session id
$this->session_id = $session_id;
// run session id re-create from time to time
$this->sessionRegenerateSessionId();
// if flagged auto close, write close session
if ($this->auto_write_close) {
$this->writeClose();