From 6c7aad2cb82bc7b5fe15072390a09e004bc5c442 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Sat, 1 Nov 2025 14:24:47 -0400 Subject: [PATCH 01/17] Add password generation feature; update Psalm configuration and dependencies; annotate overridden methods --- .github/workflows/phpunit.yml | 3 +- .idea/runConfigurations/Psalm.xml | 5 -- composer.json | 14 +++-- psalm.xml | 1 + src/Definition/PasswordDefinition.php | 75 ++++++++++++++++++++++++ src/SessionContext.php | 6 ++ src/UsersAnyDataset.php | 10 ++++ src/UsersBase.php | 21 +++++++ src/UsersDBDataset.php | 11 ++++ tests/PasswordDefinitionTest.php | 72 +++++++++++++++++++++++ tests/SessionContextTest.php | 2 + tests/UserModelTest.php | 2 + tests/UsersAnyDataset2ByEmailTest.php | 1 + tests/UsersAnyDataset2ByUsernameTest.php | 2 + tests/UsersAnyDatasetByEmailTest.php | 1 + tests/UsersAnyDatasetByUsernameTest.php | 1 + tests/UsersDBDataset2ByEmailTest.php | 1 + tests/UsersDBDataset2ByUserNameTest.php | 2 + tests/UsersDBDatasetByEmailTest.php | 1 + tests/UsersDBDatasetByUsernameTest.php | 6 ++ tests/UsersDBDatasetDefinitionTest.php | 4 ++ 21 files changed, 230 insertions(+), 11 deletions(-) delete mode 100644 .idea/runConfigurations/Psalm.xml diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index d5322bf..1474292 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -33,5 +33,6 @@ jobs: with: folder: php project: ${{ github.event.repository.name }} - secrets: inherit + secrets: + DOC_TOKEN: ${{ secrets.DOC_TOKEN }} diff --git a/.idea/runConfigurations/Psalm.xml b/.idea/runConfigurations/Psalm.xml deleted file mode 100644 index 6f80a21..0000000 --- a/.idea/runConfigurations/Psalm.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/composer.json b/composer.json index ea629b9..ced7781 100644 --- a/composer.json +++ b/composer.json @@ -14,14 +14,18 @@ "minimum-stability": "dev", "prefer-stable": true, "require": { - "php": ">=8.1 <8.4", - "byjg/micro-orm": "^5.0", - "byjg/cache-engine": "^5.0", - "byjg/jwt-wrapper": "^5.0" + "php": ">=8.1 <8.5", + "byjg/micro-orm": "^6.0", + "byjg/cache-engine": "^6.0", + "byjg/jwt-wrapper": "^6.0" }, "require-dev": { "phpunit/phpunit": "^9.6", - "vimeo/psalm": "^5.9" + "vimeo/psalm": "^5.9|^6.13" + }, + "scripts": { + "test": "vendor/bin/phpunit", + "psalm": "vendor/bin/psalm" }, "license": "MIT" } diff --git a/psalm.xml b/psalm.xml index ebabb1a..b208114 100644 --- a/psalm.xml +++ b/psalm.xml @@ -4,6 +4,7 @@ resolveFromConfigFile="true" findUnusedBaselineEntry="true" findUnusedCode="false" + cacheDirectory="/tmp/psalm" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="https://getpsalm.org/schema/config" xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd" diff --git a/src/Definition/PasswordDefinition.php b/src/Definition/PasswordDefinition.php index e4e1e6b..ad27ece 100644 --- a/src/Definition/PasswordDefinition.php +++ b/src/Definition/PasswordDefinition.php @@ -113,6 +113,81 @@ public function matchPassword(string $password): int return $result; } + public function generatePassword(int $extendSize = 0): string + { + $charsList = [ + self::REQUIRE_UPPERCASE => 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + self::REQUIRE_LOWERCASE => 'abcdefghijklmnopqrstuvwxyz', + self::REQUIRE_SYMBOLS => '!@#$%^&*()-_=+{};:,<.>', + self::REQUIRE_NUMBERS => '0123456789' + ]; + + $charsCount = [ + self::REQUIRE_UPPERCASE => 0, + self::REQUIRE_LOWERCASE => 0, + self::REQUIRE_SYMBOLS => 0, + self::REQUIRE_NUMBERS => 0 + ]; + foreach ($this->rules as $rule => $value) { + if ($rule == self::MINIMUM_CHARS) { + continue; + } + + switch ($rule) { + case self::REQUIRE_UPPERCASE: + $charsCount[self::REQUIRE_UPPERCASE] = $value; + break; + case self::REQUIRE_LOWERCASE: + $charsCount[self::REQUIRE_LOWERCASE] = $value; + break; + case self::REQUIRE_SYMBOLS: + $charsCount[self::REQUIRE_SYMBOLS] = $value; + break; + case self::REQUIRE_NUMBERS: + $charsCount[self::REQUIRE_NUMBERS] = $value; + break; + } + } + $size = $this->rules[self::MINIMUM_CHARS] + $extendSize; + $totalChars = array_sum($charsCount); + $rulesWithValueGreaterThanZero = array_filter($charsCount, function ($value) { + return $value > 0; + }); + if (empty($rulesWithValueGreaterThanZero)) { + $rulesWithValueGreaterThanZero[self::REQUIRE_LOWERCASE] = 1; + $rulesWithValueGreaterThanZero[self::REQUIRE_NUMBERS] = 1; + } + while ($totalChars < $size) { + $rule = array_rand($rulesWithValueGreaterThanZero); + $rulesWithValueGreaterThanZero[$rule]++; + $totalChars++; + } + + $password = ''; + while (strlen($password) < $size) { + foreach ($rulesWithValueGreaterThanZero as $rule => $value) { + if ($value == 0) { + continue; + } + + do { + $char = $charsList[$rule][random_int(0, strlen($charsList[$rule]) - 1)]; + $previousChar = $password[strlen($password) - 1] ?? "\0"; + $isRepeated = ($char == $previousChar); + $previousChar = strtoupper($previousChar); + $upperChar = strtoupper($char); + $isSequential = ($upperChar == chr(ord($previousChar) + 1)) || ($upperChar == chr(ord($previousChar) - 1)); + if (!$isRepeated && !$isSequential) { + break; + } + } while (true); + $password .= $char; + $rulesWithValueGreaterThanZero[$rule]--; + } + } + + return $password; + } } \ No newline at end of file diff --git a/src/SessionContext.php b/src/SessionContext.php index fe7a115..5cbf846 100644 --- a/src/SessionContext.php +++ b/src/SessionContext.php @@ -39,6 +39,7 @@ public function __construct(CachePool $cachePool, string $key = 'default') * @return bool Return true if authenticated; false otherwise. * @throws InvalidArgumentException */ + #[\Override] public function isAuthenticated(): bool { $item = $this->session->getItem("user.$this->key"); @@ -52,6 +53,7 @@ public function isAuthenticated(): bool * @return string|int The authenticated username if exists. * @throws InvalidArgumentException */ + #[\Override] public function userInfo(): string|int { $item = $this->session->getItem("user.$this->key"); @@ -63,6 +65,7 @@ public function userInfo(): string|int * @param array $data * @throws InvalidArgumentException */ + #[\Override] public function registerLogin(string|int $userId, array $data = []): void { $item = $this->session->getItem("user.$this->key"); @@ -84,6 +87,7 @@ public function registerLogin(string|int $userId, array $data = []): void * @throws InvalidArgumentException * @throws InvalidArgumentException */ + #[\Override] public function setSessionData(string $name, mixed $value): void { if (!$this->isAuthenticated()) { @@ -110,6 +114,7 @@ public function setSessionData(string $name, mixed $value): void * @throws InvalidArgumentException * @throws InvalidArgumentException */ + #[\Override] public function getSessionData(string $name): mixed { if (!$this->isAuthenticated()) { @@ -137,6 +142,7 @@ public function getSessionData(string $name): mixed * @throws InvalidArgumentException * @throws InvalidArgumentException */ + #[\Override] public function registerLogout(): void { $this->session->deleteItem("user.$this->key"); diff --git a/src/UsersAnyDataset.php b/src/UsersAnyDataset.php index 64a253b..2cda813 100644 --- a/src/UsersAnyDataset.php +++ b/src/UsersAnyDataset.php @@ -65,6 +65,7 @@ public function __construct( * @throws UserExistsException * @throws FileException */ + #[\Override] public function save(UserModel $model): UserModel { $new = true; @@ -103,6 +104,7 @@ public function save(UserModel $model): UserModel * @return UserModel|null * @throws InvalidArgumentException */ + #[\Override] public function getUser(IteratorFilter $filter): UserModel|null { $iterator = $this->anyDataSet->getIterator($filter); @@ -123,6 +125,7 @@ public function getUser(IteratorFilter $filter): UserModel|null * @throws FileException * @throws InvalidArgumentException */ + #[\Override] public function removeByLoginField(string $login): bool { //anydataset.Row @@ -154,6 +157,7 @@ public function getIterator(IteratorFilter $filter = null): IteratorInterface /** * @throws InvalidArgumentException */ + #[\Override] public function getUsersByProperty(string $propertyName, string $value): array { return $this->getUsersByPropertySet([$propertyName => $value]); @@ -162,6 +166,7 @@ public function getUsersByProperty(string $propertyName, string $value): array /** * @throws InvalidArgumentException */ + #[\Override] public function getUsersByPropertySet(array $propertiesArray): array { $filter = new IteratorFilter(); @@ -186,6 +191,7 @@ public function getUsersByPropertySet(array $propertiesArray): array * @throws UserExistsException * @throws UserNotFoundException */ + #[\Override] public function addProperty(string|HexUuidLiteral|int $userId, string $propertyName, string|null $value): bool { //anydataset.Row @@ -207,6 +213,7 @@ public function addProperty(string|HexUuidLiteral|int $userId, string $propertyN * @throws UserExistsException * @throws FileException */ + #[\Override] public function setProperty(string|HexUuidLiteral|int $userId, string $propertyName, string|null $value): bool { $user = $this->getById($userId); @@ -228,6 +235,7 @@ public function setProperty(string|HexUuidLiteral|int $userId, string $propertyN * @throws InvalidArgumentException * @throws UserExistsException */ + #[\Override] public function removeProperty(string|HexUuidLiteral|int $userId, string $propertyName, string|null $value = null): bool { $user = $this->getById($userId); @@ -258,6 +266,7 @@ public function removeProperty(string|HexUuidLiteral|int $userId, string $proper * @throws InvalidArgumentException * @throws UserExistsException */ + #[\Override] public function removeAllProperties(string $propertyName, string|null $value = null): void { $iterator = $this->getIterator(null); @@ -300,6 +309,7 @@ private function createUserModel(Row $row): UserModel * @return bool * @throws InvalidArgumentException */ + #[\Override] public function removeUserById(string|HexUuidLiteral|int $userid): bool { $iteratorFilter = new IteratorFilter(); diff --git a/src/UsersBase.php b/src/UsersBase.php index e8ed42d..c82b850 100644 --- a/src/UsersBase.php +++ b/src/UsersBase.php @@ -36,6 +36,7 @@ abstract class UsersBase implements UsersInterface /** * @return UserDefinition */ + #[\Override] public function getUserDefinition(): UserDefinition { if ($this->userTable === null) { @@ -47,6 +48,7 @@ public function getUserDefinition(): UserDefinition /** * @return UserPropertiesDefinition */ + #[\Override] public function getUserPropertiesDefinition(): UserPropertiesDefinition { if ($this->propertiesTable === null) { @@ -60,6 +62,7 @@ public function getUserPropertiesDefinition(): UserPropertiesDefinition * * @param UserModel $model */ + #[\Override] abstract public function save(UserModel $model): UserModel; /** @@ -71,6 +74,7 @@ abstract public function save(UserModel $model): UserModel; * @param string $password * @return UserModel */ + #[\Override] public function addUser(string $name, string $userName, string $email, string $password): UserModel { $model = $this->getUserDefinition()->modelInstance(); @@ -88,6 +92,7 @@ public function addUser(string $name, string $userName, string $email, string $p * @throws UserExistsException * @throws InvalidArgumentException */ + #[\Override] public function canAddUser(UserModel $model): bool { if ($this->getByEmail($model->getEmail()) !== null) { @@ -109,6 +114,7 @@ public function canAddUser(UserModel $model): bool * @param IteratorFilter $filter Filter to find user * @return UserModel|null * */ + #[\Override] abstract public function getUser(IteratorFilter $filter): UserModel|null; /** @@ -119,6 +125,7 @@ abstract public function getUser(IteratorFilter $filter): UserModel|null; * @return UserModel|null * @throws InvalidArgumentException */ + #[\Override] public function getByEmail(string $email): UserModel|null { $filter = new IteratorFilter(); @@ -134,6 +141,7 @@ public function getByEmail(string $email): UserModel|null * @return UserModel|null * @throws InvalidArgumentException */ + #[\Override] public function getByUsername(string $username): UserModel|null { $filter = new IteratorFilter(); @@ -148,6 +156,7 @@ public function getByUsername(string $username): UserModel|null * @param string $login * @return UserModel|null */ + #[\Override] public function getByLoginField(string $login): UserModel|null { $filter = new IteratorFilter(); @@ -164,6 +173,7 @@ public function getByLoginField(string $login): UserModel|null * @return UserModel|null * @throws InvalidArgumentException */ + #[\Override] public function getById(string|HexUuidLiteral|int $userid): UserModel|null { $filter = new IteratorFilter(); @@ -177,6 +187,7 @@ public function getById(string|HexUuidLiteral|int $userid): UserModel|null * @param string $login * @return bool * */ + #[\Override] abstract public function removeByLoginField(string $login): bool; /** @@ -188,6 +199,7 @@ abstract public function removeByLoginField(string $login): bool; * @return UserModel|null * @throws InvalidArgumentException */ + #[\Override] public function isValidUser(string $userName, string $password): UserModel|null { $filter = new IteratorFilter(); @@ -212,6 +224,7 @@ public function isValidUser(string $userName, string $password): UserModel|null * @throws UserNotFoundException * @throws InvalidArgumentException */ + #[\Override] public function hasProperty(string|int|HexUuidLiteral|null $userId, string $propertyName, string $value = null): bool { //anydataset.Row @@ -248,6 +261,7 @@ public function hasProperty(string|int|HexUuidLiteral|null $userId, string $prop * @throws UserNotFoundException * @throws InvalidArgumentException */ + #[\Override] public function getProperty(string|HexUuidLiteral|int $userId, string $propertyName): array|string|UserPropertiesModel|null { $user = $this->getById($userId); @@ -274,6 +288,7 @@ abstract public function getUsersByPropertySet(array $propertiesArray): array; * @param string $propertyName * @param string|null $value */ + #[\Override] abstract public function addProperty(string|HexUuidLiteral|int $userId, string $propertyName, string|null $value): bool; /** @@ -285,6 +300,7 @@ abstract public function addProperty(string|HexUuidLiteral|int $userId, string $ * @param string|null $value Property value with a site * @return bool * */ + #[\Override] abstract public function removeProperty(string|HexUuidLiteral|int $userId, string $propertyName, string|null $value = null): bool; /** @@ -295,6 +311,7 @@ abstract public function removeProperty(string|HexUuidLiteral|int $userId, strin * @param string|null $value Property value with a site * @return void * */ + #[\Override] abstract public function removeAllProperties(string $propertyName, string|null $value = null): void; /** @@ -303,6 +320,7 @@ abstract public function removeAllProperties(string $propertyName, string|null $ * @throws UserNotFoundException * @throws InvalidArgumentException */ + #[\Override] public function isAdmin(string|HexUuidLiteral|int $userId): bool { $user = $this->getById($userId); @@ -329,6 +347,7 @@ public function isAdmin(string|HexUuidLiteral|int $userId): bool * @throws UserNotFoundException * @throws InvalidArgumentException */ + #[\Override] public function createAuthToken( string $login, string $password, @@ -373,6 +392,7 @@ public function createAuthToken( * @throws NotAuthenticatedException * @throws UserNotFoundException */ + #[\Override] public function isValidToken(string $login, JwtWrapper $jwtWrapper, string $token): array|null { $user = $this->getByLoginField($login); @@ -398,5 +418,6 @@ public function isValidToken(string $login, JwtWrapper $jwtWrapper, string $toke /** * @param string|int|HexUuidLiteral $userid */ + #[\Override] abstract public function removeUserById(string|HexUuidLiteral|int $userid): bool; } diff --git a/src/UsersDBDataset.php b/src/UsersDBDataset.php index 8c29126..e1a2847 100644 --- a/src/UsersDBDataset.php +++ b/src/UsersDBDataset.php @@ -118,6 +118,7 @@ public function __construct( * @throws OrmInvalidFieldsException * @throws Exception */ + #[\Override] public function save(UserModel $model): UserModel { $newUser = false; @@ -176,6 +177,7 @@ public function getIterator(IteratorFilter $filter = null): array * @param IteratorFilter $filter Filter to find user * @return UserModel|null */ + #[\Override] public function getUser(IteratorFilter $filter): UserModel|null { $result = $this->getIterator($filter); @@ -197,6 +199,7 @@ public function getUser(IteratorFilter $filter): UserModel|null * @return bool * @throws Exception */ + #[\Override] public function removeByLoginField(string $login): bool { $user = $this->getByLoginField($login); @@ -215,6 +218,7 @@ public function removeByLoginField(string $login): bool * @return bool * @throws Exception */ + #[\Override] public function removeUserById(string|HexUuidLiteral|int $userid): bool { $updateTableProperties = DeleteQuery::getInstance() @@ -241,6 +245,7 @@ public function removeUserById(string|HexUuidLiteral|int $userid): bool * @throws InvalidArgumentException * @throws ExceptionInvalidArgumentException */ + #[\Override] public function getUsersByProperty(string $propertyName, string $value): array { return $this->getUsersByPropertySet([$propertyName => $value]); @@ -254,6 +259,7 @@ public function getUsersByProperty(string $propertyName, string $value): array * @throws InvalidArgumentException * @throws ExceptionInvalidArgumentException */ + #[\Override] public function getUsersByPropertySet(array $propertiesArray): array { $query = Query::getInstance() @@ -281,6 +287,7 @@ public function getUsersByPropertySet(array $propertiesArray): array * @throws OrmInvalidFieldsException * @throws Exception */ + #[\Override] public function addProperty(string|HexUuidLiteral|int $userId, string $propertyName, string|null $value): bool { //anydataset.Row @@ -306,6 +313,7 @@ public function addProperty(string|HexUuidLiteral|int $userId, string $propertyN * @throws ExceptionInvalidArgumentException * @throws OrmInvalidFieldsException */ + #[\Override] public function setProperty(string|HexUuidLiteral|int $userId, string $propertyName, string|null $value): bool { $query = Query::getInstance() @@ -339,6 +347,7 @@ public function setProperty(string|HexUuidLiteral|int $userId, string $propertyN * @throws InvalidArgumentException * @throws RepositoryReadOnlyException */ + #[\Override] public function removeProperty(string|HexUuidLiteral|int $userId, string $propertyName, string|null $value = null): bool { $user = $this->getById($userId); @@ -371,6 +380,7 @@ public function removeProperty(string|HexUuidLiteral|int $userId, string $proper * @throws ExceptionInvalidArgumentException * @throws RepositoryReadOnlyException */ + #[\Override] public function removeAllProperties(string $propertyName, string|null $value = null): void { $updateable = DeleteQuery::getInstance() @@ -384,6 +394,7 @@ public function removeAllProperties(string $propertyName, string|null $value = n $this->propertiesRepository->deleteByQuery($updateable); } + #[\Override] public function getProperty(string|HexUuidLiteral|int $userId, string $propertyName): array|string|UserPropertiesModel|null { $query = Query::getInstance() diff --git a/tests/PasswordDefinitionTest.php b/tests/PasswordDefinitionTest.php index 3454239..106f44d 100644 --- a/tests/PasswordDefinitionTest.php +++ b/tests/PasswordDefinitionTest.php @@ -188,4 +188,76 @@ public function testMatchCharsRepeated() $this->assertEquals(PasswordDefinition::FAIL_REPEATED, $passwordDefinition->matchPassword('oilalalapo')); // lalala is repeated $this->assertEquals(PasswordDefinition::SUCCESS, $passwordDefinition->matchPassword('hay1d11oihsc')); } + + public function testGeneratePassword() + { + for ($i = 0; $i < 100; $i++) { + $passwordDefinition = new PasswordDefinition([ + PasswordDefinition::MINIMUM_CHARS => 8, + PasswordDefinition::REQUIRE_UPPERCASE => 2, // Number of uppercase characters + PasswordDefinition::REQUIRE_LOWERCASE => 2, // Number of lowercase characters + PasswordDefinition::REQUIRE_SYMBOLS => 2, // Number of symbols + PasswordDefinition::REQUIRE_NUMBERS => 2, // Number of numbers + PasswordDefinition::ALLOW_WHITESPACE => 0, // Allow whitespace + PasswordDefinition::ALLOW_SEQUENTIAL => 0, // Allow sequential characters + PasswordDefinition::ALLOW_REPEATED => 0 // Allow repeated characters + ]); + + $password = $passwordDefinition->generatePassword(); + $this->assertEquals(PasswordDefinition::SUCCESS, $passwordDefinition->matchPassword($password)); + $this->assertEquals(8, strlen($password)); + + $password = $passwordDefinition->generatePassword(2); + $this->assertEquals(PasswordDefinition::SUCCESS, $passwordDefinition->matchPassword($password)); + $this->assertEquals(10, strlen($password)); + } + } + + public function testGeneratePassword2() + { + for ($i = 0; $i < 100; $i++) { + $passwordDefinition = new PasswordDefinition([ + PasswordDefinition::MINIMUM_CHARS => 8, + PasswordDefinition::REQUIRE_UPPERCASE => 1, // Number of uppercase characters + PasswordDefinition::REQUIRE_LOWERCASE => 1, // Number of lowercase characters + PasswordDefinition::REQUIRE_SYMBOLS => 0, // Number of symbols + PasswordDefinition::REQUIRE_NUMBERS => 0, // Number of numbers + PasswordDefinition::ALLOW_WHITESPACE => 0, // Allow whitespace + PasswordDefinition::ALLOW_SEQUENTIAL => 0, // Allow sequential characters + PasswordDefinition::ALLOW_REPEATED => 0 // Allow repeated characters + ]); + + $password = $passwordDefinition->generatePassword(); + $this->assertEquals(PasswordDefinition::SUCCESS, $passwordDefinition->matchPassword($password)); + $this->assertEquals(8, strlen($password)); + + $password = $passwordDefinition->generatePassword(2); + $this->assertEquals(PasswordDefinition::SUCCESS, $passwordDefinition->matchPassword($password)); + $this->assertEquals(10, strlen($password)); + } + } + + public function testGeneratePasswordEmpty() + { + for ($i = 0; $i < 100; $i++) { + $passwordDefinition = new PasswordDefinition([ + PasswordDefinition::MINIMUM_CHARS => 8, + PasswordDefinition::REQUIRE_UPPERCASE => 0, // Number of uppercase characters + PasswordDefinition::REQUIRE_LOWERCASE => 0, // Number of lowercase characters + PasswordDefinition::REQUIRE_SYMBOLS => 0, // Number of symbols + PasswordDefinition::REQUIRE_NUMBERS => 0, // Number of numbers + PasswordDefinition::ALLOW_WHITESPACE => 0, // Allow whitespace + PasswordDefinition::ALLOW_SEQUENTIAL => 0, // Allow sequential characters + PasswordDefinition::ALLOW_REPEATED => 0 // Allow repeated characters + ]); + + $password = $passwordDefinition->generatePassword(); + $this->assertEquals(PasswordDefinition::SUCCESS, $passwordDefinition->matchPassword($password)); + $this->assertEquals(8, strlen($password)); + + $password = $passwordDefinition->generatePassword(2); + $this->assertEquals(PasswordDefinition::SUCCESS, $passwordDefinition->matchPassword($password)); + $this->assertEquals(10, strlen($password)); + } + } } \ No newline at end of file diff --git a/tests/SessionContextTest.php b/tests/SessionContextTest.php index 33b1067..f967ba3 100644 --- a/tests/SessionContextTest.php +++ b/tests/SessionContextTest.php @@ -14,11 +14,13 @@ class SessionContextTest extends TestCase */ protected $object; + #[\Override] public function setUp(): void { $this->object = new SessionContext(Factory::createSessionPool()); } + #[\Override] public function tearDown(): void { $this->object = null; diff --git a/tests/UserModelTest.php b/tests/UserModelTest.php index a63b081..b0e1c81 100644 --- a/tests/UserModelTest.php +++ b/tests/UserModelTest.php @@ -15,11 +15,13 @@ class UserModelTest extends TestCase */ protected $object; + #[\Override] public function setUp(): void { $this->object = new UserModel(); } + #[\Override] public function tearDown(): void { $this->object = null; diff --git a/tests/UsersAnyDataset2ByEmailTest.php b/tests/UsersAnyDataset2ByEmailTest.php index 19ba913..f3039db 100644 --- a/tests/UsersAnyDataset2ByEmailTest.php +++ b/tests/UsersAnyDataset2ByEmailTest.php @@ -6,6 +6,7 @@ class UsersAnyDataset2EmailTest extends UsersAnyDatasetByUsernameTest { + #[\Override] public function setUp(): void { $this->__setUp(UserDefinition::LOGIN_IS_EMAIL); diff --git a/tests/UsersAnyDataset2ByUsernameTest.php b/tests/UsersAnyDataset2ByUsernameTest.php index 22d41e9..50eaa86 100644 --- a/tests/UsersAnyDataset2ByUsernameTest.php +++ b/tests/UsersAnyDataset2ByUsernameTest.php @@ -11,6 +11,7 @@ class UsersAnyDataset2ByUsernameTest extends UsersAnyDatasetByUsernameTest { + #[\Override] public function __setUp($loginField) { $this->prefix = "user"; @@ -50,6 +51,7 @@ public function __setUp($loginField) $this->object->addUser('User 3', 'user3', 'user3@gmail.com', 'pwd3'); } + #[\Override] public function setUp(): void { $this->__setUp(UserDefinition::LOGIN_IS_USERNAME); diff --git a/tests/UsersAnyDatasetByEmailTest.php b/tests/UsersAnyDatasetByEmailTest.php index 560ba99..b7ad3bf 100644 --- a/tests/UsersAnyDatasetByEmailTest.php +++ b/tests/UsersAnyDatasetByEmailTest.php @@ -6,6 +6,7 @@ class UsersAnyDatasetByEmailTest extends UsersAnyDatasetByUsernameTest { + #[\Override] public function setUp(): void { $this->__setUp(UserDefinition::LOGIN_IS_EMAIL); diff --git a/tests/UsersAnyDatasetByUsernameTest.php b/tests/UsersAnyDatasetByUsernameTest.php index c6b957c..d7cb2b3 100644 --- a/tests/UsersAnyDatasetByUsernameTest.php +++ b/tests/UsersAnyDatasetByUsernameTest.php @@ -61,6 +61,7 @@ public function __chooseValue($forUsername, $forEmail) return $searchForList[$this->userDefinition->loginField()]; } + #[\Override] public function setUp(): void { $this->__setUp(UserDefinition::LOGIN_IS_USERNAME); diff --git a/tests/UsersDBDataset2ByEmailTest.php b/tests/UsersDBDataset2ByEmailTest.php index 694f024..8a33475 100644 --- a/tests/UsersDBDataset2ByEmailTest.php +++ b/tests/UsersDBDataset2ByEmailTest.php @@ -6,6 +6,7 @@ class UsersDBDataset2ByEmailTest extends UsersDBDatasetByUsernameTest { + #[\Override] public function setUp(): void { $this->__setUp(UserDefinition::LOGIN_IS_EMAIL); diff --git a/tests/UsersDBDataset2ByUserNameTest.php b/tests/UsersDBDataset2ByUserNameTest.php index 382179c..a13cc78 100644 --- a/tests/UsersDBDataset2ByUserNameTest.php +++ b/tests/UsersDBDataset2ByUserNameTest.php @@ -17,6 +17,7 @@ class UsersDBDataset2ByUserNameTest extends UsersDBDatasetByUsernameTest * @throws \ReflectionException * @throws OrmModelInvalidException */ + #[\Override] public function __setUp($loginField) { $this->prefix = ""; @@ -68,6 +69,7 @@ public function __setUp($loginField) $this->object->addUser('User 3', 'user3', 'user3@gmail.com', 'pwd3'); } + #[\Override] public function setUp(): void { $this->__setUp(UserDefinition::LOGIN_IS_USERNAME); diff --git a/tests/UsersDBDatasetByEmailTest.php b/tests/UsersDBDatasetByEmailTest.php index edaa39b..e37efe5 100644 --- a/tests/UsersDBDatasetByEmailTest.php +++ b/tests/UsersDBDatasetByEmailTest.php @@ -6,6 +6,7 @@ class UsersDBDatasetByEmailTest extends UsersAnyDatasetByUsernameTest { + #[\Override] public function setUp(): void { $this->__setUp(UserDefinition::LOGIN_IS_EMAIL); diff --git a/tests/UsersDBDatasetByUsernameTest.php b/tests/UsersDBDatasetByUsernameTest.php index 52df602..d74b4a6 100644 --- a/tests/UsersDBDatasetByUsernameTest.php +++ b/tests/UsersDBDatasetByUsernameTest.php @@ -16,6 +16,7 @@ class UsersDBDatasetByUsernameTest extends UsersAnyDatasetByUsernameTest protected $db; + #[\Override] public function __setUp($loginField) { $this->prefix = ""; @@ -59,11 +60,13 @@ public function __setUp($loginField) $this->object->addUser('User 3', 'user3', 'user3@gmail.com', 'pwd3'); } + #[\Override] public function setUp(): void { $this->__setUp(UserDefinition::LOGIN_IS_USERNAME); } + #[\Override] public function tearDown(): void { $uri = new Uri(self::CONNECTION_STRING); @@ -73,6 +76,7 @@ public function tearDown(): void $this->propertyDefinition = null; } + #[\Override] public function testAddUser() { $this->object->addUser('John Doe', 'john', 'johndoe@gmail.com', 'mypassword'); @@ -96,6 +100,7 @@ public function testAddUser() $this->assertEquals('y', $user2->getAdmin()); } + #[\Override] public function testCreateAuthToken() { $login = $this->__chooseValue('user2', 'user2@gmail.com'); @@ -153,6 +158,7 @@ public function testWithUpdateValue() $this->assertEquals('2017-12-04 00:00:00', $user->getCreated()); } + #[\Override] public function testSaveAndSave() { $user = $this->object->getById("1"); diff --git a/tests/UsersDBDatasetDefinitionTest.php b/tests/UsersDBDatasetDefinitionTest.php index a568842..2edf0cd 100644 --- a/tests/UsersDBDatasetDefinitionTest.php +++ b/tests/UsersDBDatasetDefinitionTest.php @@ -48,6 +48,7 @@ class UsersDBDatasetDefinitionTest extends UsersDBDatasetByUsernameTest * @throws UserExistsException * @throws ReflectionException */ + #[\Override] public function __setUp($loginField) { $this->prefix = ""; @@ -113,6 +114,7 @@ public function __setUp($loginField) * @throws ReflectionException * @throws UserExistsException */ + #[\Override] public function setUp(): void { $this->__setUp(UserDefinition::LOGIN_IS_USERNAME); @@ -123,6 +125,7 @@ public function setUp(): void * @throws DatabaseException * @throws \ByJG\Serializer\Exception\InvalidArgumentException */ + #[\Override] public function testAddUser() { $this->object->save(new MyUserModel('John Doe', 'johndoe@gmail.com', 'john', 'mypassword', 'no', 'other john')); @@ -151,6 +154,7 @@ public function testAddUser() /** * @throws Exception */ + #[\Override] public function testWithUpdateValue() { // For Update Definitions From 6c160aa918a0c6e7200461227f3980a2d96aaf23 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Sat, 1 Nov 2025 15:30:20 -0400 Subject: [PATCH 02/17] Refactor `UserDefinition` to support `MapperFunctionInterface` for extensible field mapping; add new mappers (`ClosureMapper`, `PasswordSha1Mapper`, `UserIdGeneratorMapper`) and processors (`PassThroughEntityProcessor`, `ClosureEntityProcessor`); update dependencies and increase type safety across the codebase. --- .claude/settings.local.json | 9 ++ .idea/runConfigurations/psalm.xml | 8 + phpunit.xml.dist | 30 ++-- src/Definition/UserDefinition.php | 144 ++++++++++++------ .../ClosureEntityProcessor.php | 31 ++++ .../PassThroughEntityProcessor.php | 16 ++ src/Interfaces/UsersInterface.php | 2 +- src/MapperFunctions/ClosureMapper.php | 34 +++++ src/MapperFunctions/PasswordSha1Mapper.php | 28 ++++ src/MapperFunctions/UserIdGeneratorMapper.php | 28 ++++ src/UsersAnyDataset.php | 71 +++++---- src/UsersBase.php | 2 +- src/UsersDBDataset.php | 43 ++++-- tests/UsersAnyDataset2ByEmailTest.php | 2 +- tests/UsersDBDatasetByUsernameTest.php | 33 ++-- tests/UsersDBDatasetDefinitionTest.php | 41 ++--- 16 files changed, 381 insertions(+), 141 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 .idea/runConfigurations/psalm.xml create mode 100644 src/EntityProcessors/ClosureEntityProcessor.php create mode 100644 src/EntityProcessors/PassThroughEntityProcessor.php create mode 100644 src/MapperFunctions/ClosureMapper.php create mode 100644 src/MapperFunctions/PasswordSha1Mapper.php create mode 100644 src/MapperFunctions/UserIdGeneratorMapper.php diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..46507b9 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(find:*)" + ], + "deny": [], + "ask": [] + } +} diff --git a/.idea/runConfigurations/psalm.xml b/.idea/runConfigurations/psalm.xml new file mode 100644 index 0000000..d9c1b61 --- /dev/null +++ b/.idea/runConfigurations/psalm.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 254ada2..90e6448 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -6,15 +6,21 @@ and open the template in the editor. --> - + displayDetailsOnTestsThatTriggerDeprecations="true" + displayDetailsOnTestsThatTriggerErrors="true" + displayDetailsOnTestsThatTriggerNotices="true" + displayDetailsOnTestsThatTriggerWarnings="true" + displayDetailsOnPhpunitDeprecations="true" + failOnWarning="true" + failOnNotice="true" + failOnDeprecation="true" + failOnPhpunitDeprecation="true" + stopOnFailure="false" + xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"> @@ -22,11 +28,11 @@ and open the template in the editor. - - - ./src - - + + + ./src/ + + diff --git a/src/Definition/UserDefinition.php b/src/Definition/UserDefinition.php index 1019c98..cf1c0ed 100644 --- a/src/Definition/UserDefinition.php +++ b/src/Definition/UserDefinition.php @@ -2,8 +2,15 @@ namespace ByJG\Authenticate\Definition; +use ByJG\Authenticate\EntityProcessors\ClosureEntityProcessor; +use ByJG\Authenticate\EntityProcessors\PassThroughEntityProcessor; +use ByJG\Authenticate\MapperFunctions\ClosureMapper; +use ByJG\Authenticate\MapperFunctions\PasswordSha1Mapper; use ByJG\Authenticate\Model\UserModel; -use ByJG\MicroOrm\MapperClosure; +use ByJG\MicroOrm\Interface\EntityProcessorInterface; +use ByJG\MicroOrm\Interface\MapperFunctionInterface; +use ByJG\MicroOrm\MapperFunctions\ReadOnlyMapper; +use ByJG\MicroOrm\MapperFunctions\StandardMapper; use ByJG\Serializer\Serialize; use Closure; use InvalidArgumentException; @@ -14,7 +21,7 @@ class UserDefinition { protected string $__table = 'users'; - protected array $__closures = ["select" => [], "update" => [] ]; + protected array $__mappers = ["select" => [], "update" => [] ]; protected string $__loginField; protected string $__model; protected array $__properties = []; @@ -65,32 +72,15 @@ public function __construct( $this->__properties[$property] = $value; } - $this->defineClosureForUpdate(UserDefinition::FIELD_PASSWORD, function ($value) { - // Already have a SHA1 password - if (strlen($value) === 40) { - return $value; - } - - // Leave null - if (empty($value)) { - return null; - } - - // Return the hash password - return strtolower(sha1($value)); - }); + $this->defineMapperForUpdate(UserDefinition::FIELD_PASSWORD, PasswordSha1Mapper::class); if ($loginField !== self::LOGIN_IS_USERNAME && $loginField !== self::LOGIN_IS_EMAIL) { throw new InvalidArgumentException('Login field is invalid. '); } $this->__loginField = $loginField; - $this->beforeInsert = function ($instance) { - return $instance; - }; - $this->beforeUpdate = function ($instance) { - return $instance; - }; + $this->beforeInsert = new PassThroughEntityProcessor(); + $this->beforeUpdate = new PassThroughEntityProcessor(); } /** @@ -140,58 +130,98 @@ private function checkProperty(string $property): void /** * @param string $event * @param string $property - * @param Closure $closure + * @param MapperFunctionInterface|string $mapper */ - private function updateClosureDef(string $event, string $property, Closure $closure): void + private function updateMapperDef(string $event, string $property, MapperFunctionInterface|string $mapper): void { $this->checkProperty($property); - $this->__closures[$event][$property] = $closure; + $this->__mappers[$event][$property] = $mapper; } - private function getClosureDef(string $event, string $property): Closure + private function getMapperDef(string $event, string $property): MapperFunctionInterface|string { $this->checkProperty($property); - if (!$this->existsClosure($event, $property)) { - return MapperClosure::standard(); + if (!$this->existsMapper($event, $property)) { + return StandardMapper::class; } - return $this->__closures[$event][$property]; + return $this->__mappers[$event][$property]; } - public function existsClosure(string $event, string $property): bool + public function existsMapper(string $event, string $property): bool { // Event not set - if (!isset($this->__closures[$event])) { + if (!isset($this->__mappers[$event])) { return false; } // Event is set but there is no property - if (!array_key_exists($property, $this->__closures[$event])) { + if (!array_key_exists($property, $this->__mappers[$event])) { return false; } return true; } + /** + * @deprecated Use existsMapper instead + */ + public function existsClosure(string $event, string $property): bool + { + return $this->existsMapper($event, $property); + } + public function markPropertyAsReadOnly(string $property): void { - $this->updateClosureDef(self::UPDATE, $property, MapperClosure::readOnly()); + $this->updateMapperDef(self::UPDATE, $property, ReadOnlyMapper::class); + } + + public function defineMapperForUpdate(string $property, MapperFunctionInterface|string $mapper): void + { + $this->updateMapperDef(self::UPDATE, $property, $mapper); } + public function defineMapperForSelect(string $property, MapperFunctionInterface|string $mapper): void + { + $this->updateMapperDef(self::SELECT, $property, $mapper); + } + + /** + * @deprecated Use defineMapperForUpdate instead + */ public function defineClosureForUpdate(string $property, Closure $closure): void { - $this->updateClosureDef(self::UPDATE, $property, $closure); + $this->updateMapperDef(self::UPDATE, $property, new ClosureMapper($closure)); } + /** + * @deprecated Use defineMapperForSelect instead + */ public function defineClosureForSelect(string $property, Closure $closure): void { - $this->updateClosureDef(self::SELECT, $property, $closure); + $this->updateMapperDef(self::SELECT, $property, new ClosureMapper($closure)); + } + + public function getMapperForUpdate(string $property): MapperFunctionInterface|string + { + return $this->getMapperDef(self::UPDATE, $property); } + /** + * @deprecated Use getMapperForUpdate instead. Returns a Closure for backward compatibility. + */ public function getClosureForUpdate(string $property): Closure { - return $this->getClosureDef(self::UPDATE, $property); + $mapper = $this->getMapperDef(self::UPDATE, $property); + + // Return a closure that wraps the mapper + return function($value, $instance = null) use ($mapper) { + if (is_string($mapper)) { + $mapper = new $mapper(); + } + return $mapper->processedValue($value, $instance, null); + }; } public function defineGenerateKeyClosure(Closure $closure): void @@ -204,13 +234,27 @@ public function getGenerateKeyClosure(): ?Closure return $this->__generateKey; } + public function getMapperForSelect(string $property): MapperFunctionInterface|string + { + return $this->getMapperDef(self::SELECT, $property); + } + /** + * @deprecated Use getMapperForSelect instead. Returns a Closure for backward compatibility. * @param $property * @return Closure */ public function getClosureForSelect($property): Closure { - return $this->getClosureDef(self::SELECT, $property); + $mapper = $this->getMapperDef(self::SELECT, $property); + + // Return a closure that wraps the mapper + return function($value, $instance = null) use ($mapper) { + if (is_string($mapper)) { + $mapper = new $mapper(); + } + return $mapper->processedValue($value, $instance, null); + }; } public function model(): string @@ -224,39 +268,45 @@ public function modelInstance(): UserModel return new $model(); } - protected Closure $beforeInsert; + protected EntityProcessorInterface $beforeInsert; /** - * @return Closure + * @return EntityProcessorInterface */ - public function getBeforeInsert(): Closure + public function getBeforeInsert(): EntityProcessorInterface { return $this->beforeInsert; } /** - * @param Closure $beforeInsert + * @param EntityProcessorInterface|Closure $beforeInsert */ - public function setBeforeInsert(Closure $beforeInsert): void + public function setBeforeInsert(EntityProcessorInterface|Closure $beforeInsert): void { + if ($beforeInsert instanceof Closure) { + $beforeInsert = new ClosureEntityProcessor($beforeInsert); + } $this->beforeInsert = $beforeInsert; } - protected Closure $beforeUpdate; + protected EntityProcessorInterface $beforeUpdate; /** - * @return Closure + * @return EntityProcessorInterface */ - public function getBeforeUpdate(): Closure + public function getBeforeUpdate(): EntityProcessorInterface { return $this->beforeUpdate; } /** - * @param mixed $beforeUpdate + * @param EntityProcessorInterface|Closure $beforeUpdate */ - public function setBeforeUpdate(Closure $beforeUpdate): void + public function setBeforeUpdate(EntityProcessorInterface|Closure $beforeUpdate): void { + if ($beforeUpdate instanceof Closure) { + $beforeUpdate = new ClosureEntityProcessor($beforeUpdate); + } $this->beforeUpdate = $beforeUpdate; } } diff --git a/src/EntityProcessors/ClosureEntityProcessor.php b/src/EntityProcessors/ClosureEntityProcessor.php new file mode 100644 index 0000000..efd4d17 --- /dev/null +++ b/src/EntityProcessors/ClosureEntityProcessor.php @@ -0,0 +1,31 @@ +closure = $closure; + } + + public function process(array $instance): array + { + $result = ($this->closure)($instance); + + // If closure returns an object, convert it to array + if (is_object($result)) { + return (array) $result; + } + + return $result; + } +} diff --git a/src/EntityProcessors/PassThroughEntityProcessor.php b/src/EntityProcessors/PassThroughEntityProcessor.php new file mode 100644 index 0000000..54569f3 --- /dev/null +++ b/src/EntityProcessors/PassThroughEntityProcessor.php @@ -0,0 +1,16 @@ +closure = $closure; + } + + public function processedValue(mixed $value, mixed $instance, mixed $helper = null): mixed + { + $reflection = new ReflectionFunction($this->closure); + $paramCount = $reflection->getNumberOfParameters(); + + // Call closure with appropriate number of parameters + return match($paramCount) { + 1 => ($this->closure)($value), + 2 => ($this->closure)($value, $instance), + default => ($this->closure)($value, $instance, $helper) + }; + } +} diff --git a/src/MapperFunctions/PasswordSha1Mapper.php b/src/MapperFunctions/PasswordSha1Mapper.php new file mode 100644 index 0000000..d995ff5 --- /dev/null +++ b/src/MapperFunctions/PasswordSha1Mapper.php @@ -0,0 +1,28 @@ +getUsername())); + } + + return $value; + } +} diff --git a/src/UsersAnyDataset.php b/src/UsersAnyDataset.php index 2cda813..cb7300e 100644 --- a/src/UsersAnyDataset.php +++ b/src/UsersAnyDataset.php @@ -7,11 +7,12 @@ use ByJG\AnyDataset\Core\Exception\DatabaseException; use ByJG\AnyDataset\Core\IteratorFilter; use ByJG\AnyDataset\Core\IteratorInterface; -use ByJG\AnyDataset\Core\Row; +use ByJG\AnyDataset\Core\RowInterface; use ByJG\Authenticate\Definition\UserDefinition; use ByJG\Authenticate\Definition\UserPropertiesDefinition; use ByJG\Authenticate\Exception\UserExistsException; use ByJG\Authenticate\Exception\UserNotFoundException; +use ByJG\Authenticate\MapperFunctions\UserIdGeneratorMapper; use ByJG\Authenticate\Model\UserModel; use ByJG\Authenticate\Model\UserPropertiesModel; use ByJG\MicroOrm\Literal\HexUuidLiteral; @@ -38,19 +39,14 @@ class UsersAnyDataset extends UsersBase */ public function __construct( AnyDataset $anyDataset, - UserDefinition $userTable = null, - UserPropertiesDefinition $propertiesTable = null + UserDefinition|null $userTable = null, + UserPropertiesDefinition|null $propertiesTable = null ) { $this->anyDataSet = $anyDataset; $this->anyDataSet->save(); $this->userTable = $userTable; - if (!$userTable->existsClosure('update', UserDefinition::FIELD_USERID)) { - $userTable->defineClosureForUpdate(UserDefinition::FIELD_USERID, function ($value, $instance) { - if (empty($value)) { - return preg_replace('/(?:([\w])|([\W]))/', '\1', strtolower($instance->getUsername())); - } - return $value; - }); + if (!$userTable->existsMapper('update', UserDefinition::FIELD_USERID)) { + $userTable->defineMapperForUpdate(UserDefinition::FIELD_USERID, UserIdGeneratorMapper::class); } $this->propertiesTable = $propertiesTable; } @@ -86,9 +82,23 @@ public function save(UserModel $model): UserModel } } - $properties = $model->getProperties(); - foreach ($properties as $value) { - $this->anyDataSet->addField($value->getName(), $value->getValue()); + // Group properties by name to handle multiple values + $propertiesByName = []; + foreach ($model->getProperties() as $property) { + $name = $property->getName(); + if (!isset($propertiesByName[$name])) { + $propertiesByName[$name] = []; + } + $propertiesByName[$name][] = $property->getValue(); + } + + // Add properties, using array if multiple values exist + foreach ($propertiesByName as $name => $values) { + if (count($values) === 1) { + $this->anyDataSet->addField($name, $values[0]); + } else { + $this->anyDataSet->addField($name, $values); + } } $this->anyDataSet->save(); @@ -108,11 +118,11 @@ public function save(UserModel $model): UserModel public function getUser(IteratorFilter $filter): UserModel|null { $iterator = $this->anyDataSet->getIterator($filter); - if (!$iterator->hasNext()) { + if (!$iterator->valid()) { return null; } - return $this->createUserModel($iterator->moveNext()); + return $this->createUserModel($iterator->current()); } /** @@ -133,8 +143,8 @@ public function removeByLoginField(string $login): bool $iteratorFilter->and($this->getUserDefinition()->loginField(), Relation::EQUAL, $login); $iterator = $this->anyDataSet->getIterator($iteratorFilter); - if ($iterator->hasNext()) { - $oldRow = $iterator->moveNext(); + if ($iterator->valid()) { + $oldRow = $iterator->current(); $this->anyDataSet->removeRow($oldRow); $this->anyDataSet->save(); return true; @@ -149,7 +159,7 @@ public function removeByLoginField(string $login): bool * @param IteratorFilter|null $filter * @return IteratorInterface */ - public function getIterator(IteratorFilter $filter = null): IteratorInterface + public function getIterator(IteratorFilter|null $filter = null): IteratorInterface { return $this->anyDataSet->getIterator($filter); } @@ -270,19 +280,17 @@ public function removeProperty(string|HexUuidLiteral|int $userId, string $proper public function removeAllProperties(string $propertyName, string|null $value = null): void { $iterator = $this->getIterator(null); - while ($iterator->hasNext()) { + foreach ($iterator as $user) { //anydataset.Row - $user = $iterator->moveNext(); $this->removeProperty($user->get($this->getUserDefinition()->getUserid()), $propertyName, $value); } } /** - * @param Row $row + * @param RowInterface $row * @return UserModel - * @throws InvalidArgumentException */ - private function createUserModel(Row $row): UserModel + private function createUserModel(RowInterface $row): UserModel { $allProp = $row->toArray(); $userModel = new UserModel(); @@ -296,8 +304,17 @@ private function createUserModel(Row $row): UserModel } foreach (array_keys($allProp) as $property) { - foreach ($row->getAsArray($property) as $eachValue) { - $userModel->addProperty(new UserPropertiesModel($property, $eachValue)); + $values = $row->get($property); + + // Handle both single values and arrays + if (!is_array($values)) { + if ($values !== null) { + $userModel->addProperty(new UserPropertiesModel($property, $values)); + } + } else { + foreach ($values as $eachValue) { + $userModel->addProperty(new UserPropertiesModel($property, $eachValue)); + } } } @@ -316,8 +333,8 @@ public function removeUserById(string|HexUuidLiteral|int $userid): bool $iteratorFilter->and($this->getUserDefinition()->getUserid(), Relation::EQUAL, $userid); $iterator = $this->anyDataSet->getIterator($iteratorFilter); - if ($iterator->hasNext()) { - $oldRow = $iterator->moveNext(); + if ($iterator->valid()) { + $oldRow = $iterator->current(); $this->anyDataSet->removeRow($oldRow); return true; } diff --git a/src/UsersBase.php b/src/UsersBase.php index c82b850..e286db0 100644 --- a/src/UsersBase.php +++ b/src/UsersBase.php @@ -225,7 +225,7 @@ public function isValidUser(string $userName, string $password): UserModel|null * @throws InvalidArgumentException */ #[\Override] - public function hasProperty(string|int|HexUuidLiteral|null $userId, string $propertyName, string $value = null): bool + public function hasProperty(string|int|HexUuidLiteral|null $userId, string $propertyName, string|null $value = null): bool { //anydataset.Row $user = $this->getById($userId); diff --git a/src/UsersDBDataset.php b/src/UsersDBDataset.php index e1a2847..d502fe2 100644 --- a/src/UsersDBDataset.php +++ b/src/UsersDBDataset.php @@ -3,6 +3,7 @@ namespace ByJG\Authenticate; use ByJG\AnyDataset\Core\IteratorFilter; +use ByJG\AnyDataset\Db\DatabaseExecutor; use ByJG\AnyDataset\Db\DbDriverInterface; use ByJG\AnyDataset\Db\IteratorFilterSqlFormatter; use ByJG\Authenticate\Definition\UserDefinition; @@ -41,14 +42,14 @@ class UsersDBDataset extends UsersBase protected Repository $propertiesRepository; /** - * @var DbDriverInterface + * @var DatabaseExecutor */ - protected DbDriverInterface $provider; + protected DatabaseExecutor $executor; /** * UsersDBDataset constructor * - * @param DbDriverInterface $dbDriver + * @param DbDriverInterface|DatabaseExecutor $dbDriver * @param UserDefinition|null $userTable * @param UserPropertiesDefinition|null $propertiesTable * @@ -56,10 +57,16 @@ class UsersDBDataset extends UsersBase * @throws ReflectionException */ public function __construct( - DbDriverInterface $dbDriver, - UserDefinition $userTable = null, - UserPropertiesDefinition $propertiesTable = null + DbDriverInterface|DatabaseExecutor $dbDriver, + UserDefinition|null $userTable = null, + UserPropertiesDefinition|null $propertiesTable = null ) { + // Convert DbDriverInterface to DatabaseExecutor if needed + if ($dbDriver instanceof DbDriverInterface && !($dbDriver instanceof DatabaseExecutor)) { + $dbDriver = new DatabaseExecutor($dbDriver); + } + $this->executor = $dbDriver; + if (empty($userTable)) { $userTable = new UserDefinition(); } @@ -83,11 +90,11 @@ public function __construct( foreach ($propertyDefinition as $property => $map) { $userMapper->addFieldMapping(FieldMapping::create($property) ->withFieldName($map) - ->withUpdateFunction($userTable->getClosureForUpdate($property)) - ->withSelectFunction($userTable->getClosureForSelect($property)) + ->withUpdateFunction($userTable->getMapperForUpdate($property)) + ->withSelectFunction($userTable->getMapperForSelect($property)) ); } - $this->userRepository = new Repository($dbDriver, $userMapper); + $this->userRepository = new Repository($this->executor, $userMapper); $propertiesMapper = new Mapper( UserPropertiesModel::class, @@ -99,10 +106,10 @@ public function __construct( $propertiesMapper->addFieldMapping(FieldMapping::create('value')->withFieldName($propertiesTable->getValue())); $propertiesMapper->addFieldMapping(FieldMapping::create(UserDefinition::FIELD_USERID) ->withFieldName($propertiesTable->getUserid()) - ->withUpdateFunction($userTable->getClosureForUpdate(UserDefinition::FIELD_USERID)) - ->withSelectFunction($userTable->getClosureForSelect(UserDefinition::FIELD_USERID)) + ->withUpdateFunction($userTable->getMapperForUpdate(UserDefinition::FIELD_USERID)) + ->withSelectFunction($userTable->getMapperForSelect(UserDefinition::FIELD_USERID)) ); - $this->propertiesRepository = new Repository($dbDriver, $propertiesMapper); + $this->propertiesRepository = new Repository($this->executor, $propertiesMapper); $this->userTable = $userTable; $this->propertiesTable = $propertiesTable; @@ -114,6 +121,7 @@ public function __construct( * @param UserModel $model * @return UserModel * @throws UserExistsException + * @throws UserNotFoundException * @throws OrmBeforeInvalidException * @throws OrmInvalidFieldsException * @throws Exception @@ -137,11 +145,11 @@ public function save(UserModel $model): UserModel } if ($newUser) { - $model = $this->getByEmail($model->getEmail()); + $model = $this->getById($model->getUserid()); } if ($model === null) { - throw new UserExistsException("User not found"); + throw new UserNotFoundException("User not found"); } return $model; @@ -153,7 +161,7 @@ public function save(UserModel $model): UserModel * @param IteratorFilter|null $filter Filter to find user * @return UserModel[] */ - public function getIterator(IteratorFilter $filter = null): array + public function getIterator(IteratorFilter|null $filter = null): array { if (is_null($filter)) { $filter = new IteratorFilter(); @@ -426,7 +434,10 @@ public function getProperty(string|HexUuidLiteral|int $userId, string $propertyN */ protected function setPropertiesInUser(UserModel $userRow): void { - $value = $this->propertiesRepository->getMapper()->getFieldMap(UserDefinition::FIELD_USERID)->getUpdateFunctionValue($userRow->getUserid(), $userRow, $this->propertiesRepository->getDbDriverWrite()->getDbHelper()); + $value = $this->propertiesRepository + ->getMapper() + ->getFieldMap(UserDefinition::FIELD_USERID) + ->getUpdateFunctionValue($userRow->getUserid(), $userRow, $this->propertiesRepository->getExecutorWrite()->getHelper()); $query = Query::getInstance() ->table($this->getUserPropertiesDefinition()->table()) ->where("{$this->getUserPropertiesDefinition()->getUserid()} = :id", ['id' => $value]); diff --git a/tests/UsersAnyDataset2ByEmailTest.php b/tests/UsersAnyDataset2ByEmailTest.php index f3039db..2091e71 100644 --- a/tests/UsersAnyDataset2ByEmailTest.php +++ b/tests/UsersAnyDataset2ByEmailTest.php @@ -4,7 +4,7 @@ use ByJG\Authenticate\Definition\UserDefinition; -class UsersAnyDataset2EmailTest extends UsersAnyDatasetByUsernameTest +class UsersAnyDataset2ByEmailTest extends UsersAnyDatasetByUsernameTest { #[\Override] public function setUp(): void diff --git a/tests/UsersDBDatasetByUsernameTest.php b/tests/UsersDBDatasetByUsernameTest.php index d74b4a6..5b575a1 100644 --- a/tests/UsersDBDatasetByUsernameTest.php +++ b/tests/UsersDBDatasetByUsernameTest.php @@ -5,6 +5,7 @@ use ByJG\AnyDataset\Db\Factory; use ByJG\Authenticate\Definition\UserDefinition; use ByJG\Authenticate\Definition\UserPropertiesDefinition; +use ByJG\Authenticate\MapperFunctions\ClosureMapper; use ByJG\Authenticate\Model\UserModel; use ByJG\Authenticate\UsersDBDataset; use ByJG\Util\Uri; @@ -110,33 +111,33 @@ public function testCreateAuthToken() public function testWithUpdateValue() { // For Update Definitions - $this->userDefinition->defineClosureForUpdate(UserDefinition::FIELD_NAME, function ($value, $instance) { + $this->userDefinition->defineMapperForUpdate(UserDefinition::FIELD_NAME, new ClosureMapper(function ($value, $instance) { return '[' . $value . ']'; - }); - $this->userDefinition->defineClosureForUpdate(UserDefinition::FIELD_USERNAME, function ($value, $instance) { + })); + $this->userDefinition->defineMapperForUpdate(UserDefinition::FIELD_USERNAME, new ClosureMapper(function ($value, $instance) { return ']' . $value . '['; - }); - $this->userDefinition->defineClosureForUpdate(UserDefinition::FIELD_EMAIL, function ($value, $instance) { + })); + $this->userDefinition->defineMapperForUpdate(UserDefinition::FIELD_EMAIL, new ClosureMapper(function ($value, $instance) { return '-' . $value . '-'; - }); - $this->userDefinition->defineClosureForUpdate(UserDefinition::FIELD_PASSWORD, function ($value, $instance) { + })); + $this->userDefinition->defineMapperForUpdate(UserDefinition::FIELD_PASSWORD, new ClosureMapper(function ($value, $instance) { return "@" . $value . "@"; - }); + })); $this->userDefinition->markPropertyAsReadOnly(UserDefinition::FIELD_CREATED); // For Select Definitions - $this->userDefinition->defineClosureForSelect(UserDefinition::FIELD_NAME, function ($value, $instance) { + $this->userDefinition->defineMapperForSelect(UserDefinition::FIELD_NAME, new ClosureMapper(function ($value, $instance) { return '(' . $value . ')'; - }); - $this->userDefinition->defineClosureForSelect(UserDefinition::FIELD_USERNAME, function ($value, $instance) { + })); + $this->userDefinition->defineMapperForSelect(UserDefinition::FIELD_USERNAME, new ClosureMapper(function ($value, $instance) { return ')' . $value . '('; - }); - $this->userDefinition->defineClosureForSelect(UserDefinition::FIELD_EMAIL, function ($value, $instance) { + })); + $this->userDefinition->defineMapperForSelect(UserDefinition::FIELD_EMAIL, new ClosureMapper(function ($value, $instance) { return '#' . $value . '#'; - }); - $this->userDefinition->defineClosureForSelect(UserDefinition::FIELD_PASSWORD, function ($value, $instance) { + })); + $this->userDefinition->defineMapperForSelect(UserDefinition::FIELD_PASSWORD, new ClosureMapper(function ($value, $instance) { return '%'. $value . '%'; - }); + })); // Test it! $newObject = new UsersDBDataset( diff --git a/tests/UsersDBDatasetDefinitionTest.php b/tests/UsersDBDatasetDefinitionTest.php index 2edf0cd..82c05d6 100644 --- a/tests/UsersDBDatasetDefinitionTest.php +++ b/tests/UsersDBDatasetDefinitionTest.php @@ -7,6 +7,7 @@ use ByJG\Authenticate\Definition\UserDefinition; use ByJG\Authenticate\Definition\UserPropertiesDefinition; use ByJG\Authenticate\Exception\UserExistsException; +use ByJG\Authenticate\MapperFunctions\ClosureMapper; use ByJG\Authenticate\Model\UserModel; use ByJG\Authenticate\UsersDBDataset; use ByJG\MicroOrm\Exception\OrmBeforeInvalidException; @@ -158,39 +159,39 @@ public function testAddUser() public function testWithUpdateValue() { // For Update Definitions - $this->userDefinition->defineClosureForUpdate(UserDefinition::FIELD_NAME, function ($value, $instance) { + $this->userDefinition->defineMapperForUpdate(UserDefinition::FIELD_NAME, new ClosureMapper(function ($value, $instance) { return '[' . $value . ']'; - }); - $this->userDefinition->defineClosureForUpdate(UserDefinition::FIELD_USERNAME, function ($value, $instance) { + })); + $this->userDefinition->defineMapperForUpdate(UserDefinition::FIELD_USERNAME, new ClosureMapper(function ($value, $instance) { return ']' . $value . '['; - }); - $this->userDefinition->defineClosureForUpdate(UserDefinition::FIELD_EMAIL, function ($value, $instance) { + })); + $this->userDefinition->defineMapperForUpdate(UserDefinition::FIELD_EMAIL, new ClosureMapper(function ($value, $instance) { return '-' . $value . '-'; - }); - $this->userDefinition->defineClosureForUpdate(UserDefinition::FIELD_PASSWORD, function ($value, $instance) { + })); + $this->userDefinition->defineMapperForUpdate(UserDefinition::FIELD_PASSWORD, new ClosureMapper(function ($value, $instance) { return "@" . $value . "@"; - }); + })); $this->userDefinition->markPropertyAsReadOnly(UserDefinition::FIELD_CREATED); - $this->userDefinition->defineClosureForUpdate('otherfield', function ($value, $instance) { + $this->userDefinition->defineMapperForUpdate('otherfield', new ClosureMapper(function ($value, $instance) { return "*" . $value . "*"; - }); + })); // For Select Definitions - $this->userDefinition->defineClosureForSelect(UserDefinition::FIELD_NAME, function ($value, $instance) { + $this->userDefinition->defineMapperForSelect(UserDefinition::FIELD_NAME, new ClosureMapper(function ($value, $instance) { return '(' . $value . ')'; - }); - $this->userDefinition->defineClosureForSelect(UserDefinition::FIELD_USERNAME, function ($value, $instance) { + })); + $this->userDefinition->defineMapperForSelect(UserDefinition::FIELD_USERNAME, new ClosureMapper(function ($value, $instance) { return ')' . $value . '('; - }); - $this->userDefinition->defineClosureForSelect(UserDefinition::FIELD_EMAIL, function ($value, $instance) { + })); + $this->userDefinition->defineMapperForSelect(UserDefinition::FIELD_EMAIL, new ClosureMapper(function ($value, $instance) { return '#' . $value . '#'; - }); - $this->userDefinition->defineClosureForSelect(UserDefinition::FIELD_PASSWORD, function ($value, $instance) { + })); + $this->userDefinition->defineMapperForSelect(UserDefinition::FIELD_PASSWORD, new ClosureMapper(function ($value, $instance) { return '%' . $value . '%'; - }); - $this->userDefinition->defineClosureForSelect('otherfield', function ($value, $instance) { + })); + $this->userDefinition->defineMapperForSelect('otherfield', new ClosureMapper(function ($value, $instance) { return ']' . $value . '['; - }); + })); // Test it! $newObject = new UsersDBDataset( From 6eaf52708d3e6d669dc3b75dc3ff984335209f08 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Sat, 1 Nov 2025 16:00:53 -0400 Subject: [PATCH 03/17] Improve exception handling, replace deprecated methods with new implementations, enhance type hinting, support custom ID generation via `UniqueIdGeneratorInterface`, update test cases, and adjust dependencies for compatibility. --- composer.json | 2 +- src/Definition/PasswordDefinition.php | 4 + src/Definition/UserDefinition.php | 27 ++++-- src/Exception/NotAuthenticatedException.php | 4 +- src/Exception/NotImplementedException.php | 4 +- src/Exception/UserExistsException.php | 4 +- src/Exception/UserNotFoundException.php | 4 +- src/Interfaces/UsersInterface.php | 4 +- src/MapperFunctions/ClosureMapper.php | 4 + src/Model/UserModel.php | 2 +- src/SessionContext.php | 13 +-- src/UsersAnyDataset.php | 38 ++++---- src/UsersBase.php | 50 ++++++----- src/UsersDBDataset.php | 88 ++++++++++++++---- tests/UsersDBDatasetDefinitionTest.php | 99 +++++++++++++++++++++ 15 files changed, 271 insertions(+), 76 deletions(-) diff --git a/composer.json b/composer.json index ced7781..035f0c8 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,7 @@ "byjg/jwt-wrapper": "^6.0" }, "require-dev": { - "phpunit/phpunit": "^9.6", + "phpunit/phpunit": "^9.6|^11", "vimeo/psalm": "^5.9|^6.13" }, "scripts": { diff --git a/src/Definition/PasswordDefinition.php b/src/Definition/PasswordDefinition.php index ad27ece..12d0ff3 100644 --- a/src/Definition/PasswordDefinition.php +++ b/src/Definition/PasswordDefinition.php @@ -3,6 +3,7 @@ namespace ByJG\Authenticate\Definition; use InvalidArgumentException; +use Random\RandomException; class PasswordDefinition { @@ -113,6 +114,9 @@ public function matchPassword(string $password): int return $result; } + /** + * @throws RandomException + */ public function generatePassword(int $extendSize = 0): string { $charsList = [ diff --git a/src/Definition/UserDefinition.php b/src/Definition/UserDefinition.php index cf1c0ed..1cd3939 100644 --- a/src/Definition/UserDefinition.php +++ b/src/Definition/UserDefinition.php @@ -9,6 +9,7 @@ use ByJG\Authenticate\Model\UserModel; use ByJG\MicroOrm\Interface\EntityProcessorInterface; use ByJG\MicroOrm\Interface\MapperFunctionInterface; +use ByJG\MicroOrm\Interface\UniqueIdGeneratorInterface; use ByJG\MicroOrm\MapperFunctions\ReadOnlyMapper; use ByJG\MicroOrm\MapperFunctions\StandardMapper; use ByJG\Serializer\Serialize; @@ -25,7 +26,7 @@ class UserDefinition protected string $__loginField; protected string $__model; protected array $__properties = []; - protected Closure|null $__generateKey = null; + protected UniqueIdGeneratorInterface|string|null $__generateKey = null; const FIELD_USERID = 'userid'; const FIELD_NAME = 'name'; @@ -220,16 +221,32 @@ public function getClosureForUpdate(string $property): Closure if (is_string($mapper)) { $mapper = new $mapper(); } - return $mapper->processedValue($value, $instance, null); + return $mapper->processedValue($value, $instance); }; } + /** + * @deprecated Use defineGenerateKey instead + */ public function defineGenerateKeyClosure(Closure $closure): void { - $this->__generateKey = $closure; + throw new InvalidArgumentException('defineGenerateKeyClosure is deprecated. Use defineGenerateKey with UniqueIdGeneratorInterface instead.'); + } + + public function defineGenerateKey(UniqueIdGeneratorInterface|string $generator): void + { + $this->__generateKey = $generator; } - public function getGenerateKeyClosure(): ?Closure + public function getGenerateKey(): UniqueIdGeneratorInterface|string|null + { + return $this->__generateKey; + } + + /** + * @deprecated Use getGenerateKey instead + */ + public function getGenerateKeyClosure(): UniqueIdGeneratorInterface|string|null { return $this->__generateKey; } @@ -253,7 +270,7 @@ public function getClosureForSelect($property): Closure if (is_string($mapper)) { $mapper = new $mapper(); } - return $mapper->processedValue($value, $instance, null); + return $mapper->processedValue($value, $instance); }; } diff --git a/src/Exception/NotAuthenticatedException.php b/src/Exception/NotAuthenticatedException.php index 9685ec7..42f0982 100644 --- a/src/Exception/NotAuthenticatedException.php +++ b/src/Exception/NotAuthenticatedException.php @@ -2,7 +2,9 @@ namespace ByJG\Authenticate\Exception; -class NotAuthenticatedException extends \Exception +use Exception; + +class NotAuthenticatedException extends Exception { //put your code here } diff --git a/src/Exception/NotImplementedException.php b/src/Exception/NotImplementedException.php index 8756d4c..818ca3a 100644 --- a/src/Exception/NotImplementedException.php +++ b/src/Exception/NotImplementedException.php @@ -2,7 +2,9 @@ namespace ByJG\Authenticate\Exception; -class NotImplementedException extends \Exception +use Exception; + +class NotImplementedException extends Exception { //put your code here } diff --git a/src/Exception/UserExistsException.php b/src/Exception/UserExistsException.php index 20ab26d..5213ac0 100644 --- a/src/Exception/UserExistsException.php +++ b/src/Exception/UserExistsException.php @@ -2,7 +2,9 @@ namespace ByJG\Authenticate\Exception; -class UserExistsException extends \Exception +use Exception; + +class UserExistsException extends Exception { //put your code here } diff --git a/src/Exception/UserNotFoundException.php b/src/Exception/UserNotFoundException.php index 88bd3a9..8c9a657 100644 --- a/src/Exception/UserNotFoundException.php +++ b/src/Exception/UserNotFoundException.php @@ -2,7 +2,9 @@ namespace ByJG\Authenticate\Exception; -class UserNotFoundException extends \Exception +use Exception; + +class UserNotFoundException extends Exception { //put your code here } diff --git a/src/Interfaces/UsersInterface.php b/src/Interfaces/UsersInterface.php index 38003bc..82f12cc 100644 --- a/src/Interfaces/UsersInterface.php +++ b/src/Interfaces/UsersInterface.php @@ -113,7 +113,7 @@ public function hasProperty(string|int|HexUuidLiteral|null $userId, string $prop * @param string $propertyName * @return UserPropertiesModel|array|null|string String vector with all sites */ - public function getProperty(string|HexUuidLiteral|int $userId, string $propertyName): array|string|\ByJG\Authenticate\Model\UserPropertiesModel|null; + public function getProperty(string|HexUuidLiteral|int $userId, string $propertyName): array|string|UserPropertiesModel|null; /** * @@ -156,7 +156,7 @@ public function removeAllProperties(string $propertyName, string|null $value = n * @param int $expires * @param array $updateUserInfo * @param array $updateTokenInfo - * @return string|null Return the TOKEN or null, if can't create it. + * @return string|null Return the TOKEN or null, if we can't create it. */ public function createAuthToken( string $login, diff --git a/src/MapperFunctions/ClosureMapper.php b/src/MapperFunctions/ClosureMapper.php index 3fd4f17..fb63542 100644 --- a/src/MapperFunctions/ClosureMapper.php +++ b/src/MapperFunctions/ClosureMapper.php @@ -4,6 +4,7 @@ use ByJG\MicroOrm\Interface\MapperFunctionInterface; use Closure; +use ReflectionException; use ReflectionFunction; /** @@ -19,6 +20,9 @@ public function __construct(Closure $closure) $this->closure = $closure; } + /** + * @throws ReflectionException + */ public function processedValue(mixed $value, mixed $instance, mixed $helper = null): mixed { $reflection = new ReflectionFunction($this->closure); diff --git a/src/Model/UserModel.php b/src/Model/UserModel.php index 3dd4ee3..82cf36d 100644 --- a/src/Model/UserModel.php +++ b/src/Model/UserModel.php @@ -120,7 +120,7 @@ public function setPassword(?string $password): void if (!empty($this->passwordDefinition) && !empty($password) && strlen($password) != 40) { $match = $this->passwordDefinition->matchPassword($password); if ($match != PasswordDefinition::SUCCESS) { - throw new InvalidArgumentException("Password does not match the password definition [{$match}]"); + throw new InvalidArgumentException("Password does not match the password definition [$match]"); } } diff --git a/src/SessionContext.php b/src/SessionContext.php index 5cbf846..214d134 100644 --- a/src/SessionContext.php +++ b/src/SessionContext.php @@ -5,6 +5,7 @@ use ByJG\Authenticate\Exception\NotAuthenticatedException; use ByJG\Authenticate\Interfaces\UserContextInterface; use ByJG\Cache\Psr6\CachePool; +use Override; use Psr\SimpleCache\InvalidArgumentException; class SessionContext implements UserContextInterface @@ -39,7 +40,7 @@ public function __construct(CachePool $cachePool, string $key = 'default') * @return bool Return true if authenticated; false otherwise. * @throws InvalidArgumentException */ - #[\Override] + #[Override] public function isAuthenticated(): bool { $item = $this->session->getItem("user.$this->key"); @@ -53,7 +54,7 @@ public function isAuthenticated(): bool * @return string|int The authenticated username if exists. * @throws InvalidArgumentException */ - #[\Override] + #[Override] public function userInfo(): string|int { $item = $this->session->getItem("user.$this->key"); @@ -65,7 +66,7 @@ public function userInfo(): string|int * @param array $data * @throws InvalidArgumentException */ - #[\Override] + #[Override] public function registerLogin(string|int $userId, array $data = []): void { $item = $this->session->getItem("user.$this->key"); @@ -87,7 +88,7 @@ public function registerLogin(string|int $userId, array $data = []): void * @throws InvalidArgumentException * @throws InvalidArgumentException */ - #[\Override] + #[Override] public function setSessionData(string $name, mixed $value): void { if (!$this->isAuthenticated()) { @@ -114,7 +115,7 @@ public function setSessionData(string $name, mixed $value): void * @throws InvalidArgumentException * @throws InvalidArgumentException */ - #[\Override] + #[Override] public function getSessionData(string $name): mixed { if (!$this->isAuthenticated()) { @@ -142,7 +143,7 @@ public function getSessionData(string $name): mixed * @throws InvalidArgumentException * @throws InvalidArgumentException */ - #[\Override] + #[Override] public function registerLogout(): void { $this->session->deleteItem("user.$this->key"); diff --git a/src/UsersAnyDataset.php b/src/UsersAnyDataset.php index cb7300e..e184362 100644 --- a/src/UsersAnyDataset.php +++ b/src/UsersAnyDataset.php @@ -18,6 +18,7 @@ use ByJG\MicroOrm\Literal\HexUuidLiteral; use ByJG\Serializer\Exception\InvalidArgumentException; use ByJG\XmlUtil\Exception\FileException; +use Override; class UsersAnyDataset extends UsersBase { @@ -61,7 +62,7 @@ public function __construct( * @throws UserExistsException * @throws FileException */ - #[\Override] + #[Override] public function save(UserModel $model): UserModel { $new = true; @@ -75,8 +76,11 @@ public function save(UserModel $model): UserModel $propertyDefinition = $this->getUserDefinition()->toArray(); foreach ($propertyDefinition as $property => $map) { - $closure = $this->getUserDefinition()->getClosureForUpdate($property); - $value = $closure($model->{"get$property"}(), $model); + $mapper = $this->getUserDefinition()->getMapperForUpdate($property); + if (is_string($mapper)) { + $mapper = new $mapper(); + } + $value = $mapper->processedValue($model->{"get$property"}(), $model); if ($value !== false) { $this->anyDataSet->addField($map, $value); } @@ -112,9 +116,8 @@ public function save(UserModel $model): UserModel * * @param IteratorFilter $filter Filter to find user * @return UserModel|null - * @throws InvalidArgumentException */ - #[\Override] + #[Override] public function getUser(IteratorFilter $filter): UserModel|null { $iterator = $this->anyDataSet->getIterator($filter); @@ -135,7 +138,7 @@ public function getUser(IteratorFilter $filter): UserModel|null * @throws FileException * @throws InvalidArgumentException */ - #[\Override] + #[Override] public function removeByLoginField(string $login): bool { //anydataset.Row @@ -165,18 +168,21 @@ public function getIterator(IteratorFilter|null $filter = null): IteratorInterfa } /** - * @throws InvalidArgumentException + * @param string $propertyName + * @param string $value + * @return array */ - #[\Override] + #[Override] public function getUsersByProperty(string $propertyName, string $value): array { return $this->getUsersByPropertySet([$propertyName => $value]); } /** - * @throws InvalidArgumentException + * @param array $propertiesArray + * @return array */ - #[\Override] + #[Override] public function getUsersByPropertySet(array $propertiesArray): array { $filter = new IteratorFilter(); @@ -201,7 +207,7 @@ public function getUsersByPropertySet(array $propertiesArray): array * @throws UserExistsException * @throws UserNotFoundException */ - #[\Override] + #[Override] public function addProperty(string|HexUuidLiteral|int $userId, string $propertyName, string|null $value): bool { //anydataset.Row @@ -223,7 +229,7 @@ public function addProperty(string|HexUuidLiteral|int $userId, string $propertyN * @throws UserExistsException * @throws FileException */ - #[\Override] + #[Override] public function setProperty(string|HexUuidLiteral|int $userId, string $propertyName, string|null $value): bool { $user = $this->getById($userId); @@ -245,7 +251,7 @@ public function setProperty(string|HexUuidLiteral|int $userId, string $propertyN * @throws InvalidArgumentException * @throws UserExistsException */ - #[\Override] + #[Override] public function removeProperty(string|HexUuidLiteral|int $userId, string $propertyName, string|null $value = null): bool { $user = $this->getById($userId); @@ -276,10 +282,10 @@ public function removeProperty(string|HexUuidLiteral|int $userId, string $proper * @throws InvalidArgumentException * @throws UserExistsException */ - #[\Override] + #[Override] public function removeAllProperties(string $propertyName, string|null $value = null): void { - $iterator = $this->getIterator(null); + $iterator = $this->getIterator(); foreach ($iterator as $user) { //anydataset.Row $this->removeProperty($user->get($this->getUserDefinition()->getUserid()), $propertyName, $value); @@ -326,7 +332,7 @@ private function createUserModel(RowInterface $row): UserModel * @return bool * @throws InvalidArgumentException */ - #[\Override] + #[Override] public function removeUserById(string|HexUuidLiteral|int $userid): bool { $iteratorFilter = new IteratorFilter(); diff --git a/src/UsersBase.php b/src/UsersBase.php index e286db0..80cfe2c 100644 --- a/src/UsersBase.php +++ b/src/UsersBase.php @@ -16,6 +16,7 @@ use ByJG\JwtWrapper\JwtWrapperException; use ByJG\MicroOrm\Literal\HexUuidLiteral; use ByJG\Serializer\Exception\InvalidArgumentException; +use Override; /** * Base implementation to search and handle users in XMLNuke. @@ -36,7 +37,7 @@ abstract class UsersBase implements UsersInterface /** * @return UserDefinition */ - #[\Override] + #[Override] public function getUserDefinition(): UserDefinition { if ($this->userTable === null) { @@ -48,7 +49,7 @@ public function getUserDefinition(): UserDefinition /** * @return UserPropertiesDefinition */ - #[\Override] + #[Override] public function getUserPropertiesDefinition(): UserPropertiesDefinition { if ($this->propertiesTable === null) { @@ -62,7 +63,7 @@ public function getUserPropertiesDefinition(): UserPropertiesDefinition * * @param UserModel $model */ - #[\Override] + #[Override] abstract public function save(UserModel $model): UserModel; /** @@ -74,7 +75,7 @@ abstract public function save(UserModel $model): UserModel; * @param string $password * @return UserModel */ - #[\Override] + #[Override] public function addUser(string $name, string $userName, string $email, string $password): UserModel { $model = $this->getUserDefinition()->modelInstance(); @@ -92,7 +93,7 @@ public function addUser(string $name, string $userName, string $email, string $p * @throws UserExistsException * @throws InvalidArgumentException */ - #[\Override] + #[Override] public function canAddUser(UserModel $model): bool { if ($this->getByEmail($model->getEmail()) !== null) { @@ -114,7 +115,7 @@ public function canAddUser(UserModel $model): bool * @param IteratorFilter $filter Filter to find user * @return UserModel|null * */ - #[\Override] + #[Override] abstract public function getUser(IteratorFilter $filter): UserModel|null; /** @@ -125,7 +126,7 @@ abstract public function getUser(IteratorFilter $filter): UserModel|null; * @return UserModel|null * @throws InvalidArgumentException */ - #[\Override] + #[Override] public function getByEmail(string $email): UserModel|null { $filter = new IteratorFilter(); @@ -141,7 +142,7 @@ public function getByEmail(string $email): UserModel|null * @return UserModel|null * @throws InvalidArgumentException */ - #[\Override] + #[Override] public function getByUsername(string $username): UserModel|null { $filter = new IteratorFilter(); @@ -156,7 +157,7 @@ public function getByUsername(string $username): UserModel|null * @param string $login * @return UserModel|null */ - #[\Override] + #[Override] public function getByLoginField(string $login): UserModel|null { $filter = new IteratorFilter(); @@ -173,7 +174,7 @@ public function getByLoginField(string $login): UserModel|null * @return UserModel|null * @throws InvalidArgumentException */ - #[\Override] + #[Override] public function getById(string|HexUuidLiteral|int $userid): UserModel|null { $filter = new IteratorFilter(); @@ -187,7 +188,7 @@ public function getById(string|HexUuidLiteral|int $userid): UserModel|null * @param string $login * @return bool * */ - #[\Override] + #[Override] abstract public function removeByLoginField(string $login): bool; /** @@ -199,16 +200,19 @@ abstract public function removeByLoginField(string $login): bool; * @return UserModel|null * @throws InvalidArgumentException */ - #[\Override] + #[Override] public function isValidUser(string $userName, string $password): UserModel|null { $filter = new IteratorFilter(); - $passwordGenerator = $this->getUserDefinition()->getClosureForUpdate(UserDefinition::FIELD_PASSWORD); + $passwordMapper = $this->getUserDefinition()->getMapperForUpdate(UserDefinition::FIELD_PASSWORD); + if (is_string($passwordMapper)) { + $passwordMapper = new $passwordMapper(); + } $filter->and($this->getUserDefinition()->loginField(), Relation::EQUAL, strtolower($userName)); $filter->and( $this->getUserDefinition()->getPassword(), Relation::EQUAL, - $passwordGenerator($password, null) + $passwordMapper->processedValue($password, null) ); return $this->getUser($filter); } @@ -224,7 +228,7 @@ public function isValidUser(string $userName, string $password): UserModel|null * @throws UserNotFoundException * @throws InvalidArgumentException */ - #[\Override] + #[Override] public function hasProperty(string|int|HexUuidLiteral|null $userId, string $propertyName, string|null $value = null): bool { //anydataset.Row @@ -261,7 +265,7 @@ public function hasProperty(string|int|HexUuidLiteral|null $userId, string $prop * @throws UserNotFoundException * @throws InvalidArgumentException */ - #[\Override] + #[Override] public function getProperty(string|HexUuidLiteral|int $userId, string $propertyName): array|string|UserPropertiesModel|null { $user = $this->getById($userId); @@ -288,7 +292,7 @@ abstract public function getUsersByPropertySet(array $propertiesArray): array; * @param string $propertyName * @param string|null $value */ - #[\Override] + #[Override] abstract public function addProperty(string|HexUuidLiteral|int $userId, string $propertyName, string|null $value): bool; /** @@ -300,7 +304,7 @@ abstract public function addProperty(string|HexUuidLiteral|int $userId, string $ * @param string|null $value Property value with a site * @return bool * */ - #[\Override] + #[Override] abstract public function removeProperty(string|HexUuidLiteral|int $userId, string $propertyName, string|null $value = null): bool; /** @@ -311,7 +315,7 @@ abstract public function removeProperty(string|HexUuidLiteral|int $userId, strin * @param string|null $value Property value with a site * @return void * */ - #[\Override] + #[Override] abstract public function removeAllProperties(string $propertyName, string|null $value = null): void; /** @@ -320,7 +324,7 @@ abstract public function removeAllProperties(string $propertyName, string|null $ * @throws UserNotFoundException * @throws InvalidArgumentException */ - #[\Override] + #[Override] public function isAdmin(string|HexUuidLiteral|int $userId): bool { $user = $this->getById($userId); @@ -347,7 +351,7 @@ public function isAdmin(string|HexUuidLiteral|int $userId): bool * @throws UserNotFoundException * @throws InvalidArgumentException */ - #[\Override] + #[Override] public function createAuthToken( string $login, string $password, @@ -392,7 +396,7 @@ public function createAuthToken( * @throws NotAuthenticatedException * @throws UserNotFoundException */ - #[\Override] + #[Override] public function isValidToken(string $login, JwtWrapper $jwtWrapper, string $token): array|null { $user = $this->getByLoginField($login); @@ -418,6 +422,6 @@ public function isValidToken(string $login, JwtWrapper $jwtWrapper, string $toke /** * @param string|int|HexUuidLiteral $userid */ - #[\Override] + #[Override] abstract public function removeUserById(string|HexUuidLiteral|int $userid): bool; } diff --git a/src/UsersDBDataset.php b/src/UsersDBDataset.php index d502fe2..e07d872 100644 --- a/src/UsersDBDataset.php +++ b/src/UsersDBDataset.php @@ -2,9 +2,11 @@ namespace ByJG\Authenticate; +use ByJG\AnyDataset\Core\Exception\DatabaseException; use ByJG\AnyDataset\Core\IteratorFilter; use ByJG\AnyDataset\Db\DatabaseExecutor; use ByJG\AnyDataset\Db\DbDriverInterface; +use ByJG\AnyDataset\Db\Exception\DbDriverNotConnected; use ByJG\AnyDataset\Db\IteratorFilterSqlFormatter; use ByJG\Authenticate\Definition\UserDefinition; use ByJG\Authenticate\Definition\UserPropertiesDefinition; @@ -25,7 +27,10 @@ use ByJG\MicroOrm\Query; use ByJG\MicroOrm\Repository; use ByJG\Serializer\Exception\InvalidArgumentException; +use ByJG\XmlUtil\Exception\FileException; +use ByJG\XmlUtil\Exception\XmlUtilException; use Exception; +use Override; use ReflectionException; class UsersDBDataset extends UsersBase @@ -55,6 +60,8 @@ class UsersDBDataset extends UsersBase * * @throws OrmModelInvalidException * @throws ReflectionException + * @throws ExceptionInvalidArgumentException + * @throws ExceptionInvalidArgumentException */ public function __construct( DbDriverInterface|DatabaseExecutor $dbDriver, @@ -80,7 +87,7 @@ public function __construct( $userTable->table(), $userTable->getUserid() ); - $seed = $userTable->getGenerateKeyClosure(); + $seed = $userTable->getGenerateKey(); if (!empty($seed)) { $userMapper->withPrimaryKeySeedFunction($seed); } @@ -126,7 +133,7 @@ public function __construct( * @throws OrmInvalidFieldsException * @throws Exception */ - #[\Override] + #[Override] public function save(UserModel $model): UserModel { $newUser = false; @@ -160,6 +167,11 @@ public function save(UserModel $model): UserModel * * @param IteratorFilter|null $filter Filter to find user * @return UserModel[] + * @throws DatabaseException + * @throws DbDriverNotConnected + * @throws FileException + * @throws XmlUtilException + * @throws \Psr\SimpleCache\InvalidArgumentException */ public function getIterator(IteratorFilter|null $filter = null): array { @@ -184,8 +196,14 @@ public function getIterator(IteratorFilter|null $filter = null): array * * @param IteratorFilter $filter Filter to find user * @return UserModel|null + * @throws DatabaseException + * @throws DbDriverNotConnected + * @throws FileException + * @throws RepositoryReadOnlyException + * @throws XmlUtilException + * @throws \Psr\SimpleCache\InvalidArgumentException */ - #[\Override] + #[Override] public function getUser(IteratorFilter $filter): UserModel|null { $result = $this->getIterator($filter); @@ -207,7 +225,7 @@ public function getUser(IteratorFilter $filter): UserModel|null * @return bool * @throws Exception */ - #[\Override] + #[Override] public function removeByLoginField(string $login): bool { $user = $this->getByLoginField($login); @@ -226,7 +244,7 @@ public function removeByLoginField(string $login): bool * @return bool * @throws Exception */ - #[\Override] + #[Override] public function removeUserById(string|HexUuidLiteral|int $userid): bool { $updateTableProperties = DeleteQuery::getInstance() @@ -250,10 +268,15 @@ public function removeUserById(string|HexUuidLiteral|int $userid): bool * @param string $propertyName * @param string $value * @return array - * @throws InvalidArgumentException + * @throws DatabaseException + * @throws DbDriverNotConnected * @throws ExceptionInvalidArgumentException + * @throws FileException + * @throws InvalidArgumentException + * @throws XmlUtilException + * @throws \Psr\SimpleCache\InvalidArgumentException */ - #[\Override] + #[Override] public function getUsersByProperty(string $propertyName, string $value): array { return $this->getUsersByPropertySet([$propertyName => $value]); @@ -264,10 +287,15 @@ public function getUsersByProperty(string $propertyName, string $value): array * * @param array $propertiesArray * @return array - * @throws InvalidArgumentException + * @throws DatabaseException + * @throws DbDriverNotConnected * @throws ExceptionInvalidArgumentException + * @throws FileException + * @throws InvalidArgumentException + * @throws XmlUtilException + * @throws \Psr\SimpleCache\InvalidArgumentException */ - #[\Override] + #[Override] public function getUsersByPropertySet(array $propertiesArray): array { $query = Query::getInstance() @@ -295,7 +323,7 @@ public function getUsersByPropertySet(array $propertiesArray): array * @throws OrmInvalidFieldsException * @throws Exception */ - #[\Override] + #[Override] public function addProperty(string|HexUuidLiteral|int $userId, string $propertyName, string|null $value): bool { //anydataset.Row @@ -314,14 +342,22 @@ public function addProperty(string|HexUuidLiteral|int $userId, string $propertyN } /** - * @throws UpdateConstraintException - * @throws RepositoryReadOnlyException - * @throws InvalidArgumentException - * @throws OrmBeforeInvalidException + * @param string|HexUuidLiteral|int $userId + * @param string $propertyName + * @param string|null $value + * @return bool + * @throws DatabaseException + * @throws DbDriverNotConnected * @throws ExceptionInvalidArgumentException + * @throws FileException + * @throws OrmBeforeInvalidException * @throws OrmInvalidFieldsException + * @throws RepositoryReadOnlyException + * @throws UpdateConstraintException + * @throws XmlUtilException + * @throws \Psr\SimpleCache\InvalidArgumentException */ - #[\Override] + #[Override] public function setProperty(string|HexUuidLiteral|int $userId, string $propertyName, string|null $value): bool { $query = Query::getInstance() @@ -351,11 +387,13 @@ public function setProperty(string|HexUuidLiteral|int $userId, string $propertyN * @param string $propertyName Property name * @param string|null $value Property value with a site * @return bool + * @throws DatabaseException + * @throws DbDriverNotConnected * @throws ExceptionInvalidArgumentException * @throws InvalidArgumentException * @throws RepositoryReadOnlyException */ - #[\Override] + #[Override] public function removeProperty(string|HexUuidLiteral|int $userId, string $propertyName, string|null $value = null): bool { $user = $this->getById($userId); @@ -385,10 +423,12 @@ public function removeProperty(string|HexUuidLiteral|int $userId, string $proper * @param string $propertyName Property name * @param string|null $value Property value with a site * @return void + * @throws DatabaseException + * @throws DbDriverNotConnected * @throws ExceptionInvalidArgumentException * @throws RepositoryReadOnlyException */ - #[\Override] + #[Override] public function removeAllProperties(string $propertyName, string|null $value = null): void { $updateable = DeleteQuery::getInstance() @@ -402,7 +442,14 @@ public function removeAllProperties(string $propertyName, string|null $value = n $this->propertiesRepository->deleteByQuery($updateable); } - #[\Override] + /** + * @throws XmlUtilException + * @throws DatabaseException + * @throws DbDriverNotConnected + * @throws FileException + * @throws \Psr\SimpleCache\InvalidArgumentException + */ + #[Override] public function getProperty(string|HexUuidLiteral|int $userId, string $propertyName): array|string|UserPropertiesModel|null { $query = Query::getInstance() @@ -430,7 +477,12 @@ public function getProperty(string|HexUuidLiteral|int $userId, string $propertyN * Return all property's fields from this user * * @param UserModel $userRow + * @throws DatabaseException + * @throws DbDriverNotConnected + * @throws FileException * @throws RepositoryReadOnlyException + * @throws XmlUtilException + * @throws \Psr\SimpleCache\InvalidArgumentException */ protected function setPropertiesInUser(UserModel $userRow): void { diff --git a/tests/UsersDBDatasetDefinitionTest.php b/tests/UsersDBDatasetDefinitionTest.php index 82c05d6..443d35a 100644 --- a/tests/UsersDBDatasetDefinitionTest.php +++ b/tests/UsersDBDatasetDefinitionTest.php @@ -3,6 +3,7 @@ namespace Tests; use ByJG\AnyDataset\Core\Exception\DatabaseException; +use ByJG\AnyDataset\Db\DatabaseExecutor; use ByJG\AnyDataset\Db\Factory; use ByJG\Authenticate\Definition\UserDefinition; use ByJG\Authenticate\Definition\UserPropertiesDefinition; @@ -13,6 +14,8 @@ use ByJG\MicroOrm\Exception\OrmBeforeInvalidException; use ByJG\MicroOrm\Exception\OrmInvalidFieldsException; use ByJG\MicroOrm\Exception\OrmModelInvalidException; +use ByJG\MicroOrm\Interface\UniqueIdGeneratorInterface; +use ByJG\MicroOrm\Literal\Literal; use Exception; use ReflectionException; @@ -37,6 +40,21 @@ public function setOtherfield($otherfield) } } +class TestUniqueIdGenerator implements UniqueIdGeneratorInterface +{ + private string $prefix; + + public function __construct(string $prefix = 'TEST-') + { + $this->prefix = $prefix; + } + + public function process(DatabaseExecutor $executor, array|object $instance): string|Literal|int + { + return $this->prefix . uniqid(); + } +} + class UsersDBDatasetDefinitionTest extends UsersDBDatasetByUsernameTest { protected $db; @@ -216,4 +234,85 @@ public function testWithUpdateValue() $this->assertEquals(']*other john*[', $user->getOtherfield()); $this->assertEquals('2017-12-04 00:00:00', $user->getCreated()); } + + /** + * @throws Exception + */ + public function testDefineGenerateKeyWithInterface() + { + // Create a separate table with varchar userid for testing custom generators + $this->db->execute('create table users_custom ( + userid varchar(50) primary key, + name varchar(45), + email varchar(200), + username varchar(20), + password varchar(40), + created datetime default (datetime(\'2017-12-04\')), + admin char(1));' + ); + + // Create a new user definition with custom generator + $userDefinition = new UserDefinition('users_custom', UserModel::class, UserDefinition::LOGIN_IS_USERNAME); + $generator = new TestUniqueIdGenerator('CUSTOM-'); + $userDefinition->defineGenerateKey($generator); + + // Create dataset with custom definition + $dataset = new UsersDBDataset($this->db, $userDefinition, $this->propertyDefinition); + + // Add a user - the custom generator should be used + $user = $dataset->addUser('Test User', 'testuser', 'test@example.com', 'password123'); + + // Verify the user ID was generated with the custom prefix + $this->assertStringStartsWith('CUSTOM-', $user->getUserid()); + $this->assertEquals('Test User', $user->getName()); + $this->assertEquals('testuser', $user->getUsername()); + + // Cleanup + $this->db->execute('drop table users_custom'); + } + + /** + * @throws Exception + */ + public function testDefineGenerateKeyWithString() + { + // Create a separate table with varchar userid for testing custom generators + $this->db->execute('create table users_custom2 ( + userid varchar(50) primary key, + name varchar(45), + email varchar(200), + username varchar(20), + password varchar(40), + created datetime default (datetime(\'2017-12-04\')), + admin char(1));' + ); + + // Create a new user definition with generator class string + $userDefinition = new UserDefinition('users_custom2', UserModel::class, UserDefinition::LOGIN_IS_USERNAME); + $userDefinition->defineGenerateKey(TestUniqueIdGenerator::class); + + // Create dataset with custom definition + $dataset = new UsersDBDataset($this->db, $userDefinition, $this->propertyDefinition); + + // Add a user - the custom generator should be instantiated and used + $user = $dataset->addUser('Test User 2', 'testuser2', 'test2@example.com', 'password123'); + + // Verify the user ID was generated with the default TEST- prefix + $this->assertStringStartsWith('TEST-', $user->getUserid()); + $this->assertEquals('Test User 2', $user->getName()); + + // Cleanup + $this->db->execute('drop table users_custom2'); + } + + public function testDefineGenerateKeyClosureThrowsException() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('defineGenerateKeyClosure is deprecated. Use defineGenerateKey with UniqueIdGeneratorInterface instead.'); + + $userDefinition = new UserDefinition(); + $userDefinition->defineGenerateKeyClosure(function ($executor, $instance) { + return 'test-id'; + }); + } } From a2ee8f0402729547766c672c731d808d1baf295a Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Sat, 1 Nov 2025 16:12:07 -0400 Subject: [PATCH 04/17] Add `PasswordMd5MapperTest` to verify MD5 hashing behavior, ensure passwords are hashed or preserved correctly during save and update operations, and validate user login functionality. --- .claude/settings.local.json | 9 -- .gitignore | 1 + tests/PasswordMd5MapperTest.php | 219 ++++++++++++++++++++++++++++++++ 3 files changed, 220 insertions(+), 9 deletions(-) delete mode 100644 .claude/settings.local.json create mode 100644 tests/PasswordMd5MapperTest.php diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 46507b9..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(find:*)" - ], - "deny": [], - "ask": [] - } -} diff --git a/.gitignore b/.gitignore index f495eec..256a0e6 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ vendor .idea/* !.idea/runConfigurations .phpunit.result.cache +/.claude/settings.local.json diff --git a/tests/PasswordMd5MapperTest.php b/tests/PasswordMd5MapperTest.php new file mode 100644 index 0000000..ab35190 --- /dev/null +++ b/tests/PasswordMd5MapperTest.php @@ -0,0 +1,219 @@ +db = Factory::getDbInstance(self::CONNECTION_STRING); + $this->db->execute('create table users ( + userid integer primary key autoincrement, + name varchar(45), + email varchar(200), + username varchar(20), + password varchar(40), + created datetime default (datetime(\'2017-12-04\')), + admin char(1));' + ); + + $this->db->execute('create table users_property ( + id integer primary key autoincrement, + userid integer, + name varchar(45), + value varchar(45));' + ); + + // Create user definition with custom MD5 password mapper + $this->userDefinition = new UserDefinition('users', UserModel::class, UserDefinition::LOGIN_IS_USERNAME); + $this->userDefinition->defineMapperForUpdate(UserDefinition::FIELD_PASSWORD, PasswordMd5Mapper::class); + $this->userDefinition->markPropertyAsReadOnly(UserDefinition::FIELD_CREATED); + + $this->propertyDefinition = new UserPropertiesDefinition(); + } + + public function tearDown(): void + { + $uri = new Uri(self::CONNECTION_STRING); + if (file_exists($uri->getPath())) { + unlink($uri->getPath()); + } + $this->db = null; + $this->userDefinition = null; + $this->propertyDefinition = null; + } + + public function testPasswordIsHashedWithMd5OnSave() + { + $dataset = new UsersDBDataset($this->db, $this->userDefinition, $this->propertyDefinition); + + // Add a user with a plain text password + $plainPassword = 'mySecretPassword123'; + $user = $dataset->addUser('John Doe', 'johndoe', 'john@example.com', $plainPassword); + + // Verify the password was hashed with MD5 + $expectedHash = md5($plainPassword); + $this->assertEquals($expectedHash, $user->getPassword()); + $this->assertEquals(32, strlen($user->getPassword())); // MD5 is always 32 chars + $this->assertTrue(ctype_xdigit($user->getPassword())); // MD5 is hexadecimal + } + + public function testPasswordIsNotRehashedIfAlreadyMd5() + { + $dataset = new UsersDBDataset($this->db, $this->userDefinition, $this->propertyDefinition); + + // Create a user + $user = $dataset->addUser('Jane Doe', 'janedoe', 'jane@example.com', 'password123'); + $originalHash = $user->getPassword(); + + // Update the user without changing password + $user->setName('Jane Smith'); + $updatedUser = $dataset->save($user); + + // Password hash should remain the same + $this->assertEquals($originalHash, $updatedUser->getPassword()); + } + + public function testPasswordIsHashedWhenUpdating() + { + $dataset = new UsersDBDataset($this->db, $this->userDefinition, $this->propertyDefinition); + + // Create a user + $user = $dataset->addUser('Jane Doe', 'janedoe', 'jane@example.com', 'oldPassword'); + $oldHash = $user->getPassword(); + + // Update the password with a new plain text password + $newPlainPassword = 'newPassword123'; + $user->setPassword($newPlainPassword); + $updatedUser = $dataset->save($user); + + // Verify the new password was hashed with MD5 + $expectedNewHash = md5($newPlainPassword); + $this->assertEquals($expectedNewHash, $updatedUser->getPassword()); + + // Verify it's different from the old hash + $this->assertNotEquals($oldHash, $updatedUser->getPassword()); + + // Verify user can login with new password + $authenticatedUser = $dataset->isValidUser('janedoe', $newPlainPassword); + $this->assertNotNull($authenticatedUser); + + // Verify user cannot login with old password + $authenticatedUserOld = $dataset->isValidUser('janedoe', 'oldPassword'); + $this->assertNull($authenticatedUserOld); + } + + public function testPasswordRemainsUnchangedWhenUpdatingOtherFields() + { + $dataset = new UsersDBDataset($this->db, $this->userDefinition, $this->propertyDefinition); + + // Create a user + $originalPassword = 'myPassword123'; + $user = $dataset->addUser('John Smith', 'johnsmith', 'john@example.com', $originalPassword); + $originalHash = $user->getPassword(); + + // Update other fields WITHOUT touching the password + $user->setName('John Updated'); + $user->setEmail('johnupdated@example.com'); + $user->setAdmin('y'); + $updatedUser = $dataset->save($user); + + // Verify the password hash remained exactly the same + $this->assertEquals($originalHash, $updatedUser->getPassword()); + $this->assertEquals(32, strlen($updatedUser->getPassword())); + + // Verify other fields were updated + $this->assertEquals('John Updated', $updatedUser->getName()); + $this->assertEquals('johnupdated@example.com', $updatedUser->getEmail()); + $this->assertEquals('y', $updatedUser->getAdmin()); + + // Verify user can still login with original password + $authenticatedUser = $dataset->isValidUser('johnsmith', $originalPassword); + $this->assertNotNull($authenticatedUser); + $this->assertEquals('John Updated', $authenticatedUser->getName()); + } + + public function testUserCanLoginWithMd5HashedPassword() + { + $dataset = new UsersDBDataset($this->db, $this->userDefinition, $this->propertyDefinition); + + // Add a user + $plainPassword = 'testPassword456'; + $dataset->addUser('Test User', 'testuser', 'test@example.com', $plainPassword); + + // Verify user can login with the plain text password + $authenticatedUser = $dataset->isValidUser('testuser', $plainPassword); + + $this->assertNotNull($authenticatedUser); + $this->assertEquals('Test User', $authenticatedUser->getName()); + $this->assertEquals('testuser', $authenticatedUser->getUsername()); + } + + public function testUserCannotLoginWithWrongPassword() + { + $dataset = new UsersDBDataset($this->db, $this->userDefinition, $this->propertyDefinition); + + // Add a user + $dataset->addUser('Test User', 'testuser', 'test@example.com', 'correctPassword'); + + // Try to login with wrong password + $authenticatedUser = $dataset->isValidUser('testuser', 'wrongPassword'); + + $this->assertNull($authenticatedUser); + } + + public function testEmptyPasswordReturnsNull() + { + $mapper = new PasswordMd5Mapper(); + + $this->assertNull($mapper->processedValue('', null)); + $this->assertNull($mapper->processedValue(null, null)); + } + + public function testExistingMd5HashIsNotRehashed() + { + $mapper = new PasswordMd5Mapper(); + $existingHash = '5f4dcc3b5aa765d61d8327deb882cf99'; // MD5 of 'password' + + $result = $mapper->processedValue($existingHash, null); + + $this->assertEquals($existingHash, $result); + } +} From c6e09defa993840abb369e90a078abd1ca1032bc Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Sat, 1 Nov 2025 17:35:31 -0400 Subject: [PATCH 05/17] Change parameter type order in `UsersDBDataset` constructor to `DatabaseExecutor|DbDriverInterface` for improved type clarity. --- src/UsersDBDataset.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/UsersDBDataset.php b/src/UsersDBDataset.php index e07d872..7d32ef5 100644 --- a/src/UsersDBDataset.php +++ b/src/UsersDBDataset.php @@ -64,7 +64,7 @@ class UsersDBDataset extends UsersBase * @throws ExceptionInvalidArgumentException */ public function __construct( - DbDriverInterface|DatabaseExecutor $dbDriver, + DatabaseExecutor|DbDriverInterface $dbDriver, UserDefinition|null $userTable = null, UserPropertiesDefinition|null $propertiesTable = null ) { From b3e8bcd5deaab92db6b42ec49694744534be1e7c Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Sun, 2 Nov 2025 15:06:36 -0500 Subject: [PATCH 06/17] Migrate from `UniqueIdGeneratorInterface` to `MapperFunctionInterface` for custom key generation, update related methods and tests, replace deprecated messages, enhance type safety, and align with new mapper conventions. --- src/Definition/UserDefinition.php | 11 +++++------ src/MapperFunctions/PasswordSha1Mapper.php | 4 ++-- src/MapperFunctions/UserIdGeneratorMapper.php | 4 ++-- src/UsersDBDataset.php | 2 +- tests/UsersDBDatasetDefinitionTest.php | 19 ++++++++++--------- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/Definition/UserDefinition.php b/src/Definition/UserDefinition.php index 1cd3939..df16fad 100644 --- a/src/Definition/UserDefinition.php +++ b/src/Definition/UserDefinition.php @@ -9,7 +9,6 @@ use ByJG\Authenticate\Model\UserModel; use ByJG\MicroOrm\Interface\EntityProcessorInterface; use ByJG\MicroOrm\Interface\MapperFunctionInterface; -use ByJG\MicroOrm\Interface\UniqueIdGeneratorInterface; use ByJG\MicroOrm\MapperFunctions\ReadOnlyMapper; use ByJG\MicroOrm\MapperFunctions\StandardMapper; use ByJG\Serializer\Serialize; @@ -26,7 +25,7 @@ class UserDefinition protected string $__loginField; protected string $__model; protected array $__properties = []; - protected UniqueIdGeneratorInterface|string|null $__generateKey = null; + protected MapperFunctionInterface|string|null $__generateKey = null; const FIELD_USERID = 'userid'; const FIELD_NAME = 'name'; @@ -230,15 +229,15 @@ public function getClosureForUpdate(string $property): Closure */ public function defineGenerateKeyClosure(Closure $closure): void { - throw new InvalidArgumentException('defineGenerateKeyClosure is deprecated. Use defineGenerateKey with UniqueIdGeneratorInterface instead.'); + throw new InvalidArgumentException('defineGenerateKeyClosure is deprecated. Use defineGenerateKey with MapperFunctionsInterface instead.'); } - public function defineGenerateKey(UniqueIdGeneratorInterface|string $generator): void + public function defineGenerateKey(MapperFunctionInterface|string $generator): void { $this->__generateKey = $generator; } - public function getGenerateKey(): UniqueIdGeneratorInterface|string|null + public function getGenerateKey(): MapperFunctionInterface|string|null { return $this->__generateKey; } @@ -246,7 +245,7 @@ public function getGenerateKey(): UniqueIdGeneratorInterface|string|null /** * @deprecated Use getGenerateKey instead */ - public function getGenerateKeyClosure(): UniqueIdGeneratorInterface|string|null + public function getGenerateKeyClosure(): MapperFunctionInterface|string|null { return $this->__generateKey; } diff --git a/src/MapperFunctions/PasswordSha1Mapper.php b/src/MapperFunctions/PasswordSha1Mapper.php index d995ff5..30ae0e0 100644 --- a/src/MapperFunctions/PasswordSha1Mapper.php +++ b/src/MapperFunctions/PasswordSha1Mapper.php @@ -2,7 +2,7 @@ namespace ByJG\Authenticate\MapperFunctions; -use ByJG\AnyDataset\Db\DbFunctionsInterface; +use ByJG\AnyDataset\Db\DatabaseExecutor; use ByJG\MicroOrm\Interface\MapperFunctionInterface; /** @@ -10,7 +10,7 @@ */ class PasswordSha1Mapper implements MapperFunctionInterface { - public function processedValue(mixed $value, mixed $instance, ?DbFunctionsInterface $helper = null): mixed + public function processedValue(mixed $value, mixed $instance, ?DatabaseExecutor $executor = null): mixed { // Already have a SHA1 password (40 characters) if (is_string($value) && strlen($value) === 40) { diff --git a/src/MapperFunctions/UserIdGeneratorMapper.php b/src/MapperFunctions/UserIdGeneratorMapper.php index 4043550..d28d1e6 100644 --- a/src/MapperFunctions/UserIdGeneratorMapper.php +++ b/src/MapperFunctions/UserIdGeneratorMapper.php @@ -2,7 +2,7 @@ namespace ByJG\Authenticate\MapperFunctions; -use ByJG\AnyDataset\Db\DbFunctionsInterface; +use ByJG\AnyDataset\Db\DatabaseExecutor; use ByJG\Authenticate\Model\UserModel; use ByJG\MicroOrm\Interface\MapperFunctionInterface; @@ -11,7 +11,7 @@ */ class UserIdGeneratorMapper implements MapperFunctionInterface { - public function processedValue(mixed $value, mixed $instance, ?DbFunctionsInterface $helper = null): mixed + public function processedValue(mixed $value, mixed $instance, ?DatabaseExecutor $executor = null): mixed { // If value is already set, use it if (!empty($value)) { diff --git a/src/UsersDBDataset.php b/src/UsersDBDataset.php index 7d32ef5..f3cce7b 100644 --- a/src/UsersDBDataset.php +++ b/src/UsersDBDataset.php @@ -489,7 +489,7 @@ protected function setPropertiesInUser(UserModel $userRow): void $value = $this->propertiesRepository ->getMapper() ->getFieldMap(UserDefinition::FIELD_USERID) - ->getUpdateFunctionValue($userRow->getUserid(), $userRow, $this->propertiesRepository->getExecutorWrite()->getHelper()); + ->getUpdateFunctionValue($userRow->getUserid(), $userRow, $this->propertiesRepository->getExecutorWrite()); $query = Query::getInstance() ->table($this->getUserPropertiesDefinition()->table()) ->where("{$this->getUserPropertiesDefinition()->getUserid()} = :id", ['id' => $value]); diff --git a/tests/UsersDBDatasetDefinitionTest.php b/tests/UsersDBDatasetDefinitionTest.php index 443d35a..cdff5b8 100644 --- a/tests/UsersDBDatasetDefinitionTest.php +++ b/tests/UsersDBDatasetDefinitionTest.php @@ -14,9 +14,9 @@ use ByJG\MicroOrm\Exception\OrmBeforeInvalidException; use ByJG\MicroOrm\Exception\OrmInvalidFieldsException; use ByJG\MicroOrm\Exception\OrmModelInvalidException; -use ByJG\MicroOrm\Interface\UniqueIdGeneratorInterface; -use ByJG\MicroOrm\Literal\Literal; +use ByJG\MicroOrm\Interface\MapperFunctionInterface; use Exception; +use Override; use ReflectionException; class MyUserModel extends UserModel @@ -40,7 +40,7 @@ public function setOtherfield($otherfield) } } -class TestUniqueIdGenerator implements UniqueIdGeneratorInterface +class TestUniqueIdGenerator implements MapperFunctionInterface { private string $prefix; @@ -49,7 +49,8 @@ public function __construct(string $prefix = 'TEST-') $this->prefix = $prefix; } - public function process(DatabaseExecutor $executor, array|object $instance): string|Literal|int + #[Override] + public function processedValue(mixed $value, mixed $instance, ?DatabaseExecutor $executor = null): mixed { return $this->prefix . uniqid(); } @@ -67,7 +68,7 @@ class UsersDBDatasetDefinitionTest extends UsersDBDatasetByUsernameTest * @throws UserExistsException * @throws ReflectionException */ - #[\Override] + #[Override] public function __setUp($loginField) { $this->prefix = ""; @@ -133,7 +134,7 @@ public function __setUp($loginField) * @throws ReflectionException * @throws UserExistsException */ - #[\Override] + #[Override] public function setUp(): void { $this->__setUp(UserDefinition::LOGIN_IS_USERNAME); @@ -144,7 +145,7 @@ public function setUp(): void * @throws DatabaseException * @throws \ByJG\Serializer\Exception\InvalidArgumentException */ - #[\Override] + #[Override] public function testAddUser() { $this->object->save(new MyUserModel('John Doe', 'johndoe@gmail.com', 'john', 'mypassword', 'no', 'other john')); @@ -173,7 +174,7 @@ public function testAddUser() /** * @throws Exception */ - #[\Override] + #[Override] public function testWithUpdateValue() { // For Update Definitions @@ -308,7 +309,7 @@ public function testDefineGenerateKeyWithString() public function testDefineGenerateKeyClosureThrowsException() { $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('defineGenerateKeyClosure is deprecated. Use defineGenerateKey with UniqueIdGeneratorInterface instead.'); + $this->expectExceptionMessage('defineGenerateKeyClosure is deprecated. Use defineGenerateKey with MapperFunctionsInterface instead.'); $userDefinition = new UserDefinition(); $userDefinition->defineGenerateKeyClosure(function ($executor, $instance) { From 95ced77a57a2e96ea2934833a3e3f2ed2eeda947 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Nov 2025 20:33:54 +0000 Subject: [PATCH 07/17] Add comprehensive Docusaurus documentation and update README - Created comprehensive documentation in /docs folder with 12 markdown files - Added sidebar_position to all docs following the order in README.md - Fixed inaccurate code examples in README.md to match current implementation - Updated README with links to all documentation sections - Fixed example.php to use correct API (UsersAnyDataset constructor, SessionContext) - Organized docs with proper Docusaurus frontmatter and features - Added detailed guides for: * Getting started and installation * User management (CRUD operations) * Authentication methods (session and JWT) * Session context and storage options * User properties (custom key-value data) * Database storage and schema customization * Password validation and generation * JWT token authentication * Custom fields and extending UserModel * Mappers and entity processors * Complete working examples - Removed duplicate and outdated information from README - Added feature list and improved organization - All code examples now reflect actual API usage --- README.md | 419 ++++++++++++++--------------- docs/authentication.md | 150 +++++++++++ docs/custom-fields.md | 375 ++++++++++++++++++++++++++ docs/database-storage.md | 256 ++++++++++++++++++ docs/examples.md | 520 ++++++++++++++++++++++++++++++++++++ docs/getting-started.md | 58 ++++ docs/installation.md | 51 ++++ docs/jwt-tokens.md | 382 ++++++++++++++++++++++++++ docs/mappers.md | 433 ++++++++++++++++++++++++++++++ docs/password-validation.md | 288 ++++++++++++++++++++ docs/session-context.md | 197 ++++++++++++++ docs/user-management.md | 154 +++++++++++ docs/user-properties.md | 248 +++++++++++++++++ example.php | 43 ++- 14 files changed, 3347 insertions(+), 227 deletions(-) create mode 100644 docs/authentication.md create mode 100644 docs/custom-fields.md create mode 100644 docs/database-storage.md create mode 100644 docs/examples.md create mode 100644 docs/getting-started.md create mode 100644 docs/installation.md create mode 100644 docs/jwt-tokens.md create mode 100644 docs/mappers.md create mode 100644 docs/password-validation.md create mode 100644 docs/session-context.md create mode 100644 docs/user-management.md create mode 100644 docs/user-properties.md diff --git a/README.md b/README.md index a16b060..b973e33 100644 --- a/README.md +++ b/README.md @@ -6,297 +6,258 @@ [![GitHub license](https://img.shields.io/github/license/byjg/php-authuser.svg)](https://opensource.byjg.com/opensource/licensing.html) [![GitHub release](https://img.shields.io/github/release/byjg/php-authuser.svg)](https://github.com/byjg/php-authuser/releases/) -A simple and customizable class for enable user authentication inside your application. It is available on XML files, Relational Databases. +A simple and customizable library for user authentication in PHP applications. It supports multiple storage backends including databases and XML files. -The main purpose is just to handle all complexity of validate a user, add properties and create access token abstracting the database layer. -This class can persist into session (or file, memcache, etc) the user data between requests. +The main purpose is to handle all complexity of user validation, authentication, properties management, and access tokens, abstracting the database layer. +This class can persist user data into session (or file, memcache, etc.) between requests. -## Creating a Users handling class +## Documentation -Using the FileSystem (XML) as the user storage: +- [Getting Started](docs/getting-started.md) +- [Installation](docs/installation.md) +- [User Management](docs/user-management.md) +- [Authentication](docs/authentication.md) +- [Session Context](docs/session-context.md) +- [User Properties](docs/user-properties.md) +- [Database Storage](docs/database-storage.md) +- [Password Validation](docs/password-validation.md) +- [JWT Tokens](docs/jwt-tokens.md) +- [Custom Fields](docs/custom-fields.md) +- [Mappers](docs/mappers.md) +- [Examples](docs/examples.md) -```php -isAuthenticated()) { - - // Get the userId of the authenticated users - $userId = $sessionContext->userInfo(); +// Create or load AnyDataset +$anyDataset = new AnyDataset('/tmp/users.xml'); - // Get the user and your name - $user = $users->getById($userId); - echo "Hello: " . $user->getName(); -} +// Initialize user management +$users = new UsersAnyDataset($anyDataset); ``` -## Saving extra info into the user session +*Note*: See the [AnyDataset DB project](https://github.com/byjg/anydataset-db) for available databases and connection strings. -You can save data in the session data exists only during the user is logged in. Once the user logged off the -data stored with the user session will be released. +## Basic Usage -Store the data for the current user session: +### Creating and Authenticating Users ```php setSessionData('key', 'value'); -``` +use ByJG\Authenticate\SessionContext; +use ByJG\Cache\Factory; -Getting the data from the current user session: +// Add a new user +$user = $users->addUser('John Doe', 'johndoe', 'john@example.com', 'SecurePass123'); -```php -getSessionData('key'); -``` +// Validate user credentials +$authenticatedUser = $users->isValidUser('johndoe', 'SecurePass123'); -Note: If the user is not logged an error will be throw +if ($authenticatedUser !== null) { + // Create session context + $sessionContext = new SessionContext(Factory::createSessionPool()); -## Adding a custom property to the users + // Register the login + $sessionContext->registerLogin($authenticatedUser->getUserid()); -```php -getById($userId); -$user->setField('somefield', 'somevalue'); -$users->save(); + echo "Welcome, " . $authenticatedUser->getName(); +} ``` -## Logout from a session +### Check if User is Authenticated ```php registerLogout(); -``` +$sessionContext = new SessionContext(Factory::createSessionPool()); -## Important note about SessionContext +// Check if the user is authenticated +if ($sessionContext->isAuthenticated()) { + // Get the userId of the authenticated user + $userId = $sessionContext->userInfo(); -`SessionContext` object will store the info about the current context. -As SessionContext uses CachePool interface defined in PSR-6 you can set any storage -to save your session context. + // Get the user and display name + $user = $users->getById($userId); + echo "Hello: " . $user->getName(); +} else { + echo "Please log in"; +} +``` -In our examples we are using a regular PHP Session for store the user context -(`Factory::createSessionPool()`). But if you are using another store like MemCached -you have to define a UNIQUE prefix for that session. Note if TWO users have the same -prefix you probably have an unexpected result for the SessionContext. +## Managing Session Data -Example for memcached: +You can store temporary data in the user session that exists only while the user is logged in. Once the user logs out, the data is automatically released. + +### Store Session Data ```php setSessionData('shopping_cart', [ + 'item1' => 'Product A', + 'item2' => 'Product B' +]); -## Architecture - -```text - ┌───────────────────┐ - │ SessionContext │ - └───────────────────┘ - │ -┌────────────────────────┐ ┌────────────────────────┐ -│ UserDefinition │─ ─ ┐ │ ─ ─ ┤ UserModel │ -└────────────────────────┘ ┌───────────────────┐ │ └────────────────────────┘ -┌────────────────────────┐ └────│ UsersInterface │────┐ ┌────────────────────────┐ -│ UserPropertyDefinition │─ ─ ┘ └───────────────────┘ ─ ─ ┤ UserPropertyModel │ -└────────────────────────┘ ▲ └────────────────────────┘ - │ - ┌────────────────────────┼─────────────────────────┐ - │ │ │ - │ │ │ - │ │ │ - ┌───────────────────┐ ┌───────────────────┐ ┌────────────────────┐ - │ UsersAnyDataset │ │ UsersDBDataset │ │ xxxxxxxxxxxxxxxxxx │ - └───────────────────┘ └───────────────────┘ └────────────────────┘ +$sessionContext->setSessionData('last_page', '/products'); ``` -- UserInterface contain the basic interface for the concrete implementation -- UsersDBDataset is a concrete implementation to retrieve/save user in a Database -- UserAnyDataset is a concrete implementation to retrieve/save user in a Xml file -- UserModel is the basic model get/set for the user -- UserPropertyModel is the basic model get/set for extra user property -- UserDefinition will map the model to the database - -### Database +### Retrieve Session Data -The default structure adopted for store the user data in the database through the -UsersDBDataset class is the follow: +```php +getSessionData('shopping_cart'); +$lastPage = $sessionContext->getSessionData('last_page'); ``` -Using the database structure above you can create the UsersDBDatase as follow: +:::note +A `NotAuthenticatedException` will be thrown if the user is not authenticated when accessing session data. +::: -```php - 'fieldname of userid', - UserDefinition::FIELD_NAME => 'fieldname of name', - UserDefinition::FIELD_EMAIL => 'fieldname of email', - UserDefinition::FIELD_USERNAME => 'fieldname of username', - UserDefinition::FIELD_PASSWORD => 'fieldname of password', - UserDefinition::FIELD_CREATED => 'fieldname of created', - UserDefinition::FIELD_ADMIN => 'fieldname of admin' - ] -); +// Add a property to a user +$users->addProperty($userId, 'phone', '555-1234'); +$users->addProperty($userId, 'department', 'Engineering'); + +// Users can have multiple values for the same property +$users->addProperty($userId, 'role', 'developer'); +$users->addProperty($userId, 'role', 'manager'); ``` -### Adding custom modifiers for read and update +### Using UserModel ```php the current value to be updated -// $instance -> The array with all other fields; -$userDefinition->defineClosureForUpdate(UserDefinition::FIELD_PASSWORD, function ($value, $instance) { - return strtoupper(sha1($value)); -}); - -// Defines a custom function to be applied After the field UserDefinition::FIELD_CREATED is read but before -// the user get the result -// $value --> the current value retrieved from database -// $instance -> The array with all other fields; -$userDefinition->defineClosureForSelect(UserDefinition::FIELD_CREATED, function ($value, $instance) { - return date('Y', $value); -}); - -// If you want make the field READONLY just do it: -$userDefinition->markPropertyAsReadOnly(UserDefinition::FIELD_CREATED); -``` - -## Extending UserModel +$user = $users->getById($userId); -It is possible extending the UserModel table, since you create a new class extending from UserModel to add the new fields. +// Set a property (update or create) +$user->set('phone', '555-1234'); -For example, imagine your table has one field called "otherfield". +// Save changes +$users->save($user); +``` -You'll have to extend like this: +## Logout ```php setOtherfield($field); - } - - public function getOtherfield() - { - return $this->otherfield; - } - - public function setOtherfield($otherfield) - { - $this->otherfield = $otherfield; - } -} +$sessionContext->registerLogout(); ``` -After that you can use your new definition: +## JWT Token Authentication + +For stateless API authentication, you can use JWT tokens: ```php createAuthToken( + 'johndoe', // Login + 'SecurePass123', // Password + $jwtWrapper, + 3600, // Expires in 1 hour (seconds) + [], // Additional user info to save + ['role' => 'admin'] // Additional token data ); + +// Validate token +$result = $users->isValidToken('johndoe', $jwtWrapper, $token); +if ($result !== null) { + $user = $result['user']; + $tokenData = $result['data']; +} ``` -## Install +See [JWT Tokens](docs/jwt-tokens.md) for detailed information. -Just type: +## Database Schema -```bash -composer require "byjg/authuser" +The default database schema uses two tables: + +```sql +CREATE TABLE users ( + userid INTEGER AUTO_INCREMENT NOT NULL, + name VARCHAR(50), + email VARCHAR(120), + username VARCHAR(15) NOT NULL, + password CHAR(40) NOT NULL, + created DATETIME, + admin ENUM('Y','N'), + CONSTRAINT pk_users PRIMARY KEY (userid) +) ENGINE=InnoDB; + +CREATE TABLE users_property ( + customid INTEGER AUTO_INCREMENT NOT NULL, + name VARCHAR(20), + value VARCHAR(100), + userid INTEGER NOT NULL, + CONSTRAINT pk_custom PRIMARY KEY (customid), + CONSTRAINT fk_custom_user FOREIGN KEY (userid) REFERENCES users (userid) +) ENGINE=InnoDB; ``` +You can customize table and column names using `UserDefinition` and `UserPropertiesDefinition`. See [Database Storage](docs/database-storage.md) for details. + +## Features + +- **Complete User Management** - Create, read, update, and delete users +- **Flexible Authentication** - Username/email + password or JWT tokens +- **Session Management** - PSR-6 compatible cache storage +- **User Properties** - Store custom key-value data per user +- **Password Validation** - Built-in password strength requirements +- **Multiple Storage Backends** - Database (MySQL, PostgreSQL, SQLite, etc.) or XML files +- **Customizable Schema** - Map to existing database tables +- **Field Mappers** - Transform data during read/write operations +- **Extensible User Model** - Add custom fields easily + ## Running Tests Because this project uses PHP Session you need to run the unit test the following manner: @@ -305,15 +266,41 @@ Because this project uses PHP Session you need to run the unit test the followin ./vendor/bin/phpunit --stderr ``` +## Architecture + +```text + ┌───────────────────┐ + │ SessionContext │ + └───────────────────┘ + │ +┌────────────────────────┐ ┌────────────────────────┐ +│ UserDefinition │─ ─ ┐ │ ─ ─ ┤ UserModel │ +└────────────────────────┘ ┌───────────────────┐ │ └────────────────────────┘ +┌────────────────────────┐ └────│ UsersInterface │────┐ ┌────────────────────────┐ +│ UserPropertyDefinition │─ ─ ┘ └───────────────────┘ ─ ─ ┤ UserPropertyModel │ +└────────────────────────┘ ▲ └────────────────────────┘ + │ + ┌────────────────────────┼─────────────────────────┐ + │ │ │ + │ │ │ + │ │ │ + ┌───────────────────┐ ┌───────────────────┐ ┌────────────────────┐ + │ UsersAnyDataset │ │ UsersDBDataset │ │ Custom Impl. │ + └───────────────────┘ └───────────────────┘ └────────────────────┘ +``` + ## Dependencies -```mermaid -flowchart TD +```mermaid +flowchart TD byjg/authuser --> byjg/micro-orm byjg/authuser --> byjg/cache-engine - byjg/authuser --> byjg/jwt-wrapper + byjg/authuser --> byjg/jwt-wrapper ``` +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. ---- [Open source ByJG](http://opensource.byjg.com) diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 0000000..b18cc31 --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,150 @@ +--- +sidebar_position: 4 +title: Authentication +--- + +# Authentication + +## Validating User Credentials + +Use the `isValidUser()` method to validate a username/email and password combination: + +```php +isValidUser('johndoe', 'SecurePass123'); + +if ($user !== null) { + echo "Authentication successful!"; + echo "User ID: " . $user->getUserid(); +} else { + echo "Invalid credentials"; +} +``` + +:::tip Login Field +The `isValidUser()` method uses the login field defined in your `UserDefinition`. This can be either the email or username field. +::: + +## Password Hashing + +By default, passwords are automatically hashed using SHA-1 when saved. The library uses the `PasswordSha1Mapper` for this purpose. + +```php +setPassword('plaintext password'); +$users->save($user); + +// The password is stored as SHA-1 hash in the database +``` + +:::warning SHA-1 Deprecation +SHA-1 is used for backward compatibility. For new projects, consider implementing a custom password hasher using bcrypt or Argon2. See [Custom Mappers](mappers.md) for details. +::: + +## Workflow + +### Basic Authentication Flow + +```php +isValidUser('johndoe', 'SecurePass123'); + +if ($user !== null) { + // 2. Create session context + $sessionContext = new SessionContext(Factory::createSessionPool()); + + // 3. Register login + $sessionContext->registerLogin($user->getUserid()); + + // 4. User is now authenticated + echo "Welcome, " . $user->getName(); +} +``` + +### Checking Authentication Status + +```php +isAuthenticated()) { + $userId = $sessionContext->userInfo(); + $user = $users->getById($userId); + echo "Hello, " . $user->getName(); +} else { + echo "Please log in"; +} +``` + +### Logging Out + +```php +registerLogout(); +``` + +## JWT Token Authentication + +For stateless authentication, you can use JWT tokens: + +```php +createAuthToken( + 'johndoe', // Login + 'SecurePass123', // Password + $jwtWrapper, + 3600, // Expires in 1 hour (seconds) + [], // Additional user info to save + ['role' => 'admin'] // Additional token data +); + +if ($token !== null) { + echo "Token: " . $token; +} +``` + +### Validating JWT Tokens + +```php +isValidToken('johndoe', $jwtWrapper, $token); + +if ($result !== null) { + $user = $result['user']; + $tokenData = $result['data']; + + echo "User: " . $user->getName(); + echo "Role: " . $tokenData['role']; +} +``` + +:::info Token Storage +When a JWT token is created, a hash of the token is stored in the user's properties as `TOKEN_HASH`. This ensures tokens can be invalidated if needed. +::: + +## Security Best Practices + +1. **Always use HTTPS** in production to prevent credential theft +2. **Implement rate limiting** to prevent brute force attacks +3. **Use strong passwords** - see [Password Validation](password-validation.md) +4. **Set appropriate session timeouts** +5. **Validate and sanitize** all user inputs + +## Next Steps + +- [Session Context](session-context.md) - Manage user sessions +- [JWT Tokens](jwt-tokens.md) - Deep dive into JWT authentication +- [Password Validation](password-validation.md) - Enforce password policies diff --git a/docs/custom-fields.md b/docs/custom-fields.md new file mode 100644 index 0000000..04d2bc5 --- /dev/null +++ b/docs/custom-fields.md @@ -0,0 +1,375 @@ +--- +sidebar_position: 10 +title: Custom Fields +--- + +# Custom Fields + +You can extend the `UserModel` to add custom fields that match your database schema. + +## Extending UserModel + +### Creating a Custom User Model + +```php +phone = $phone; + $this->department = $department; + } + + public function getPhone(): ?string + { + return $this->phone; + } + + public function setPhone(?string $phone): void + { + $this->phone = $phone; + } + + public function getDepartment(): ?string + { + return $this->department; + } + + public function setDepartment(?string $department): void + { + $this->department = $department; + } + + public function getTitle(): ?string + { + return $this->title; + } + + public function setTitle(?string $title): void + { + $this->title = $title; + } + + public function getProfilePicture(): ?string + { + return $this->profilePicture; + } + + public function setProfilePicture(?string $profilePicture): void + { + $this->profilePicture = $profilePicture; + } +} +``` + +## Database Schema + +Add the custom fields to your users table: + +```sql +CREATE TABLE users +( + userid INTEGER AUTO_INCREMENT NOT NULL, + name VARCHAR(50), + email VARCHAR(120), + username VARCHAR(15) NOT NULL, + password CHAR(40) NOT NULL, + created DATETIME, + admin ENUM('Y','N'), + -- Custom fields + phone VARCHAR(20), + department VARCHAR(50), + title VARCHAR(50), + profile_picture VARCHAR(255), + + CONSTRAINT pk_users PRIMARY KEY (userid) +) ENGINE=InnoDB; +``` + +## Configuring UserDefinition + +Map the custom fields in your `UserDefinition`: + +```php + 'userid', + UserDefinition::FIELD_NAME => 'name', + UserDefinition::FIELD_EMAIL => 'email', + UserDefinition::FIELD_USERNAME => 'username', + UserDefinition::FIELD_PASSWORD => 'password', + UserDefinition::FIELD_CREATED => 'created', + UserDefinition::FIELD_ADMIN => 'admin', + // Custom fields + 'phone' => 'phone', + 'department' => 'department', + 'title' => 'title', + 'profilePicture' => 'profile_picture' + ] +); +``` + +## Using the Custom Model + +### Creating Users + +```php +setName('John Doe'); +$user->setEmail('john@example.com'); +$user->setUsername('johndoe'); +$user->setPassword('SecurePass123'); +$user->setPhone('+1-555-1234'); +$user->setDepartment('Engineering'); +$user->setTitle('Senior Developer'); + +$users->save($user); +``` + +### Retrieving Users + +```php +getById($userId); + +// Access custom fields +echo $user->getName(); +echo $user->getPhone(); +echo $user->getDepartment(); +echo $user->getTitle(); +``` + +### Updating Custom Fields + +```php +getById($userId); +$user->setDepartment('Sales'); +$user->setTitle('Sales Manager'); +$users->save($user); +``` + +## Read-Only Fields + +You can mark fields as read-only to prevent updates: + +```php +markPropertyAsReadOnly(UserDefinition::FIELD_CREATED); + +// Make custom field read-only +$userDefinition->markPropertyAsReadOnly('phone'); +``` + +Read-only fields: +- Can be set during creation +- Cannot be updated after creation +- Are ignored during updates + +## Auto-Generated Fields + +### Auto-Increment IDs + +For auto-increment IDs, the database handles generation automatically. No configuration needed. + +### UUID Fields + +For UUID primary keys: + +```php +defineGenerateKey(UserIdGeneratorMapper::class); +``` + +### Custom ID Generation + +Create a custom mapper for custom ID generation: + +```php +defineGenerateKey(CustomIdMapper::class); +``` + +## Field Transformation + +You can transform fields during read/write operations using mappers. See [Mappers](mappers.md) for details. + +## Complex Data Types + +### JSON Fields + +For storing JSON data in custom fields: + +```php +defineMapperForUpdate('metadata', JsonMapper::class); +$userDefinition->defineMapperForSelect('metadata', JsonDecodeMapper::class); +``` + +### Date/Time Fields + +```php +format('Y-m-d H:i:s'); + } + return $value; + } +} + +$userDefinition->defineMapperForUpdate('created', DateTimeMapper::class); +``` + +## Complete Example + +```php + 'userid', + UserDefinition::FIELD_NAME => 'name', + UserDefinition::FIELD_EMAIL => 'email', + UserDefinition::FIELD_USERNAME => 'username', + UserDefinition::FIELD_PASSWORD => 'password', + UserDefinition::FIELD_CREATED => 'created', + UserDefinition::FIELD_ADMIN => 'admin', + 'phone' => 'phone', + 'department' => 'department', + 'title' => 'title', + 'profilePicture' => 'profile_picture' + ] +); + +// Make created field read-only +$userDefinition->markPropertyAsReadOnly(UserDefinition::FIELD_CREATED); + +// Initialize user management +$users = new UsersDBDataset($dbDriver, $userDefinition); + +// Create a user +$user = new CustomUserModel(); +$user->setName('Jane Smith'); +$user->setEmail('jane@example.com'); +$user->setUsername('janesmith'); +$user->setPassword('SecurePass123'); +$user->setPhone('+1-555-5678'); +$user->setDepartment('Marketing'); +$user->setTitle('Marketing Director'); + +$savedUser = $users->save($user); + +// Retrieve and update +$user = $users->getById($savedUser->getUserid()); +$user->setTitle('VP of Marketing'); +$users->save($user); +``` + +## When to Use Custom Fields vs Properties + +| Use Custom Fields When | Use Properties When | +|------------------------|---------------------| +| Field is used frequently | Field is rarely used | +| Field is searched/filtered | Field is key-value metadata | +| Field is fixed schema | Field is dynamic/flexible | +| Better performance needed | Schema flexibility needed | +| Field is required | Field is optional | + +## Next Steps + +- [Mappers](mappers.md) - Custom field transformations +- [Database Storage](database-storage.md) - Schema configuration +- [User Properties](user-properties.md) - Flexible metadata storage diff --git a/docs/database-storage.md b/docs/database-storage.md new file mode 100644 index 0000000..8e9c872 --- /dev/null +++ b/docs/database-storage.md @@ -0,0 +1,256 @@ +--- +sidebar_position: 7 +title: Database Storage +--- + +# Database Storage + +The library supports storing users in relational databases through the `UsersDBDataset` class. + +## Database Setup + +### Default Schema + +The default database structure uses two tables: + +```sql +CREATE TABLE users +( + userid INTEGER AUTO_INCREMENT NOT NULL, + name VARCHAR(50), + email VARCHAR(120), + username VARCHAR(15) NOT NULL, + password CHAR(40) NOT NULL, + created DATETIME, + admin ENUM('Y','N'), + + CONSTRAINT pk_users PRIMARY KEY (userid) +) ENGINE=InnoDB; + +CREATE TABLE users_property +( + customid INTEGER AUTO_INCREMENT NOT NULL, + name VARCHAR(20), + value VARCHAR(100), + userid INTEGER NOT NULL, + + CONSTRAINT pk_custom PRIMARY KEY (customid), + CONSTRAINT fk_custom_user FOREIGN KEY (userid) REFERENCES users (userid) +) ENGINE=InnoDB; +``` + +## Basic Usage + +### Using Default Configuration + +```php + 'user_id', + UserDefinition::FIELD_NAME => 'full_name', + UserDefinition::FIELD_EMAIL => 'email_address', + UserDefinition::FIELD_USERNAME => 'user_name', + UserDefinition::FIELD_PASSWORD => 'password_hash', + UserDefinition::FIELD_CREATED => 'date_created', + UserDefinition::FIELD_ADMIN => 'is_admin' + ] +); + +$users = new UsersDBDataset($dbDriver, $userDefinition); +``` + +### Custom Properties Table + +```php + 'id', + UserDefinition::FIELD_NAME => 'fullname', + UserDefinition::FIELD_EMAIL => 'email', + UserDefinition::FIELD_USERNAME => 'username', + UserDefinition::FIELD_PASSWORD => 'pwd', + UserDefinition::FIELD_CREATED => 'created_at', + UserDefinition::FIELD_ADMIN => 'is_admin' + ] +); + +// Custom properties definition +$propertiesDefinition = new UserPropertiesDefinition( + 'app_user_meta', + 'id', + 'meta_key', + 'meta_value', + 'user_id' +); + +// Initialize +$users = new UsersDBDataset($dbDriver, $userDefinition, $propertiesDefinition); + +// Use it +$user = $users->addUser('John Doe', 'johndoe', 'john@example.com', 'password123'); +``` + +## XML/File Storage + +For simple applications or development, you can use XML file storage: + +```php +addUser('John Doe', 'johndoe', 'john@example.com', 'password123'); +``` + +:::warning Production Use +XML file storage is suitable for development and small applications. For production applications with multiple users, use database storage. +::: + +## Architecture + +```text + ┌───────────────────┐ + │ SessionContext │ + └───────────────────┘ + │ +┌────────────────────────┐ ┌────────────────────────┐ +│ UserDefinition │─ ─ ┐ │ ─ ─ ┤ UserModel │ +└────────────────────────┘ ┌───────────────────┐ │ └────────────────────────┘ +┌────────────────────────┐ └────│ UsersInterface │────┐ ┌────────────────────────┐ +│ UserPropertyDefinition │─ ─ ┘ └───────────────────┘ ─ ─ ┤ UserPropertyModel │ +└────────────────────────┘ ▲ └────────────────────────┘ + │ + ┌────────────────────────┼─────────────────────────┐ + │ │ │ + │ │ │ + │ │ │ + ┌───────────────────┐ ┌───────────────────┐ ┌────────────────────┐ + │ UsersAnyDataset │ │ UsersDBDataset │ │ Custom Impl. │ + └───────────────────┘ └───────────────────┘ └────────────────────┘ +``` + +- **UserInterface**: Base interface for all implementations +- **UsersDBDataset**: Database implementation +- **UsersAnyDataset**: XML file implementation +- **UserModel**: The user data model +- **UserPropertyModel**: The user property data model +- **UserDefinition**: Maps model to database schema +- **UserPropertiesDefinition**: Maps properties to database schema + +## Next Steps + +- [User Management](user-management.md) - Managing users +- [Custom Fields](custom-fields.md) - Extending UserModel +- [Mappers](mappers.md) - Custom field transformations diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 0000000..deba131 --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,520 @@ +--- +sidebar_position: 12 +title: Complete Examples +--- + +# Complete Examples + +This page contains complete, working examples for common use cases. + +## Simple Web Application + +### Setup + +```php +isValidUser($username, $password); + + if ($user !== null) { + $sessionContext->registerLogin($user->getUserid()); + $sessionContext->setSessionData('login_time', time()); + + header('Location: dashboard.php'); + exit; + } else { + $error = 'Invalid username or password'; + } + } catch (Exception $e) { + $error = 'An error occurred: ' . $e->getMessage(); + } +} +?> + + + + Login + + +

Login

+ + +
+ + +
+
+ + +
+
+ + +
+ +
+ +

Create an account

+ + +``` + +### Registration Page + +```php + 8, + PasswordDefinition::REQUIRE_UPPERCASE => 1, + PasswordDefinition::REQUIRE_LOWERCASE => 1, + PasswordDefinition::REQUIRE_NUMBERS => 1, + ]); + + $result = $passwordDef->matchPassword($password); + if ($result !== PasswordDefinition::SUCCESS) { + throw new Exception('Password does not meet requirements'); + } + + // Create user + $user = $users->addUser($name, $username, $email, $password); + + // Auto-login + $sessionContext->registerLogin($user->getUserid()); + + header('Location: dashboard.php'); + exit; + + } catch (UserExistsException $e) { + $error = 'Username or email already exists'; + } catch (Exception $e) { + $error = $e->getMessage(); + } +} +?> + + + + Register + + +

Create Account

+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + Minimum 8 characters, at least 1 uppercase, 1 lowercase, and 1 number +
+
+ + +
+ +
+ +

Already have an account?

+ + +``` + +### Protected Dashboard + +```php +isAuthenticated()) { + header('Location: login.php'); + exit; +} + +// Get current user +$userId = $sessionContext->userInfo(); +$user = $users->getById($userId); +$loginTime = $sessionContext->getSessionData('login_time'); +?> + + + + Dashboard + + +

