From 183cadb0fd2525228165be4476ce8da6c0553530 Mon Sep 17 00:00:00 2001 From: Clemens Schwaighofer Date: Tue, 7 Jun 2022 18:05:50 +0900 Subject: [PATCH] Class ACL\Login update with phpunit testing Move logic from constructor to separate function Add more public access methods for internal variable access (password min length settings, error login code, error login string error) All error messages are declared in constructor with wrapper function to create html error string for template creation Add wrapper function for exit/abort and page name read for easier mocking in testing Fixes for multi login main function caller and cached query problem: do not cache query for login Add reverse default access list SESSION variable and public readers Update logout with unset of full SESSION array to empty, use external session class for all session calls. Also unset euid on logout --- 4dev/tests/CoreLibsACLLoginTest.php | 708 ++++++++++++++++++-- www/lib/CoreLibs/ACL/Login.php | 982 ++++++++++++++++++---------- 2 files changed, 1318 insertions(+), 372 deletions(-) diff --git a/4dev/tests/CoreLibsACLLoginTest.php b/4dev/tests/CoreLibsACLLoginTest.php index 418fdbc6..ad82c043 100644 --- a/4dev/tests/CoreLibsACLLoginTest.php +++ b/4dev/tests/CoreLibsACLLoginTest.php @@ -101,6 +101,17 @@ final class CoreLibsACLLoginTest extends TestCase 'Cannot find edit_user table in ACL\Login database for testing' ); } + // insert additional content for testing (locked user, etc) + $queries = [ + "INSERT INTO edit_access_data " + . "(edit_access_id, name, value, enabled) VALUES " + . "((SELECT edit_access_id FROM edit_access WHERE uid = 'AdminAccess'), " + . "'test', 'value', 1)" + ]; + foreach ($queries as $query) { + self::$db->dbExec($query); + } + // define mandatory constant // must set // TARGET @@ -146,15 +157,15 @@ final class CoreLibsACLLoginTest extends TestCase */ public function loginProvider(): array { - // 0: mock settings + // 0: mock settings/override flag settings // 1: post array IN // login_login, login_username, login_password, login_logout // change_password, pw_username, pw_old_password, pw_new_password, // pw_new_password_confirm // 2: override session set - // 3: expected error code, 0 for all ok, 3 for login page view - // note that 1 (no db), 2 (no session) must be tested too - // 4: expected return on ok (error: 0) + // 3: expected error code, 0 for all ok, 3000 for login page view + // note that 1000 (no db), 2000 (no session) must be tested too + // 4: expected return array, eg login_error code, or other info data to match return [ 'load, no login' => [ // error code, only for exceptions @@ -165,7 +176,9 @@ final class CoreLibsACLLoginTest extends TestCase [], 3000, [ - 'login_error' => 0 + 'login_error' => 0, + 'error_string' => 'Success: No error', + 'error_string_text' => 'Success: No error', ], ], 'load, session euid set only, php error' => [ @@ -183,6 +196,8 @@ final class CoreLibsACLLoginTest extends TestCase [ 'page_name' => 'edit_users.php', 'edit_access_id' => 1, + 'edit_access_uid' => 'AdminAccess', + 'edit_access_data' => 'test', 'base_access' => 'list', 'page_access' => 'list', ], @@ -195,14 +210,19 @@ final class CoreLibsACLLoginTest extends TestCase 'GROUP_ACL_LEVEL' => -1, 'PAGES_ACL_LEVEL' => [], 'USER_ACL_LEVEL' => -1, + 'UNIT_UID' => [ + 'AdminAccess' => 1, + ], 'UNIT' => [ 1 => [ 'acl_level' => 80, 'name' => 'Admin Access', - 'uid' => '', + 'uid' => 'AdminAccess', 'level' => -1, 'default' => 0, - 'data' => [] + 'data' => [ + 'test' => 'value', + ], ], ], // 'UNIT_DEFAULT' => '', @@ -214,6 +234,7 @@ final class CoreLibsACLLoginTest extends TestCase 'admin_flag' => true, 'check_access' => true, 'check_access_id' => 1, + 'check_access_data' => 'value', 'base_access' => true, 'page_access' => true, ], @@ -231,7 +252,11 @@ final class CoreLibsACLLoginTest extends TestCase [], 3000, [ - 'login_error' => 102 + 'login_error' => 102, + 'error_string' => 'Fatal Error: ' + . 'Login Failed - Please enter username and password', + 'error_string_text' => 'Fatal Error: ' + . 'Login Failed - Please enter username and password' ] ], // login: missing username @@ -247,7 +272,11 @@ final class CoreLibsACLLoginTest extends TestCase [], 3000, [ - 'login_error' => 102 + 'login_error' => 102, + 'error_string' => 'Fatal Error: ' + . 'Login Failed - Please enter username and password', + 'error_string_text' => 'Fatal Error: ' + . 'Login Failed - Please enter username and password' ] ], // login: missing password @@ -263,7 +292,11 @@ final class CoreLibsACLLoginTest extends TestCase [], 3000, [ - 'login_error' => 102 + 'login_error' => 102, + 'error_string' => 'Fatal Error: ' + . 'Login Failed - Please enter username and password', + 'error_string_text' => 'Fatal Error: ' + . 'Login Failed - Please enter username and password' ] ], // login: user not found @@ -279,7 +312,11 @@ final class CoreLibsACLLoginTest extends TestCase [], 3000, [ - 'login_error' => 1010 + 'login_error' => 1010, + 'error_string' => 'Fatal Error: ' + . 'Login Failed - Wrong Username or Password', + 'error_string_text' => 'Fatal Error: ' + . 'Login Failed - Wrong Username or Password' ] ], // login: invalid password @@ -299,16 +336,91 @@ final class CoreLibsACLLoginTest extends TestCase 3000, [ // default password is plain text - 'login_error' => 1012 + 'login_error' => 1012, + 'error_string' => 'Fatal Error: ' + . 'Login Failed - Wrong Username or Password', + 'error_string_text' => 'Fatal Error: ' + . 'Login Failed - Wrong Username or Password' ] ], // login: ok (but not enabled) + 'login: ok, but not enabled' => [ + [ + 'page_name' => 'edit_users.php', + 'edit_access_id' => 1, + 'base_access' => 'list', + 'page_access' => 'list', + 'test_enabled' => true + ], + [ + 'login_login' => 'Login', + 'login_username' => 'admin', + 'login_password' => 'admin', + ], + [], + 3000, + [ + 'login_error' => 104, + 'error_string' => 'Fatal Error: ' + . 'Login Failed - User not enabled', + 'error_string_text' => 'Fatal Error: ' + . 'Login Failed - User not enabled' + ] + ], // login: ok (but locked) + 'login: ok, but locked' => [ + [ + 'page_name' => 'edit_users.php', + 'edit_access_id' => 1, + 'base_access' => 'list', + 'page_access' => 'list', + 'test_locked' => true + ], + [ + 'login_login' => 'Login', + 'login_username' => 'admin', + 'login_password' => 'admin', + ], + [], + 3000, + [ + 'login_error' => 105, + 'error_string' => 'Fatal Error: ' + . 'Login Failed - User is locked', + 'error_string_text' => 'Fatal Error: ' + . 'Login Failed - User is locked' + ] + ], + // login: make user get locked strict + 'login: ok, get locked, strict' => [ + [ + 'page_name' => 'edit_users.php', + 'edit_access_id' => 1, + 'base_access' => 'list', + 'page_access' => 'list', + 'test_get_locked' => true, + 'max_login_error_count' => 2, + 'test_locked_strict' => true, + ], + [ + 'login_login' => 'Login', + 'login_username' => 'admin', + 'login_password' => 'admin', + ], + [], + 0, + [ + 'lock_run_login_error' => 1012, + 'login_error' => 105, + ] + ], // login: ok 'login: ok' => [ [ 'page_name' => 'edit_users.php', 'edit_access_id' => 1, + 'edit_access_uid' => 'AdminAccess', + 'edit_access_data' => 'test', 'base_access' => 'list', 'page_access' => 'list', ], @@ -324,6 +436,7 @@ final class CoreLibsACLLoginTest extends TestCase 'admin_flag' => true, 'check_access' => true, 'check_access_id' => 1, + 'check_access_data' => 'value', 'base_access' => true, 'page_access' => true, ] @@ -340,7 +453,7 @@ final class CoreLibsACLLoginTest extends TestCase * @dataProvider loginProvider * @testdox ACL\Login Class tests [$_dataName] * - * @param array $mock_settings + * @param array $mock_settings * @param array $post * @param array $session * @param int $error @@ -354,9 +467,7 @@ final class CoreLibsACLLoginTest extends TestCase int $error, array $expected ): void { - // echo "ACL LOGIN TEST\n"; $_SESSION = []; - // init session (as MOCK) /** @var \CoreLibs\Create\Session&MockObject */ $session_mock = $this->createPartialMock( @@ -404,6 +515,77 @@ final class CoreLibsACLLoginTest extends TestCase ->method('loginPrintLogin') ->willReturnCallback(function () { }); + + // if mock_settings: enabled OFF + // run DB update and set off + if (!empty($mock_settings['test_enabled'])) { + self::$db->dbExec( + "UPDATE edit_user SET enabled = 0 WHERE LOWER(username) = " + . self::$db->dbEscapeLiteral($post['login_username']) + ); + } + // test locked already + if (!empty($mock_settings['test_locked'])) { + self::$db->dbExec( + "UPDATE edit_user SET locked = 1 WHERE LOWER(username) = " + . self::$db->dbEscapeLiteral($post['login_username']) + ); + } + // test get locked + if (!empty($mock_settings['test_get_locked'])) { + // enable strict if needed + if (!empty($mock_settings['test_locked_strict'])) { + self::$db->dbExec( + "UPDATE edit_user SET strict = 1 WHERE LOWER(username) = " + . self::$db->dbEscapeLiteral($post['login_username']) + ); + } + // reset any previous login error counts + self::$db->dbExec( + "UPDATE edit_user " + . "SET login_error_count = 0, login_error_date_last = NULL, " + . "login_error_date_first = NULL " + . "WHERE LOWER(username) = " + . self::$db->dbEscapeLiteral($post['login_username']) + ); + // check the max login error count and try login until one time before + // on each run, check that lock count is matching + // next run (run test) should then fail with user locked IF strict, + // else fail as normal login + $login_mock->loginSetMaxLoginErrorCount($mock_settings['max_login_error_count']); + // temporary wrong password + $_POST['login_password'] = 'wrong'; + for ($run = 1, $max_run = $login_mock->loginGetMaxLoginErrorCount(); $run <= $max_run; $run++) { + try { + $login_mock->loginMainCall(); + } catch (\Exception $e) { + // print 'Expected error code: ' . $e->getCode() + // . ', M:' . $e->getMessage() + // . ', L:' . $e->getLine() + // . ', E: ' . $login_mock->loginGetLastErrorCode() + // . "\n"; + $this->assertEquals( + $expected['lock_run_login_error'], + $login_mock->loginGetLastErrorCode(), + 'Assert login error code, exit on lock run' + ); + } + // check user error count + $res = self::$db->dbReturnRow( + "SELECT login_error_count FROM edit_user " + . "WHERE LOWER(username) = " + . self::$db->dbEscapeLiteral($post['login_username']) + ); + $this->assertEquals( + $res['login_error_count'], + $run, + 'Assert equal login error count' + ); + } + // set correct password next locked login + $_POST['login_password'] = $post['login_password']; + } + // run test try { $login_mock->loginMainCall(); @@ -421,7 +603,21 @@ final class CoreLibsACLLoginTest extends TestCase 'Assert page name' ); // - loginCheckPermissions [duplicated from loginrun] + $this->assertTrue( + $login_mock->loginCheckPermissions(), + 'Assert true for login permission ok' + ); // - loginCheckAccess [use Base, Page below] + $this->assertEquals( + $expected['base_access'], + $login_mock->loginCheckAccess('base', $mock_settings['base_access']), + 'Assert base access, via main method' + ); + $this->assertEquals( + $expected['base_access'], + $login_mock->loginCheckAccess('page', $mock_settings['base_access']), + 'Assert page access, via main method' + ); // - loginCheckAccessBase $this->assertEquals( $expected['base_access'], @@ -446,14 +642,39 @@ final class CoreLibsACLLoginTest extends TestCase $login_mock->loginCheckEditAccessId((int)$mock_settings['edit_access_id']), 'Assert check access id valid' ); - // - loginGetEditAccessData [test extra] + // - loginGetEditAccessIdFromUid + $this->assertEquals( + $expected['check_access_id'], + $login_mock->loginGetEditAccessIdFromUid($mock_settings['edit_access_uid']), + 'Assert check access uid to id valid' + ); + // - loginGetEditAccessData + $this->assertEquals( + $expected['check_access_data'], + $login_mock->loginGetEditAccessData( + $mock_settings['edit_access_id'], + $mock_settings['edit_access_data'] + ), + 'Assert check access id data value valid' + ); // - loginIsAdmin $this->assertEquals( $expected['admin_flag'], $login_mock->loginIsAdmin(), 'Assert admin flag set' ); + // - loginGetAcl + $this->assertIsArray( + $login_mock->loginGetAcl(), + 'Assert get acl is array' + ); + // TODO: detail match of ACL array (loginGetAcl) + // .. end with: loginLogoutUser + // _POST['login_logout'] = 'lgogout + // $login_mock->loginMainCall(); + // - loginCheckPermissions + // - loginGetPermissionOkay } catch (\Exception $e) { // print "[E]: " . $e->getCode() . ", ERROR: " . $login_mock->loginGetLastErrorCode() . "/" // . ($expected['login_error'] ?? 0) . "\n"; @@ -464,6 +685,37 @@ final class CoreLibsACLLoginTest extends TestCase $login_mock->loginGetLastErrorCode(), 'Assert login error code, exit' ); + // - loginGetErrorMsg + $this->assertEquals( + $expected['error_string'], + $login_mock->loginGetErrorMsg($login_mock->loginGetLastErrorCode()), + 'Assert error string, html' + ); + $this->assertEquals( + $expected['error_string_text'], + $login_mock->loginGetErrorMsg($login_mock->loginGetLastErrorCode(), true), + 'Assert error string, text' + ); + // - loginGetLoginHTML + $this->assertStringContainsString( + '', + $login_mock->loginGetLoginHTML(), + 'Assert login html string exits' + ); + // check that login script has above error message + if (!empty($login_mock->loginGetLastErrorCode())) { + $this->assertStringContainsString( + $login_mock->loginGetErrorMsg($login_mock->loginGetLastErrorCode()), + $login_mock->loginGetLoginHTML(), + 'Assert login error string exits' + ); + } else { + $this->assertStringNotContainsString( + $login_mock->loginGetErrorMsg($login_mock->loginGetLastErrorCode()), + $login_mock->loginGetLoginHTML(), + 'Assert login error string does not exit' + ); + } } // print "EXCEPTION: " . print_r($e, true) . "\n"; $this->assertEquals( @@ -474,38 +726,418 @@ final class CoreLibsACLLoginTest extends TestCase . ', ' . $e->getLine() ); } - // print "PAGENAME: " . $login_mock->loginGetPageName() . "\n"; - // $echo_string = $this->getActualOutput(); - // $this->setOutputCallback( - // function ($echo) { - // // echo "A"; - // echo "--" . $echo . "--\n"; - // } - // ); - - // $echo_string = $this->getActualOutput(); - // echo "~~~~~~~~~~~~~~~~\n"; - // print "ECHO: " . $echo_string . "\n"; - - // compare result to expected + // enable user again if flag set + if (!empty($mock_settings['test_enabled'])) { + self::$db->dbExec( + "UPDATE edit_user SET enabled = 1 " + . "WHERE LOWER(username) = " + . self::$db->dbEscapeLiteral($post['login_username']) + ); + } + // reset lock flag + if (!empty($mock_settings['test_locked'])) { + self::$db->dbExec( + "UPDATE edit_user SET locked = 0 " + . "WHERE LOWER(username) = " + . self::$db->dbEscapeLiteral($post['login_username']) + ); + } + // rest the get locked flow + if (!empty($mock_settings['test_get_locked'])) { + self::$db->dbExec( + "UPDATE edit_user " + . "SET login_error_count = 0, login_error_date_last = NULL, " + . "login_error_date_first = NULL, locked = 0, strict = 0 " + . "WHERE LOWER(username) = " + . self::$db->dbEscapeLiteral($post['login_username']) + ); + } } - // other tests - // - loginSetPasswordMinLength - // + // - loginGetAclList (null, invalid,) + + public function aclListProvider(): array + { + // 0: level (int|null) + // 2: type (string), null for skip (if 0 = null) + // 1: acl return from level (array) + // 2: level number to return (must match 0) + return [ + 'null, get full list' => [ + null, + null, + [ + 0 => [ + 'type' => 'none', + 'name' => 'No Access', + ], + 10 => [ + 'type' => 'list', + 'name' => 'List', + ], + 20 => [ + 'type' => 'read', + 'name' => 'Read', + ], + 30 => [ + 'type' => 'mod_trans', + 'name' => 'Translator', + ], + 40 => [ + 'type' => 'mod', + 'name' => 'Modify', + ], + 60 => [ + 'type' => 'write', + 'name' => 'Create/Write', + ], + 80 => [ + 'type' => 'del', + 'name' => 'Delete', + ], + 90 => [ + 'type' => 'siteadmin', + 'name' => 'Site Admin', + ], + 100 => [ + 'type' => 'admin', + 'name' => 'Admin', + ], + ], + null + ], + 'valid, search' => [ + 20, + 'read', + [ + 'type' => 'read', + 'name' => 'Read' + ], + 20 + ], + 'invalud search' => [ + 12, + 'foo', + [], + false, + ] + ]; + } /** * Undocumented function * - * @testdox ACL\Login Class empty void + * @dataProvider aclListProvider() + * @testdox ACL\Login list if $level and $type exepcted level is $expected_level [$_dataName] * + * @param int|null $level + * @param string|null $type + * @param array $expected_list + * @param int|null|bool $expected_level * @return void */ - // public function testOther(): void - // { - // echo "HERE EMPTY 1\n"; - // } + public function testAclLoginList( + ?int $level, + ?string $type, + array $expected_list, + $expected_level + ): void { + $_SESSION = []; + // init session (as MOCK) + /** @var \CoreLibs\Create\Session&MockObject */ + $session_mock = $this->createPartialMock( + \CoreLibs\Create\Session::class, + ['startSession', 'checkActiveSession', 'sessionDestroy'] + ); + $session_mock->method('startSession')->willReturn('ACLLOGINTEST34'); + $session_mock->method('checkActiveSession')->willReturn(true); + $session_mock->method('sessionDestroy')->will( + $this->returnCallback(function () { + global $_SESSION; + $_SESSION = []; + return true; + }) + ); + /** @var \CoreLibs\ACL\Login&MockObject */ + $login_mock = $this->getMockBuilder(\CoreLibs\ACL\Login::class) + ->setConstructorArgs([self::$db, self::$log, $session_mock, false]) + ->onlyMethods(['loginTerminate']) + ->getMock(); + $login_mock->expects($this->any()) + ->method('loginTerminate') + ->will( + $this->returnCallback(function ($code) { + throw new \Exception('', $code); + }) + ); + + $list = $login_mock->loginGetAclList($level); + $this->assertIsArray( + $list, + 'assert get acl list is array' + ); + $this->assertEquals( + $expected_list, + $list + ); + if ($type !== null) { + $this->assertEquals( + $expected_level, + $login_mock->loginGetAclListFromType($type), + 'assert type is level' + ); + // only back assert if found + if (isset($list['type'])) { + $this->assertEquals( + $list['type'], + $type, + 'assert level read type is type' + ); + } + } + } + + /** + * Undocumented function + * + * @return array + */ + public function minPasswordCheckProvider(): array + { + // 0: set length + // 1: expected return from set + // 2: expected set length + return [ + 'set new length' => [ + 12, + true, + 12, + ], + 'set new length, too short' => [ + 5, + false, + 9 + ], + 'set new length, too long' => [ + 500, + false, + 9 + ] + ]; + } + + /** + * check setting minimum password length + * + * @covers ::loginSetPasswordMinLength + * @covers ::loginGetPasswordLenght + * @dataProvider minPasswordCheckProvider() + * @testdox ACL\Login password min length set $input is $expected_return and matches $expected [$_dataName] + * + * @param int $input + * @param bool $expected_return + * @param int $expected + * @return void + */ + public function testACLLoginPasswordMinLenght(int $input, bool $expected_return, int $expected): void + { + $_SESSION = []; + // init session (as MOCK) + /** @var \CoreLibs\Create\Session&MockObject */ + $session_mock = $this->createPartialMock( + \CoreLibs\Create\Session::class, + ['startSession', 'checkActiveSession', 'sessionDestroy'] + ); + $session_mock->method('startSession')->willReturn('ACLLOGINTEST34'); + $session_mock->method('checkActiveSession')->willReturn(true); + $session_mock->method('sessionDestroy')->will( + $this->returnCallback(function () { + global $_SESSION; + $_SESSION = []; + return true; + }) + ); + /** @var \CoreLibs\ACL\Login&MockObject */ + $login_mock = $this->getMockBuilder(\CoreLibs\ACL\Login::class) + ->setConstructorArgs([self::$db, self::$log, $session_mock, false]) + ->onlyMethods(['loginTerminate']) + ->getMock(); + $login_mock->expects($this->any()) + ->method('loginTerminate') + ->will( + $this->returnCallback(function ($code) { + throw new \Exception('', $code); + }) + ); + + // set new min password length + $this->assertEquals( + $expected_return, + $login_mock->loginSetPasswordMinLength($input), + 'assert bool set password min length' + ); + // check value + $this->assertEquals( + $expected, + $login_mock->loginGetPasswordLenght('min'), + 'assert get password min length' + ); + } + + /** + * Undocumented function + * + * @return array + */ + public function getPasswordLengthProvider(): array + { + return [ + 'min' => ['min'], + 'lower' => ['lower'], + 'max' => ['max'], + 'upper' => ['upper'], + 'minimum_length' => ['minimum_length'], + 'min_length' => ['min_length'], + 'length' => ['length'], + ]; + } + + /** + * check all possible readable password length params + * + * @covers ::loginGetPasswordLenght + * @dataProvider getPasswordLengthProvider() + * @testdox ACL\Login get password length $input [$_dataName] + * + * @param string $input + * @return void + */ + public function testACLLoginGetPasswordLenght(string $input): void + { + $_SESSION = []; + // init session (as MOCK) + /** @var \CoreLibs\Create\Session&MockObject */ + $session_mock = $this->createPartialMock( + \CoreLibs\Create\Session::class, + ['startSession', 'checkActiveSession', 'sessionDestroy'] + ); + $session_mock->method('startSession')->willReturn('ACLLOGINTEST34'); + $session_mock->method('checkActiveSession')->willReturn(true); + $session_mock->method('sessionDestroy')->will( + $this->returnCallback(function () { + global $_SESSION; + $_SESSION = []; + return true; + }) + ); + /** @var \CoreLibs\ACL\Login&MockObject */ + $login_mock = $this->getMockBuilder(\CoreLibs\ACL\Login::class) + ->setConstructorArgs([self::$db, self::$log, $session_mock, false]) + ->onlyMethods(['loginTerminate']) + ->getMock(); + $login_mock->expects($this->any()) + ->method('loginTerminate') + ->will( + $this->returnCallback(function ($code) { + throw new \Exception('', $code); + }) + ); + + $this->assertMatchesRegularExpression( + "/^\d+$/", + (string)$login_mock->loginGetPasswordLenght($input) + ); + } + + /** + * Undocumented function + * + * @return array + */ + public function loginMaxErrorProvider(): array + { + return [ + 'set valid max failed login' => [ + 10, + true, + 10 + ], + 'set valid unlimted' => [ + -1, + true, + -1 + ], + 'set invalid 0' => [ + 0, + false, + -1 + ], + 'set invalid negative' => [ + -5, + false, + -1 + ], + ]; + } + + /** + * Undocumented function + * + * @covers ::loginSetMaxLoginErrorCount + * @covers ::loginGetMaxLoginErrorCount + * @dataProvider loginMaxErrorProvider() + * @testdox ACL\Login failed login set/get $input is $expected_return and matches $expected [$_dataName] + * + * @param int $input + * @param bool $expected_return + * @param int $expected + * @return void + */ + public function testACLLoginErrorCount(int $input, bool $expected_return, int $expected): void + { + $_SESSION = []; + // init session (as MOCK) + /** @var \CoreLibs\Create\Session&MockObject */ + $session_mock = $this->createPartialMock( + \CoreLibs\Create\Session::class, + ['startSession', 'checkActiveSession', 'sessionDestroy'] + ); + $session_mock->method('startSession')->willReturn('ACLLOGINTEST34'); + $session_mock->method('checkActiveSession')->willReturn(true); + $session_mock->method('sessionDestroy')->will( + $this->returnCallback(function () { + global $_SESSION; + $_SESSION = []; + return true; + }) + ); + /** @var \CoreLibs\ACL\Login&MockObject */ + $login_mock = $this->getMockBuilder(\CoreLibs\ACL\Login::class) + ->setConstructorArgs([self::$db, self::$log, $session_mock, false]) + ->onlyMethods(['loginTerminate']) + ->getMock(); + $login_mock->expects($this->any()) + ->method('loginTerminate') + ->will( + $this->returnCallback(function ($code) { + throw new \Exception('', $code); + }) + ); + + // set new min password length + $this->assertEquals( + $expected_return, + $login_mock->loginSetMaxLoginErrorCount($input), + 'assert bool set max login errors' + ); + // check value + $this->assertEquals( + $expected, + $login_mock->loginGetMaxLoginErrorCount(), + 'assert get max login errors' + ); + } } // __END__ diff --git a/www/lib/CoreLibs/ACL/Login.php b/www/lib/CoreLibs/ACL/Login.php index ab5eff13..aaed6143 100644 --- a/www/lib/CoreLibs/ACL/Login.php +++ b/www/lib/CoreLibs/ACL/Login.php @@ -87,9 +87,6 @@ class Login private $password; // login password /** @var string */ private $logout; // logout button - // login error code, can be matched to the array login_error_msg, which holds the string - /** @var int */ - private $login_error = 0; /** @var bool */ private $password_change = false; // if this is set to true, the user can change passwords /** @var bool */ @@ -112,20 +109,23 @@ class Login /** @var array */ private $pw_change_deny_users = []; // array of users for which the password change is forbidden /** @var string */ - private $logout_target; + private $logout_target = ''; /** @var int */ private $max_login_error_count = -1; /** @var array */ private $lock_deny_users = []; /** @var string */ - private $page_name; + private $page_name = ''; // if we have password change we need to define some rules /** @var int */ - private $password_min_length = PASSWORD_MIN_LENGTH; - // max length is fixed as 255 (for input type max), if set highter, it will be set back to 255 + private $password_min_length = 9; + /** @var int an true maxium min, can never be set below this */ + private $password_min_length_max = 9; + // max length is fixed as 255 (for input type max), if set highter + // it will be set back to 255 /** @var int */ - private $password_max_length = PASSWORD_MAX_LENGTH; + private $password_max_length = 255; // can have several regexes, if nothing set, all is ok /** @var array */ private $password_valid_chars = [ @@ -133,10 +133,14 @@ class Login // '^(?.*(\pL)u)(?=.*(\pN)u)(?=.*([^\pL\pN])u).{8,}', ]; - // all possible login error conditions - /** @var array */ + // login error code, can be matched to the array login_error_msg, + // which holds the string + /** @var int */ + private $login_error = 0; + /** @var array all possible login error conditions */ private $login_error_msg = []; - // this is an array holding all strings & templates passed from the outside (translation) + // this is an array holding all strings & templates passed + // rom the outside (translation) /** @var array */ private $login_template = [ 'strings' => [], @@ -146,9 +150,13 @@ class Login // acl vars /** @var array */ - public $acl = []; + private $acl = []; /** @var array */ - public $default_acl_list = []; + private $default_acl_list = []; + /** @var array Reverse list to lookup level from type */ + private $default_acl_list_type = []; + /** @var int default ACL level to be based on if nothing set */ + private $default_acl_level = 0; // login html, if we are on an ajax page /** @var string|null */ private $login_html = ''; @@ -171,11 +179,15 @@ class Login * @param \CoreLibs\DB\IO $db Database connection class * @param \CoreLibs\Debug\Logging $log Logging class * @param \CoreLibs\Create\Session $session Session interface class + * @param bool $auto_login [default true] Auto login flag, legacy + * If set to true will run login + * during construction */ public function __construct( \CoreLibs\DB\IO $db, \CoreLibs\Debug\Logging $log, - \CoreLibs\Create\Session $session + \CoreLibs\Create\Session $session, + bool $auto_login = true ) { // attach db class $this->db = $db; @@ -185,90 +197,108 @@ class Login $this->log = $log; // attach session class $this->session = $session; - // set internal page name - $this->page_name = \CoreLibs\Get\System::getPageName(); - // set db special errors - if (!$this->db->dbGetConnectionStatus()) { - echo 'Could not connect to DB
'; - // if I can't connect to the DB to auth exit hard. No access allowed - exit; - } - // initial the session if there is no session running already - // check if session exists and could be created - // TODO: move session creation and check to outside? - if ($this->session->checkActiveSession() === false) { - $this->login_error = 1; - echo 'No active session found'; - exit; - } - - // pre-check that password min/max lengths are inbetween 1 and 255; - if ($this->password_max_length > 255) { - $this->password_max_length = 255; - } - if ($this->password_min_length < 1) { - $this->password_min_length = 1; - } - - // set global is ajax page for if we show the data directly, - // or need to pass it back - // to the continue AJAX class for output back to the user - $this->login_is_ajax_page = isset($GLOBALS['AJAX_PAGE']) && $GLOBALS['AJAX_PAGE'] ? true : false; - - // if we have a search path we need to set it, to use the correct DB to login - // check what schema to use. if there is a login schema use this, else check - // if there is a schema set in the config, or fall back to DB_SCHEMA - // if this exists, if this also does not exists use public schema - /** @phpstan-ignore-next-line */ - if (!empty(LOGIN_DB_SCHEMA)) { - $SCHEMA = LOGIN_DB_SCHEMA; - } elseif (!empty($this->db->dbGetSchema(true))) { - $SCHEMA = $this->db->dbGetSchema(true); - } elseif (defined('PUBLIC_SCHEMA')) { - $SCHEMA = PUBLIC_SCHEMA; - } else { - $SCHEMA = 'public'; - } - // echo "