Welcome, getName()) ?>

+ +

Email: getEmail()) ?>

+

Logged in at:

+ + isAdmin($userId)): ?> +

You are an administrator

+

Admin Panel

+ + +

Edit Profile

+

Logout

+ + +``` + +### Logout + +```php +registerLogout(); +session_destroy(); + +header('Location: login.php'); +exit; +``` + +## REST API with JWT + +### API Configuration + +```php + 'Method not allowed'], 405); +} + +$input = json_decode(file_get_contents('php://input'), true); +$username = $input['username'] ?? ''; +$password = $input['password'] ?? ''; + +try { + $token = $users->createAuthToken( + $username, + $password, + $jwtWrapper, + 3600, // 1 hour + [ + 'last_login' => date('Y-m-d H:i:s'), + 'last_ip' => $_SERVER['REMOTE_ADDR'] + ], + [ + 'ip' => $_SERVER['REMOTE_ADDR'] + ] + ); + + if ($token === null) { + jsonResponse(['error' => 'Invalid credentials'], 401); + } + + jsonResponse([ + 'success' => true, + 'token' => $token, + 'expires_in' => 3600 + ]); + +} catch (Exception $e) { + jsonResponse(['error' => $e->getMessage()], 500); +} +``` + +### Protected Endpoint + +```php + 'No token provided'], 401); +} + +$token = $matches[1]; + +try { + // Decode token to get username + $jwtData = $jwtWrapper->extractData($token); + $username = $jwtData->data['login'] ?? null; + + if (!$username) { + jsonResponse(['error' => 'Invalid token'], 401); + } + + // Validate token + $result = $users->isValidToken($username, $jwtWrapper, $token); + + if ($result === null) { + jsonResponse(['error' => 'Token validation failed'], 401); + } + + $user = $result['user']; + + // Handle request + if ($_SERVER['REQUEST_METHOD'] === 'GET') { + // Get user info + jsonResponse([ + 'id' => $user->getUserid(), + 'name' => $user->getName(), + 'email' => $user->getEmail(), + 'username' => $user->getUsername(), + 'admin' => $users->isAdmin($user->getUserid()) + ]); + } elseif ($_SERVER['REQUEST_METHOD'] === 'PUT') { + // Update user info + $input = json_decode(file_get_contents('php://input'), true); + + if (isset($input['name'])) { + $user->setName($input['name']); + } + if (isset($input['email'])) { + $user->setEmail($input['email']); + } + + $users->save($user); + + jsonResponse(['success' => true, 'message' => 'User updated']); + } else { + jsonResponse(['error' => 'Method not allowed'], 405); + } + +} catch (Exception $e) { + jsonResponse(['error' => $e->getMessage()], 500); +} +``` + +## Multi-Tenant Application + +```php +addProperty($userId, 'organization', $orgId); + $users->addProperty($userId, "org_{$orgId}_role", $role); +} + +// Check if user has access to organization +function hasOrganizationAccess($users, $userId, $orgId) +{ + return $users->hasProperty($userId, 'organization', $orgId); +} + +// Get user's role in organization +function getOrganizationRole($users, $userId, $orgId) +{ + return $users->getProperty($userId, "org_{$orgId}_role"); +} + +// Get all users in organization +function getOrganizationUsers($users, $orgId) +{ + return $users->getUsersByProperty('organization', $orgId); +} + +// Usage +$userId = 1; +$orgId = 'org-123'; + +// Add user to organization +addUserToOrganization($users, $userId, $orgId, 'admin'); + +// Check access +if (hasOrganizationAccess($users, $userId, $orgId)) { + $role = getOrganizationRole($users, $userId, $orgId); + echo "User has access as: $role\n"; + + // Get all members + $members = getOrganizationUsers($users, $orgId); + foreach ($members as $member) { + echo "- " . $member->getName() . "\n"; + } +} +``` + +## Permission System + +```php +users = $users; + } + + public function grantPermission($userId, $resource, $action) + { + $permission = "$resource:$action"; + $this->users->addProperty($userId, 'permission', $permission); + } + + public function revokePermission($userId, $resource, $action) + { + $permission = "$resource:$action"; + $this->users->removeProperty($userId, 'permission', $permission); + } + + public function hasPermission($userId, $resource, $action) + { + $permission = "$resource:$action"; + return $this->users->hasProperty($userId, 'permission', $permission); + } + + public function getPermissions($userId) + { + $permissions = $this->users->getProperty($userId, 'permission'); + return is_array($permissions) ? $permissions : [$permissions]; + } +} + +// Usage +$permissionManager = new PermissionManager($users); + +// Grant permissions +$permissionManager->grantPermission($userId, 'posts', 'create'); +$permissionManager->grantPermission($userId, 'posts', 'edit'); +$permissionManager->grantPermission($userId, 'posts', 'delete'); +$permissionManager->grantPermission($userId, 'users', 'view'); + +// Check permissions +if ($permissionManager->hasPermission($userId, 'posts', 'delete')) { + echo "User can delete posts\n"; +} + +// Get all permissions +$permissions = $permissionManager->getPermissions($userId); +print_r($permissions); + +// Revoke permission +$permissionManager->revokePermission($userId, 'posts', 'delete'); +``` + +## Next Steps + +- [Getting Started](getting-started.md) - Basic concepts +- [User Management](user-management.md) - Managing users +- [Authentication](authentication.md) - Authentication methods +- [JWT Tokens](jwt-tokens.md) - Token-based authentication diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..aaa8bf6 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,58 @@ +--- +sidebar_position: 1 +title: Getting Started +--- + +# Getting Started + +Auth User PHP is a simple and customizable library for user authentication in PHP applications. It provides an abstraction layer for managing users, authentication, and user properties, supporting multiple storage backends including databases and XML files. + +## Key Features + +- **User Management**: Complete CRUD operations for users +- **Authentication**: Validate user credentials and manage sessions +- **User Properties**: Store and retrieve custom user properties +- **JWT Support**: Create and validate JWT tokens for stateless authentication +- **Password Validation**: Built-in password strength validation +- **Flexible Storage**: Support for databases (via AnyDataset) and XML files +- **Session Management**: PSR-6 compatible cache for session storage + +## Quick Example + +Here's a quick example of how to use the library: + +```php +addUser('John Doe', 'johndoe', 'john@example.com', 'SecurePass123'); + +// Validate user credentials +$user = $users->isValidUser('johndoe', 'SecurePass123'); + +if ($user !== null) { + // Create a session + $sessionContext = new SessionContext(Factory::createSessionPool()); + $sessionContext->registerLogin($user->getUserid()); + + echo "User authenticated successfully!"; +} +``` + +## Next Steps + +- [Installation](installation.md) - Install the library via Composer +- [User Management](user-management.md) - Learn how to manage users +- [Authentication](authentication.md) - Understand authentication methods +- [Session Context](session-context.md) - Manage user sessions diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..68852ae --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,51 @@ +--- +sidebar_position: 2 +title: Installation +--- + +# Installation + +## Requirements + +- PHP 8.1 or higher +- Composer + +## Install via Composer + +Install the library using Composer: + +```bash +composer require byjg/authuser +``` + +## Dependencies + +The library depends on the following packages: + +- `byjg/micro-orm` - For database operations +- `byjg/cache-engine` - For session management +- `byjg/jwt-wrapper` - For JWT token support + +These dependencies are automatically installed by Composer. + +:::info Dependency Graph +```mermaid +flowchart TD + byjg/authuser --> byjg/micro-orm + byjg/authuser --> byjg/cache-engine + byjg/authuser --> byjg/jwt-wrapper +``` +::: + +## Running Tests + +Because this project uses PHP Session, you need to run the unit tests with the `--stderr` flag: + +```bash +./vendor/bin/phpunit --stderr +``` + +## Next Steps + +- [Getting Started](getting-started.md) - Learn the basics +- [Database Storage](database-storage.md) - Set up database storage diff --git a/docs/jwt-tokens.md b/docs/jwt-tokens.md new file mode 100644 index 0000000..0993d28 --- /dev/null +++ b/docs/jwt-tokens.md @@ -0,0 +1,382 @@ +--- +sidebar_position: 9 +title: JWT Tokens +--- + +# JWT Tokens + +The library provides built-in support for JWT (JSON Web Token) authentication through integration with [byjg/jwt-wrapper](https://github.com/byjg/jwt-wrapper). + +## What is JWT? + +JWT (JSON Web Tokens) is a compact, URL-safe means of representing claims to be transferred between two parties. JWTs are commonly used for: + +- **Stateless authentication** - No server-side session storage needed +- **API authentication** - Perfect for REST APIs and microservices +- **Single Sign-On (SSO)** - Share authentication across domains +- **Mobile apps** - Efficient token-based authentication + +## Setup + +### Creating a JWT Wrapper + +```php +createAuthToken( + 'johndoe', // Login (username or email) + 'password123', // Password + $jwtWrapper, // JWT wrapper instance + 3600 // Expires in 1 hour (seconds) +); + +if ($token !== null) { + // Return token to client + echo json_encode(['token' => $token]); +} else { + // Authentication failed + http_response_code(401); + echo json_encode(['error' => 'Invalid credentials']); +} +``` + +### Token with Custom Data + +You can include additional data in the JWT payload: + +```php +createAuthToken( + 'johndoe', + 'password123', + $jwtWrapper, + 3600, + [], // Update user properties (optional) + [ // Additional token data + 'role' => 'admin', + 'permissions' => ['read', 'write'], + 'tenant_id' => '12345' + ] +); +``` + +### Update User Properties on Login + +```php +createAuthToken( + 'johndoe', + 'password123', + $jwtWrapper, + 3600, + [ // User properties to update + 'last_login' => date('Y-m-d H:i:s'), + 'login_count' => $loginCount + 1 + ], + [ // Token data + 'role' => 'admin' + ] +); +``` + +## Validating JWT Tokens + +### Token Validation + +```php +isValidToken('johndoe', $jwtWrapper, $token); + + if ($result !== null) { + $user = $result['user']; // UserModel instance + $tokenData = $result['data']; // Token payload data + + echo "Authenticated: " . $user->getName(); + echo "Role: " . $tokenData['role']; + } + +} catch (\ByJG\Authenticate\Exception\UserNotFoundException $e) { + echo "User not found"; +} catch (\ByJG\Authenticate\Exception\NotAuthenticatedException $e) { + echo "Token validation failed: " . $e->getMessage(); +} catch (\ByJG\JwtWrapper\JwtWrapperException $e) { + echo "JWT error: " . $e->getMessage(); +} +``` + +### Validation Checks + +The `isValidToken()` method performs the following checks: + +1. **User exists** - Verifies the user account exists +2. **Token hash matches** - Compares stored token hash +3. **JWT signature** - Validates the token signature +4. **Token expiration** - Checks if token has expired + +## Token Storage and Invalidation + +### How Tokens Are Stored + +When a token is created: + +```php +set('TOKEN_HASH', $tokenHash); +``` + +This allows you to invalidate tokens without maintaining a token blacklist. + +### Invalidating Tokens + +#### Logout (Invalidate Current Token) + +```php +removeProperty($userId, 'TOKEN_HASH'); +``` + +#### Force Re-authentication (Invalidate All Tokens) + +```php +createAuthToken($login, $password, $jwtWrapper, 3600); +``` + +## Complete API Example + +### Login Endpoint + +```php +createAuthToken( + $input['username'], + $input['password'], + $jwtWrapper, + 3600, // 1 hour expiration + [ + 'last_login' => date('Y-m-d H:i:s'), + 'last_ip' => $_SERVER['REMOTE_ADDR'] + ], + [ + 'ip' => $_SERVER['REMOTE_ADDR'], + 'user_agent' => $_SERVER['HTTP_USER_AGENT'] + ] + ); + + if ($token === null) { + throw new Exception('Authentication failed'); + } + + echo json_encode([ + 'success' => true, + 'token' => $token, + 'expires_in' => 3600 + ]); + +} catch (Exception $e) { + http_response_code(401); + echo json_encode([ + 'success' => false, + 'error' => $e->getMessage() + ]); +} +``` + +### Protected Endpoint + +```php + 'No token provided']); + exit; +} + +$token = $matches[1]; + +// Extract username from token (you need to decode it first) +try { + $jwtData = $jwtWrapper->extractData($token); + $username = $jwtData->data['login'] ?? null; + + if (!$username) { + throw new Exception('Invalid token structure'); + } + + // Validate token + $result = $users->isValidToken($username, $jwtWrapper, $token); + + if ($result === null) { + throw new Exception('Invalid token'); + } + + $user = $result['user']; + + // Process request + echo json_encode([ + 'success' => true, + 'user' => [ + 'id' => $user->getUserid(), + 'name' => $user->getName(), + 'email' => $user->getEmail() + ] + ]); + +} catch (Exception $e) { + http_response_code(401); + echo json_encode(['error' => $e->getMessage()]); +} +``` + +### Logout Endpoint + +```php +extractData($token); + $username = $jwtData->data['login'] ?? null; + + $user = $users->getByLoginField($username); + if ($user !== null) { + $users->removeProperty($user->getUserid(), 'TOKEN_HASH'); + } + + echo json_encode(['success' => true, 'message' => 'Logged out']); + + } catch (Exception $e) { + echo json_encode(['success' => false, 'error' => $e->getMessage()]); + } +} else { + http_response_code(400); + echo json_encode(['error' => 'No token provided']); +} +``` + +## Token Expiration + +### Setting Expiration Time + +```php +createAuthToken($login, $password, $jwtWrapper, 900); + +// 1 hour +$token = $users->createAuthToken($login, $password, $jwtWrapper, 3600); + +// 24 hours +$token = $users->createAuthToken($login, $password, $jwtWrapper, 86400); + +// 7 days +$token = $users->createAuthToken($login, $password, $jwtWrapper, 604800); +``` + +### Refresh Tokens + +For long-lived sessions, implement a refresh token pattern: + +```php +createAuthToken( + $login, + $password, + $jwtWrapper, + 900, // 15 minutes + [], + ['type' => 'access'] +); + +// Create long-lived refresh token +$refreshToken = $users->createAuthToken( + $login, + $password, + $jwtWrapperRefresh, // Different wrapper/key + 604800, // 7 days + [], + ['type' => 'refresh'] +); + +echo json_encode([ + 'access_token' => $accessToken, + 'refresh_token' => $refreshToken +]); +``` + +## Security Best Practices + +1. **Use HTTPS** - Always transmit tokens over HTTPS +2. **Short expiration times** - Use short-lived tokens (15-60 minutes) +3. **Implement refresh tokens** - For longer sessions +4. **Validate on every request** - Don't trust the client +5. **Store securely** - Don't store tokens in localStorage if possible +6. **Include audience claims** - Limit token usage scope +7. **Monitor for abuse** - Track token usage patterns +8. **Rotate secrets** - Periodically rotate JWT secrets + +## Common Pitfalls + +❌ **Don't store sensitive data in JWT payload** - It's not encrypted, only signed + +❌ **Don't use weak secret keys** - Use cryptographically random keys + +❌ **Don't skip expiration** - Always set reasonable expiration times + +❌ **Don't forget to invalidate** - Provide logout functionality + +❌ **Don't use HTTP** - Always use HTTPS in production + +## Next Steps + +- [Authentication](authentication.md) - Other authentication methods +- [Session Context](session-context.md) - Session-based authentication +- [User Properties](user-properties.md) - Managing user data diff --git a/docs/mappers.md b/docs/mappers.md new file mode 100644 index 0000000..26e693d --- /dev/null +++ b/docs/mappers.md @@ -0,0 +1,433 @@ +--- +sidebar_position: 11 +title: Mappers and Entity Processors +--- + +# Mappers and Entity Processors + +Mappers and Entity Processors allow you to transform data as it's read from or written to the database. + +## What Are Mappers? + +Mappers implement the `MapperFunctionInterface` and transform individual field values during database operations. + +- **Update Mappers**: Transform values **before** saving to database +- **Select Mappers**: Transform values **after** reading from database + +## Built-in Mappers + +### PasswordSha1Mapper + +Automatically hashes passwords using SHA-1: + +```php +defineMapperForUpdate( + UserDefinition::FIELD_PASSWORD, + PasswordSha1Mapper::class +); +``` + +### StandardMapper + +Default mapper that passes values through unchanged: + +```php +defineMapperForUpdate('name', StandardMapper::class); +``` + +### ReadOnlyMapper + +Prevents field updates: + +```php +markPropertyAsReadOnly(UserDefinition::FIELD_CREATED); + +// Or explicitly +$userDefinition->defineMapperForUpdate( + UserDefinition::FIELD_CREATED, + ReadOnlyMapper::class +); +``` + +## Creating Custom Mappers + +### Mapper Interface + +```php + 12]); + } +} + +// Use it +$userDefinition->defineMapperForUpdate( + UserDefinition::FIELD_PASSWORD, + BcryptPasswordMapper::class +); +``` + +### Example: Email Normalization Mapper + +```php +defineMapperForUpdate( + UserDefinition::FIELD_EMAIL, + EmailNormalizationMapper::class +); +``` + +### Example: JSON Serialization Mappers + +```php +defineMapperForUpdate('preferences', JsonEncodeMapper::class); +$userDefinition->defineMapperForSelect('preferences', JsonDecodeMapper::class); +``` + +### Example: Date Formatting Mapper + +```php +format('Y-m-d H:i:s'); + } + if (is_string($value)) { + return $value; + } + if (is_int($value)) { + return date('Y-m-d H:i:s', $value); + } + return $value; + } +} + +class DateParseMapper implements MapperFunctionInterface +{ + public function processedValue(mixed $value, mixed $instance): mixed + { + if (empty($value)) { + return null; + } + try { + return new \DateTime($value); + } catch (\Exception $e) { + return $value; + } + } +} + +$userDefinition->defineMapperForUpdate('created', DateFormatMapper::class); +$userDefinition->defineMapperForSelect('created', DateParseMapper::class); +``` + +## Entity Processors + +Entity Processors transform the **entire entity** (UserModel) before insert or update operations. + +### Entity Processor Interface + +```php +setBeforeInsert(new PassThroughEntityProcessor()); +``` + +### Custom Entity Processors + +#### Example: Auto-Set Created Timestamp + +```php +getCreated())) { + $instance->setCreated(date('Y-m-d H:i:s')); + } + } + } +} + +$userDefinition->setBeforeInsert(new CreatedTimestampProcessor()); +``` + +#### Example: Username Validation + +```php +getUsername(); + + if (strlen($username) < 3) { + throw new \InvalidArgumentException('Username must be at least 3 characters'); + } + + if (!preg_match('/^[a-zA-Z0-9_]+$/', $username)) { + throw new \InvalidArgumentException('Username can only contain letters, numbers, and underscores'); + } + } + } +} + +$userDefinition->setBeforeInsert(new UsernameValidationProcessor()); +$userDefinition->setBeforeUpdate(new UsernameValidationProcessor()); +``` + +#### Example: Audit Trail + +```php +userId = $userId; + } + + public function process(mixed $instance): void + { + if ($instance instanceof UserModel) { + $instance->set('modified_by', $this->userId); + $instance->set('modified_at', date('Y-m-d H:i:s')); + } + } +} + +$userDefinition->setBeforeUpdate(new AuditProcessor($currentUserId)); +``` + +## Using Closures (Legacy) + +For backward compatibility, you can use closures instead of dedicated mapper classes: + +```php +defineMapperForUpdate( + UserDefinition::FIELD_EMAIL, + new ClosureMapper(function ($value, $instance) { + return strtolower(trim($value)); + }) +); + +// Select mapper +$userDefinition->defineMapperForSelect( + UserDefinition::FIELD_CREATED, + new ClosureMapper(function ($value, $instance) { + return date('Y', strtotime($value)); + }) +); +``` + +:::warning Deprecated Methods +The following methods are deprecated but still work: +- `defineClosureForUpdate()` - Use `defineMapperForUpdate()` with `ClosureMapper` +- `defineClosureForSelect()` - Use `defineMapperForSelect()` with `ClosureMapper` +- `getClosureForUpdate()` - Use `getMapperForUpdate()` +- `getClosureForSelect()` - Use `getMapperForSelect()` +::: + +## Complete Example + +```php +getCreated())) { + $instance->setCreated(date('Y-m-d H:i:s')); + } + if (empty($instance->getAdmin())) { + $instance->setAdmin('no'); + } + } + } +} + +// Configure User Definition +$userDefinition = new UserDefinition(); + +// Apply mappers +$userDefinition->defineMapperForUpdate('name', TrimMapper::class); +$userDefinition->defineMapperForUpdate('email', LowercaseMapper::class); +$userDefinition->defineMapperForUpdate('username', LowercaseMapper::class); + +// Apply entity processors +$userDefinition->setBeforeInsert(new DefaultsProcessor()); + +// Initialize +$users = new UsersDBDataset($dbDriver, $userDefinition); +``` + +## Best Practices + +1. **Keep mappers simple** - Each mapper should do one thing +2. **Chain mappers** - Use composition for complex transformations +3. **Handle null values** - Always check for null/empty values +4. **Be idempotent** - Applying mapper multiple times should be safe +5. **Use entity processors for validation** - Validate complete entities +6. **Document side effects** - Make it clear what each mapper does + +## Next Steps + +- [Custom Fields](custom-fields.md) - Extending UserModel +- [Password Validation](password-validation.md) - Password policies +- [Database Storage](database-storage.md) - Schema configuration diff --git a/docs/password-validation.md b/docs/password-validation.md new file mode 100644 index 0000000..8df36b7 --- /dev/null +++ b/docs/password-validation.md @@ -0,0 +1,288 @@ +--- +sidebar_position: 8 +title: Password Validation +--- + +# Password Validation + +The `PasswordDefinition` class provides comprehensive password strength validation and generation capabilities. + +## Basic Usage + +### Creating a Password Definition + +```php +withPasswordDefinition($passwordDefinition); + +// Now password is validated when set +$userModel->setPassword('WeakPwd'); // Throws InvalidArgumentException +``` + +## Password Rules + +### Default Rules + +The default password policy requires: + +| Rule | Default Value | Description | +|---------------------|---------------|------------------------------------------| +| `minimum_chars` | 8 | Minimum password length | +| `require_uppercase` | 0 | Number of uppercase letters required | +| `require_lowercase` | 1 | Number of lowercase letters required | +| `require_symbols` | 0 | Number of symbols required | +| `require_numbers` | 1 | Number of digits required | +| `allow_whitespace` | 0 | Allow whitespace characters (0 = no) | +| `allow_sequential` | 0 | Allow sequential characters (0 = no) | +| `allow_repeated` | 0 | Allow repeated patterns (0 = no) | + +### Custom Rules + +```php + 12, + PasswordDefinition::REQUIRE_UPPERCASE => 2, + PasswordDefinition::REQUIRE_LOWERCASE => 2, + PasswordDefinition::REQUIRE_SYMBOLS => 1, + PasswordDefinition::REQUIRE_NUMBERS => 2, + PasswordDefinition::ALLOW_WHITESPACE => 0, + PasswordDefinition::ALLOW_SEQUENTIAL => 0, + PasswordDefinition::ALLOW_REPEATED => 0 +]); +``` + +### Setting Individual Rules + +```php +setRule(PasswordDefinition::MINIMUM_CHARS, 10); +$passwordDefinition->setRule(PasswordDefinition::REQUIRE_UPPERCASE, 1); +$passwordDefinition->setRule(PasswordDefinition::REQUIRE_SYMBOLS, 1); +``` + +## Validating Passwords + +### Validation Result Codes + +The `matchPassword()` method returns a bitwise result: + +```php +matchPassword('weak'); + +if ($result === PasswordDefinition::SUCCESS) { + echo "Password is valid"; +} else { + // Check specific failures + if ($result & PasswordDefinition::FAIL_MINIMUM_CHARS) { + echo "Password is too short\n"; + } + if ($result & PasswordDefinition::FAIL_UPPERCASE) { + echo "Missing uppercase letters\n"; + } + if ($result & PasswordDefinition::FAIL_LOWERCASE) { + echo "Missing lowercase letters\n"; + } + if ($result & PasswordDefinition::FAIL_NUMBERS) { + echo "Missing numbers\n"; + } + if ($result & PasswordDefinition::FAIL_SYMBOLS) { + echo "Missing symbols\n"; + } + if ($result & PasswordDefinition::FAIL_WHITESPACE) { + echo "Whitespace not allowed\n"; + } + if ($result & PasswordDefinition::FAIL_SEQUENTIAL) { + echo "Sequential characters detected\n"; + } + if ($result & PasswordDefinition::FAIL_REPEATED) { + echo "Repeated patterns detected\n"; + } +} +``` + +### Available Failure Codes + +| Constant | Value | Description | +|---------------------------|-------|----------------------------------| +| `SUCCESS` | 0 | Password is valid | +| `FAIL_MINIMUM_CHARS` | 1 | Password too short | +| `FAIL_UPPERCASE` | 2 | Missing uppercase letters | +| `FAIL_LOWERCASE` | 4 | Missing lowercase letters | +| `FAIL_SYMBOLS` | 8 | Missing symbols | +| `FAIL_NUMBERS` | 16 | Missing numbers | +| `FAIL_WHITESPACE` | 32 | Whitespace not allowed | +| `FAIL_SEQUENTIAL` | 64 | Sequential characters detected | +| `FAIL_REPEATED` | 128 | Repeated patterns detected | + +## Password Generation + +### Generate a Random Password + +```php +generatePassword(); +echo $password; // e.g., "aB3dE7fG9" +``` + +### Generate Longer Passwords + +```php +generatePassword(5); +``` + +The generated password will: +- Meet all defined rules +- Be cryptographically random +- Avoid sequential and repeated patterns + +## User Registration with Password Validation + +### Complete Example + +```php + 10, + PasswordDefinition::REQUIRE_UPPERCASE => 1, + PasswordDefinition::REQUIRE_LOWERCASE => 1, + PasswordDefinition::REQUIRE_SYMBOLS => 1, + PasswordDefinition::REQUIRE_NUMBERS => 1, +]); + +// Create user with password validation +try { + $user = new UserModel(); + $user->withPasswordDefinition($passwordDefinition); + + $user->setName('John Doe'); + $user->setEmail('john@example.com'); + $user->setUsername('johndoe'); + $user->setPassword($_POST['password']); // Validated automatically + + $users->save($user); + echo "User created successfully"; + +} catch (InvalidArgumentException $e) { + echo "Password validation failed: " . $e->getMessage(); +} +``` + +## User-Friendly Error Messages + +```php +matchPassword($_POST['password']); +if ($result !== PasswordDefinition::SUCCESS) { + $errors = getPasswordErrors($result); + foreach ($errors as $error) { + echo "- " . $error . "\n"; + } +} +``` + +## Sequential and Repeated Patterns + +### Sequential Characters + +Sequential patterns that are detected include: +- **Alphabetic**: abc, bcd, cde, xyz, etc. (case-insensitive) +- **Numeric**: 012, 123, 234, 789, 890, etc. +- **Reverse**: 987, 876, 765, 321, etc. + +### Repeated Patterns + +Repeated patterns include: +- **Repeated characters**: aaa, 111, etc. +- **Repeated sequences**: ababab, 123123, etc. + +## Password Change Flow + +```php +getById($userId); + $user->withPasswordDefinition($passwordDefinition); + + // Verify old password + $existingUser = $users->isValidUser($user->getUsername(), $_POST['old_password']); + if ($existingUser === null) { + throw new Exception("Current password is incorrect"); + } + + // Set new password (validated automatically) + $user->setPassword($_POST['new_password']); + $users->save($user); + + echo "Password changed successfully"; + +} catch (InvalidArgumentException $e) { + echo "New password validation failed: " . $e->getMessage(); +} +``` + +## Best Practices + +1. **Balance security and usability** - Don't make rules too restrictive +2. **Educate users** - Provide clear error messages +3. **Use password generation** - Offer to generate strong passwords +4. **Consider passphrases** - Allow longer passwords with spaces if appropriate +5. **Combine with rate limiting** - Prevent brute force attacks + +## Next Steps + +- [Authentication](authentication.md) - Validating credentials +- [User Management](user-management.md) - Managing users +- [Mappers](mappers.md) - Custom password hashing diff --git a/docs/session-context.md b/docs/session-context.md new file mode 100644 index 0000000..d0f9290 --- /dev/null +++ b/docs/session-context.md @@ -0,0 +1,197 @@ +--- +sidebar_position: 5 +title: Session Context +--- + +# Session Context + +The `SessionContext` class manages user authentication state using PSR-6 compatible cache storage. + +## Creating a Session Context + +```php +registerLogin($userId); + +// With additional session data +$sessionContext->registerLogin($userId, ['ip' => $_SERVER['REMOTE_ADDR']]); +``` + +### Check Authentication Status + +```php +isAuthenticated()) { + echo "User is logged in"; +} else { + echo "User is not authenticated"; +} +``` + +### Get Current User Info + +```php +isAuthenticated()) { + $userId = $sessionContext->userInfo(); + // Use $userId to fetch user details +} +``` + +### Logout + +```php +registerLogout(); +``` + +## Storing Session Data + +You can store custom data in the user's session. This data exists only while the user is logged in. + +### Store Data + +```php +setSessionData('shopping_cart', [ + 'item1' => 'Product A', + 'item2' => 'Product B' +]); + +$sessionContext->setSessionData('last_page', '/products'); +``` + +:::warning Authentication Required +The user must be authenticated to use `setSessionData()`. If not, a `NotAuthenticatedException` will be thrown. +::: + +### Retrieve Data + +```php +getSessionData('shopping_cart'); +$lastPage = $sessionContext->getSessionData('last_page'); +``` + +Returns `false` if: +- The user is not authenticated +- The key doesn't exist + +### Session Data Lifecycle + +- Session data is stored when the user logs in +- It persists across requests while the user remains logged in +- It is automatically deleted when the user logs out +- It is lost if the session expires + +## Complete Example + +```php +isValidUser($_POST['username'], $_POST['password']); + + if ($user !== null) { + $sessionContext->registerLogin($user->getUserid()); + $sessionContext->setSessionData('login_time', time()); + header('Location: /dashboard'); + exit; + } +} + +// Protected pages +if (!$sessionContext->isAuthenticated()) { + header('Location: /login'); + exit; +} + +$userId = $sessionContext->userInfo(); +$user = $users->getById($userId); +$loginTime = $sessionContext->getSessionData('login_time'); + +echo "Welcome, " . $user->getName(); +echo "Logged in at: " . date('Y-m-d H:i:s', $loginTime); + +// Logout +if (isset($_POST['logout'])) { + $sessionContext->registerLogout(); + header('Location: /login'); + exit; +} +``` + +## Best Practices + +1. **Use PHP Session storage** unless you have specific requirements for distributed sessions +2. **Always check authentication** before accessing protected resources +3. **Clear sensitive session data** when no longer needed +4. **Set appropriate session timeouts** based on your security requirements +5. **Regenerate session IDs** after login to prevent session fixation attacks + +## Next Steps + +- [Authentication](authentication.md) - User authentication methods +- [User Properties](user-properties.md) - Store persistent user data diff --git a/docs/user-management.md b/docs/user-management.md new file mode 100644 index 0000000..3ed82a4 --- /dev/null +++ b/docs/user-management.md @@ -0,0 +1,154 @@ +--- +sidebar_position: 3 +title: User Management +--- + +# User Management + +## Creating Users + +### Using addUser() Method + +The simplest way to add a user: + +```php +addUser( + 'John Doe', // Full name + 'johndoe', // Username + 'john@example.com', // Email + 'SecurePass123' // Password +); +``` + +### Using UserModel + +For more control, create a `UserModel` instance: + +```php +setName('John Doe'); +$userModel->setUsername('johndoe'); +$userModel->setEmail('john@example.com'); +$userModel->setPassword('SecurePass123'); +$userModel->setAdmin('no'); + +$savedUser = $users->save($userModel); +``` + +## Retrieving Users + +### Get User by ID + +```php +getById($userId); +``` + +### Get User by Email + +```php +getByEmail('john@example.com'); +``` + +### Get User by Username + +```php +getByUsername('johndoe'); +``` + +### Get User by Login Field + +The login field is determined by the `UserDefinition` (either email or username): + +```php +getByLoginField('johndoe'); +``` + +### Using Custom Filters + +For advanced queries, use `IteratorFilter`: + +```php +and('email', Relation::EQUAL, 'john@example.com'); +$filter->and('admin', Relation::EQUAL, 'yes'); + +$user = $users->getUser($filter); +``` + +## Updating Users + +```php +getById($userId); + +// Update fields +$user->setName('Jane Doe'); +$user->setEmail('jane@example.com'); + +// Save changes +$users->save($user); +``` + +## Deleting Users + +### Delete by ID + +```php +removeUserById($userId); +``` + +### Delete by Login + +```php +removeByLoginField('johndoe'); +``` + +## Checking Admin Status + +```php +isAdmin($userId)) { + echo "User is an administrator"; +} +``` + +The admin field accepts the following values as `true`: +- `yes`, `YES`, `y`, `Y` +- `true`, `TRUE`, `t`, `T` +- `1` +- `s`, `S` (from Portuguese "sim") + +## UserModel Properties + +The `UserModel` class provides the following properties: + +| Property | Type | Description | +|------------|---------------------|--------------------------------| +| userid | string\|int\|null | User ID (auto-generated) | +| name | string\|null | User's full name | +| email | string\|null | User's email address | +| username | string\|null | User's username | +| password | string\|null | User's password (hashed) | +| created | string\|null | Creation timestamp | +| admin | string\|null | Admin flag (yes/no) | + +## Next Steps + +- [Authentication](authentication.md) - Validate user credentials +- [User Properties](user-properties.md) - Store custom user data +- [Password Validation](password-validation.md) - Enforce password policies diff --git a/docs/user-properties.md b/docs/user-properties.md new file mode 100644 index 0000000..1666821 --- /dev/null +++ b/docs/user-properties.md @@ -0,0 +1,248 @@ +--- +sidebar_position: 6 +title: User Properties +--- + +# User Properties + +User properties allow you to store custom key-value data associated with users. This is useful for storing additional information beyond the standard user fields. + +## Adding Properties + +### Add a Single Property + +```php +addProperty($userId, 'phone', '555-1234'); +$users->addProperty($userId, 'department', 'Engineering'); +``` + +:::info Duplicates +`addProperty()` will not add the property if it already exists with the same value. +::: + +### Add Multiple Values for the Same Property + +Users can have multiple values for the same property: + +```php +addProperty($userId, 'role', 'developer'); +$users->addProperty($userId, 'role', 'manager'); +``` + +### Set a Property (Update or Create) + +Use `setProperty()` to update an existing property or create it if it doesn't exist: + +```php +setProperty($userId, 'phone', '555-5678'); +``` + +## Using UserModel + +You can also manage properties directly through the `UserModel`: + +```php +getById($userId); + +// Set a property value +$user->set('phone', '555-1234'); + +// Add a property model +$property = new UserPropertiesModel('department', 'Engineering'); +$user->addProperty($property); + +// Save the user to persist properties +$users->save($user); +``` + +## Retrieving Properties + +### Get a Single Property + +```php +getProperty($userId, 'phone'); +// Returns: '555-1234' +``` + +### Get Multiple Values + +If a property has multiple values, an array is returned: + +```php +getProperty($userId, 'role'); +// Returns: ['developer', 'manager'] +``` + +Returns `null` if the property doesn't exist. + +### Get Properties from UserModel + +```php +getById($userId); + +// Get property value(s) +$phone = $user->get('phone'); + +// Get property as UserPropertiesModel instance +$propertyModel = $user->get('phone', true); + +// Get all properties +$allProperties = $user->getProperties(); +foreach ($allProperties as $property) { + echo $property->getName() . ': ' . $property->getValue(); +} +``` + +## Checking Properties + +### Check if User Has a Property + +```php +hasProperty($userId, 'phone')) { + echo "User has a phone number"; +} + +// Check if property has a specific value +if ($users->hasProperty($userId, 'role', 'admin')) { + echo "User is an admin"; +} +``` + +:::tip Admin Bypass +The `hasProperty()` method always returns `true` for admin users, regardless of the actual property values. +::: + +## Removing Properties + +### Remove a Specific Property Value + +```php +removeProperty($userId, 'role', 'developer'); +``` + +### Remove All Values of a Property + +```php +removeProperty($userId, 'phone'); +``` + +### Remove Property from All Users + +```php +removeAllProperties('temporary_flag'); + +// Remove a specific value from all users +$users->removeAllProperties('role', 'guest'); +``` + +## Finding Users by Properties + +### Find Users with a Specific Property Value + +```php +getUsersByProperty('department', 'Engineering'); +// Returns array of UserModel objects +``` + +### Find Users with Multiple Properties + +```php +getUsersByPropertySet([ + 'department' => 'Engineering', + 'role' => 'senior', + 'status' => 'active' +]); +// Returns users that have ALL these properties with the specified values +``` + +## Common Use Cases + +### User Roles and Permissions + +```php +addProperty($userId, 'role', 'viewer'); +$users->addProperty($userId, 'role', 'editor'); +$users->addProperty($userId, 'role', 'admin'); + +// Check permissions +if ($users->hasProperty($userId, 'role', 'admin')) { + // Allow admin actions +} + +// Get all roles +$roles = $users->getProperty($userId, 'role'); +``` + +### User Preferences + +```php +setProperty($userId, 'theme', 'dark'); +$users->setProperty($userId, 'language', 'en'); +$users->setProperty($userId, 'timezone', 'America/New_York'); + +// Retrieve preferences +$theme = $users->getProperty($userId, 'theme'); +``` + +### Multi-tenant Applications + +```php +addProperty($userId, 'organization', 'org-123'); +$users->addProperty($userId, 'organization', 'org-456'); + +// Find all users in an organization +$orgUsers = $users->getUsersByProperty('organization', 'org-123'); + +// Check access +if ($users->hasProperty($userId, 'organization', $requestedOrgId)) { + // Grant access +} +``` + +## Property Storage + +### Database Storage + +Properties are stored in a separate table (default: `users_property`): + +| Column | Description | +|------------|--------------------------| +| customid | Property ID | +| userid | User ID (foreign key) | +| name | Property name | +| value | Property value | + +### XML/AnyDataset Storage + +Properties are stored as fields within each user's record, with arrays used for multiple values. + +## Next Steps + +- [User Management](user-management.md) - Basic user operations +- [Database Storage](database-storage.md) - Configure property storage +- [Custom Fields](custom-fields.md) - Extend the UserModel diff --git a/example.php b/example.php index 99f0e14..49c2da6 100644 --- a/example.php +++ b/example.php @@ -2,20 +2,41 @@ require "vendor/autoload.php"; -$users = new ByJG\Authenticate\UsersAnyDataset('/tmp/pass.anydata.xml'); +use ByJG\Authenticate\UsersAnyDataset; +use ByJG\Authenticate\SessionContext; +use ByJG\AnyDataset\Core\AnyDataset; +use ByJG\Cache\Factory; -$users->addUser('Some User Full Name', 'someuser', 'someuser@someemail.com', '12345'); -//$users->save(); +// Create or load AnyDataset from XML file +$anyDataset = new AnyDataset('/tmp/users.xml'); -$user = $users->isValidUser('someuser', '12345'); -var_dump($user); -if (!is_null($user)) -{ - $session = new \ByJG\Authenticate\SessionContext(); - $session->registerLogin($userId); +// Initialize user management +$users = new UsersAnyDataset($anyDataset); - echo "Authenticated: " . $session->isAuthenticated(); - print_r($session->userInfo()); +// Add a new user +$user = $users->addUser('Some User Full Name', 'someuser', 'someuser@someemail.com', '12345'); +echo "User created with ID: " . $user->getUserid() . "\n"; + +// Validate user credentials +$authenticatedUser = $users->isValidUser('someuser', '12345'); +var_dump($authenticatedUser); + +if ($authenticatedUser !== null) { + // Create session context + $session = new SessionContext(Factory::createSessionPool()); + + // Register login + $session->registerLogin($authenticatedUser->getUserid()); + + echo "Authenticated: " . ($session->isAuthenticated() ? 'yes' : 'no') . "\n"; + echo "User ID: " . $session->userInfo() . "\n"; + + // Store some session data + $session->setSessionData('login_time', time()); + + // Get the user info + $currentUser = $users->getById($session->userInfo()); + echo "Welcome, " . $currentUser->getName() . "\n"; } From 421ede17f9eed22c53f7e7b68dd679a71481604d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Nov 2025 20:42:07 +0000 Subject: [PATCH 08/17] Move Dependencies section to end of README before footer --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index b973e33..d610074 100644 --- a/README.md +++ b/README.md @@ -289,6 +289,10 @@ Because this project uses PHP Session you need to run the unit test the followin └───────────────────┘ └───────────────────┘ └────────────────────┘ ``` +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + ## Dependencies ```mermaid @@ -298,9 +302,5 @@ flowchart TD byjg/authuser --> byjg/jwt-wrapper ``` -## License - -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. - ---- [Open source ByJG](http://opensource.byjg.com) From 1e5e4b5fa9349485723cd45ad89cc1946df09f4f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Nov 2025 20:48:14 +0000 Subject: [PATCH 09/17] Apply DRY pattern to documentation Consolidated duplicate content and improved documentation structure: README.md changes: - Removed duplicate installation examples - Simplified Basic Usage to single example - Removed detailed sections (now in specific docs) - Replaced with Features section linking to docs - Simplified Dependencies section docs/getting-started.md changes: - Removed duplicate Key Features list - Simplified Quick Example code docs/authentication.md changes: - Added cross-reference to Password Validation docs/custom-fields.md changes: - Added clarity on when to use vs Database Storage Benefits: - Single source of truth for each concept - Clear navigation through cross-references - Better maintainability --- README.md | 231 ++++------------------------------------ docs/authentication.md | 6 +- docs/custom-fields.md | 4 + docs/getting-started.md | 28 +---- 4 files changed, 36 insertions(+), 233 deletions(-) diff --git a/README.md b/README.md index d610074..ba2dd87 100644 --- a/README.md +++ b/README.md @@ -34,229 +34,44 @@ This class can persist user data into session (or file, memcache, etc.) between composer require byjg/authuser ``` -### Using Database Storage - -```php -addUser('John Doe', 'johndoe', 'john@example.com', 'SecurePass123'); +// Initialize with database +$users = new UsersDBDataset(DbFactory::getDbInstance('mysql://user:pass@host/db')); -// Validate user credentials +// Create and authenticate a user +$user = $users->addUser('John Doe', 'johndoe', 'john@example.com', 'SecurePass123'); $authenticatedUser = $users->isValidUser('johndoe', 'SecurePass123'); if ($authenticatedUser !== null) { - // Create session context $sessionContext = new SessionContext(Factory::createSessionPool()); - - // Register the login $sessionContext->registerLogin($authenticatedUser->getUserid()); - echo "Welcome, " . $authenticatedUser->getName(); } ``` -### Check if User is Authenticated - -```php -isAuthenticated()) { - // Get the userId of the authenticated user - $userId = $sessionContext->userInfo(); - - // Get the user and display name - $user = $users->getById($userId); - echo "Hello: " . $user->getName(); -} else { - echo "Please log in"; -} -``` - -## Managing Session Data - -You can store temporary data in the user session that exists only while the user is logged in. Once the user logs out, the data is automatically released. - -### Store Session Data - -```php -setSessionData('shopping_cart', [ - 'item1' => 'Product A', - 'item2' => 'Product B' -]); - -$sessionContext->setSessionData('last_page', '/products'); -``` - -### Retrieve Session Data - -```php -getSessionData('shopping_cart'); -$lastPage = $sessionContext->getSessionData('last_page'); -``` - -:::note -A `NotAuthenticatedException` will be thrown if the user is not authenticated when accessing session data. -::: - -## Managing User Properties - -User properties allow you to store custom key-value data associated with users permanently. - -### Add Custom Properties - -```php -addProperty($userId, 'phone', '555-1234'); -$users->addProperty($userId, 'department', 'Engineering'); - -// Users can have multiple values for the same property -$users->addProperty($userId, 'role', 'developer'); -$users->addProperty($userId, 'role', 'manager'); -``` - -### Using UserModel - -```php -getById($userId); - -// Set a property (update or create) -$user->set('phone', '555-1234'); - -// Save changes -$users->save($user); -``` - -## Logout - -```php -registerLogout(); -``` - -## JWT Token Authentication - -For stateless API authentication, you can use JWT tokens: - -```php -createAuthToken( - 'johndoe', // Login - 'SecurePass123', // Password - $jwtWrapper, - 3600, // Expires in 1 hour (seconds) - [], // Additional user info to save - ['role' => 'admin'] // Additional token data -); - -// Validate token -$result = $users->isValidToken('johndoe', $jwtWrapper, $token); -if ($result !== null) { - $user = $result['user']; - $tokenData = $result['data']; -} -``` - -See [JWT Tokens](docs/jwt-tokens.md) for detailed information. - -## Database Schema - -The default database schema uses two tables: - -```sql -CREATE TABLE users ( - userid INTEGER AUTO_INCREMENT NOT NULL, - name VARCHAR(50), - email VARCHAR(120), - username VARCHAR(15) NOT NULL, - password CHAR(40) NOT NULL, - created DATETIME, - admin ENUM('Y','N'), - CONSTRAINT pk_users PRIMARY KEY (userid) -) ENGINE=InnoDB; - -CREATE TABLE users_property ( - customid INTEGER AUTO_INCREMENT NOT NULL, - name VARCHAR(20), - value VARCHAR(100), - userid INTEGER NOT NULL, - CONSTRAINT pk_custom PRIMARY KEY (customid), - CONSTRAINT fk_custom_user FOREIGN KEY (userid) REFERENCES users (userid) -) ENGINE=InnoDB; -``` - -You can customize table and column names using `UserDefinition` and `UserPropertiesDefinition`. See [Database Storage](docs/database-storage.md) for details. +See [Getting Started](docs/getting-started.md) for a complete introduction and [Examples](docs/examples.md) for more use cases. ## Features -- **Complete User Management** - Create, read, update, and delete users -- **Flexible Authentication** - Username/email + password or JWT tokens -- **Session Management** - PSR-6 compatible cache storage -- **User Properties** - Store custom key-value data per user -- **Password Validation** - Built-in password strength requirements -- **Multiple Storage Backends** - Database (MySQL, PostgreSQL, SQLite, etc.) or XML files -- **Customizable Schema** - Map to existing database tables -- **Field Mappers** - Transform data during read/write operations -- **Extensible User Model** - Add custom fields easily +- **User Management** - Complete CRUD operations. See [User Management](docs/user-management.md) +- **Authentication** - Username/email + password or JWT tokens. See [Authentication](docs/authentication.md) and [JWT Tokens](docs/jwt-tokens.md) +- **Session Management** - PSR-6 compatible cache storage. See [Session Context](docs/session-context.md) +- **User Properties** - Store custom key-value metadata. See [User Properties](docs/user-properties.md) +- **Password Validation** - Built-in strength requirements. See [Password Validation](docs/password-validation.md) +- **Multiple Storage** - Database (MySQL, PostgreSQL, SQLite, etc.) or XML files. See [Database Storage](docs/database-storage.md) +- **Custom Schema** - Map to existing database tables. See [Database Storage](docs/database-storage.md) +- **Field Mappers** - Transform data during read/write. See [Mappers](docs/mappers.md) +- **Extensible Model** - Add custom fields easily. See [Custom Fields](docs/custom-fields.md) ## Running Tests @@ -295,12 +110,12 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file ## Dependencies -```mermaid -flowchart TD - byjg/authuser --> byjg/micro-orm - byjg/authuser --> byjg/cache-engine - byjg/authuser --> byjg/jwt-wrapper -``` +This library depends on: +- **byjg/micro-orm** - For database operations +- **byjg/cache-engine** - For session management +- **byjg/jwt-wrapper** - For JWT token support + +See [Installation](docs/installation.md) for details and dependency graph. ---- [Open source ByJG](http://opensource.byjg.com) diff --git a/docs/authentication.md b/docs/authentication.md index b18cc31..18f756e 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -39,7 +39,11 @@ $users->save($user); ``` :::warning SHA-1 Deprecation -SHA-1 is used for backward compatibility. For new projects, consider implementing a custom password hasher using bcrypt or Argon2. See [Custom Mappers](mappers.md) for details. +SHA-1 is used for backward compatibility. For new projects, consider implementing a custom password hasher using bcrypt or Argon2. See [Mappers](mappers.md#example-bcrypt-password-mapper) for details. +::: + +:::tip Enforce Password Strength +To enforce password policies (minimum length, complexity rules, etc.), see [Password Validation](password-validation.md). ::: ## Workflow diff --git a/docs/custom-fields.md b/docs/custom-fields.md index 04d2bc5..050c6e8 100644 --- a/docs/custom-fields.md +++ b/docs/custom-fields.md @@ -7,6 +7,10 @@ title: Custom Fields You can extend the `UserModel` to add custom fields that match your database schema. +:::info When to Use This +This guide is for **adding new fields** beyond the standard user fields. If you just need to **map existing database columns** to the standard fields, see [Database Storage](database-storage.md#custom-database-schema) instead. +::: + ## Extending UserModel ### Creating a Custom User Model diff --git a/docs/getting-started.md b/docs/getting-started.md index aaa8bf6..fecc7c9 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -7,45 +7,25 @@ title: Getting Started Auth User PHP is a simple and customizable library for user authentication in PHP applications. It provides an abstraction layer for managing users, authentication, and user properties, supporting multiple storage backends including databases and XML files. -## Key Features - -- **User Management**: Complete CRUD operations for users -- **Authentication**: Validate user credentials and manage sessions -- **User Properties**: Store and retrieve custom user properties -- **JWT Support**: Create and validate JWT tokens for stateless authentication -- **Password Validation**: Built-in password strength validation -- **Flexible Storage**: Support for databases (via AnyDataset) and XML files -- **Session Management**: PSR-6 compatible cache for session storage - ## Quick Example -Here's a quick example of how to use the library: - ```php addUser('John Doe', 'johndoe', 'john@example.com', 'SecurePass123'); - -// Validate user credentials $user = $users->isValidUser('johndoe', 'SecurePass123'); if ($user !== null) { - // Create a session $sessionContext = new SessionContext(Factory::createSessionPool()); $sessionContext->registerLogin($user->getUserid()); - echo "User authenticated successfully!"; } ``` From e9b21a22116d63a822ee811f0e6a1ec87adaec5e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Nov 2025 21:15:30 +0000 Subject: [PATCH 10/17] Update Dependencies section to show only mermaid diagram - Removed text descriptions from Dependencies section - Now shows only mermaid flowchart with project dependencies - Dependencies sourced from composer.json: * byjg/authuser --> byjg/micro-orm * byjg/authuser --> byjg/cache-engine * byjg/authuser --> byjg/jwt-wrapper - Section remains as last section before footer --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index ba2dd87..a45a321 100644 --- a/README.md +++ b/README.md @@ -110,12 +110,12 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file ## Dependencies -This library depends on: -- **byjg/micro-orm** - For database operations -- **byjg/cache-engine** - For session management -- **byjg/jwt-wrapper** - For JWT token support - -See [Installation](docs/installation.md) for details and dependency graph. +```mermaid +flowchart TD + byjg/authuser --> byjg/micro-orm + byjg/authuser --> byjg/cache-engine + byjg/authuser --> byjg/jwt-wrapper +``` ---- [Open source ByJG](http://opensource.byjg.com) From f6c5c2e85ee56bf4de5f8af5e43d4cc87bd3d7fb Mon Sep 17 00:00:00 2001 From: Joao M Date: Thu, 6 Nov 2025 15:17:34 -0600 Subject: [PATCH 11/17] Update installation.md by removing dependencies section Removed dependencies section and related information from installation documentation. --- docs/installation.md | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/docs/installation.md b/docs/installation.md index 68852ae..4b46c5c 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -18,25 +18,6 @@ Install the library using Composer: composer require byjg/authuser ``` -## Dependencies - -The library depends on the following packages: - -- `byjg/micro-orm` - For database operations -- `byjg/cache-engine` - For session management -- `byjg/jwt-wrapper` - For JWT token support - -These dependencies are automatically installed by Composer. - -:::info Dependency Graph -```mermaid -flowchart TD - byjg/authuser --> byjg/micro-orm - byjg/authuser --> byjg/cache-engine - byjg/authuser --> byjg/jwt-wrapper -``` -::: - ## Running Tests Because this project uses PHP Session, you need to run the unit tests with the `--stderr` flag: From 6b0565356df29f91d9652f8f8b12e8c032eac70b Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Thu, 6 Nov 2025 16:27:39 -0500 Subject: [PATCH 12/17] Update authentication docs to recommend JWT-based authentication, deprecate `SessionContext` usage, and clarify differences for security and scalability. --- docs/authentication.md | 104 ++++++++++++++++++++++------------------- 1 file changed, 56 insertions(+), 48 deletions(-) diff --git a/docs/authentication.md b/docs/authentication.md index 18f756e..7ba0842 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -46,55 +46,9 @@ SHA-1 is used for backward compatibility. For new projects, consider implementin To enforce password policies (minimum length, complexity rules, etc.), see [Password Validation](password-validation.md). ::: -## Workflow +## JWT Token Authentication (Recommended) -### Basic Authentication Flow - -```php -isValidUser('johndoe', 'SecurePass123'); - -if ($user !== null) { - // 2. Create session context - $sessionContext = new SessionContext(Factory::createSessionPool()); - - // 3. Register login - $sessionContext->registerLogin($user->getUserid()); - - // 4. User is now authenticated - echo "Welcome, " . $user->getName(); -} -``` - -### Checking Authentication Status - -```php -isAuthenticated()) { - $userId = $sessionContext->userInfo(); - $user = $users->getById($userId); - echo "Hello, " . $user->getName(); -} else { - echo "Please log in"; -} -``` - -### Logging Out - -```php -registerLogout(); -``` - -## JWT Token Authentication - -For stateless authentication, you can use JWT tokens: +For modern, stateless authentication, use JWT tokens. This is the **recommended approach** for new applications as it provides better security and scalability. ```php isValidUser('johndoe', 'SecurePass123'); + +if ($user !== null) { + // 2. Create session context + $sessionContext = new SessionContext(Factory::createSessionPool()); + + // 3. Register login + $sessionContext->registerLogin($user->getUserid()); + + // 4. User is now authenticated + echo "Welcome, " . $user->getName(); +} +``` + +### Checking Authentication Status + +```php +isAuthenticated()) { + $userId = $sessionContext->userInfo(); + $user = $users->getById($userId); + echo "Hello, " . $user->getName(); +} else { + echo "Please log in"; +} +``` + +### Logging Out + +```php +registerLogout(); +``` + ## Security Best Practices 1. **Always use HTTPS** in production to prevent credential theft From 268b612de00888beda1b0652b8cdaf61070c24ab Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Thu, 6 Nov 2025 17:26:02 -0500 Subject: [PATCH 13/17] Fix PSalm --- .github/workflows/phpunit.yml | 1 + composer.json | 4 +-- src/Definition/PasswordDefinition.php | 4 +-- src/Definition/UserDefinition.php | 8 +++++ .../ClosureEntityProcessor.php | 1 + .../PassThroughEntityProcessor.php | 1 + src/MapperFunctions/ClosureMapper.php | 7 ++-- src/MapperFunctions/PasswordSha1Mapper.php | 1 + src/MapperFunctions/UserIdGeneratorMapper.php | 1 + tests/PasswordDefinitionTest.php | 32 ++++++++--------- tests/PasswordMd5MapperTest.php | 21 ++++++----- tests/SessionContextTest.php | 6 ++-- tests/UserModelTest.php | 8 ++--- tests/UsersAnyDatasetByUsernameTest.php | 35 ++++++++++++------- tests/UsersDBDatasetByUsernameTest.php | 12 +++++++ tests/UsersDBDatasetDefinitionTest.php | 16 +++++---- 16 files changed, 100 insertions(+), 58 deletions(-) diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index 1474292..da3e01b 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -16,6 +16,7 @@ jobs: strategy: matrix: php-version: + - "8.4" - "8.3" - "8.2" - "8.1" diff --git a/composer.json b/composer.json index 035f0c8..5a7ac7c 100644 --- a/composer.json +++ b/composer.json @@ -20,8 +20,8 @@ "byjg/jwt-wrapper": "^6.0" }, "require-dev": { - "phpunit/phpunit": "^9.6|^11", - "vimeo/psalm": "^5.9|^6.13" + "phpunit/phpunit": "^10|^11", + "vimeo/psalm": "^5.9|^6.12" }, "scripts": { "test": "vendor/bin/phpunit", diff --git a/src/Definition/PasswordDefinition.php b/src/Definition/PasswordDefinition.php index 12d0ff3..ad2c644 100644 --- a/src/Definition/PasswordDefinition.php +++ b/src/Definition/PasswordDefinition.php @@ -154,8 +154,8 @@ public function generatePassword(int $extendSize = 0): string } } - $size = $this->rules[self::MINIMUM_CHARS] + $extendSize; - $totalChars = array_sum($charsCount); + $size = intval($this->rules[self::MINIMUM_CHARS]) + $extendSize; + $totalChars = intval(array_sum($charsCount)); $rulesWithValueGreaterThanZero = array_filter($charsCount, function ($value) { return $value > 0; }); diff --git a/src/Definition/UserDefinition.php b/src/Definition/UserDefinition.php index df16fad..a927402 100644 --- a/src/Definition/UserDefinition.php +++ b/src/Definition/UserDefinition.php @@ -17,6 +17,14 @@ /** * Structure to represent the users + * + * @method string getUserid() + * @method string getName() + * @method string getEmail() + * @method string getUsername() + * @method string getPassword() + * @method string getCreated() + * @method string getAdmin() */ class UserDefinition { diff --git a/src/EntityProcessors/ClosureEntityProcessor.php b/src/EntityProcessors/ClosureEntityProcessor.php index efd4d17..b1bc747 100644 --- a/src/EntityProcessors/ClosureEntityProcessor.php +++ b/src/EntityProcessors/ClosureEntityProcessor.php @@ -17,6 +17,7 @@ public function __construct(Closure $closure) $this->closure = $closure; } + #[\Override] public function process(array $instance): array { $result = ($this->closure)($instance); diff --git a/src/EntityProcessors/PassThroughEntityProcessor.php b/src/EntityProcessors/PassThroughEntityProcessor.php index 54569f3..d1aef84 100644 --- a/src/EntityProcessors/PassThroughEntityProcessor.php +++ b/src/EntityProcessors/PassThroughEntityProcessor.php @@ -9,6 +9,7 @@ */ class PassThroughEntityProcessor implements EntityProcessorInterface { + #[\Override] public function process(array $instance): array { return $instance; diff --git a/src/MapperFunctions/ClosureMapper.php b/src/MapperFunctions/ClosureMapper.php index fb63542..0e8cd0d 100644 --- a/src/MapperFunctions/ClosureMapper.php +++ b/src/MapperFunctions/ClosureMapper.php @@ -23,16 +23,17 @@ public function __construct(Closure $closure) /** * @throws ReflectionException */ - public function processedValue(mixed $value, mixed $instance, mixed $helper = null): mixed + #[\Override] + public function processedValue(mixed $value, mixed $instance, mixed $executor = null): mixed { $reflection = new ReflectionFunction($this->closure); $paramCount = $reflection->getNumberOfParameters(); - // Call closure with appropriate number of parameters + // Call closure with the appropriate number of parameters return match($paramCount) { 1 => ($this->closure)($value), 2 => ($this->closure)($value, $instance), - default => ($this->closure)($value, $instance, $helper) + default => ($this->closure)($value, $instance, $executor) }; } } diff --git a/src/MapperFunctions/PasswordSha1Mapper.php b/src/MapperFunctions/PasswordSha1Mapper.php index 30ae0e0..97c2bac 100644 --- a/src/MapperFunctions/PasswordSha1Mapper.php +++ b/src/MapperFunctions/PasswordSha1Mapper.php @@ -10,6 +10,7 @@ */ class PasswordSha1Mapper implements MapperFunctionInterface { + #[\Override] public function processedValue(mixed $value, mixed $instance, ?DatabaseExecutor $executor = null): mixed { // Already have a SHA1 password (40 characters) diff --git a/src/MapperFunctions/UserIdGeneratorMapper.php b/src/MapperFunctions/UserIdGeneratorMapper.php index d28d1e6..40ec5a0 100644 --- a/src/MapperFunctions/UserIdGeneratorMapper.php +++ b/src/MapperFunctions/UserIdGeneratorMapper.php @@ -11,6 +11,7 @@ */ class UserIdGeneratorMapper implements MapperFunctionInterface { + #[\Override] public function processedValue(mixed $value, mixed $instance, ?DatabaseExecutor $executor = null): mixed { // If value is already set, use it diff --git a/tests/PasswordDefinitionTest.php b/tests/PasswordDefinitionTest.php index 106f44d..8e8af3e 100644 --- a/tests/PasswordDefinitionTest.php +++ b/tests/PasswordDefinitionTest.php @@ -18,41 +18,41 @@ class PasswordDefinitionTest extends TestCase PasswordDefinition::ALLOW_REPEATED => 0 // Allow repeated characters ]; - public function test__construct() + public function test__construct(): void { // Create Empty Password Definition $passwordDefinition = new PasswordDefinition(); $this->assertEquals($this->defaultRules, $passwordDefinition->getRules()); } - public function testSetRule() + public function testSetRule(): void { $passwordDefinition = new PasswordDefinition(); $passwordDefinition->setRule(PasswordDefinition::MINIMUM_CHARS, 10); $this->assertEquals(10, $passwordDefinition->getRule(PasswordDefinition::MINIMUM_CHARS)); } - public function testSetRuleInvalid() + public function testSetRuleInvalid(): void { $this->expectException(\InvalidArgumentException::class); $passwordDefinition = new PasswordDefinition(); $passwordDefinition->setRule('invalid', 10); } - public function testGetRule() + public function testGetRule(): void { $passwordDefinition = new PasswordDefinition(); $this->assertEquals(8, $passwordDefinition->getRule(PasswordDefinition::MINIMUM_CHARS)); } - public function testGetRuleInvalid() + public function testGetRuleInvalid(): void { $this->expectException(\InvalidArgumentException::class); $passwordDefinition = new PasswordDefinition(); $passwordDefinition->getRule('invalid'); } - public function testMatchPasswordMinimumChars() + public function testMatchPasswordMinimumChars(): void { $passwordDefinition = new PasswordDefinition([ PasswordDefinition::MINIMUM_CHARS => 8, @@ -68,7 +68,7 @@ public function testMatchPasswordMinimumChars() $this->assertEquals(PasswordDefinition::SUCCESS, $passwordDefinition->matchPassword('12345678')); } - public function testMatchPasswordUppercase() + public function testMatchPasswordUppercase(): void { $passwordDefinition = new PasswordDefinition([ PasswordDefinition::MINIMUM_CHARS => 8, @@ -85,7 +85,7 @@ public function testMatchPasswordUppercase() $this->assertEquals(PasswordDefinition::SUCCESS, $passwordDefinition->matchPassword('1234567BA')); } - public function testMatchPasswordLowercase() + public function testMatchPasswordLowercase(): void { $passwordDefinition = new PasswordDefinition([ PasswordDefinition::MINIMUM_CHARS => 8, @@ -102,7 +102,7 @@ public function testMatchPasswordLowercase() $this->assertEquals(PasswordDefinition::SUCCESS, $passwordDefinition->matchPassword('1234567ba')); } - public function testMatchPasswordSymbols() + public function testMatchPasswordSymbols(): void { $passwordDefinition = new PasswordDefinition([ PasswordDefinition::MINIMUM_CHARS => 8, @@ -119,7 +119,7 @@ public function testMatchPasswordSymbols() $this->assertEquals(PasswordDefinition::SUCCESS, $passwordDefinition->matchPassword('1234567!!')); } - public function testMatchPasswordNumbers() + public function testMatchPasswordNumbers(): void { $passwordDefinition = new PasswordDefinition([ PasswordDefinition::MINIMUM_CHARS => 8, @@ -136,7 +136,7 @@ public function testMatchPasswordNumbers() $this->assertEquals(PasswordDefinition::SUCCESS, $passwordDefinition->matchPassword('abcdef11')); } - public function testMatchPasswordWhitespace() + public function testMatchPasswordWhitespace(): void { $passwordDefinition = new PasswordDefinition([ PasswordDefinition::MINIMUM_CHARS => 8, @@ -151,7 +151,7 @@ public function testMatchPasswordWhitespace() $this->assertEquals(PasswordDefinition::FAIL_WHITESPACE, $passwordDefinition->matchPassword('1234 678')); } - public function testMatchPasswordSequential() + public function testMatchPasswordSequential(): void { $passwordDefinition = new PasswordDefinition([ PasswordDefinition::MINIMUM_CHARS => 8, @@ -170,7 +170,7 @@ public function testMatchPasswordSequential() $this->assertEquals(PasswordDefinition::SUCCESS, $passwordDefinition->matchPassword('diykdsn132')); } - public function testMatchCharsRepeated() + public function testMatchCharsRepeated(): void { $passwordDefinition = new PasswordDefinition([ PasswordDefinition::MINIMUM_CHARS => 8, @@ -189,7 +189,7 @@ public function testMatchCharsRepeated() $this->assertEquals(PasswordDefinition::SUCCESS, $passwordDefinition->matchPassword('hay1d11oihsc')); } - public function testGeneratePassword() + public function testGeneratePassword(): void { for ($i = 0; $i < 100; $i++) { $passwordDefinition = new PasswordDefinition([ @@ -213,7 +213,7 @@ public function testGeneratePassword() } } - public function testGeneratePassword2() + public function testGeneratePassword2(): void { for ($i = 0; $i < 100; $i++) { $passwordDefinition = new PasswordDefinition([ @@ -237,7 +237,7 @@ public function testGeneratePassword2() } } - public function testGeneratePasswordEmpty() + public function testGeneratePasswordEmpty(): void { for ($i = 0; $i < 100; $i++) { $passwordDefinition = new PasswordDefinition([ diff --git a/tests/PasswordMd5MapperTest.php b/tests/PasswordMd5MapperTest.php index ab35190..94b071f 100644 --- a/tests/PasswordMd5MapperTest.php +++ b/tests/PasswordMd5MapperTest.php @@ -16,7 +16,8 @@ */ class PasswordMd5Mapper implements MapperFunctionInterface { - public function processedValue(mixed $value, mixed $instance, mixed $helper = null): mixed + #[\Override] + public function processedValue(mixed $value, mixed $instance, mixed $executor = null): mixed { // Already have an MD5 hash (32 characters) if (is_string($value) && strlen($value) === 32 && ctype_xdigit($value)) { @@ -41,6 +42,7 @@ class PasswordMd5MapperTest extends TestCase protected $userDefinition; protected $propertyDefinition; + #[\Override] public function setUp(): void { $this->db = Factory::getDbInstance(self::CONNECTION_STRING); @@ -69,6 +71,7 @@ public function setUp(): void $this->propertyDefinition = new UserPropertiesDefinition(); } + #[\Override] public function tearDown(): void { $uri = new Uri(self::CONNECTION_STRING); @@ -80,7 +83,7 @@ public function tearDown(): void $this->propertyDefinition = null; } - public function testPasswordIsHashedWithMd5OnSave() + public function testPasswordIsHashedWithMd5OnSave(): void { $dataset = new UsersDBDataset($this->db, $this->userDefinition, $this->propertyDefinition); @@ -95,7 +98,7 @@ public function testPasswordIsHashedWithMd5OnSave() $this->assertTrue(ctype_xdigit($user->getPassword())); // MD5 is hexadecimal } - public function testPasswordIsNotRehashedIfAlreadyMd5() + public function testPasswordIsNotRehashedIfAlreadyMd5(): void { $dataset = new UsersDBDataset($this->db, $this->userDefinition, $this->propertyDefinition); @@ -111,7 +114,7 @@ public function testPasswordIsNotRehashedIfAlreadyMd5() $this->assertEquals($originalHash, $updatedUser->getPassword()); } - public function testPasswordIsHashedWhenUpdating() + public function testPasswordIsHashedWhenUpdating(): void { $dataset = new UsersDBDataset($this->db, $this->userDefinition, $this->propertyDefinition); @@ -140,7 +143,7 @@ public function testPasswordIsHashedWhenUpdating() $this->assertNull($authenticatedUserOld); } - public function testPasswordRemainsUnchangedWhenUpdatingOtherFields() + public function testPasswordRemainsUnchangedWhenUpdatingOtherFields(): void { $dataset = new UsersDBDataset($this->db, $this->userDefinition, $this->propertyDefinition); @@ -170,7 +173,7 @@ public function testPasswordRemainsUnchangedWhenUpdatingOtherFields() $this->assertEquals('John Updated', $authenticatedUser->getName()); } - public function testUserCanLoginWithMd5HashedPassword() + public function testUserCanLoginWithMd5HashedPassword(): void { $dataset = new UsersDBDataset($this->db, $this->userDefinition, $this->propertyDefinition); @@ -186,7 +189,7 @@ public function testUserCanLoginWithMd5HashedPassword() $this->assertEquals('testuser', $authenticatedUser->getUsername()); } - public function testUserCannotLoginWithWrongPassword() + public function testUserCannotLoginWithWrongPassword(): void { $dataset = new UsersDBDataset($this->db, $this->userDefinition, $this->propertyDefinition); @@ -199,7 +202,7 @@ public function testUserCannotLoginWithWrongPassword() $this->assertNull($authenticatedUser); } - public function testEmptyPasswordReturnsNull() + public function testEmptyPasswordReturnsNull(): void { $mapper = new PasswordMd5Mapper(); @@ -207,7 +210,7 @@ public function testEmptyPasswordReturnsNull() $this->assertNull($mapper->processedValue(null, null)); } - public function testExistingMd5HashIsNotRehashed() + public function testExistingMd5HashIsNotRehashed(): void { $mapper = new PasswordMd5Mapper(); $existingHash = '5f4dcc3b5aa765d61d8327deb882cf99'; // MD5 of 'password' diff --git a/tests/SessionContextTest.php b/tests/SessionContextTest.php index f967ba3..f09f672 100644 --- a/tests/SessionContextTest.php +++ b/tests/SessionContextTest.php @@ -26,7 +26,7 @@ public function tearDown(): void $this->object = null; } - public function testUserContext() + public function testUserContext(): void { $this->assertFalse($this->object->isAuthenticated()); @@ -46,13 +46,13 @@ public function testUserContext() $this->assertFalse($this->object->isAuthenticated()); } - public function testUserContextNotActiveSession() + public function testUserContextNotActiveSession(): void { $this->expectException(\ByJG\Authenticate\Exception\NotAuthenticatedException::class); $this->assertEmpty($this->object->getSessionData('property1')); } - public function testUserContextNotActive2Session() + public function testUserContextNotActive2Session(): void { $this->expectException(\ByJG\Authenticate\Exception\NotAuthenticatedException::class); $this->object->setSessionData('property1', 'value'); diff --git a/tests/UserModelTest.php b/tests/UserModelTest.php index b0e1c81..8bea9f8 100644 --- a/tests/UserModelTest.php +++ b/tests/UserModelTest.php @@ -27,7 +27,7 @@ public function tearDown(): void $this->object = null; } - public function testUserModel() + public function testUserModel(): void { $this->object->setUserid("10"); $this->object->setName('John'); @@ -42,7 +42,7 @@ public function testUserModel() $this->assertEquals('johnuser', $this->object->getUsername()); } - public function testUserModelProperties() + public function testUserModelProperties(): void { $this->object->setUserid("10"); $this->object->setName('John'); @@ -66,7 +66,7 @@ public function testUserModelProperties() ], $this->object->getProperties()); } - public function testPasswordDefinition() + public function testPasswordDefinition(): void { $this->object->withPasswordDefinition(new PasswordDefinition([ PasswordDefinition::MINIMUM_CHARS => 12, @@ -84,7 +84,7 @@ public function testPasswordDefinition() $this->assertEmpty($this->object->setPassword('!Ab18Uk*H2oU9NQ')); } - public function testPasswordDefinitionError() + public function testPasswordDefinitionError(): void { $this->expectException(InvalidArgumentException::class); diff --git a/tests/UsersAnyDatasetByUsernameTest.php b/tests/UsersAnyDatasetByUsernameTest.php index d7cb2b3..b2452d0 100644 --- a/tests/UsersAnyDatasetByUsernameTest.php +++ b/tests/UsersAnyDatasetByUsernameTest.php @@ -67,6 +67,9 @@ public function setUp(): void $this->__setUp(UserDefinition::LOGIN_IS_USERNAME); } + /** + * @return void + */ public function testAddUser() { $this->object->addUser('John Doe', 'john', 'johndoe@gmail.com', 'mypassword'); @@ -80,13 +83,13 @@ public function testAddUser() $this->assertEquals('91dfd9ddb4198affc5c194cd8ce6d338fde470e2', $user->getPassword()); } - public function testAddUserError() + public function testAddUserError(): void { $this->expectException(UserExistsException::class); $this->object->addUser('some user with same username', 'user2', 'user2@gmail.com', 'mypassword'); } - public function testAddProperty() + public function testAddProperty(): void { // Check state $user = $this->object->getById($this->prefix . '2'); @@ -121,7 +124,7 @@ public function testAddProperty() } - public function testRemoveAllProperties() + public function testRemoveAllProperties(): void { // Add the properties $this->object->addProperty($this->prefix . '2', 'city', 'Rio de Janeiro'); @@ -158,7 +161,7 @@ public function testRemoveAllProperties() } - public function testRemoveByLoginField() + public function testRemoveByLoginField(): void { $login = $this->__chooseValue('user1', 'user1@gmail.com'); @@ -172,7 +175,7 @@ public function testRemoveByLoginField() $this->assertNull($user); } - public function testEditUser() + public function testEditUser(): void { $login = $this->__chooseValue('user1', 'user1@gmail.com'); @@ -189,7 +192,7 @@ public function testEditUser() $this->assertEquals('Other name', $user->getName()); } - public function testIsValidUser() + public function testIsValidUser(): void { $login = $this->__chooseValue('user3', 'user3@gmail.com'); $loginFalse = $this->__chooseValue('user3@gmail.com', 'user3'); @@ -203,7 +206,7 @@ public function testIsValidUser() $this->assertNull($user); } - public function testIsAdmin() + public function testIsAdmin(): void { // Check is Admin $this->assertFalse($this->object->isAdmin($this->prefix . '3')); @@ -218,7 +221,7 @@ public function testIsAdmin() $this->assertTrue($this->object->isAdmin($this->prefix . '3')); } - protected function expectedToken($tokenData, $login, $userId) + protected function expectedToken($tokenData, $login, $userId): void { $loginCreated = $this->__chooseValue('user2', 'user2@gmail.com'); @@ -249,6 +252,9 @@ protected function expectedToken($tokenData, $login, $userId) ); } + /** + * @return void + */ public function testCreateAuthToken() { $login = $this->__chooseValue('user2', 'user2@gmail.com'); @@ -256,7 +262,7 @@ public function testCreateAuthToken() $this->expectedToken('tokenValue', $login, 'user2'); } - public function testValidateTokenWithAnotherUser() + public function testValidateTokenWithAnotherUser(): void { $this->expectException(NotAuthenticatedException::class); $login = $this->__chooseValue('user2', 'user2@gmail.com'); @@ -275,6 +281,9 @@ public function testValidateTokenWithAnotherUser() $this->object->isValidToken($loginToFail, $jwtWrapper, $token); } + /** + * @return void + */ public function testSaveAndSave() { $user = $this->object->getById('user1'); @@ -285,7 +294,7 @@ public function testSaveAndSave() $this->assertEquals($user, $user2); } - public function testRemoveUserById() + public function testRemoveUserById(): void { $user = $this->object->getById($this->prefix . '1'); $this->assertNotNull($user); @@ -296,7 +305,7 @@ public function testRemoveUserById() $this->assertNull($user2); } - public function testGetByUsername() + public function testGetByUsername(): void { $user = $this->object->getByUsername('user2'); @@ -307,7 +316,7 @@ public function testGetByUsername() $this->assertEquals('c88b5c841897dafe75cdd9f8ba98b32f007d6bc3', $user->getPassword()); } - public function testGetByUserProperty() + public function testGetByUserProperty(): void { // Add property to user1 $user = $this->object->getById($this->prefix . '1'); @@ -340,7 +349,7 @@ public function testGetByUserProperty() } - public function testSetProperty() + public function testSetProperty(): void { $this->assertFalse($this->object->hasProperty($this->prefix . '1', 'propertySet')); $this->object->setProperty($this->prefix . '1', 'propertySet', 'somevalue'); diff --git a/tests/UsersDBDatasetByUsernameTest.php b/tests/UsersDBDatasetByUsernameTest.php index 5b575a1..ac2bebe 100644 --- a/tests/UsersDBDatasetByUsernameTest.php +++ b/tests/UsersDBDatasetByUsernameTest.php @@ -77,6 +77,9 @@ public function tearDown(): void $this->propertyDefinition = null; } + /** + * @return void + */ #[\Override] public function testAddUser() { @@ -101,6 +104,9 @@ public function testAddUser() $this->assertEquals('y', $user2->getAdmin()); } + /** + * @return void + */ #[\Override] public function testCreateAuthToken() { @@ -108,6 +114,9 @@ public function testCreateAuthToken() $this->expectedToken('tokenValue', $login, 2); } + /** + * @return void + */ public function testWithUpdateValue() { // For Update Definitions @@ -159,6 +168,9 @@ public function testWithUpdateValue() $this->assertEquals('2017-12-04 00:00:00', $user->getCreated()); } + /** + * @return void + */ #[\Override] public function testSaveAndSave() { diff --git a/tests/UsersDBDatasetDefinitionTest.php b/tests/UsersDBDatasetDefinitionTest.php index cdff5b8..5acba81 100644 --- a/tests/UsersDBDatasetDefinitionTest.php +++ b/tests/UsersDBDatasetDefinitionTest.php @@ -34,7 +34,7 @@ public function getOtherfield() return $this->otherfield; } - public function setOtherfield($otherfield) + public function setOtherfield($otherfield): void { $this->otherfield = $otherfield; } @@ -144,6 +144,8 @@ public function setUp(): void * @throws UserExistsException * @throws DatabaseException * @throws \ByJG\Serializer\Exception\InvalidArgumentException + * + * @return void */ #[Override] public function testAddUser() @@ -173,6 +175,8 @@ public function testAddUser() /** * @throws Exception + * + * @return void */ #[Override] public function testWithUpdateValue() @@ -239,7 +243,7 @@ public function testWithUpdateValue() /** * @throws Exception */ - public function testDefineGenerateKeyWithInterface() + public function testDefineGenerateKeyWithInterface(): void { // Create a separate table with varchar userid for testing custom generators $this->db->execute('create table users_custom ( @@ -264,7 +268,7 @@ public function testDefineGenerateKeyWithInterface() $user = $dataset->addUser('Test User', 'testuser', 'test@example.com', 'password123'); // Verify the user ID was generated with the custom prefix - $this->assertStringStartsWith('CUSTOM-', $user->getUserid()); + $this->assertStringStartsWith('CUSTOM-', (string)$user->getUserid()); $this->assertEquals('Test User', $user->getName()); $this->assertEquals('testuser', $user->getUsername()); @@ -275,7 +279,7 @@ public function testDefineGenerateKeyWithInterface() /** * @throws Exception */ - public function testDefineGenerateKeyWithString() + public function testDefineGenerateKeyWithString(): void { // Create a separate table with varchar userid for testing custom generators $this->db->execute('create table users_custom2 ( @@ -299,14 +303,14 @@ public function testDefineGenerateKeyWithString() $user = $dataset->addUser('Test User 2', 'testuser2', 'test2@example.com', 'password123'); // Verify the user ID was generated with the default TEST- prefix - $this->assertStringStartsWith('TEST-', $user->getUserid()); + $this->assertStringStartsWith('TEST-', (string)$user->getUserid()); $this->assertEquals('Test User 2', $user->getName()); // Cleanup $this->db->execute('drop table users_custom2'); } - public function testDefineGenerateKeyClosureThrowsException() + public function testDefineGenerateKeyClosureThrowsException(): void { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('defineGenerateKeyClosure is deprecated. Use defineGenerateKey with MapperFunctionsInterface instead.'); From 4d2daf29eac8bb5086b095c5463d851f88d844c3 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Thu, 6 Nov 2025 17:34:30 -0500 Subject: [PATCH 14/17] Fix PSalm --- tests/Fixture/MyUserModel.php | 26 ++++++++++++++++ tests/Fixture/PasswordMd5Mapper.php | 28 +++++++++++++++++ tests/Fixture/TestUniqueIdGenerator.php | 23 ++++++++++++++ tests/PasswordMd5MapperTest.php | 25 +-------------- tests/UsersDBDatasetDefinitionTest.php | 41 ++----------------------- 5 files changed, 80 insertions(+), 63 deletions(-) create mode 100644 tests/Fixture/MyUserModel.php create mode 100644 tests/Fixture/PasswordMd5Mapper.php create mode 100644 tests/Fixture/TestUniqueIdGenerator.php diff --git a/tests/Fixture/MyUserModel.php b/tests/Fixture/MyUserModel.php new file mode 100644 index 0000000..3142fe7 --- /dev/null +++ b/tests/Fixture/MyUserModel.php @@ -0,0 +1,26 @@ +setOtherfield($field); + } + + public function getOtherfield() + { + return $this->otherfield; + } + + public function setOtherfield($otherfield): void + { + $this->otherfield = $otherfield; + } +} diff --git a/tests/Fixture/PasswordMd5Mapper.php b/tests/Fixture/PasswordMd5Mapper.php new file mode 100644 index 0000000..96cbb1f --- /dev/null +++ b/tests/Fixture/PasswordMd5Mapper.php @@ -0,0 +1,28 @@ +prefix = $prefix; + } + + #[Override] + public function processedValue(mixed $value, mixed $instance, ?DatabaseExecutor $executor = null): mixed + { + return $this->prefix . uniqid(); + } +} diff --git a/tests/PasswordMd5MapperTest.php b/tests/PasswordMd5MapperTest.php index 94b071f..a39b580 100644 --- a/tests/PasswordMd5MapperTest.php +++ b/tests/PasswordMd5MapperTest.php @@ -7,32 +7,9 @@ use ByJG\Authenticate\Definition\UserPropertiesDefinition; use ByJG\Authenticate\Model\UserModel; use ByJG\Authenticate\UsersDBDataset; -use ByJG\MicroOrm\Interface\MapperFunctionInterface; use ByJG\Util\Uri; use PHPUnit\Framework\TestCase; - -/** - * Custom MD5 Password Mapper for testing - */ -class PasswordMd5Mapper implements MapperFunctionInterface -{ - #[\Override] - public function processedValue(mixed $value, mixed $instance, mixed $executor = null): mixed - { - // Already have an MD5 hash (32 characters) - if (is_string($value) && strlen($value) === 32 && ctype_xdigit($value)) { - return $value; - } - - // Leave null - if (empty($value)) { - return null; - } - - // Return the MD5 hash - return strtolower(md5($value)); - } -} +use Tests\Fixture\PasswordMd5Mapper; class PasswordMd5MapperTest extends TestCase { diff --git a/tests/UsersDBDatasetDefinitionTest.php b/tests/UsersDBDatasetDefinitionTest.php index 5acba81..91240fa 100644 --- a/tests/UsersDBDatasetDefinitionTest.php +++ b/tests/UsersDBDatasetDefinitionTest.php @@ -3,7 +3,6 @@ namespace Tests; use ByJG\AnyDataset\Core\Exception\DatabaseException; -use ByJG\AnyDataset\Db\DatabaseExecutor; use ByJG\AnyDataset\Db\Factory; use ByJG\Authenticate\Definition\UserDefinition; use ByJG\Authenticate\Definition\UserPropertiesDefinition; @@ -14,47 +13,11 @@ use ByJG\MicroOrm\Exception\OrmBeforeInvalidException; use ByJG\MicroOrm\Exception\OrmInvalidFieldsException; use ByJG\MicroOrm\Exception\OrmModelInvalidException; -use ByJG\MicroOrm\Interface\MapperFunctionInterface; use Exception; use Override; use ReflectionException; - -class MyUserModel extends UserModel -{ - protected $otherfield; - - public function __construct($name = "", $email = "", $username = "", $password = "", $admin = "no", $field = "") - { - parent::__construct($name, $email, $username, $password, $admin); - $this->setOtherfield($field); - } - - public function getOtherfield() - { - return $this->otherfield; - } - - public function setOtherfield($otherfield): void - { - $this->otherfield = $otherfield; - } -} - -class TestUniqueIdGenerator implements MapperFunctionInterface -{ - private string $prefix; - - public function __construct(string $prefix = 'TEST-') - { - $this->prefix = $prefix; - } - - #[Override] - public function processedValue(mixed $value, mixed $instance, ?DatabaseExecutor $executor = null): mixed - { - return $this->prefix . uniqid(); - } -} +use Tests\Fixture\MyUserModel; +use Tests\Fixture\TestUniqueIdGenerator; class UsersDBDatasetDefinitionTest extends UsersDBDatasetByUsernameTest { From 53684801e9eb96871f6d3bde91e5e75cf2aad479 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Thu, 6 Nov 2025 17:39:17 -0500 Subject: [PATCH 15/17] Fix PSalm --- .github/workflows/phpunit.yml | 1 - composer.json | 2 +- docs/installation.md | 2 +- tests/UserModelTest.php | 12 +++++++++--- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index da3e01b..300e54f 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -19,7 +19,6 @@ jobs: - "8.4" - "8.3" - "8.2" - - "8.1" steps: - uses: actions/checkout@v4 diff --git a/composer.json b/composer.json index 5a7ac7c..3f5a5d7 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,7 @@ "minimum-stability": "dev", "prefer-stable": true, "require": { - "php": ">=8.1 <8.5", + "php": ">=8.2 <8.5", "byjg/micro-orm": "^6.0", "byjg/cache-engine": "^6.0", "byjg/jwt-wrapper": "^6.0" diff --git a/docs/installation.md b/docs/installation.md index 4b46c5c..6b3c7f9 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -7,7 +7,7 @@ title: Installation ## Requirements -- PHP 8.1 or higher +- PHP 8.2 or higher - Composer ## Install via Composer diff --git a/tests/UserModelTest.php b/tests/UserModelTest.php index 8bea9f8..c5b0be9 100644 --- a/tests/UserModelTest.php +++ b/tests/UserModelTest.php @@ -79,9 +79,15 @@ public function testPasswordDefinition(): void PasswordDefinition::ALLOW_REPEATED => 0 // Allow repeated characters ])); - $this->assertEmpty($this->object->setPassword(null)); - $this->assertEmpty($this->object->setPassword('')); - $this->assertEmpty($this->object->setPassword('!Ab18Uk*H2oU9NQ')); + // These passwords should be accepted (null, empty, and valid password) + $this->object->setPassword(null); + $this->assertNull($this->object->getPassword()); + + $this->object->setPassword(''); + $this->assertEmpty($this->object->getPassword()); + + $this->object->setPassword('!Ab18Uk*H2oU9NQ'); + $this->assertEquals('!Ab18Uk*H2oU9NQ', $this->object->getPassword()); } public function testPasswordDefinitionError(): void From 052bb1814ebcf2dfff61e9391fbe1f4b393df05b Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Tue, 11 Nov 2025 19:01:07 -0500 Subject: [PATCH 16/17] Refactor `isAdmin` logic: move method to `UserModel`, update usages across codebase, remove redundant interfaces and exceptions, and adjust related tests and documentation. --- docs/examples.md | 4 ++-- docs/user-management.md | 3 ++- src/Interfaces/UsersInterface.php | 7 ------ src/Model/UserModel.php | 7 ++++++ src/UsersBase.php | 31 ++++++------------------- tests/UsersAnyDatasetByUsernameTest.php | 6 +++-- 6 files changed, 22 insertions(+), 36 deletions(-) diff --git a/docs/examples.md b/docs/examples.md index deba131..184c36e 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -217,7 +217,7 @@ $loginTime = $sessionContext->getSessionData('login_time');

Email: getEmail()) ?>

Logged in at:

- isAdmin($userId)): ?> + isAdmin()): ?>

You are an administrator

Admin Panel

@@ -364,7 +364,7 @@ try { 'name' => $user->getName(), 'email' => $user->getEmail(), 'username' => $user->getUsername(), - 'admin' => $users->isAdmin($user->getUserid()) + 'admin' => $user->isAdmin() ]); } elseif ($_SERVER['REQUEST_METHOD'] === 'PUT') { // Update user info diff --git a/docs/user-management.md b/docs/user-management.md index 3ed82a4..5a4456c 100644 --- a/docs/user-management.md +++ b/docs/user-management.md @@ -122,7 +122,8 @@ $users->removeByLoginField('johndoe'); ```php isAdmin($userId)) { +/** @var $user \ByJG\Authenticate\Model\UserModel */ +if ($user->isAdmin()) { echo "User is an administrator"; } ``` diff --git a/src/Interfaces/UsersInterface.php b/src/Interfaces/UsersInterface.php index 82f12cc..aa52622 100644 --- a/src/Interfaces/UsersInterface.php +++ b/src/Interfaces/UsersInterface.php @@ -91,13 +91,6 @@ public function removeByLoginField(string $login): bool; */ public function isValidUser(string $userName, string $password): UserModel|null; - /** - * - * @param string|int|HexUuidLiteral $userId - * @return bool - */ - public function isAdmin(string|HexUuidLiteral|int $userId): bool; - /** * @desc Check if the user have rights to edit specific site. * @param string|int|HexUuidLiteral|null $userId diff --git a/src/Model/UserModel.php b/src/Model/UserModel.php index 82cf36d..4209cff 100644 --- a/src/Model/UserModel.php +++ b/src/Model/UserModel.php @@ -224,4 +224,11 @@ public function withPasswordDefinition(PasswordDefinition $passwordDefinition): $this->passwordDefinition = $passwordDefinition; return $this; } + + public function isAdmin(): bool + { + return + preg_match('/^(yes|YES|[yY]|true|TRUE|[tT]|1|[sS])$/', $this->getAdmin()) === 1 + ; + } } diff --git a/src/UsersBase.php b/src/UsersBase.php index 80cfe2c..5e08667 100644 --- a/src/UsersBase.php +++ b/src/UsersBase.php @@ -232,13 +232,17 @@ public function isValidUser(string $userName, string $password): UserModel|null public function hasProperty(string|int|HexUuidLiteral|null $userId, string $propertyName, string|null $value = null): bool { //anydataset.Row - $user = $this->getById($userId); + $userIdMapper = $this->getUserDefinition()->getMapperForUpdate(UserDefinition::FIELD_USERID); + if (is_string($userIdMapper)) { + $userIdMapper = new $userIdMapper(); + } + $user = $this->getById($userIdMapper->processedValue($userId, null)); if (empty($user)) { return false; } - if ($this->isAdmin($userId)) { + if ($user->isAdmin()) { return true; } @@ -262,7 +266,6 @@ public function hasProperty(string|int|HexUuidLiteral|null $userId, string $prop * @param string|int|HexUuidLiteral $userId User ID * @param string $propertyName Property name * @return array|string|UserPropertiesModel|null - * @throws UserNotFoundException * @throws InvalidArgumentException */ #[Override] @@ -272,7 +275,7 @@ public function getProperty(string|HexUuidLiteral|int $userId, string $propertyN if ($user !== null) { $values = $user->get($propertyName); - if ($this->isAdmin($userId)) { + if ($user->isAdmin()) { return array(UserDefinition::FIELD_ADMIN => "admin"); } @@ -318,26 +321,6 @@ abstract public function removeProperty(string|HexUuidLiteral|int $userId, strin #[Override] abstract public function removeAllProperties(string $propertyName, string|null $value = null): void; - /** - * @param string|int|HexUuidLiteral $userId - * @return bool - * @throws UserNotFoundException - * @throws InvalidArgumentException - */ - #[Override] - public function isAdmin(string|HexUuidLiteral|int $userId): bool - { - $user = $this->getById($userId); - - if (is_null($user)) { - throw new UserNotFoundException("Cannot find the user"); - } - - return - preg_match('/^(yes|YES|[yY]|true|TRUE|[tT]|1|[sS])$/', $user->getAdmin()) === 1 - ; - } - /** * Authenticate a user and create a token if it is valid * diff --git a/tests/UsersAnyDatasetByUsernameTest.php b/tests/UsersAnyDatasetByUsernameTest.php index b2452d0..8e336d2 100644 --- a/tests/UsersAnyDatasetByUsernameTest.php +++ b/tests/UsersAnyDatasetByUsernameTest.php @@ -209,7 +209,8 @@ public function testIsValidUser(): void public function testIsAdmin(): void { // Check is Admin - $this->assertFalse($this->object->isAdmin($this->prefix . '3')); + $user3 = $this->object->getById($this->prefix . '3'); + $this->assertFalse($user3->isAdmin()); // Set the Admin Flag $login = $this->__chooseValue('user3', 'user3@gmail.com'); @@ -218,7 +219,8 @@ public function testIsAdmin(): void $this->object->save($user); // Check is Admin - $this->assertTrue($this->object->isAdmin($this->prefix . '3')); + $user3 = $this->object->getById($this->prefix . '3'); + $this->assertTrue($user3->isAdmin()); } protected function expectedToken($tokenData, $login, $userId): void From 6df1a9fa9283b5c2e54be74f20081d8567a4a6f6 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Tue, 11 Nov 2025 19:56:09 -0500 Subject: [PATCH 17/17] Refactor user retrieval methods: consolidate `getById`, `getByEmail`, `getByUsername`, and `getByLoginField` into a single `get` method, update all references, adjust tests, and update documentation accordingly. --- docs/authentication.md | 2 +- docs/custom-fields.md | 6 +- docs/database-storage.md | 2 +- docs/examples.md | 2 +- docs/jwt-tokens.md | 2 +- docs/password-validation.md | 2 +- docs/session-context.md | 2 +- docs/user-management.md | 30 ++++++++-- docs/user-properties.md | 4 +- example.php | 6 +- src/Interfaces/UsersInterface.php | 26 +-------- src/UsersAnyDataset.php | 6 +- src/UsersBase.php | 74 +++++++------------------ src/UsersDBDataset.php | 31 +++++++++-- tests/UsersAnyDatasetByUsernameTest.php | 59 ++++++++++---------- tests/UsersDBDatasetByUsernameTest.php | 12 ++-- tests/UsersDBDatasetDefinitionTest.php | 8 +-- 17 files changed, 132 insertions(+), 142 deletions(-) diff --git a/docs/authentication.md b/docs/authentication.md index 7ba0842..8a52ed4 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -133,7 +133,7 @@ $sessionContext = new SessionContext(Factory::createSessionPool()); if ($sessionContext->isAuthenticated()) { $userId = $sessionContext->userInfo(); - $user = $users->getById($userId); + $user = $users->get($userId); echo "Hello, " . $user->getName(); } else { echo "Please log in"; diff --git a/docs/custom-fields.md b/docs/custom-fields.md index 050c6e8..4312bb6 100644 --- a/docs/custom-fields.md +++ b/docs/custom-fields.md @@ -166,7 +166,7 @@ $users->save($user); ```php getById($userId); +$user = $users->get($userId); // Access custom fields echo $user->getName(); @@ -179,7 +179,7 @@ echo $user->getTitle(); ```php getById($userId); +$user = $users->get($userId); $user->setDepartment('Sales'); $user->setTitle('Sales Manager'); $users->save($user); @@ -357,7 +357,7 @@ $user->setTitle('Marketing Director'); $savedUser = $users->save($user); // Retrieve and update -$user = $users->getById($savedUser->getUserid()); +$user = $users->get($savedUser->getUserid()); $user->setTitle('VP of Marketing'); $users->save($user); ``` diff --git a/docs/database-storage.md b/docs/database-storage.md index 8e9c872..4484591 100644 --- a/docs/database-storage.md +++ b/docs/database-storage.md @@ -148,7 +148,7 @@ $userDefinition = new UserDefinition( ``` :::tip Login Field -The login field affects methods like `isValidUser()` and `getByLoginField()`. They will use the configured field for authentication. +The login field affects methods like `isValidUser()`. They will use the configured field for authentication. ::: ## Complete Example diff --git a/docs/examples.md b/docs/examples.md index 184c36e..0d04a97 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -203,7 +203,7 @@ if (!$sessionContext->isAuthenticated()) { // Get current user $userId = $sessionContext->userInfo(); -$user = $users->getById($userId); +$user = $users->get($userId); $loginTime = $sessionContext->getSessionData('login_time'); ?> diff --git a/docs/jwt-tokens.md b/docs/jwt-tokens.md index 0993d28..02cdf2d 100644 --- a/docs/jwt-tokens.md +++ b/docs/jwt-tokens.md @@ -285,7 +285,7 @@ if (preg_match('/Bearer\s+(.*)$/i', $authHeader, $matches)) { $jwtData = $jwtWrapper->extractData($token); $username = $jwtData->data['login'] ?? null; - $user = $users->getByLoginField($username); + $user = $users->get($username, $users->getUserDefinition()->loginField()); if ($user !== null) { $users->removeProperty($user->getUserid(), 'TOKEN_HASH'); } diff --git a/docs/password-validation.md b/docs/password-validation.md index 8df36b7..1306ac4 100644 --- a/docs/password-validation.md +++ b/docs/password-validation.md @@ -253,7 +253,7 @@ Repeated patterns include: getById($userId); + $user = $users->get($userId); $user->withPasswordDefinition($passwordDefinition); // Verify old password diff --git a/docs/session-context.md b/docs/session-context.md index d0f9290..d361105 100644 --- a/docs/session-context.md +++ b/docs/session-context.md @@ -169,7 +169,7 @@ if (!$sessionContext->isAuthenticated()) { } $userId = $sessionContext->userInfo(); -$user = $users->getById($userId); +$user = $users->get($userId); $loginTime = $sessionContext->getSessionData('login_time'); echo "Welcome, " . $user->getName(); diff --git a/docs/user-management.md b/docs/user-management.md index 5a4456c..cd749c6 100644 --- a/docs/user-management.md +++ b/docs/user-management.md @@ -41,34 +41,48 @@ $savedUser = $users->save($userModel); ## Retrieving Users +To retrieve users you can use the `get($value, ?string $field = null)` method. +When the `$field` argument is omitted it defaults to the primary key +defined in your `UserDefinition`. Passing any other column automatically builds the right filter and throws an +`\InvalidArgumentException` if the field is not one of the allowed values (`userid`, `username`, or `email`). + +```php +get('john@example.com', $users->getUserDefinition()->getEmail()); +``` + +The following examples show the common calls: + ### Get User by ID ```php getById($userId); +$user = $users->get($userId); +# OR +$user = $users->get($userId, $users->getUserDefinition()->getUserid()); ``` ### Get User by Email ```php getByEmail('john@example.com'); +$user = $users->get('john@example.com', $users->getUserDefinition()->getEmail()); ``` ### Get User by Username ```php getByUsername('johndoe'); +$user = $users->get('johndoe', $users->getUserDefinition()->getUsername()); ``` ### Get User by Login Field -The login field is determined by the `UserDefinition` (either email or username): +The login field is determined by the `UserDefinition::loginField()` (either email or username): ```php getByLoginField('johndoe'); +$user = $users->get('johndoe', $users->getUserDefinition()->loginField()); ``` ### Using Custom Filters @@ -92,7 +106,7 @@ $user = $users->getUser($filter); ```php getById($userId); +$user = $users->get($userId); // Update fields $user->setName('Jane Doe'); @@ -120,6 +134,10 @@ $users->removeByLoginField('johndoe'); ## Checking Admin Status +The admin flag is now interpreted entirely inside `UserModel`. Use `$user->isAdmin()` to read the computed boolean value, +and `$user->setAdmin(true)` (or one of the accepted string values) to change it. This replaces the old `$users->isAdmin()` +method that lived in `UsersInterface`. + ```php getById($userId); +$user = $users->get($userId); // Set a property value $user->set('phone', '555-1234'); @@ -88,7 +88,7 @@ Returns `null` if the property doesn't exist. ```php getById($userId); +$user = $users->get($userId); // Get property value(s) $phone = $user->get('phone'); diff --git a/example.php b/example.php index 49c2da6..a33cf2f 100644 --- a/example.php +++ b/example.php @@ -2,9 +2,9 @@ require "vendor/autoload.php"; -use ByJG\Authenticate\UsersAnyDataset; -use ByJG\Authenticate\SessionContext; use ByJG\AnyDataset\Core\AnyDataset; +use ByJG\Authenticate\SessionContext; +use ByJG\Authenticate\UsersAnyDataset; use ByJG\Cache\Factory; // Create or load AnyDataset from XML file @@ -35,7 +35,7 @@ $session->setSessionData('login_time', time()); // Get the user info - $currentUser = $users->getById($session->userInfo()); + $currentUser = $users->get($session->userInfo()); echo "Welcome, " . $currentUser->getName() . "\n"; } diff --git a/src/Interfaces/UsersInterface.php b/src/Interfaces/UsersInterface.php index aa52622..2facd33 100644 --- a/src/Interfaces/UsersInterface.php +++ b/src/Interfaces/UsersInterface.php @@ -50,31 +50,11 @@ public function getUser(IteratorFilter $filter): UserModel|null; /** * Enter description here... * - * @param string|HexUuidLiteral|int $userid + * @param string|HexUuidLiteral|int $value + * @param string|null $field * @return UserModel|null */ - public function getById(string|HexUuidLiteral|int $userid): UserModel|null; - - /** - * @desc Get the user based on his email. - * @param string $email Email to find - * @return UserModel|null if user was found; null, otherwise - */ - public function getByEmail(string $email): UserModel|null; - - /** - * @desc Get the user based on his username. - * @param string $username - * @return UserModel|null if user was found; null, otherwise - */ - public function getByUsername(string $username): UserModel|null; - - /** - * @desc Get the user based on his login - * @param string $login - * @return UserModel|null if user was found; null, otherwise - */ - public function getByLoginField(string $login): UserModel|null; + public function get(string|HexUuidLiteral|int $value, ?string $field = null): UserModel|null; /** * @desc Remove the user based on his login. diff --git a/src/UsersAnyDataset.php b/src/UsersAnyDataset.php index e184362..3f7b489 100644 --- a/src/UsersAnyDataset.php +++ b/src/UsersAnyDataset.php @@ -211,7 +211,7 @@ public function getUsersByPropertySet(array $propertiesArray): array public function addProperty(string|HexUuidLiteral|int $userId, string $propertyName, string|null $value): bool { //anydataset.Row - $user = $this->getById($userId); + $user = $this->get($userId); if ($user !== null) { if (!$this->hasProperty($user->getUserid(), $propertyName, $value)) { $user->addProperty(new UserPropertiesModel($propertyName, $value)); @@ -232,7 +232,7 @@ public function addProperty(string|HexUuidLiteral|int $userId, string $propertyN #[Override] public function setProperty(string|HexUuidLiteral|int $userId, string $propertyName, string|null $value): bool { - $user = $this->getById($userId); + $user = $this->get($userId); if ($user !== null) { $user->set($propertyName, $value); $this->save($user); @@ -254,7 +254,7 @@ public function setProperty(string|HexUuidLiteral|int $userId, string $propertyN #[Override] public function removeProperty(string|HexUuidLiteral|int $userId, string $propertyName, string|null $value = null): bool { - $user = $this->getById($userId); + $user = $this->get($userId); if (!empty($user)) { $properties = $user->getProperties(); foreach ($properties as $key => $property) { diff --git a/src/UsersBase.php b/src/UsersBase.php index 5e08667..706f068 100644 --- a/src/UsersBase.php +++ b/src/UsersBase.php @@ -96,7 +96,7 @@ public function addUser(string $name, string $userName, string $email, string $p #[Override] public function canAddUser(UserModel $model): bool { - if ($this->getByEmail($model->getEmail()) !== null) { + if (!empty($model->getUserid()) && $this->get($model->getUserid()) !== null) { throw new UserExistsException('Email already exists'); } $filter = new IteratorFilter(); @@ -119,66 +119,32 @@ public function canAddUser(UserModel $model): bool abstract public function getUser(IteratorFilter $filter): UserModel|null; /** - * Get the user based on his email. - * Return Row if user was found; null, otherwise - * - * @param string $email - * @return UserModel|null - * @throws InvalidArgumentException - */ - #[Override] - public function getByEmail(string $email): UserModel|null - { - $filter = new IteratorFilter(); - $filter->and($this->getUserDefinition()->getEmail(), Relation::EQUAL, strtolower($email)); - return $this->getUser($filter); - } - - /** - * Get the user based on his username. + * Get the user based on his id. * Return Row if user was found; null, otherwise * - * @param string $username + * @param string|HexUuidLiteral|int $value + * @param string|null $field * @return UserModel|null * @throws InvalidArgumentException */ #[Override] - public function getByUsername(string $username): UserModel|null + public function get(string|HexUuidLiteral|int $value, ?string $field = null): UserModel|null { - $filter = new IteratorFilter(); - $filter->and($this->getUserDefinition()->getUsername(), Relation::EQUAL, $username); - return $this->getUser($filter); - } - - /** - * Get the user based on his login. - * Return Row if user was found; null, otherwise - * - * @param string $login - * @return UserModel|null - */ - #[Override] - public function getByLoginField(string $login): UserModel|null - { - $filter = new IteratorFilter(); - $filter->and($this->getUserDefinition()->loginField(), Relation::EQUAL, strtolower($login)); + if (empty($field)) { + $field = $this->getUserDefinition()->getUserid(); + } - return $this->getUser($filter); - } + $validFields = [ + $this->getUserDefinition()->getUserid(), + $this->getUserDefinition()->getUsername(), + $this->getUserDefinition()->getEmail() + ]; - /** - * Get the user based on his id. - * Return Row if user was found; null, otherwise - * - * @param string|HexUuidLiteral|int $userid - * @return UserModel|null - * @throws InvalidArgumentException - */ - #[Override] - public function getById(string|HexUuidLiteral|int $userid): UserModel|null - { + if (!in_array($field, $validFields)) { + throw new \InvalidArgumentException("Invalid field type provided. Should be one of " . implode(", ", $validFields)); + } $filter = new IteratorFilter(); - $filter->and($this->getUserDefinition()->getUserid(), Relation::EQUAL, $userid); + $filter->and($field, Relation::EQUAL, $value); return $this->getUser($filter); } @@ -236,7 +202,7 @@ public function hasProperty(string|int|HexUuidLiteral|null $userId, string $prop if (is_string($userIdMapper)) { $userIdMapper = new $userIdMapper(); } - $user = $this->getById($userIdMapper->processedValue($userId, null)); + $user = $this->get($userIdMapper->processedValue($userId, null)); if (empty($user)) { return false; @@ -271,7 +237,7 @@ public function hasProperty(string|int|HexUuidLiteral|null $userId, string $prop #[Override] public function getProperty(string|HexUuidLiteral|int $userId, string $propertyName): array|string|UserPropertiesModel|null { - $user = $this->getById($userId); + $user = $this->get($userId); if ($user !== null) { $values = $user->get($propertyName); @@ -382,7 +348,7 @@ public function createAuthToken( #[Override] public function isValidToken(string $login, JwtWrapper $jwtWrapper, string $token): array|null { - $user = $this->getByLoginField($login); + $user = $this->get($login, $this->getUserDefinition()->loginField()); if (is_null($user)) { throw new UserNotFoundException('User not found!'); diff --git a/src/UsersDBDataset.php b/src/UsersDBDataset.php index f3cce7b..4c04845 100644 --- a/src/UsersDBDataset.php +++ b/src/UsersDBDataset.php @@ -152,7 +152,7 @@ public function save(UserModel $model): UserModel } if ($newUser) { - $model = $this->getById($model->getUserid()); + $model = $this->get($model->getUserid()); } if ($model === null) { @@ -162,6 +162,29 @@ public function save(UserModel $model): UserModel return $model; } + #[\Override] + public function get(string|HexUuidLiteral|int $value, ?string $field = null): ?UserModel + { + if (empty($field)) { + $field = $this->getUserDefinition()->getUserid(); + } + + $function = match ($field) { + $this->getUserDefinition()->getEmail() => $this->getUserDefinition()->getMapperForUpdate(UserDefinition::FIELD_EMAIL), + $this->getUserDefinition()->getUsername() => $this->getUserDefinition()->getMapperForUpdate(UserDefinition::FIELD_USERNAME), + default => $this->getUserDefinition()->getMapperForUpdate(UserDefinition::FIELD_USERID), + }; + + if (!empty($function)) { + if (is_string($function)) { + $function = new $function(); + } + $value = $function->processedValue($value, null); + } + + return parent::get($value, $field); + } + /** * Get the users database information based on a filter. * @@ -228,7 +251,7 @@ public function getUser(IteratorFilter $filter): UserModel|null #[Override] public function removeByLoginField(string $login): bool { - $user = $this->getByLoginField($login); + $user = $this->get($login, $this->getUserDefinition()->loginField()); if ($user !== null) { return $this->removeUserById($user->getUserid()); @@ -327,7 +350,7 @@ public function getUsersByPropertySet(array $propertiesArray): array public function addProperty(string|HexUuidLiteral|int $userId, string $propertyName, string|null $value): bool { //anydataset.Row - $user = $this->getById($userId); + $user = $this->get($userId); if (empty($user)) { return false; } @@ -396,7 +419,7 @@ public function setProperty(string|HexUuidLiteral|int $userId, string $propertyN #[Override] public function removeProperty(string|HexUuidLiteral|int $userId, string $propertyName, string|null $value = null): bool { - $user = $this->getById($userId); + $user = $this->get($userId); if ($user !== null) { $updateable = DeleteQuery::getInstance() diff --git a/tests/UsersAnyDatasetByUsernameTest.php b/tests/UsersAnyDatasetByUsernameTest.php index 8e336d2..e258900 100644 --- a/tests/UsersAnyDatasetByUsernameTest.php +++ b/tests/UsersAnyDatasetByUsernameTest.php @@ -52,7 +52,7 @@ public function __setUp($loginField) $this->object->addUser('User 3', 'user3', 'user3@gmail.com', 'pwd3'); } - public function __chooseValue($forUsername, $forEmail) + public function __chooseValue($forUsername, $forEmail): string { $searchForList = [ $this->userDefinition->getUsername() => $forUsername, @@ -74,7 +74,10 @@ public function testAddUser() { $this->object->addUser('John Doe', 'john', 'johndoe@gmail.com', 'mypassword'); - $user = $this->object->getByLoginField($this->__chooseValue('john', 'johndoe@gmail.com')); + $user = $this->object->get( + $this->__chooseValue('john', 'johndoe@gmail.com'), + $this->object->getUserDefinition()->loginField() + ); $this->assertEquals('john', $user->getUserid()); $this->assertEquals('John Doe', $user->getName()); @@ -92,17 +95,17 @@ public function testAddUserError(): void public function testAddProperty(): void { // Check state - $user = $this->object->getById($this->prefix . '2'); + $user = $this->object->get($this->prefix . '2'); $this->assertEmpty($user->get('city')); // Add one property $this->object->addProperty($this->prefix . '2', 'city', 'Rio de Janeiro'); - $user = $this->object->getById($this->prefix . '2'); + $user = $this->object->get($this->prefix . '2'); $this->assertEquals('Rio de Janeiro', $user->get('city')); // Add another property (cannot change) $this->object->addProperty($this->prefix . '2', 'city', 'Belo Horizonte'); - $user = $this->object->getById($this->prefix . '2'); + $user = $this->object->get($this->prefix . '2'); $this->assertEquals(['Rio de Janeiro', 'Belo Horizonte'], $user->get('city')); // Get Property @@ -110,12 +113,12 @@ public function testAddProperty(): void // Add another property $this->object->addProperty($this->prefix . '2', 'state', 'RJ'); - $user = $this->object->getById($this->prefix . '2'); + $user = $this->object->get($this->prefix . '2'); $this->assertEquals('RJ', $user->get('state')); // Remove Property $this->object->removeProperty($this->prefix . '2', 'state', 'RJ'); - $user = $this->object->getById($this->prefix . '2'); + $user = $this->object->get($this->prefix . '2'); $this->assertEmpty($user->get('state')); // Remove Property Again @@ -130,32 +133,32 @@ public function testRemoveAllProperties(): void $this->object->addProperty($this->prefix . '2', 'city', 'Rio de Janeiro'); $this->object->addProperty($this->prefix . '2', 'city', 'Niteroi'); $this->object->addProperty($this->prefix . '2', 'state', 'RJ'); - $user = $this->object->getById($this->prefix . '2'); + $user = $this->object->get($this->prefix . '2'); $this->assertEquals(['Rio de Janeiro', 'Niteroi'], $user->get('city')); $this->assertEquals('RJ', $user->get('state')); // Add another properties $this->object->addProperty($this->prefix . '1', 'city', 'Niteroi'); $this->object->addProperty($this->prefix . '1', 'state', 'BA'); - $user = $this->object->getById($this->prefix . '1'); + $user = $this->object->get($this->prefix . '1'); $this->assertEquals('Niteroi', $user->get('city')); $this->assertEquals('BA', $user->get('state')); // Remove Properties $this->object->removeAllProperties('state'); - $user = $this->object->getById($this->prefix . '2'); + $user = $this->object->get($this->prefix . '2'); $this->assertEquals(['Rio de Janeiro', 'Niteroi'], $user->get('city')); $this->assertEmpty($user->get('state')); - $user = $this->object->getById($this->prefix . '1'); + $user = $this->object->get($this->prefix . '1'); $this->assertEquals('Niteroi', $user->get('city')); $this->assertEmpty($user->get('state')); // Remove Properties Again $this->object->removeAllProperties('city', 'Niteroi'); - $user = $this->object->getById($this->prefix . '2'); + $user = $this->object->get($this->prefix . '2'); $this->assertEquals('Rio de Janeiro', $user->get('city')); $this->assertEmpty($user->get('state')); - $user = $this->object->getById($this->prefix . '1'); + $user = $this->object->get($this->prefix . '1'); $this->assertEmpty($user->get('city')); $this->assertEmpty($user->get('state')); @@ -165,13 +168,13 @@ public function testRemoveByLoginField(): void { $login = $this->__chooseValue('user1', 'user1@gmail.com'); - $user = $this->object->getByLoginField($login); + $user = $this->object->get($login, $this->object->getUserDefinition()->loginField()); $this->assertNotNull($user); $result = $this->object->removeByLoginField($login); $this->assertTrue($result); - $user = $this->object->getByLoginField($login); + $user = $this->object->get($login, $this->object->getUserDefinition()->loginField()); $this->assertNull($user); } @@ -180,7 +183,7 @@ public function testEditUser(): void $login = $this->__chooseValue('user1', 'user1@gmail.com'); // Getting data - $user = $this->object->getByLoginField($login); + $user = $this->object->get($login, $this->object->getUserDefinition()->loginField()); $this->assertEquals('User 1', $user->getName()); // Change and Persist data @@ -188,7 +191,7 @@ public function testEditUser(): void $this->object->save($user); // Check if data persists - $user = $this->object->getById($this->prefix . '1'); + $user = $this->object->get($this->prefix . '1'); $this->assertEquals('Other name', $user->getName()); } @@ -209,17 +212,17 @@ public function testIsValidUser(): void public function testIsAdmin(): void { // Check is Admin - $user3 = $this->object->getById($this->prefix . '3'); + $user3 = $this->object->get($this->prefix . '3'); $this->assertFalse($user3->isAdmin()); // Set the Admin Flag $login = $this->__chooseValue('user3', 'user3@gmail.com'); - $user = $this->object->getByLoginField($login); + $user = $this->object->get($login, $this->object->getUserDefinition()->loginField()); $user->setAdmin('Y'); $this->object->save($user); // Check is Admin - $user3 = $this->object->getById($this->prefix . '3'); + $user3 = $this->object->get($this->prefix . '3'); $this->assertTrue($user3->isAdmin()); } @@ -238,7 +241,7 @@ protected function expectedToken($tokenData, $login, $userId): void ['tokenData'=>$tokenData] ); - $user = $this->object->getByLoginField($login); + $user = $this->object->get($login, $this->object->getUserDefinition()->loginField()); $dataFromToken = new \stdClass(); $dataFromToken->tokenData = $tokenData; @@ -288,28 +291,28 @@ public function testValidateTokenWithAnotherUser(): void */ public function testSaveAndSave() { - $user = $this->object->getById('user1'); + $user = $this->object->get('user1'); $this->object->save($user); - $user2 = $this->object->getById('user1'); + $user2 = $this->object->get('user1'); $this->assertEquals($user, $user2); } public function testRemoveUserById(): void { - $user = $this->object->getById($this->prefix . '1'); + $user = $this->object->get($this->prefix . '1'); $this->assertNotNull($user); $this->object->removeUserById($this->prefix . '1'); - $user2 = $this->object->getById($this->prefix . '1'); + $user2 = $this->object->get($this->prefix . '1'); $this->assertNull($user2); } public function testGetByUsername(): void { - $user = $this->object->getByUsername('user2'); + $user = $this->object->get('user2', $this->object->getUserDefinition()->getUsername()); $this->assertEquals($this->prefix . '2', $user->getUserid()); $this->assertEquals('User 2', $user->getName()); @@ -321,12 +324,12 @@ public function testGetByUsername(): void public function testGetByUserProperty(): void { // Add property to user1 - $user = $this->object->getById($this->prefix . '1'); + $user = $this->object->get($this->prefix . '1'); $user->set('property1', 'somevalue'); $this->object->save($user); // Add property to user2 - $user = $this->object->getById($this->prefix . '2'); + $user = $this->object->get($this->prefix . '2'); $user->set('property1', 'value1'); $user->set('property2', 'value2'); $this->object->save($user); diff --git a/tests/UsersDBDatasetByUsernameTest.php b/tests/UsersDBDatasetByUsernameTest.php index ac2bebe..4430db8 100644 --- a/tests/UsersDBDatasetByUsernameTest.php +++ b/tests/UsersDBDatasetByUsernameTest.php @@ -87,7 +87,7 @@ public function testAddUser() $login = $this->__chooseValue('john', 'johndoe@gmail.com'); - $user = $this->object->getByLoginField($login); + $user = $this->object->get($login, $this->object->getUserDefinition()->loginField()); $this->assertEquals('4', $user->getUserid()); $this->assertEquals('John Doe', $user->getName()); $this->assertEquals('john', $user->getUsername()); @@ -100,7 +100,7 @@ public function testAddUser() $user->setAdmin('y'); $this->object->save($user); - $user2 = $this->object->getByLoginField($login); + $user2 = $this->object->get($login, $this->object->getUserDefinition()->loginField()); $this->assertEquals('y', $user2->getAdmin()); } @@ -157,9 +157,9 @@ public function testWithUpdateValue() $newObject->addUser('User 4', 'user4', 'user4@gmail.com', 'pwd4'); - $login = $this->__chooseValue(']user4[', '-user4@gmail.com-'); + $login = $this->__chooseValue('user4', 'user4@gmail.com'); - $user = $newObject->getByLoginField($login); + $user = $newObject->get($login, $newObject->getUserDefinition()->loginField()); $this->assertEquals('4', $user->getUserid()); $this->assertEquals('([User 4])', $user->getName()); $this->assertEquals(')]user4[(', $user->getUsername()); @@ -174,10 +174,10 @@ public function testWithUpdateValue() #[\Override] public function testSaveAndSave() { - $user = $this->object->getById("1"); + $user = $this->object->get("1"); $this->object->save($user); - $user2 = $this->object->getById("1"); + $user2 = $this->object->get("1"); $this->assertEquals($user, $user2); } diff --git a/tests/UsersDBDatasetDefinitionTest.php b/tests/UsersDBDatasetDefinitionTest.php index 91240fa..c7c0472 100644 --- a/tests/UsersDBDatasetDefinitionTest.php +++ b/tests/UsersDBDatasetDefinitionTest.php @@ -117,7 +117,7 @@ public function testAddUser() $login = $this->__chooseValue('john', 'johndoe@gmail.com'); - $user = $this->object->getByLoginField($login); + $user = $this->object->get($login, $this->object->getUserDefinition()->loginField()); $this->assertEquals('4', $user->getUserid()); $this->assertEquals('John Doe', $user->getName()); $this->assertEquals('john', $user->getUsername()); @@ -132,7 +132,7 @@ public function testAddUser() $user->setAdmin('y'); $this->object->save($user); - $user2 = $this->object->getByLoginField($login); + $user2 = $this->object->get($login, $this->object->getUserDefinition()->loginField()); $this->assertEquals('y', $user2->getAdmin()); } @@ -190,9 +190,9 @@ public function testWithUpdateValue() new MyUserModel('User 4', 'user4@gmail.com', 'user4', 'pwd4', 'no', 'other john') ); - $login = $this->__chooseValue(']user4[', '-user4@gmail.com-'); + $login = $this->__chooseValue('user4', 'user4@gmail.com'); - $user = $newObject->getByLoginField($login); + $user = $newObject->get($login, $newObject->getUserDefinition()->loginField()); $this->assertEquals('4', $user->getUserid()); $this->assertEquals('([User 4])', $user->getName()); $this->assertEquals(')]user4[(', $user->getUsername());