*****SCHEMA******

: $SCHEMA
"; - // set schema if schema differs to schema set in db conneciton - if ($this->db->dbGetSchema() != $SCHEMA) { - $this->db->dbExec("SET search_path TO " . $SCHEMA); - } - // if there is none, there is none, saves me POST/GET check - $this->euid = array_key_exists('EUID', $_SESSION) ? $_SESSION['EUID'] : 0; - // get login vars, are so, can't be changed - // prepare - // pass on vars to Object vars - $this->login = $_POST['login_login'] ?? ''; - $this->username = $_POST['login_username'] ?? ''; - $this->password = $_POST['login_password'] ?? ''; - $this->logout = $_POST['login_logout'] ?? ''; - // password change vars - $this->change_password = $_POST['change_password'] ?? ''; - $this->pw_username = $_POST['pw_username'] ?? ''; - $this->pw_old_password = $_POST['pw_old_password'] ?? ''; - $this->pw_new_password = $_POST['pw_new_password'] ?? ''; - $this->pw_new_password_confirm = $_POST['pw_new_password_confirm'] ?? ''; - // logout target (from config) - $this->logout_target = LOGOUT_TARGET; - // disallow user list for password change - $this->pw_change_deny_users = ['admin']; - // set flag if password change is okay - if (defined('PASSWORD_CHANGE')) { - $this->password_change = PASSWORD_CHANGE; - } - // NOTE: forgot password flow with email - if (defined('PASSWORD_FORGOT')) { - $this->password_forgot = PASSWORD_FORGOT; - } - // max login counts before error reporting - $this->max_login_error_count = 10; - // users that never get locked, even if they are set strict - $this->lock_deny_users = ['admin']; + // string key, msg: string, flag: e (error), o (ok) + $this->login_error_msg = [ + '0' => [ + 'msg' => 'No error', + 'flag' => 'o' + ], + // actually obsolete + '100' => [ + 'msg' => '[EUID] came in as GET/POST!', + 'flag' => 'e', + ], + // query errors + '1009' => [ + 'msg' => 'Login query reading failed', + 'flag' => 'e', + ], + // user not found + '1010' => [ + 'msg' => 'Login Failed - Wrong Username or Password', + 'flag' => 'e' + ], + // blowfish password wrong + '1011' => [ + 'msg' => 'Login Failed - Wrong Username or Password', + 'flag' => 'e' + ], + // fallback md5 password wrong + '1012' => [ + 'msg' => 'Login Failed - Wrong Username or Password', + 'flag' => 'e' + ], + // new password_hash wrong + '1013' => [ + 'msg' => 'Login Failed - Wrong Username or Password', + 'flag' => 'e' + ], + '102' => [ + 'msg' => 'Login Failed - Please enter username and password', + 'flag' => 'e' + ], + '103' => [ + 'msg' => 'You do not have the rights to access this Page', + 'flag' => 'e' + ], + '104' => [ + 'msg' => 'Login Failed - User not enabled', + 'flag' => 'e' + ], + '105' => [ + 'msg' => 'Login Failed - User is locked', + 'flag' => 'e' + ], + '109' => [ + 'msg' => 'Check permission query reading failed', + 'flag' => 'e' + ], + // actually this is an illegal user, but I mask it + '220' => [ + 'msg' => 'Password change - The user could not be found', + 'flag' => 'e' + ], + '200' => [ + 'msg' => 'Password change - Please enter username and old password', + 'flag' => 'e' + ], + '201' => [ + 'msg' => 'Password change - The user could not be found', + 'flag' => 'e' + ], + '202' => [ + 'msg' => 'Password change - The old password is not correct', + 'flag' => 'e' + ], + '203' => [ + 'msg' => 'Password change - Please fill out both new password fields', + 'flag' => 'e' + ], + '204' => [ + 'msg' => 'Password change - The new passwords do not match', + 'flag' => 'e' + ], + // we should also not here WHAT is valid + '205' => [ + 'msg' => 'Password change - The new password is not in a valid format', + 'flag' => 'e' + ], + // for OK password change + '300' => [ + 'msg' => 'Password change successful', + 'flag' => 'o' + ], + // this is bad bad error + '9999' => [ + 'msg' => 'Necessary crypt engine could not be found. Login is impossible', + 'flag' => 'e' + ], + ]; // init default ACL list array $_SESSION['DEFAULT_ACL_LIST'] = []; + $_SESSION['DEFAULT_ACL_LIST_TYPE'] = []; // read the current edit_access_right list into an array $q = "SELECT level, type, name FROM edit_access_right " . "WHERE level >= 0 ORDER BY level"; @@ -278,94 +308,52 @@ class Login 'type' => $res['type'], 'name' => $res['name'] ]; + $this->default_acl_list_type[$res['type']] = $res['level']; } // write that into the session $_SESSION['DEFAULT_ACL_LIST'] = $this->default_acl_list; + $_SESSION['DEFAULT_ACL_LIST_TYPE'] = $this->default_acl_list_type; - // if username & password & !$euid start login - $this->loginLoginUser(); - // checks if $euid given check if user is okay for that side - $this->loginCheckPermissions(); - // logsout user - $this->loginLogoutUser(); - // ** LANGUAGE SET AFTER LOGIN ** - // set the locale - if ( - $this->session->checkActiveSession() === true && - !empty($_SESSION['DEFAULT_LANG']) - ) { - $locale = $_SESSION['DEFAULT_LOCALE'] ?? ''; - } else { - $locale = !empty(SITE_LOCALE) ? - SITE_LOCALE : - /** @phpstan-ignore-next-line DEFAULT_LOCALE could be empty */ - (!empty(DEFAULT_LOCALE) ? - DEFAULT_LOCALE : 'en.UTF-8'); + // this will be deprecated + if ($auto_login === true) { + $this->loginMainCall(); } - // set domain - if (defined('CONTENT_PATH')) { - $domain = str_replace('/', '', CONTENT_PATH); - } else { - $domain = 'admin'; - } - $this->l = new \CoreLibs\Language\L10n($locale, $domain); - // if the password change flag is okay, run the password change method - if ($this->password_change) { - $this->loginPasswordChange(); - } - // password forgot - if ($this->password_forgot) { - $this->loginPasswordForgot(); - } - // if !$euid || permission not okay, print login screan - $this->login_html = $this->loginPrintLogin(); - // closing all connections, depending on error status, exit - if (!$this->loginCloseClass()) { - // if variable AJAX flag is not set, show output - // else pass through for ajax work - if ($this->login_is_ajax_page !== true) { - // the login screen if we hav no login permission & login screen html data - if ($this->login_html !== null) { - echo $this->login_html; - } - // do not go anywhere, quit processing here - // do something with possible debug data? - if (TARGET == 'live' || TARGET == 'remote') { - // login - $this->log->setLogLevelAll('debug', DEBUG ? true : false); - $this->log->setLogLevelAll('echo', false); - $this->log->setLogLevelAll('print', DEBUG ? true : false); - } - $status_msg = $this->log->printErrorMsg(); - // if ($this->echo_output_all) { - if ($this->log->getLogLevelAll('echo')) { - echo $status_msg; - } - // exit so we don't process anything further, at all - exit; - } else { - // if we are on an ajax page reset any POST/GET array data to avoid - // any accidentical processing going on - $_POST = []; - $_GET = []; - // set the action to login so we can trigger special login html return - $_POST['action'] = 'login'; - $_POST['login_html'] = $this->login_html; - // NOTE: this part needs to be catched by the frontend AJAX - // and some function needs to then set something like this - // document.getElementsByTagName('html')[0].innerHTML = data.content.login_html; - } - } - // set acls for this user/group and this page - $this->loginSetAcl(); + } + + // ************************************************************************* + // **** PROTECTED INTERNAL + // ************************************************************************* + + /** + * Wrapper for exit calls + * + * @param int $code + * @return void + */ + protected function loginTerminate($code = 0): void + { + exit($code); } /** - * deconstructory, called with the last function to close DB connection + * return current page name + * + * @return string Current page name */ - public function __destruct() + protected function loginReadPageName(): string { - // NO OP + // set internal page name as is + return \CoreLibs\Get\System::getPageName(); + } + + /** + * print out login HTML via echo + * + * @return void + */ + protected function loginPrintLogin(): void + { + echo $this->loginGetLoginHTML(); } // ************************************************************************* @@ -410,7 +398,7 @@ class Login preg_match("/^\\$2y\\$/", $hash) && !Password::passwordVerify($password, $hash) ) { - // this is the new password hash methid, is only $2y$ + // this is the new password hash method, is only $2y$ // all others are not valid anymore $this->login_error = 1013; $password_ok = false; @@ -439,8 +427,7 @@ class Login private function loginLoginUser(): void { // if pressed login at least and is not yet loggined in - // if (!(!$this->euid && $this->login)) { - if ($this->euid && !$this->login) { + if ($this->euid || !$this->login) { return; } // if not username AND password where given @@ -476,13 +463,16 @@ class Login . "eg.edit_access_right_id = eareg.edit_access_right_id AND " // password match is done in script, against old plain or new blowfish encypted . "(LOWER(username) = '" . $this->db->dbEscapeString(strtolower($this->username)) . "') "; - $res = $this->db->dbReturn($q); + // reset any query data that might exist + $this->db->dbCacheReset($q); + // never cache return data + $res = $this->db->dbReturn($q, $this->db::NO_CACHE); // query was not run successful - if (!is_array($res)) { + if (!empty($this->db->dbGetLastError())) { $this->login_error = 1009; $this->permission_okay = false; return; - } elseif (empty($this->db->dbGetCursorNumRows($q))) { + } elseif (!is_array($res)) { // username is wrong, but we throw for wrong username // and wrong password the same error $this->login_error = 1010; @@ -677,6 +667,7 @@ class Login if ($res['edit_default']) { $_SESSION['UNIT_DEFAULT'] = $res['edit_access_id']; } + $_SESSION['UNIT_UID'][$res['uid']] = $res['edit_access_id']; // sub arrays for simple access array_push($eauid, $res['edit_access_id']); $unit_acl[$res['edit_access_id']] = $res['level']; @@ -745,10 +736,10 @@ class Login $this->acl['user_name'] = $_SESSION['USER_NAME']; $this->acl['group_name'] = $_SESSION['GROUP_NAME']; // we start with the default acl - $this->acl['base'] = DEFAULT_ACL_LEVEL; + $this->acl['base'] = $this->default_acl_level; // set admin flag and base to 100 - if ($_SESSION['ADMIN']) { + if (!empty($_SESSION['ADMIN'])) { $this->acl['admin'] = 1; $this->acl['base'] = 100; } else { @@ -760,7 +751,10 @@ class Login $this->acl['base'] = $_SESSION['GROUP_ACL_LEVEL']; } // page ACL 1 - if ($_SESSION['PAGES_ACL_LEVEL'][$this->page_name] != -1) { + if ( + isset($_SESSION['PAGES_ACL_LEVEL'][$this->page_name]) && + $_SESSION['PAGES_ACL_LEVEL'][$this->page_name] != -1 + ) { $this->acl['base'] = $_SESSION['PAGES_ACL_LEVEL'][$this->page_name]; } // user ACL 2 @@ -788,7 +782,7 @@ class Login // PER ACCOUNT (UNIT/edit access)-> foreach ($_SESSION['UNIT'] as $ea_id => $unit) { // if admin flag is set, all units are set to 100 - if ($this->acl['admin']) { + if (!empty($this->acl['admin'])) { $this->acl['unit'][$ea_id] = $this->acl['base']; } else { if ($unit['acl_level'] != -1) { @@ -801,12 +795,12 @@ class Login $this->acl['unit_detail'][$ea_id] = [ 'name' => $unit['name'], 'uid' => $unit['uid'], - 'level' => $this->default_acl_list[$this->acl['unit'][$ea_id]]['name'], + 'level' => $this->default_acl_list[$this->acl['unit'][$ea_id]]['name'] ?? -1, 'default' => $unit['default'], 'data' => $unit['data'] ]; // set default - if ($unit['default']) { + if (!empty($unit['default'])) { $this->acl['unit_id'] = $unit['id']; $this->acl['unit_name'] = $unit['name']; $this->acl['unit_uid'] = $unit['uid']; @@ -820,43 +814,14 @@ class Login } // set the default edit access $this->acl['default_edit_access'] = $_SESSION['UNIT_DEFAULT'] ?? null; - $this->acl['min'] = []; // integrate the type acl list, but only for the keyword -> level - foreach ($this->default_acl_list as $level => $data) { - $this->acl['min'][$data['type']] = $level; - } - // set the full acl list too - $this->acl['acl_list'] = $_SESSION['DEFAULT_ACL_LIST'] ?? []; + $this->acl['min'] = $this->default_acl_list_type ?? []; + // set the full acl list too (lookup level number and get level data) + $this->acl['acl_list'] = $this->default_acl_list ?? []; // debug // $this->debug('ACL', $this->print_ar($this->acl)); } - /** - * Check if source (page, base) is matching to the given min access string - * min access string must be valid access level string (eg read, mod, write) - * This does not take in account admin flag set - * - * @param string $source a valid base level string eg base, page - * @param string $min_access a valid min level string, eg read, mod, siteadmin - * @return bool True for valid access, False for invalid - */ - public function loginCheckAccess(string $source, string $min_access): bool - { - $source = 'base'; - if ( - empty($this->acl['min'][$min_access]) || - empty($this->acl[$source]) - ) { - return false; - } - // phan claims $this->acl['min'] can be null, but above should skip - /** @phan-suppress-next-line PhanTypeArraySuspiciousNullable */ - if ($this->acl[$source] >= $this->acl['min'][$min_access]) { - return true; - } - return false; - } - /** * checks if the password is in a valid format * @@ -875,7 +840,10 @@ class Login } } // check for min length - if (strlen($password) < $this->password_min_length || strlen($password) > $this->password_max_length) { + if ( + strlen($password) < $this->password_min_length || + strlen($password) > $this->password_max_length + ) { $is_valid_password = false; } return $is_valid_password; @@ -993,11 +961,12 @@ class Login } /** - * prints out login html part if no permission (error) is set + * creates the login html part if no permission (error) is set + * this does not print anything yet * * @return string|null html data for login page, or null for nothing */ - private function loginPrintLogin() + private function loginCreateLoginHTML() { $html_string = null; // if permission is ok, return null @@ -1007,10 +976,10 @@ class Login // set the templates now $this->loginSetTemplates(); // if there is a global logout target ... - if (file_exists($this->logout_target) && $this->logout_target) { + if (file_exists($this->logout_target)) { $LOGOUT_TARGET = $this->logout_target; } else { - $LOGOUT_TARGET = ""; + $LOGOUT_TARGET = ''; } $html_string = (string)$this->login_template['template']; @@ -1033,7 +1002,7 @@ class Login if ($this->login_error) { $html_string_password_change = str_replace( '{ERROR_MSG}', - $this->login_error_msg[$this->login_error] . '
', + $this->loginGetErrorMsg($this->login_error) . '
', $html_string_password_change ); } else { @@ -1076,13 +1045,13 @@ class Login if ($this->login_error) { $html_string = str_replace( '{ERROR_MSG}', - $this->login_error_msg[$this->login_error] . '
', + $this->loginGetErrorMsg($this->login_error) . '
', $html_string ); } elseif ($this->password_change_ok && $this->password_change) { $html_string = str_replace( '{ERROR_MSG}', - $this->login_error_msg[300] . '
', + $this->loginGetErrorMsg(300) . '
', $html_string ); } else { @@ -1155,40 +1124,6 @@ class Login 'PASSWORD_CHANGE_BUTTON_VALUE' => $this->l->__('Change Password') ]; - $error_msgs = [ - // actually obsolete - '100' => $this->l->__('Fatal Error: [EUID] came in as GET/POST!'), - // query errors - '1009' => $this->l->__('Fatal Error: Login query reading failed'), - // user not found - '1010' => $this->l->__('Fatal Error: Login Failed - Wrong Username or Password'), - // blowfish password wrong - '1011' => $this->l->__('Fatal Error: Login Failed - Wrong Username or Password'), - // fallback md5 password wrong - '1012' => $this->l->__('Fatal Error: Login Failed - Wrong Username or Password'), - // new password_hash wrong - '1013' => $this->l->__('Fatal Error: Login Failed - Wrong Username or Password'), - '102' => $this->l->__('Fatal Error: Login Failed - Please enter username and password'), - '103' => $this->l->__('Fatal Error: You do not have the rights to access this Page'), - '104' => $this->l->__('Fatal Error: Login Failed - User not enabled'), - '105' => $this->l->__('Fatal Error: Login Failed - User is locked'), - '109' => $this->l->__('Fatal Error: Check permission query reading failed'), - // actually this is an illegal user, but I mask it - '220' => $this->l->__('Fatal Error: Password change - The user could not be found'), - '200' => $this->l->__('Fatal Error: Password change - Please enter username and old password'), - '201' => $this->l->__('Fatal Error: Password change - The user could not be found'), - '202' => $this->l->__('Fatal Error: Password change - The old password is not correct'), - '203' => $this->l->__('Fatal Error: Password change - Please fill out both new password fields'), - '204' => $this->l->__('Fatal Error: Password change - The new passwords do not match'), - // we should also not here WHAT is valid - '205' => $this->l->__('Fatal Error: Password change - The new password is not in a valid format'), - // for OK password change - '300' => $this->l->__('Success: Password change successful'), - // this is bad bad error - '9999' => $this->l->__('Fatal Error: necessary crypt engine could not be found. ' - . 'Login is impossible'), - ]; - // if password change is okay if ($this->password_change) { $strings = array_merge($strings, [ @@ -1238,20 +1173,14 @@ EOM; ]); } - // first check if all strings are set from outside, if not, set with default ones + // first check if all strings are set from outside, + // if not, set with default ones foreach ($strings as $string => $data) { if (!array_key_exists($string, $this->login_template['strings'])) { $this->login_template['strings'][$string] = $data; } } - // error msgs the same - foreach ($error_msgs as $code => $data) { - if (!array_key_exists($code, $this->login_error_msg)) { - $this->login_error_msg[$code] = $data; - } - } - // now check templates if (!$this->login_template['template']) { $this->login_template['template'] = <<login_error = 0; + // set db special errors + if (!$this->db->dbGetConnectionStatus()) { + $this->login_error = 1; + echo 'Could not connect to DB
'; + // if I can't connect to the DB to auth exit hard. No access allowed + $this->loginTerminate(1000); + } + // initial the session if there is no session running already + // check if session exists and could be created + // TODO: move session creation and check to outside? + if ($this->session->checkActiveSession() === false) { + $this->login_error = 2; + echo 'No active session found'; + $this->loginTerminate(2000); + } + + // if we have a search path we need to set it, to use the correct DB to login + // check what schema to use. if there is a login schema use this, else check + // if there is a schema set in the config, or fall back to DB_SCHEMA + // if this exists, if this also does not exists use public schema + /** @phpstan-ignore-next-line */ + if (defined('LOGIN_DB_SCHEMA') && !empty(LOGIN_DB_SCHEMA)) { + $SCHEMA = LOGIN_DB_SCHEMA; + } elseif (!empty($this->db->dbGetSchema(true))) { + $SCHEMA = $this->db->dbGetSchema(true); + } elseif (defined('PUBLIC_SCHEMA')) { + $SCHEMA = PUBLIC_SCHEMA; + } else { + $SCHEMA = 'public'; + } + // set schema if schema differs to schema set in db conneciton + if ($this->db->dbGetSchema() != $SCHEMA) { + $this->db->dbExec("SET search_path TO " . $SCHEMA); + } + + // set internal page name + $this->page_name = $this->loginReadPageName(); + + // set default ACL Level + if (defined('DEFAULT_ACL_LEVEL')) { + $this->default_acl_level = DEFAULT_ACL_LEVEL; + } + + if (defined('PASSWORD_MIN_LENGTH')) { + $this->password_min_length = PASSWORD_MIN_LENGTH; + $this->password_min_length_max = PASSWORD_MIN_LENGTH; + } + if (defined('PASSWORD_MIN_LENGTH')) { + $this->password_max_length = PASSWORD_MAX_LENGTH; + } + + // pre-check that password min/max lengths are inbetween 1 and 255; + if ($this->password_max_length > 255) { + $this->password_max_length = 255; + } + if ($this->password_min_length < 1) { + $this->password_min_length = 1; + } + + // set global is ajax page for if we show the data directly, + // or need to pass it back + // to the continue AJAX class for output back to the user + $this->login_is_ajax_page = isset($GLOBALS['AJAX_PAGE']) && $GLOBALS['AJAX_PAGE'] ? true : false; + + // if there is none, there is none, saves me POST/GET check + $this->euid = array_key_exists('EUID', $_SESSION) ? $_SESSION['EUID'] : 0; + // get login vars, are so, can't be changed + // prepare + // pass on vars to Object vars + $this->login = $_POST['login_login'] ?? ''; + $this->username = $_POST['login_username'] ?? ''; + $this->password = $_POST['login_password'] ?? ''; + $this->logout = $_POST['login_logout'] ?? ''; + // password change vars + $this->change_password = $_POST['change_password'] ?? ''; + $this->pw_username = $_POST['pw_username'] ?? ''; + $this->pw_old_password = $_POST['pw_old_password'] ?? ''; + $this->pw_new_password = $_POST['pw_new_password'] ?? ''; + $this->pw_new_password_confirm = $_POST['pw_new_password_confirm'] ?? ''; + // logout target (from config) + if (defined('LOGOUT_TARGET')) { + $this->logout_target = LOGOUT_TARGET; + } + // disallow user list for password change + $this->pw_change_deny_users = ['admin']; + // set flag if password change is okay + if (defined('PASSWORD_CHANGE')) { + $this->password_change = PASSWORD_CHANGE; + } + // NOTE: forgot password flow with email + if (defined('PASSWORD_FORGOT')) { + $this->password_forgot = PASSWORD_FORGOT; + } + // max login counts before error reporting + $this->max_login_error_count = 10; + // users that never get locked, even if they are set strict + $this->lock_deny_users = ['admin']; + + // if username & password & !$euid start login + $this->loginLoginUser(); + // checks if $euid given check if user is okay for that side + $this->loginCheckPermissions(); + // logsout user + $this->loginLogoutUser(); + // ** LANGUAGE SET AFTER LOGIN ** + // set the locale + if ( + $this->session->checkActiveSession() === true && + !empty($_SESSION['DEFAULT_LANG']) + ) { + $locale = $_SESSION['DEFAULT_LOCALE'] ?? ''; + } else { + $locale = (defined('SITE_LOCALE') && !empty(SITE_LOCALE)) ? + SITE_LOCALE : + /** @phpstan-ignore-next-line DEFAULT_LOCALE could be empty */ + ((defined('DEFAULT_LOCALE') && !empty(DEFAULT_LOCALE)) ? + DEFAULT_LOCALE : 'en.UTF-8'); + } + // set domain + if (defined('CONTENT_PATH')) { + $domain = str_replace('/', '', CONTENT_PATH); + } else { + $domain = 'admin'; + } + $this->l = new \CoreLibs\Language\L10n($locale, $domain); + // if the password change flag is okay, run the password change method + if ($this->password_change) { + $this->loginPasswordChange(); + } + // password forgot + if ($this->password_forgot) { + $this->loginPasswordForgot(); + } + // if !$euid || permission not okay, print login screan + $this->login_html = $this->loginCreateLoginHTML(); + // closing all connections, depending on error status, exit + if (!$this->loginCloseClass()) { + // if variable AJAX flag is not set, show output + // else pass through for ajax work + if ($this->login_is_ajax_page === false) { + // the login screen if we hav no login permission & login screen html data + if ($this->login_html !== null) { + // echo $this->login_html; + $this->loginPrintLogin(); + } + // do not go anywhere, quit processing here + // do something with possible debug data? + if (TARGET == 'live' || TARGET == 'remote') { + // login + $this->log->setLogLevelAll('debug', DEBUG ? true : false); + $this->log->setLogLevelAll('echo', false); + $this->log->setLogLevelAll('print', DEBUG ? true : false); + } + $status_msg = $this->log->printErrorMsg(); + // if ($this->echo_output_all) { + if ($this->log->getLogLevelAll('echo')) { + echo $status_msg; + } + // exit so we don't process anything further, at all + $this->loginTerminate(3000); + } else { + // if we are on an ajax page reset any POST/GET array data to avoid + // any accidentical processing going on + $_POST = []; + $_GET = []; + // set the action to login so we can trigger special login html return + $_POST['action'] = 'login'; + $_POST['login_html'] = $this->login_html; + // NOTE: this part needs to be catched by the frontend AJAX + // and some function needs to then set something like this + // document.getElementsByTagName('html')[0].innerHTML = data.content.login_html; + } + } + // set acls for this user/group and this page + $this->loginSetAcl(); + } + + /** + * Returns current set login_html content + * + * @return string login page html content, created, empty string if none + */ + public function loginGetLoginHTML(): string + { + return $this->login_html ?? ''; + } + + /** + * return the current set page name or empty string for nothing set + * + * @return string current page name set + */ + public function loginGetPageName(): string + { + return $this->page_name; + } + + /** + * returns the last set error code + * + * @return int Last set error code, 0 for no error + */ + public function loginGetLastErrorCode(): int + { + return $this->login_error; + } + + /** + * return set error message + * if nothing found for given code, return general error message + * + * @param int $code The error code for which we want the error string + * @param bool $text If set to true, do not use HTML code + * @return string Error string + */ + public function loginGetErrorMsg(int $code, bool $text = false): string + { + $string = ''; + if ( + !empty($this->login_error_msg[(string)$code]['msg']) && + !empty($this->login_error_msg[(string)$code]['flag']) + ) { + $error_str_prefix = ''; + switch ($this->login_error_msg[(string)$code]['flag']) { + case 'e': + $error_str_prefix = ($text ? '' : '') + . $this->l->__('Fatal Error:') + . ($text ? '' : ''); + break; + case 'o': + $error_str_prefix = $this->l->__('Success:'); + break; + } + $string = $error_str_prefix . ' ' + . ($text ? '' : '') + . $this->login_error_msg[(string)$code]['msg'] + . ($text ? '' : ''); + } elseif (!empty($code)) { + $string = $this->l->__('LOGIN: undefined error message'); + } + return $string; + } + + /** + * Sets the minium length and checks on valid. + * Current max length is 255 characters * * @param int $length set the minimum length * @return bool true/false on success @@ -1396,13 +1580,74 @@ EOM; { // check that numeric, positive numeric, not longer than max input string lenght // and not short than min password length - if (is_numeric($length) && $length >= PASSWORD_MIN_LENGTH && $length <= $this->password_max_length) { + if ( + is_numeric($length) && + $length >= $this->password_min_length_max && + $length <= $this->password_max_length && + $length <= 255 + ) { $this->password_min_length = $length; return true; } return false; } + /** + * return password min/max length values as selected + * min: return current minimum lenght + * max: return current set maximum length + * min_length: get the fixed minimum password length + * + * @param string $select Can be min/max or min_length + * @return int + */ + public function loginGetPasswordLenght(string $select): int + { + $value = 0; + switch (strtolower($select)) { + case 'min': + case 'lower': + $value = $this->password_min_length; + break; + case 'max': + case 'upper': + $value = $this->password_max_length; + break; + case 'minimum_length': + case 'min_length': + case 'length': + $value = $this->password_min_length_max; + break; + } + return $value; + } + + /** + * Set the maximum login errors a user can have before getting locked + * if the user has the strict lock setting turned on + * + * @param int $times Value can be -1 (no locking) or greater than 0 + * @return bool True on sueccess set, or false on error + */ + public function loginSetMaxLoginErrorCount(int $times): bool + { + if ($times == -1 || $times > 0) { + $this->max_login_error_count = $times; + return true; + } + return false; + } + + /** + * Get the current maximum login error count + * + * @return int Current set max login error count, Can be -1 or greater than 0 + */ + public function loginGetMaxLoginErrorCount(): int + { + return $this->max_login_error_count; + } + /** * if a user pressed on logout, destroyes session and unsets all global vars * @@ -1414,42 +1659,10 @@ EOM; if (!$this->logout && !$this->login_error) { return; } - // unregister and destroy session vars - foreach ( - // TODO move this into some global array for easier update - [ - 'ADMIN', - 'BASE_ACL_LEVEL', - 'DB_DEBUG', - 'DEBUG_ALL', - 'DEFAULT_ACL_LIST', - 'DEFAULT_CHARSET', - 'DEFAULT_LANG', - 'DEFAULT_LOCALE', - 'EAID', - 'EUID', - 'GROUP_ACL_LEVEL', - 'GROUP_ACL_TYPE', - 'GROUP_NAME', - 'HEADER_COLOR', - 'LANG', - 'PAGES_ACL_LEVEL', - 'PAGES', - 'TEMPLATE', - 'UNIT_ACL_LEVEL', - 'UNIT_DEFAULT', - 'UNIT', - 'USER_ACL_LEVEL', - 'USER_ACL_TYPE', - 'USER_NAME', - ] as $session_var - ) { - unset($_SESSION[$session_var]); - } - // final unset all - session_unset(); - // final destroy session - session_destroy(); + // unset session vars set/used in this login + $this->session->sessionDestroy(); + // unset euid + $this->euid = ''; // then prints the login screen again $this->permission_okay = false; } @@ -1464,7 +1677,7 @@ EOM; // start with not allowed $this->permission_okay = false; // bail for no euid (no login) - if (!$this->euid) { + if (empty($this->euid)) { return $this->permission_okay; } // bail for previous wrong page match, eg if method is called twice @@ -1472,13 +1685,13 @@ EOM; return $this->permission_okay; } // if ($this->euid && $this->login_error != 103) { - $q = "SELECT filename " + $q = "SELECT ep.filename " . "FROM edit_page ep, edit_page_access epa, edit_group eg, edit_user eu " . "WHERE ep.edit_page_id = epa.edit_page_id " . "AND eg.edit_group_id = epa.edit_group_id " . "AND eg.edit_group_id = eu.edit_group_id " . "AND eu.edit_user_id = " . $this->euid . " " - . "AND filename = '" . $this->page_name . "' " + . "AND ep.filename = '" . $this->page_name . "' " . "AND eg.enabled = 1 AND epa.enabled = 1"; $res = $this->db->dbReturnRow($q); if (!is_array($res)) { @@ -1495,41 +1708,38 @@ EOM; } /** - * Return ACL array as is + * Return current permission status; * - * @return array + * @return bool True for permission ok, False for not */ - public function loginGetAcl(): array + public function loginGetPermissionOkay(): bool { - return $this->acl; + return $this->permission_okay; } /** - * checks if this edit access id is valid + * Check if source (page, base) is matching to the given min access string + * min access string must be valid access level string (eg read, mod, write) + * This does not take in account admin flag set * - * @param int|null $edit_access_id access id pk to check - * @return bool true/false: if the edit access is not - * in the valid list: false + * @param string $source a valid base level string eg base, page + * @param string $min_access a valid min level string, eg read, mod, siteadmin + * @return bool True for valid access, False for invalid */ - public function loginCheckEditAccess($edit_access_id): bool + public function loginCheckAccess(string $source, string $min_access): bool { - if ($edit_access_id === null) { + if (!in_array($source, ['page', 'base'])) { + $source = 'base'; + } + if ( + empty($this->acl['min'][$min_access]) || + empty($this->acl[$source]) + ) { return false; } - if (array_key_exists($edit_access_id, $this->acl['unit'])) { - return true; - } - return false; - } - - /** - * Check if admin flag is set - * - * @return bool True if admin flag set - */ - public function loginIsAdmin(): bool - { - if (!empty($this->acl['admin'])) { + // phan claims $this->acl['min'] can be null, but above should skip + /** @phan-suppress-next-line PhanTypeArraySuspiciousNullable */ + if ($this->acl[$source] >= $this->acl['min'][$min_access]) { return true; } return false; @@ -1559,8 +1769,75 @@ EOM; return $this->loginCheckAccess('page', $min_access); } + /** + * Return ACL array as is + * + * @return array + */ + public function loginGetAcl(): array + { + return $this->acl; + } + + /** + * return full default acl list or a list entry if level is set and found + * for getting level from list type + * $login->loginGetAclList('list')['level'] ?? 0 + * + * @param int|null $level Level to get or null/empty for full list + * @return array Full default ACL level list or level entry if found + */ + public function loginGetAclList(?int $level = null): array + { + // if no level given, return full list + if (empty($level)) { + return $this->default_acl_list; + } + // if level given and exist return this array block (name/level) + if ( + !empty($level) && + !empty($this->default_acl_list[$level]) + ) { + return $this->default_acl_list[$level]; + } else { + // else return empty array + return []; + } + } + + /** + * return level number in int from acl list depending on level + * if not found return false + * + * @param string $type Type name to look in the acl list + * @return int|bool Either int level or false for not found + */ + public function loginGetAclListFromType(string $type) + { + return $this->default_acl_list_type[$type] ?? false; + } + + /** + * checks if this edit access id is valid + * + * @param int|null $edit_access_id access id pk to check + * @return bool true/false: if the edit access is not + * in the valid list: false + */ + public function loginCheckEditAccess($edit_access_id): bool + { + if ($edit_access_id === null) { + return false; + } + if (array_key_exists($edit_access_id, $this->acl['unit'])) { + return true; + } + return false; + } + /** * checks that the given edit access id is valid for this user + * return null if nothing set, or the edit access id * * @param int|null $edit_access_id edit access id to check * @return int|null same edit access id if ok @@ -1581,21 +1858,58 @@ EOM; } /** - * retunrn a set entry from the UNIT session for an edit access_id + * return a set entry from the UNIT session for an edit access_id * if not found return false * * @param int $edit_access_id edit access id * @param string|int $data_key key value to search for * @return bool|string false for not found or string for found data */ - public function loginSetEditAccessData(int $edit_access_id, $data_key) + public function loginGetEditAccessData(int $edit_access_id, $data_key) { if (!isset($_SESSION['UNIT'][$edit_access_id]['data'][$data_key])) { return false; } return $_SESSION['UNIT'][$edit_access_id]['data'][$data_key]; } - // close class + + /** + * Return edit access primary key id from edit access uid + * false on not found + * + * @param string $uid Edit Access UID to look for + * @return int|bool Either primary key in int or false in bool for not found + */ + public function loginGetEditAccessIdFromUid(string $uid) + { + return $_SESSION['UNIT_UID'][$uid] ?? false; + } + + /** + * Check if admin flag is set + * + * @return bool True if admin flag set + */ + public function loginIsAdmin(): bool + { + if (!empty($this->acl['admin'])) { + return true; + } + return false; + } + + /** + * old name for loginGetEditAccessData + * + * @deprecated Use $login->loginGetEditAccessData() + * @param int $edit_access_id + * @param string|int $data_key + * @return bool|string + */ + public function loginSetEditAccessData(int $edit_access_id, $data_key) + { + return $this->loginGetEditAccessData($edit_access_id, $data_key); + } } // __END__