diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index d5322bf..7d2e33a 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -12,16 +12,18 @@ on: jobs: Build: runs-on: 'ubuntu-latest' - container: 'byjg/php:${{ matrix.php-version }}-cli' + container: + image: 'byjg/php:${{ matrix.php-version }}-cli' + options: --user root --privileged strategy: matrix: php-version: + - "8.4" - "8.3" - "8.2" - - "8.1" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - run: composer install - run: ./vendor/bin/psalm - run: ./vendor/bin/phpunit --stderr @@ -33,5 +35,6 @@ jobs: with: folder: php project: ${{ github.event.repository.name }} - secrets: inherit + secrets: + DOC_TOKEN: ${{ secrets.DOC_TOKEN }} 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/.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/.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/README.md b/README.md index a16b060..1a8c726 100644 --- a/README.md +++ b/README.md @@ -6,111 +6,93 @@ [![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 using a clean repository and service layer architecture. -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(); - - // Get the user and your name - $user = $users->getById($userId); - echo "Hello: " . $user->getName(); +use ByJG\AnyDataset\Db\DatabaseExecutor; +use ByJG\AnyDataset\Db\Factory as DbFactory; +use ByJG\Authenticate\Enum\LoginField; +use ByJG\Authenticate\Model\UserModel; +use ByJG\Authenticate\Model\UserPropertiesModel; +use ByJG\Authenticate\Repository\UsersRepository; +use ByJG\Authenticate\Repository\UserPropertiesRepository; +use ByJG\Authenticate\Service\UsersService; +use ByJG\Authenticate\SessionContext; +use ByJG\Cache\Factory; + +// Initialize repositories and service +$dbDriver = DbFactory::getDbInstance('mysql://user:pass@host/db'); +$db = DatabaseExecutor::using($dbDriver); +$usersRepo = new UsersRepository($db, UserModel::class); +$propsRepo = new UserPropertiesRepository($db, UserPropertiesModel::class); +$users = new UsersService($usersRepo, $propsRepo, LoginField::Username); + +// Create and authenticate a user +$user = $users->addUser('John Doe', 'johndoe', 'john@example.com', 'SecurePass123'); +$authenticatedUser = $users->isValidUser('johndoe', 'SecurePass123'); + +if ($authenticatedUser !== null) { + $sessionContext = new SessionContext(Factory::createSessionPool()); + $sessionContext->registerLogin($authenticatedUser->getUserid()); + echo "Welcome, " . $authenticatedUser->getName(); } ``` -## Saving extra info into the user session - -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. - -Store the data for the current user session: - -```php -setSessionData('key', 'value'); -``` - -Getting the data from the current user session: - -```php -getSessionData('key'); -``` - -Note: If the user is not logged an error will be throw - -## Adding a custom property to the users - -```php -getById($userId); -$user->setField('somefield', 'somevalue'); -$users->save(); -``` - -## Logout from a session +Set the third constructor argument to `LoginField::Email` if you prefer authenticating users by email instead of username. -```php -registerLogout(); -``` +See [Getting Started](docs/getting-started.md) for a complete introduction and [Examples](docs/examples.md) for more use cases. -## Important note about SessionContext +## Features -`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. +- **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) +- **Database Storage** - Supports MySQL, PostgreSQL, SQLite, and more. 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) -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. +## Running Tests -Example for memcached: +Because this project uses PHP Session you need to run the unit test the following manner: -```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' - ] -); -``` - -### Adding custom modifiers for read and update - -```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 - -It is possible extending the UserModel table, since you create a new class extending from UserModel to add the new fields. - -For example, imagine your table has one field called "otherfield". - -You'll have to extend like this: - -```php -setOtherfield($field); - } - - public function getOtherfield() - { - return $this->otherfield; - } - - public function setOtherfield($otherfield) - { - $this->otherfield = $otherfield; - } -} -``` - -After that you can use your new definition: - -```php - byjg/micro-orm byjg/authuser --> byjg/cache-engine - byjg/authuser --> byjg/jwt-wrapper + byjg/authuser --> byjg/jwt-wrapper ``` - ---- [Open source ByJG](http://opensource.byjg.com) diff --git a/composer.json b/composer.json index ea629b9..3f5a5d7 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.2 <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" + "phpunit/phpunit": "^10|^11", + "vimeo/psalm": "^5.9|^6.12" + }, + "scripts": { + "test": "vendor/bin/phpunit", + "psalm": "vendor/bin/psalm" }, "license": "MIT" } diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 0000000..352aa04 --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,192 @@ +--- +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 configured in your `UsersService` constructor. Pass either `LoginField::Email` or `LoginField::Username` when creating the service. +::: +`LoginField` lives in the `ByJG\Authenticate\Enum` namespace. + +## 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 [Mappers](mappers.md) for details. +::: + +:::tip Enforce Password Strength +To enforce password policies (minimum length, complexity rules, etc.), see [Password Validation](password-validation.md). +::: + +## JWT Token Authentication (Recommended) + +For modern, stateless authentication, use JWT tokens. This is the **recommended approach** for new applications as it provides better security and scalability. + +```php +createAuthToken( + 'johndoe', // Login + 'SecurePass123', // Password + $jwtWrapper, + 3600, // Expires in 1 hour (seconds) + [], // Additional user info to save + ['role' => 'admin'] // Additional token data +); + +if ($userToken !== null) { + echo "Token: " . $userToken->token; +} +``` + +Need to include standard user columns (name, email, etc.) automatically? Pass the optional seventh argument with `User` enum values or strings. See [JWT Tokens](jwt-tokens.md#copy-user-fields-automatically) for details. + +:::tip UserToken Return Type +Both `createAuthToken()` and `createInsecureAuthToken()` return a `UserToken` object with three properties: +- `token` - The JWT token string +- `user` - The UserModel instance +- `data` - Array of token payload data + +This provides immediate access to both the token and user information without additional database queries. +::: + +### Creating Tokens Without Password Validation + +Use `createInsecureAuthToken()` when you need to create tokens without password validation: + +```php +getByEmail($email); + +$userToken = $users->createInsecureAuthToken( + $user, // Can pass UserModel or login string + $jwtWrapper, + 3600 +); + +echo "Token: " . $userToken->token; +``` + +See [JWT Tokens](jwt-tokens.md#creating-tokens-without-password-validation) for complete details and use cases. + +### Validating JWT Tokens + +```php +isValidToken('johndoe', $jwtWrapper, $token); + +if ($userToken !== null) { + $user = $userToken->user; + $tokenData = $userToken->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. +::: + +:::tip Why JWT? +JWT tokens provide stateless authentication, better scalability, and easier integration with modern frontend frameworks and mobile applications. They're also more secure than traditional PHP sessions. +::: + +## Session-Based Authentication + +The `SessionContext` class persists the authenticated user ID inside a PSR-6 cache pool. Use this when you need server-managed sessions (for example, classic PHP applications). + +### 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(); +``` + +## 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..845b30d --- /dev/null +++ b/docs/custom-fields.md @@ -0,0 +1,308 @@ +--- +sidebar_position: 10 +title: Custom Fields +--- + +# 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 + +```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_at DATETIME, + updated_at DATETIME, + deleted_at DATETIME, + role VARCHAR(20), + -- Custom fields + phone VARCHAR(20), + department VARCHAR(50), + title VARCHAR(50), + profile_picture VARCHAR(255), + + CONSTRAINT pk_users PRIMARY KEY (userid) +) ENGINE=InnoDB; +``` + +## Using the Custom Model + +### Initializing the Service + +```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 using the `ReadOnlyMapper`: + +```php +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..1ffbcfb --- /dev/null +++ b/docs/database-storage.md @@ -0,0 +1,401 @@ +--- +sidebar_position: 7 +title: Database Storage +--- + +# Database Storage + +The library uses a repository pattern to store users in relational databases through `UsersRepository` and `UserPropertiesRepository`. + +## Database Setup + +### Default Schema + +The default database structure uses two tables. Below are the schema definitions for different databases: + +
+MySQL / MariaDB + +```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_at DATETIME DEFAULT (now()), + updated_at DATETIME ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME, + role VARCHAR(20), + + CONSTRAINT pk_users PRIMARY KEY (userid), + CONSTRAINT ix_username UNIQUE (username), + CONSTRAINT ix_users_2 UNIQUE (email), + INDEX ix_users_email (email ASC, password ASC), + INDEX ix_users_username (username ASC, password ASC) +) ENGINE=InnoDB; + +CREATE TABLE users_property +( + id INTEGER AUTO_INCREMENT NOT NULL, + name VARCHAR(20), + value VARCHAR(100), + userid INTEGER NOT NULL, + + CONSTRAINT pk_custom PRIMARY KEY (id), + CONSTRAINT fk_custom_user FOREIGN KEY (userid) REFERENCES users (userid) +) ENGINE=InnoDB; +``` + +
+ +
+SQLite + +```sql +CREATE TABLE users +( + userid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + name VARCHAR(50), + email VARCHAR(120), + username VARCHAR(15) NOT NULL, + password CHAR(40) NOT NULL, + created_at DATETIME DEFAULT (datetime('now')), + updated_at DATETIME DEFAULT (datetime('now')), + deleted_at DATETIME, + role VARCHAR(20), + + CONSTRAINT ix_username UNIQUE (username), + CONSTRAINT ix_users_2 UNIQUE (email) +); + +CREATE INDEX ix_users_email ON users (email ASC, password ASC); +CREATE INDEX ix_users_username ON users (username ASC, password ASC); + +CREATE TRIGGER update_users_updated_at +AFTER UPDATE ON users +FOR EACH ROW +BEGIN + UPDATE users SET updated_at = datetime('now') WHERE userid = NEW.userid; +END; + +CREATE TABLE users_property +( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + name VARCHAR(20), + value VARCHAR(100), + userid INTEGER NOT NULL, + + CONSTRAINT fk_custom_user FOREIGN KEY (userid) REFERENCES users (userid) +); +``` + +
+ +
+PostgreSQL + +```sql +CREATE TABLE users +( + userid SERIAL NOT NULL, + name VARCHAR(50), + email VARCHAR(120), + username VARCHAR(15) NOT NULL, + password CHAR(40) NOT NULL, + created_at TIMESTAMP DEFAULT now(), + updated_at TIMESTAMP DEFAULT now(), + deleted_at TIMESTAMP, + role VARCHAR(20), + + CONSTRAINT pk_users PRIMARY KEY (userid), + CONSTRAINT ix_username UNIQUE (username), + CONSTRAINT ix_users_2 UNIQUE (email) +); + +CREATE INDEX ix_users_email ON users (email ASC, password ASC); +CREATE INDEX ix_users_username ON users (username ASC, password ASC); + +CREATE OR REPLACE FUNCTION update_users_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_update_users_updated_at +BEFORE UPDATE ON users +FOR EACH ROW +EXECUTE FUNCTION update_users_updated_at(); + +CREATE TABLE users_property +( + id SERIAL NOT NULL, + name VARCHAR(20), + value VARCHAR(100), + userid INTEGER NOT NULL, + + CONSTRAINT pk_custom PRIMARY KEY (id), + CONSTRAINT fk_custom_user FOREIGN KEY (userid) REFERENCES users (userid) +); +``` + +
+ +
+SQL Server + +```sql +CREATE TABLE users +( + userid INTEGER IDENTITY(1,1) NOT NULL, + name VARCHAR(50), + email VARCHAR(120), + username VARCHAR(15) NOT NULL, + password CHAR(40) NOT NULL, + created_at DATETIME DEFAULT GETDATE(), + updated_at DATETIME DEFAULT GETDATE(), + deleted_at DATETIME, + role VARCHAR(20), + + CONSTRAINT pk_users PRIMARY KEY (userid), + CONSTRAINT ix_username UNIQUE (username), + CONSTRAINT ix_users_2 UNIQUE (email) +); + +CREATE INDEX ix_users_email ON users (email ASC, password ASC); +CREATE INDEX ix_users_username ON users (username ASC, password ASC); + +GO +CREATE TRIGGER trigger_update_users_updated_at +ON users +AFTER UPDATE +AS +BEGIN + SET NOCOUNT ON; + UPDATE users + SET updated_at = GETDATE() + FROM users u + INNER JOIN inserted i ON u.userid = i.userid; +END; +GO + +CREATE TABLE users_property +( + id INTEGER IDENTITY(1,1) NOT NULL, + name VARCHAR(20), + value VARCHAR(100), + userid INTEGER NOT NULL, + + CONSTRAINT pk_custom PRIMARY KEY (id), + CONSTRAINT fk_custom_user FOREIGN KEY (userid) REFERENCES users (userid) +); +``` + +
+ +## Basic Usage + +### Using Default Configuration + +```php +addUser('John Doe', 'johndoe', 'john@example.com', 'password123'); +``` + +## Architecture + +```text + ┌───────────────────┐ + │ SessionContext │ + └───────────────────┘ + │ + │ + ┌───────────────────┐ + │ UsersService │ (Business Logic) + └───────────────────┘ + │ + ┌────────────────────┴────────────────────┐ + │ │ + ┌───────────────────┐ ┌──────────────────────┐ + │ UsersRepository │ │ PropertiesRepository │ + └───────────────────┘ └──────────────────────┘ + │ │ + ┌───────┴───────┐ ┌──────────┴──────────┐ + │ │ │ │ + ┌───────────────┐ ┌────────┐ ┌───────────────┐ ┌──────────────┐ + │ UserModel │ │ Mapper │ │ PropsModel │ │ Mapper │ + └───────────────┘ └────────┘ └───────────────┘ └──────────────┘ +``` + +- **UsersService**: High-level business logic for user operations +- **UsersRepository**: Data access layer for user records +- **UserPropertiesRepository**: Data access layer for user properties +- **UserModel**: User entity with table/field mapping via attributes +- **UserPropertiesModel**: Properties entity with table/field mapping +- **Mapper**: Field transformation functions (e.g., PasswordSha1Mapper, ReadOnlyMapper) + +## 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..4ea2496 --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,750 @@ +--- +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:

+ + hasRole('admin')): ?> +

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 { + $userToken = $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 ($userToken === null) { + jsonResponse(['error' => 'Invalid credentials'], 401); + } + + jsonResponse([ + 'success' => true, + 'token' => $userToken->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 + $userToken = $users->isValidToken($username, $jwtWrapper, $token); + + if ($userToken === null) { + jsonResponse(['error' => 'Token validation failed'], 401); + } + + $user = $userToken->user; + + // Handle request + if ($_SERVER['REQUEST_METHOD'] === 'GET') { + // Get user info + jsonResponse([ + 'id' => $user->getUserid(), + 'name' => $user->getName(), + 'email' => $user->getEmail(), + 'username' => $user->getUsername(), + 'role' => $user->getRole() + ]); + } 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'); +``` + +## OAuth Authentication Example + +This example shows how to integrate OAuth authentication using `createInsecureAuthToken()`. + +```php + 'user@example.com', + 'name' => 'John Doe', + 'provider' => 'google', + 'provider_id' => '123456789' +]; + +try { + // Check if user exists + $user = $users->getByEmail($oauthUserData['email']); + + if ($user === null) { + // Create new user for first-time OAuth login + $user = $users->addUser( + $oauthUserData['name'], + $oauthUserData['email'], // Use email as username + $oauthUserData['email'], + bin2hex(random_bytes(16)) // Random password (user won't use it) + ); + + // Store OAuth provider info + $users->addProperty($user->getUserid(), 'oauth_provider', $oauthUserData['provider']); + $users->addProperty($user->getUserid(), 'oauth_provider_id', $oauthUserData['provider_id']); + } + + // Create token without password validation + $userToken = $users->createInsecureAuthToken( + $user, // Pass UserModel directly + $jwtWrapper, + 3600, + ['last_oauth_login' => date('Y-m-d H:i:s')], + [ + 'auth_method' => 'oauth', + 'provider' => $oauthUserData['provider'] + ] + ); + + // Return token to client + jsonResponse([ + 'success' => true, + 'token' => $userToken->token, + 'user' => [ + 'id' => $user->getUserid(), + 'name' => $user->getName(), + 'email' => $user->getEmail() + ] + ]); + +} catch (Exception $e) { + jsonResponse(['error' => $e->getMessage()], 500); +} +``` + +## Token Refresh Example + +Implement a token refresh mechanism using `createInsecureAuthToken()`. + +```php + 'No refresh token provided'], 401); +} + +$refreshToken = $matches[1]; + +try { + // Decode refresh token + $jwtData = $refreshWrapper->extractData($refreshToken); + $userId = $jwtData->data['userid'] ?? null; + + if (!$userId) { + jsonResponse(['error' => 'Invalid refresh token'], 401); + } + + // Get user + $user = $users->getById($userId); + + if ($user === null) { + jsonResponse(['error' => 'User not found'], 404); + } + + // Verify refresh token is valid + $userToken = $users->isValidToken($user->getEmail(), $refreshWrapper, $refreshToken); + + if ($userToken === null) { + jsonResponse(['error' => 'Invalid or expired refresh token'], 401); + } + + // Create new access token (using main JWT wrapper) + $newAccessToken = $users->createInsecureAuthToken( + $user, + $jwtWrapper, + 900, // 15 minutes + [], + ['token_type' => 'access'] + ); + + jsonResponse([ + 'success' => true, + 'access_token' => $newAccessToken->token, + 'expires_in' => 900 + ]); + +} catch (Exception $e) { + jsonResponse(['error' => $e->getMessage()], 500); +} +``` + +### Initial Login with Refresh Token + +```php + 'Method not allowed'], 405); +} + +$input = json_decode(file_get_contents('php://input'), true); +$username = $input['username'] ?? ''; +$password = $input['password'] ?? ''; + +try { + // Main JWT wrapper for access tokens + $jwtSecret = getenv('JWT_SECRET') ?: 'secret-here=='; + $jwtWrapper = new JwtWrapper('api.example.com', new JwtHashHmacSecret($jwtSecret)); + + // Separate wrapper for refresh tokens + $refreshSecret = getenv('JWT_REFRESH_SECRET') ?: 'refresh-secret-here=='; + $refreshWrapper = new JwtWrapper('api.example.com', new JwtHashHmacSecret($refreshSecret)); + + // Validate credentials + $user = $users->isValidUser($username, $password); + + if ($user === null) { + jsonResponse(['error' => 'Invalid credentials'], 401); + } + + // Create short-lived access token + $accessToken = $users->createInsecureAuthToken( + $user, + $jwtWrapper, + 900, // 15 minutes + ['last_login' => date('Y-m-d H:i:s')], + ['token_type' => 'access'] + ); + + // Create long-lived refresh token + $refreshToken = $users->createInsecureAuthToken( + $user, + $refreshWrapper, + 604800, // 7 days + [], + ['token_type' => 'refresh'] + ); + + jsonResponse([ + 'success' => true, + 'access_token' => $accessToken->token, + 'refresh_token' => $refreshToken->token, + 'expires_in' => 900 + ]); + +} catch (Exception $e) { + jsonResponse(['error' => $e->getMessage()], 500); +} +``` + +## 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..540ee4e --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,50 @@ +--- +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 a clean repository and service layer architecture for managing users, authentication, and user properties with database storage. + +## Quick Example + +```php +addUser('John Doe', 'johndoe', 'john@example.com', 'SecurePass123'); +$user = $users->isValidUser('johndoe', 'SecurePass123'); + +if ($user !== null) { + $sessionContext = new SessionContext(Factory::createSessionPool()); + $sessionContext->registerLogin($user->getUserid()); + echo "User authenticated successfully!"; +} +``` + +To authenticate users by email instead of username, create the service with `LoginField::Email`. + +## 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..6b3c7f9 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,32 @@ +--- +sidebar_position: 2 +title: Installation +--- + +# Installation + +## Requirements + +- PHP 8.2 or higher +- Composer + +## Install via Composer + +Install the library using Composer: + +```bash +composer require byjg/authuser +``` + +## 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..22cd7f5 --- /dev/null +++ b/docs/jwt-tokens.md @@ -0,0 +1,462 @@ +--- +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 ($userToken !== null) { + // Return token to client + echo json_encode(['token' => $userToken->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' + ] +); + +// Access the token string +$token = $userToken->token; +``` + +### 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' + ] +); +``` + +### Copy User Fields Automatically + +Instead of manually adding every field to `$updateTokenInfo`, pass a seventh argument with an array of `User` enum values or string property names. The service will read the corresponding getters (or custom properties) from the `UserModel` and copy them into the token payload. + +```php +createAuthToken( + 'johndoe', + 'password123', + $jwtWrapper, + 3600, + [], + [], + [UserField::Name, UserField::Email, 'department'] +); +``` + +In the example above, the token payload receives the user's `name`, `email`, and the value returned by `$user->get('department')` automatically. + +### Creating Tokens Without Password Validation + +The `createInsecureAuthToken()` method creates JWT tokens without validating the user's password. This is useful for: +- Creating tokens after social authentication (OAuth, SAML, etc.) +- Implementing "remember me" functionality +- Token refresh mechanisms +- Administrative token generation + +:::warning Security Warning +Use `createInsecureAuthToken()` with caution. Only call it after you've verified the user's identity through another secure method. +::: + +#### Using Login String + +```php +createInsecureAuthToken( + 'johndoe', // Login (username or email) + $jwtWrapper, + 3600, // Expires in 1 hour + [], // Update user properties (optional) + ['auth_method' => 'oauth'] // Additional token data +); + +echo "Token: " . $userToken->token; +``` + +#### Using UserModel Object + +```php +getByEmail($oauthEmail); + +if ($user !== null) { + $userToken = $users->createInsecureAuthToken( + $user, // Pass UserModel directly + $jwtWrapper, + 3600, + ['last_oauth_login' => date('Y-m-d H:i:s')], + ['oauth_provider' => 'google'] + ); + + echo "Token: " . $userToken->token; +} +``` + +#### Comparison: createAuthToken vs createInsecureAuthToken + +| Feature | createAuthToken | createInsecureAuthToken | +|---------------------|-------------------|----------------------------------------| +| Password validation | ✅ Required | ❌ Skipped | +| First parameter | Login string only | Login string OR UserModel | +| Use case | Normal login | OAuth, token refresh, admin operations | +| Security level | High | Use with caution | + +## Validating JWT Tokens + +### Token Validation + +```php +isValidToken('johndoe', $jwtWrapper, $token); + + if ($userToken !== null) { + $user = $userToken->user; // UserModel instance + $tokenData = $userToken->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 ($userToken === null) { + throw new Exception('Authentication failed'); + } + + echo json_encode([ + 'success' => true, + 'token' => $userToken->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 + $userToken = $users->isValidToken($username, $jwtWrapper, $token); + + if ($userToken === null) { + throw new Exception('Invalid token'); + } + + $user = $userToken->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->getByLogin($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 +$refreshUserToken = $users->createAuthToken( + $login, + $password, + $jwtWrapperRefresh, // Different wrapper/key + 604800, // 7 days + [], + ['type' => 'refresh'] +); + +echo json_encode([ + 'access_token' => $accessUserToken->token, + 'refresh_token' => $refreshUserToken->token +]); +``` + +## 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..f881e3f --- /dev/null +++ b/docs/mappers.md @@ -0,0 +1,269 @@ +--- +sidebar_position: 11 +title: Mappers and Entity Processors +--- + +# Mappers and Entity Processors + +Auth User PHP relies on [byjg/micro-orm](https://github.com/byjg/micro-orm) to map models to database rows. Every property in `UserModel` and `UserPropertiesModel` can define how its value is transformed when it is inserted, updated, or selected. This page shows how to take advantage of those hooks. + +## Controlling Fields with Attributes + +`FieldAttribute` accepts optional mapper functions for each lifecycle event: + +- `updateFunction` – runs before an UPDATE or when calling `save()` (most common). +- `insertFunction` – runs only when the record is first inserted. +- `selectFunction` – runs when values are loaded from the database. + +```php + strtolower(trim((string) $value))), + selectFunction: StandardMapper::class + )] + protected ?string $email = null; +} +``` + +### Built-in Mapper Helpers + +This package ships with a few mapper utilities that complement the ones provided by Micro ORM: + +- **PasswordSha1Mapper** – hashes passwords using SHA-1 (the default on `UserModel`). Replace it with your own mapper to change the hashing algorithm. +- **UserIdGeneratorMapper** – derives a user ID from the username when the primary key is empty. + ```php + 12]); + } +} +``` + +Attach it to the password field: + +```php +getUsername(); + if (strlen($username) < 3) { + throw new \InvalidArgumentException('Username must have at least 3 characters.'); + } + + if (!preg_match('/^[A-Za-z0-9_]+$/', $username)) { + throw new \InvalidArgumentException('Username can only contain letters, numbers, and underscores.'); + } + } +} + +class AuditProcessor implements EntityProcessorInterface +{ + public function __construct(private readonly int $actorId) + { + } + + public function process(mixed $instance): void + { + if ($instance instanceof UserModel) { + $instance->set('modified_by', (string) $this->actorId); + $instance->set('modified_at', date('Y-m-d H:i:s')); + } + } +} +``` + +Attach them using the table attribute: + +```php +getUsersRepository()->getRepository(); +$repository->setBeforeUpdate(new AuditProcessor($currentUserId)); +``` + +## Complete Example + +```php + strtolower(trim((string) $value))))] + protected ?string $email = null; +} + +$dbDriver = DbFactory::getDbInstance('mysql://user:pass@localhost/app'); +$db = DatabaseExecutor::using($dbDriver); +$usersRepo = new UsersRepository($db, CustomUserModel::class); +$propsRepo = new UserPropertiesRepository($db, \ByJG\Authenticate\Model\UserPropertiesModel::class); +$users = new UsersService($usersRepo, $propsRepo, LoginField::Username); +``` + +With these tools you can precisely control how data flows between your database schema and the authentication service while keeping the domain models clean. diff --git a/docs/password-validation.md b/docs/password-validation.md new file mode 100644 index 0000000..3082d2f --- /dev/null +++ b/docs/password-validation.md @@ -0,0 +1,327 @@ +--- +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 +``` + +### Applying Rules Through UsersService + +Instead of attaching the definition to every `UserModel` manually, pass it to the `UsersService` constructor. Every entity created through the service (e.g., via `addUser()` or `getUsersEntity()`) receives the same `PasswordDefinition`. + +```php + 12, + PasswordDefinition::REQUIRE_NUMBERS => 2, +]); + +$users = new UsersService( + $usersRepo, + $propsRepo, + LoginField::Username, + $passwordDefinition +); + +$newUser = $users->addUser('Jane', 'jane', 'jane@example.com', 'StrongPassword123!'); +``` + +Need a blank entity with the validation rules already loaded? Use `getUsersEntity()`: + +```php +getUsersEntity([ + 'name' => 'John Doe', + 'email' => 'john@example.com', +]); +$user->setPassword('AnotherStrongPass123!'); +$users->save($user); +``` + +This approach guarantees that every model created or retrieved through the service (`getById()`, `getByEmail()`, `getUsersByProperty()`, etc.) automatically enforces your password policy. + +## 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..697c4fe --- /dev/null +++ b/docs/session-context.md @@ -0,0 +1,208 @@ +--- +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); +``` +Call `setSessionData()` after `registerLogin()` if you need to store extra session metadata (e.g., IP address, login time). + +### 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..c100d4e --- /dev/null +++ b/docs/user-management.md @@ -0,0 +1,190 @@ +--- +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->setRole('user'); + +$savedUser = $users->save($userModel); +``` + +### Creating Entities via UsersService + +When you need a new `UserModel` that already respects the mapper configuration (including any `PasswordDefinition` passed to the `UsersService` constructor), call `getUsersEntity()`: + +```php +getUsersEntity([ + 'name' => 'Jane Smith', + 'email' => 'jane@example.com', + 'username' => 'janesmith', +]); +$user->setPassword('Str0ngPass!'); +$users->save($user); +``` + +The returned model is identical to instantiating `UserModel` manually, but it is pre-configured with the service-level password rules and field mappings. + +## Retrieving Users + +The `UsersService` provides several methods to retrieve 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 `UsersService` constructor (either email or username): + +```php +getByLogin('johndoe'); +``` + +### Using Custom Queries + +For advanced queries, use the repository directly: + +```php +getUsersRepository(); + +// Build custom query +$query = Query::getInstance() + ->table('users') + ->where('email = :email', ['email' => 'john@example.com']) + ->where('admin = :admin', ['admin' => 'yes']); + +$results = $usersRepo->getRepository()->getByQuery($query); +``` + +## 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 +removeById($userId); +``` + +### Delete by Login + +```php +removeByLogin('johndoe'); +``` + +## Checking User Roles + +Users can have assigned roles stored in the `role` field. Use `$user->hasRole()` to check if a user has a specific role: + +```php +hasRole('admin')) { + echo "User is an administrator"; +} + +if ($user->hasRole('moderator')) { + echo "User is a moderator"; +} + +// Set a role +$user->setRole('admin'); +$users->save($user); + +// Get current role +$role = $user->getRole(); +``` + +The `hasRole()` method performs case-insensitive comparison, so `hasRole('admin')` and `hasRole('ADMIN')` are equivalent. + +## UserModel Properties + +The `UserModel` class provides the following properties: + +| Property | Type | Description | +|-------------|-----------------------------------|------------------------------------------------------------------| +| `userid` | string\|int\|Literal\|null | Primary key. Generated by the database unless you override it. | +| `name` | string\|null | User's display name. | +| `email` | string\|null | User's email address. | +| `username` | string\|null | Unique username (used when `LoginField::Username` is selected). | +| `password` | string\|null | Hashed password. Uses `PasswordSha1Mapper` by default. | +| `role` | string\|null | Optional application role. | +| `createdAt` | string\|null | Automatically set when the record is inserted. | +| `updatedAt` | string\|null | Automatically updated whenever the record is saved. | +| `deletedAt` | string\|null | Nullable soft-delete timestamp (requires manual handling). | +| `properties`| `UserPropertiesModel[]` (array) | Loaded via `getProperties()` or manipulated through helpers. | + +## 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..e721387 --- /dev/null +++ b/docs/user-properties.md @@ -0,0 +1,244 @@ +--- +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"; +} +``` + +## 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..e1657e9 100644 --- a/example.php +++ b/example.php @@ -2,20 +2,51 @@ require "vendor/autoload.php"; -$users = new ByJG\Authenticate\UsersAnyDataset('/tmp/pass.anydata.xml'); +use ByJG\AnyDataset\Db\DatabaseExecutor; +use ByJG\AnyDataset\Db\Factory as DbFactory; +use ByJG\Authenticate\Model\UserModel; +use ByJG\Authenticate\Model\UserPropertiesModel; +use ByJG\Authenticate\Repository\UserPropertiesRepository; +use ByJG\Authenticate\Repository\UsersRepository; +use ByJG\Authenticate\Service\UsersService; +use ByJG\Authenticate\SessionContext; +use ByJG\Cache\Factory; -$users->addUser('Some User Full Name', 'someuser', 'someuser@someemail.com', '12345'); -//$users->save(); +// Create database connection (using SQLite for this example) +$dbDriver = DbFactory::getDbInstance('sqlite:///tmp/users.db'); +$db = DatabaseExecutor::using($dbDriver); -$user = $users->isValidUser('someuser', '12345'); -var_dump($user); -if (!is_null($user)) -{ - $session = new \ByJG\Authenticate\SessionContext(); - $session->registerLogin($userId); +// Initialize repositories +$usersRepository = new UsersRepository($db, UserModel::class); +$propertiesRepository = new UserPropertiesRepository($db, UserPropertiesModel::class); - echo "Authenticated: " . $session->isAuthenticated(); - print_r($session->userInfo()); +// Initialize user service +$users = new UsersService($usersRepository, $propertiesRepository, UsersService::LOGIN_IS_USERNAME); + +// 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"; } 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/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..5697a47 100644 --- a/src/Definition/PasswordDefinition.php +++ b/src/Definition/PasswordDefinition.php @@ -2,7 +2,9 @@ namespace ByJG\Authenticate\Definition; +use ByJG\Authenticate\Interfaces\PasswordMapperInterface; use InvalidArgumentException; +use Random\RandomException; class PasswordDefinition { @@ -17,6 +19,8 @@ class PasswordDefinition protected array $rules = []; + protected PasswordMapperInterface|string|null $passwordMapper = null; + public function __construct($rules = null) { $this->rules = [ @@ -47,7 +51,7 @@ public function getRules(): array return $this->rules; } - public function getRule($rule): string|bool|int + public function getRule(string $rule): string|bool|int { if (!array_key_exists($rule, $this->rules)) { throw new InvalidArgumentException("Invalid rule"); @@ -113,6 +117,95 @@ public function matchPassword(string $password): int return $result; } + /** + * @throws RandomException + */ + 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 = intval($this->rules[self::MINIMUM_CHARS]) + $extendSize; + $totalChars = intval(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; + } + public function getPasswordMapper(): PasswordMapperInterface|string|null + { + return $this->passwordMapper; + } + + // This is used internally; + public function setPasswordMapper(PasswordMapperInterface|string|null $passwordMapper): void + { + $this->passwordMapper = $passwordMapper; + } } \ No newline at end of file diff --git a/src/Definition/UserDefinition.php b/src/Definition/UserDefinition.php deleted file mode 100644 index 1019c98..0000000 --- a/src/Definition/UserDefinition.php +++ /dev/null @@ -1,262 +0,0 @@ - [], "update" => [] ]; - protected string $__loginField; - protected string $__model; - protected array $__properties = []; - protected Closure|null $__generateKey = null; - - const FIELD_USERID = 'userid'; - const FIELD_NAME = 'name'; - const FIELD_EMAIL = 'email'; - const FIELD_USERNAME = 'username'; - const FIELD_PASSWORD = 'password'; - const FIELD_CREATED = 'created'; - const FIELD_ADMIN = 'admin'; - - const UPDATE="update"; - const SELECT="select"; - - - const LOGIN_IS_EMAIL="email"; - const LOGIN_IS_USERNAME="username"; - - /** - * Define the name of fields and table to store and retrieve info from database - * - * @param string $table - * @param string $model - * @param string $loginField - * @param array $fieldDef - */ - public function __construct( - string $table = 'users', - string $model = UserModel::class, - string $loginField = self::LOGIN_IS_USERNAME, - array $fieldDef = [] - ) { - $this->__table = $table; - $this->__model = $model; - - // Set Default User Definition - $modelInstance = $this->modelInstance(); - $modelProperties = Serialize::from($modelInstance)->toArray(); - foreach (array_keys($modelProperties) as $property) { - $this->__properties[$property] = $property; - } - - // Set custom Properties - foreach ($fieldDef as $property => $value) { - $this->checkProperty($property); - $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)); - }); - - 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; - }; - } - - /** - * @return string - */ - public function table(): string - { - return $this->__table; - } - - - public function __get(string $name) - { - $this->checkProperty($name); - return $this->__properties[$name]; - } - - public function __call(string $name, array $arguments) - { - if (str_starts_with($name, 'get')) { - $name = strtolower(substr($name, 3)); - return $this->{$name}; - } - throw new InvalidArgumentException("Method '$name' does not exists'"); - } - - public function toArray(): array - { - return $this->__properties; - } - - /** - * @return string - */ - public function loginField(): string - { - return $this->{$this->__loginField}; - } - - private function checkProperty(string $property): void - { - if (!isset($this->__properties[$property])) { - throw new InvalidArgumentException("Property '$property' does not exists'"); - } - } - - /** - * @param string $event - * @param string $property - * @param Closure $closure - */ - private function updateClosureDef(string $event, string $property, Closure $closure): void - { - $this->checkProperty($property); - $this->__closures[$event][$property] = $closure; - } - - private function getClosureDef(string $event, string $property): Closure - { - $this->checkProperty($property); - - if (!$this->existsClosure($event, $property)) { - return MapperClosure::standard(); - } - - return $this->__closures[$event][$property]; - } - - public function existsClosure(string $event, string $property): bool - { - // Event not set - if (!isset($this->__closures[$event])) { - return false; - } - - // Event is set but there is no property - if (!array_key_exists($property, $this->__closures[$event])) { - return false; - } - - return true; - } - - public function markPropertyAsReadOnly(string $property): void - { - $this->updateClosureDef(self::UPDATE, $property, MapperClosure::readOnly()); - } - - public function defineClosureForUpdate(string $property, Closure $closure): void - { - $this->updateClosureDef(self::UPDATE, $property, $closure); - } - - public function defineClosureForSelect(string $property, Closure $closure): void - { - $this->updateClosureDef(self::SELECT, $property, $closure); - } - - public function getClosureForUpdate(string $property): Closure - { - return $this->getClosureDef(self::UPDATE, $property); - } - - public function defineGenerateKeyClosure(Closure $closure): void - { - $this->__generateKey = $closure; - } - - public function getGenerateKeyClosure(): ?Closure - { - return $this->__generateKey; - } - - /** - * @param $property - * @return Closure - */ - public function getClosureForSelect($property): Closure - { - return $this->getClosureDef(self::SELECT, $property); - } - - public function model(): string - { - return $this->__model; - } - - public function modelInstance(): UserModel - { - $model = $this->__model; - return new $model(); - } - - protected Closure $beforeInsert; - - /** - * @return Closure - */ - public function getBeforeInsert(): Closure - { - return $this->beforeInsert; - } - - /** - * @param Closure $beforeInsert - */ - public function setBeforeInsert(Closure $beforeInsert): void - { - $this->beforeInsert = $beforeInsert; - } - - protected Closure $beforeUpdate; - - /** - * @return Closure - */ - public function getBeforeUpdate(): Closure - { - return $this->beforeUpdate; - } - - /** - * @param mixed $beforeUpdate - */ - public function setBeforeUpdate(Closure $beforeUpdate): void - { - $this->beforeUpdate = $beforeUpdate; - } -} diff --git a/src/Definition/UserPropertiesDefinition.php b/src/Definition/UserPropertiesDefinition.php deleted file mode 100644 index 9ee9edc..0000000 --- a/src/Definition/UserPropertiesDefinition.php +++ /dev/null @@ -1,76 +0,0 @@ -_UserTable->Id = "userid"]. - * - * @param string $table - * @param string $id - * @param string $name - * @param string $value - * @param string $userid - */ - public function __construct( - string $table = 'users_property', - string $id = 'id', - string $name = 'name', - string $value = 'value', - string $userid = UserDefinition::FIELD_USERID - ) { - $this->table = $table; - $this->id = $id; - $this->name = $name; - $this->value = $value; - $this->userid = $userid; - } - - /** - * @return string - */ - public function table(): string - { - return $this->table; - } - - /** - * @return string - */ - public function getId(): string - { - return $this->id; - } - - /** - * @return string - */ - public function getName(): string - { - return $this->name; - } - - /** - * @return string - */ - public function getValue(): string - { - return $this->value; - } - - /** - * @return string - */ - public function getUserid(): string - { - return $this->userid; - } -} diff --git a/src/Enum/LoginField.php b/src/Enum/LoginField.php new file mode 100644 index 0000000..79ae8ef --- /dev/null +++ b/src/Enum/LoginField.php @@ -0,0 +1,9 @@ +|null|string String vector with all sites - */ - public function getProperty(string|HexUuidLiteral|int $userId, string $propertyName): array|string|\ByJG\Authenticate\Model\UserPropertiesModel|null; - - /** - * - * @param string|int|HexUuidLiteral $userId - * @param string $propertyName - * @param string|null $value - */ - public function addProperty(string|HexUuidLiteral|int $userId, string $propertyName, string|null $value): bool; - - /** - * - * @param string|int|HexUuidLiteral $userId - * @param string $propertyName - * @param string|null $value - */ - public function setProperty(string|HexUuidLiteral|int $userId, string $propertyName, string|null $value): bool; - - /** - * - * @param string|int|HexUuidLiteral $userId - * @param string $propertyName - * @param string|null $value - */ - public function removeProperty(string|HexUuidLiteral|int $userId, string $propertyName, string|null $value = null): bool; - - /** - * @desc Remove a specific site from all users - * @param string $propertyName - * @param string|null $value - * @return void - */ - public function removeAllProperties(string $propertyName, string|null $value = null): void; - - /** - * Authenticate a user and create a token if it is valid - * - * @param string $login - * @param string $password - * @param JwtWrapper $jwtWrapper - * @param int $expires - * @param array $updateUserInfo - * @param array $updateTokenInfo - * @return string|null Return the TOKEN or null, if can't create it. - */ - public function createAuthToken( - string $login, - string $password, - JwtWrapper $jwtWrapper, - int $expires = 1200, - array $updateUserInfo = [], - array $updateTokenInfo = [] - ): string|null; - - /** - * Check if the Auth Token is valid - * - * @param string $login - * @param JwtWrapper $jwtWrapper - * @param string $token - * @return array|null - */ - public function isValidToken(string $login, JwtWrapper $jwtWrapper, string $token): array|null; - - /** - * @return UserDefinition Description - */ - public function getUserDefinition(): UserDefinition; - - /** - * @return UserPropertiesDefinition Description - */ - public function getUserPropertiesDefinition(): UserPropertiesDefinition; - - /** - * @param string|int|HexUuidLiteral $userid - * @return bool - */ - public function removeUserById(string|HexUuidLiteral|int $userid): bool; -} diff --git a/src/Interfaces/UsersServiceInterface.php b/src/Interfaces/UsersServiceInterface.php new file mode 100644 index 0000000..6ee739f --- /dev/null +++ b/src/Interfaces/UsersServiceInterface.php @@ -0,0 +1,210 @@ +closure = $closure; + } + + /** + * @throws ReflectionException + */ + #[\Override] + public function processedValue(mixed $value, mixed $instance, mixed $executor = null): mixed + { + $reflection = new ReflectionFunction($this->closure); + $paramCount = $reflection->getNumberOfParameters(); + + // 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, $executor) + }; + } +} diff --git a/src/MapperFunctions/PasswordSha1Mapper.php b/src/MapperFunctions/PasswordSha1Mapper.php new file mode 100644 index 0000000..db3b79d --- /dev/null +++ b/src/MapperFunctions/PasswordSha1Mapper.php @@ -0,0 +1,35 @@ +isPasswordEncrypted($value)) { + return $value; + } + + // Leave null + if (empty($value)) { + return null; + } + + // Return the hash password + return strtolower(sha1($value)); + } + + #[\Override] + public function isPasswordEncrypted(mixed $password): bool + { + return (is_string($password) && strlen($password) === 40); + } +} diff --git a/src/MapperFunctions/UserIdGeneratorMapper.php b/src/MapperFunctions/UserIdGeneratorMapper.php new file mode 100644 index 0000000..40ec5a0 --- /dev/null +++ b/src/MapperFunctions/UserIdGeneratorMapper.php @@ -0,0 +1,29 @@ +getUsername())); + } + + return $value; + } +} diff --git a/src/Model/UserModel.php b/src/Model/UserModel.php index 3dd4ee3..e9e5c81 100644 --- a/src/Model/UserModel.php +++ b/src/Model/UserModel.php @@ -3,18 +3,38 @@ namespace ByJG\Authenticate\Model; use ByJG\Authenticate\Definition\PasswordDefinition; -use ByJG\MicroOrm\Literal\HexUuidLiteral; +use ByJG\Authenticate\MapperFunctions\PasswordSha1Mapper; +use ByJG\MicroOrm\Attributes\FieldAttribute; +use ByJG\MicroOrm\Attributes\TableAttribute; +use ByJG\MicroOrm\Literal\Literal; +use ByJG\MicroOrm\Trait\CreatedAt; +use ByJG\MicroOrm\Trait\DeletedAt; +use ByJG\MicroOrm\Trait\UpdatedAt; use InvalidArgumentException; +#[TableAttribute(tableName: 'users')] class UserModel { - protected string|int|HexUuidLiteral|null $userid = null; + use CreatedAt; + use UpdatedAt; + use DeletedAt; + #[FieldAttribute(primaryKey: true)] + protected string|int|Literal|null $userid = null; + + #[FieldAttribute] protected ?string $name = null; + + #[FieldAttribute] protected ?string $email = null; + + #[FieldAttribute] protected ?string $username = null; + + #[FieldAttribute(updateFunction: PasswordSha1Mapper::class)] protected ?string $password = null; - protected ?string $created = null; - protected ?string $admin = null; + + #[FieldAttribute] + protected ?string $role = null; protected ?PasswordDefinition $passwordDefinition = null; @@ -27,30 +47,30 @@ class UserModel * @param string $email * @param string $username * @param string $password - * @param string $admin + * @param string $role */ - public function __construct(string $name = "", string $email = "", string $username = "", string $password = "", string $admin = "no") + public function __construct(string $name = "", string $email = "", string $username = "", string $password = "", string $role = "") { $this->name = $name; $this->email = $email; $this->username = $username; $this->setPassword($password); - $this->admin = $admin; + $this->role = $role; } /** - * @return string|int|HexUuidLiteral|null + * @return string|int|Literal|null */ - public function getUserid(): string|int|HexUuidLiteral|null + public function getUserid(): string|int|Literal|null { return $this->userid; } /** - * @param string|int|HexUuidLiteral|null $userid + * @param string|int|Literal|null $userid */ - public function setUserid(string|int|HexUuidLiteral|null $userid): void + public function setUserid(string|int|Literal|null $userid): void { $this->userid = $userid; } @@ -120,7 +140,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]"); } } @@ -130,33 +150,28 @@ public function setPassword(?string $password): void /** * @return string|null */ - public function getCreated(): ?string - { - return $this->created; - } - - /** - * @param string|null $created - */ - public function setCreated(?string $created): void + public function getRole(): ?string { - $this->created = $created; + return $this->role; } /** - * @return string|null + * @param string|null $role */ - public function getAdmin(): ?string + public function setRole(?string $role): void { - return $this->admin; + $this->role = $role; } /** - * @param string|null $admin + * Check if user has a specific role + * + * @param string $role Role name to check + * @return bool */ - public function setAdmin(?string $admin): void + public function hasRole(string $role): bool { - $this->admin = $admin; + return $this->role !== null && $this->role !== '' && strcasecmp($this->role, $role) === 0; } public function set(string $name, string|null $value): void @@ -222,6 +237,7 @@ public function addProperty(UserPropertiesModel $property): void public function withPasswordDefinition(PasswordDefinition $passwordDefinition): static { $this->passwordDefinition = $passwordDefinition; + $this->setPassword($this->password); return $this; } } diff --git a/src/Model/UserPropertiesModel.php b/src/Model/UserPropertiesModel.php index 2d38c66..cf2ad4f 100644 --- a/src/Model/UserPropertiesModel.php +++ b/src/Model/UserPropertiesModel.php @@ -2,39 +2,49 @@ namespace ByJG\Authenticate\Model; -use ByJG\MicroOrm\Literal\HexUuidLiteral; +use ByJG\MicroOrm\Attributes\FieldAttribute; +use ByJG\MicroOrm\Attributes\TableAttribute; +use ByJG\MicroOrm\Literal\Literal; +#[TableAttribute(tableName: 'users_property')] class UserPropertiesModel { - protected string|int|HexUuidLiteral|null $userid = null; + #[FieldAttribute] + protected string|int|Literal|null $userid = null; + + #[FieldAttribute(primaryKey: true)] protected ?string $id = null; + + #[FieldAttribute] protected ?string $name = null; + + #[FieldAttribute] protected ?string $value = null; /** * UserPropertiesModel constructor. * - * @param string $name - * @param string $value + * @param string|null $name + * @param string|null $value */ - public function __construct(string $name = "", string $value = "") + public function __construct(?string $name = null, ?string $value = null) { $this->name = $name; $this->value = $value; } /** - * @return string|int|HexUuidLiteral|null + * @return string|int|Literal|null */ - public function getUserid(): string|int|HexUuidLiteral|null + public function getUserid(): string|int|Literal|null { return $this->userid; } /** - * @param string|int|HexUuidLiteral|null $userid + * @param string|int|Literal|null $userid */ - public function setUserid(string|int|HexUuidLiteral|null $userid): void + public function setUserid(string|int|Literal|null $userid): void { $this->userid = $userid; } diff --git a/src/Model/UserToken.php b/src/Model/UserToken.php new file mode 100644 index 0000000..06759b8 --- /dev/null +++ b/src/Model/UserToken.php @@ -0,0 +1,17 @@ +user = $user; + $this->token = $token; + $this->data = $data; + } +} \ No newline at end of file diff --git a/src/Repository/UserPropertiesRepository.php b/src/Repository/UserPropertiesRepository.php new file mode 100644 index 0000000..874bc9b --- /dev/null +++ b/src/Repository/UserPropertiesRepository.php @@ -0,0 +1,225 @@ +mapper = new Mapper($propertiesClass); + $this->repository = new Repository($executor, $propertiesClass); + } + + /** + * Save a property + * + * @param UserPropertiesModel $model + * @return UserPropertiesModel + * @throws DatabaseException + * @throws DbDriverNotConnected + * @throws FileException + * @throws InvalidArgumentException + * @throws OrmBeforeInvalidException + * @throws OrmInvalidFieldsException + * @throws RepositoryReadOnlyException + * @throws XmlUtilException + * @throws \ByJG\MicroOrm\Exception\InvalidArgumentException + * @throws UpdateConstraintException + */ + public function save(UserPropertiesModel $model): UserPropertiesModel + { + $this->repository->save($model); + return $model; + } + + /** + * Get properties by user ID + * + * @param string|Literal|int $userid + * @return UserPropertiesModel[] + * @throws DatabaseException + * @throws DbDriverNotConnected + * @throws FileException + * @throws XmlUtilException + * @throws InvalidArgumentException + */ + public function getByUserId(string|Literal|int $userid): array + { + $userIdMapping = $this->mapper->getFieldMap(UserPropertyField::Userid->value); + $userIdField = $userIdMapping->getFieldName(); + $query = Query::getInstance() + ->table($this->mapper->getTable()) + ->where("$userIdField = :userid", ['userid' => $userIdMapping->getUpdateFunctionValue($userid, null)]); + + return $this->repository->getByQuery($query); + } + + /** + * Get specific property by user ID and name + * + * @param string|Literal|int $userid + * @param string $propertyName + * @return UserPropertiesModel[] + * @throws DatabaseException + * @throws DbDriverNotConnected + * @throws FileException + * @throws XmlUtilException + * @throws InvalidArgumentException + */ + public function getByUserIdAndName(string|Literal|int $userid, string $propertyName): array + { + $userIdMapping = $this->mapper->getFieldMap(UserPropertyField::Userid->value); + $nameMapping = $this->mapper->getFieldMap(UserPropertyField::Name->value); + + $userIdField = $userIdMapping->getFieldName(); + $nameField = $nameMapping->getFieldName(); + + $query = Query::getInstance() + ->table($this->mapper->getTable()) + ->where("$userIdField = :userid", ['userid' => $userIdMapping->getUpdateFunctionValue($userid, null)]) + ->where("$nameField = :name", ['name' => $propertyName]); + + return $this->repository->getByQuery($query); + } + + /** + * Delete all properties for a user + * + * @param string|Literal|int $userid + * @return void + * @throws DatabaseException + * @throws DbDriverNotConnected + * @throws RepositoryReadOnlyException + * @throws \ByJG\MicroOrm\Exception\InvalidArgumentException + */ + public function deleteByUserId(string|Literal|int $userid): void + { + $userIdMapping = $this->mapper->getFieldMap(UserPropertyField::Userid->value); + $userIdField = $userIdMapping->getFieldName(); + + $deleteQuery = DeleteQuery::getInstance() + ->table($this->mapper->getTable()) + ->where("$userIdField = :userid", ['userid' => $userIdMapping->getUpdateFunctionValue($userid, null)]); + + $this->repository->deleteByQuery($deleteQuery); + } + + /** + * Delete specific property by user ID and name + * + * @param string|Literal|int $userid + * @param string $propertyName + * @param string|null $value + * @return void + * @throws DatabaseException + * @throws DbDriverNotConnected + * @throws RepositoryReadOnlyException + */ + public function deleteByUserIdAndName(string|Literal|int $userid, string $propertyName, ?string $value = null): void + { + $userIdMapping = $this->mapper->getFieldMap(UserPropertyField::Userid->value); + $userIdField = $userIdMapping->getFieldName(); + $nameField = $this->mapper->getFieldMap(UserPropertyField::Name->value)->getFieldName(); + $valueField = $this->mapper->getFieldMap(UserPropertyField::Value->value)->getFieldName(); + + $deleteQuery = DeleteQuery::getInstance() + ->table($this->mapper->getTable()) + ->where("$userIdField = :userid", ['userid' => $userIdMapping->getUpdateFunctionValue($userid, null)]) + ->where("$nameField = :name", ['name' => $propertyName]); + + if ($value !== null) { + $deleteQuery->where("$valueField = :value", ['value' => $value]); + } + + $this->repository->deleteByQuery($deleteQuery); + } + + /** + * Delete properties by name (all users) + * + * @param string $propertyName + * @param string|null $value + * @return void + * @throws DatabaseException + * @throws DbDriverNotConnected + * @throws RepositoryReadOnlyException + */ + public function deleteByName(string $propertyName, ?string $value = null): void + { + $nameField = $this->mapper->getFieldMap(UserPropertyField::Name->value)->getFieldName(); + $valueField = $this->mapper->getFieldMap(UserPropertyField::Value->value)->getFieldName(); + + $deleteQuery = DeleteQuery::getInstance() + ->table($this->mapper->getTable()) + ->where("$nameField = :name", ['name' => $propertyName]); + + if ($value !== null) { + $deleteQuery->where("$valueField = :value", ['value' => $value]); + } + + $this->repository->deleteByQuery($deleteQuery); + } + + /** + * Get the table name from mapper + * + * @return string + */ + public function getTableName(): string + { + return $this->mapper->getTable(); + } + + /** + * Get the mapper + * + * @return Mapper + */ + public function getMapper(): Mapper + { + return $this->mapper; + } + + /** + * Get the underlying repository + * + * @return Repository + */ + public function getRepository(): Repository + { + return $this->repository; + } +} diff --git a/src/Repository/UsersRepository.php b/src/Repository/UsersRepository.php new file mode 100644 index 0000000..fe84c40 --- /dev/null +++ b/src/Repository/UsersRepository.php @@ -0,0 +1,177 @@ +mapper = new Mapper($usersClass); + $this->repository = new Repository($executor, $usersClass); + } + + /** + * Save a user + * + * @param UserModel $model + * @return UserModel + * @throws DatabaseException + * @throws DbDriverNotConnected + * @throws FileException + * @throws InvalidArgumentException + * @throws OrmBeforeInvalidException + * @throws OrmInvalidFieldsException + * @throws XmlUtilException + * @throws RepositoryReadOnlyException + * @throws UpdateConstraintException + * @throws \Psr\SimpleCache\InvalidArgumentException + */ + public function save(UserModel $model): UserModel + { + $this->repository->save($model); + return $model; + } + + /** + * Get user by ID + * + * @param string|Literal|int $userid + * @return UserModel|null + * @throws DatabaseException + * @throws DbDriverNotConnected + * @throws FileException + * @throws InvalidArgumentException + * @throws OrmInvalidFieldsException + * @throws XmlUtilException + * @throws \Psr\SimpleCache\InvalidArgumentException + */ + public function getById(string|Literal|int $userid): ?UserModel + { + return $this->repository->get($userid); + } + + /** + * Get user by field value + * + * @param string $field Property name (e.g., 'username', 'email') + * @param string|Literal|int $value + * @return UserModel|null + * @throws DatabaseException + * @throws DbDriverNotConnected + * @throws FileException + * @throws XmlUtilException + * @throws \Psr\SimpleCache\InvalidArgumentException + */ + public function getByField(string $field, string|Literal|int $value): ?UserModel + { + // Map the property name to the actual database column name + $fieldMapping = $this->mapper->getFieldMap($field); + $dbColumnName = $fieldMapping ? $fieldMapping->getFieldName() : $field; + + $query = Query::getInstance() + ->table($this->mapper->getTable()) + ->where("$dbColumnName = :value", ['value' => $value]); + + $result = $this->repository->getByQuery($query); + return count($result) > 0 ? $result[0] : null; + } + + /** + * Get all users + * + * @return UserModel[] + * @throws DatabaseException + * @throws DbDriverNotConnected + * @throws FileException + * @throws XmlUtilException + * @throws \Psr\SimpleCache\InvalidArgumentException + */ + public function getAll(): array + { + $query = Query::getInstance()->table($this->mapper->getTable()); + return $this->repository->getByQuery($query); + } + + /** + * Delete user by ID + * + * @param string|Literal|int $userid + * @return void + * @throws \Exception + */ + public function deleteById(string|Literal|int $userid): void + { + $this->repository->delete($userid); + } + + /** + * Get the table name from mapper + * + * @return string + */ + public function getTableName(): string + { + return $this->mapper->getTable(); + } + + /** + * Get the primary key field name from mapper + * + * @return string|array + */ + public function getPrimaryKeyName(): string|array + { + return $this->mapper->getPrimaryKey(); + } + + /** + * Get the mapper + * + * @return Mapper + */ + public function getMapper(): Mapper + { + return $this->mapper; + } + + /** + * Get the underlying repository + * + * @return Repository + */ + public function getRepository(): Repository + { + return $this->repository; + } +} diff --git a/src/Service/UsersService.php b/src/Service/UsersService.php new file mode 100644 index 0000000..53cc21f --- /dev/null +++ b/src/Service/UsersService.php @@ -0,0 +1,600 @@ +usersRepository = $usersRepository; + $this->propertiesRepository = $propertiesRepository; + $this->passwordDefinition = $passwordDefinition; + $this->loginField = $loginField; + + $userMapper = $usersRepository->getRepository()->getMapper(); + $userCheck = ($userMapper->getFieldMap(UserField::Userid->value) !== null) && + ($userMapper->getFieldMap(UserField::Name->value) !== null) && + ($userMapper->getFieldMap(UserField::Email->value) !== null) && + ($userMapper->getFieldMap(UserField::Username->value) !== null) && + ($userMapper->getFieldMap(UserField::Password->value) !== null) && + ($userMapper->getFieldMap(UserField::Role->value) !== null); + + if (!$userCheck) { + throw new InvalidArgumentException('Invalid user repository field mappings'); + } + + // Validate password mapper implements PasswordMapperInterface (handles both string class name and instance) + $passwordUpdateFunction = $userMapper->getFieldMap(UserField::Password->value)->getUpdateFunction(); + if (!is_subclass_of($passwordUpdateFunction, PasswordMapperInterface::class, true)) { + throw new InvalidArgumentException('Password update function must implement PasswordMapperInterface'); + } + + $this->passwordDefinition?->setPasswordMapper($passwordUpdateFunction); + + $propertyMapper = $propertiesRepository->getRepository()->getMapper(); + $propertyCheck = ($propertyMapper->getFieldMap(UserPropertyField::Userid->value) !== null) && + ($propertyMapper->getFieldMap(UserPropertyField::Name->value) !== null) && + ($propertyMapper->getFieldMap(UserPropertyField::Value->value) !== null); + + if (!$propertyCheck) { + throw new InvalidArgumentException('Invalid property repository field mappings'); + } + } + + /** + * Get the user's repository + * + * @return UsersRepository + */ + public function getUsersRepository(): UsersRepository + { + return $this->usersRepository; + } + + /** + * Get the property repository + * + * @return UserPropertiesRepository + */ + public function getPropertiesRepository(): UserPropertiesRepository + { + return $this->propertiesRepository; + } + + /** + * @inheritDoc + */ + #[\Override] + public function save(UserModel $model): UserModel + { + $newUser = false; + if (empty($model->getUserid())) { + $this->canAddUser($model); + $newUser = true; + } + + $this->usersRepository->save($model); + + // Save properties + foreach ($model->getProperties() as $property) { + $property->setUserid($model->getUserid()); + $this->propertiesRepository->save($property); + } + + if ($newUser) { + $model = $this->getById($model->getUserid()); + } + + if ($model === null) { + throw new UserNotFoundException("User not found"); + } + + return $model; + } + + /** + * @param string|null $role + * @inheritDoc + */ + #[\Override] + public function addUser(string $name, string $userName, string $email, string $password, ?string $role = null): UserModel + { + + $mapper = $this->usersRepository->getMapper(); + $model = $mapper->getEntity([ + $mapper->getFieldMap(UserField::Name->value)->getFieldName() => $name, + $mapper->getFieldMap(UserField::Email->value)->getFieldName() => $email, + $mapper->getFieldMap(UserField::Username->value)->getFieldName() => $userName, + $mapper->getFieldMap(UserField::Role->value)->getFieldName() => $role + ]); + $this->applyPasswordDefinition($model); + $model->setPassword($password); + + return $this->save($model); + } + + /** + * Check if user can be added (doesn't exist already) + * + * @param UserModel $model + * @return bool + * @throws UserExistsException + */ + protected function canAddUser(UserModel $model): bool + { + if (!empty($model->getUserid()) && $this->getById($model->getUserid()) !== null) { + throw new UserExistsException('User ID already exists'); + } + + if ($this->getByUsername($model->getUsername()) !== null) { + throw new UserExistsException('Username already exists'); + } + + return true; + } + + /** + * @inheritDoc + */ + #[\Override] + public function getById(string|Literal|int $userid): ?UserModel + { + $user = $this->usersRepository->getById($userid); + if ($user !== null) { + $this->loadUserProperties($user); + $this->applyPasswordDefinition($user); + } + return $user; + } + + /** + * @inheritDoc + */ + #[\Override] + public function getByEmail(string $email): ?UserModel + { + $fieldMap = $this->usersRepository->getMapper()->getFieldMap(UserField::Email->value); + $user = $this->usersRepository->getByField($fieldMap->getFieldName(), $email); + if ($user !== null) { + $this->loadUserProperties($user); + $this->applyPasswordDefinition($user); + } + return $user; + } + + /** + * @inheritDoc + */ + #[\Override] + public function getByUsername(string $username): ?UserModel + { + $fieldMap = $this->usersRepository->getMapper()->getFieldMap(UserField::Username->value); + $user = $this->usersRepository->getByField($fieldMap->getFieldName(), $username); + if ($user !== null) { + $this->loadUserProperties($user); + $this->applyPasswordDefinition($user); + } + return $user; + } + + /** + * @inheritDoc + */ + #[\Override] + public function getByLogin(string $login): ?UserModel + { + return $this->loginField === LoginField::Email + ? $this->getByEmail($login) + : $this->getByUsername($login); + } + + /** + * Load user properties into user model + * + * @param UserModel $user + * @return void + */ + protected function loadUserProperties(UserModel $user): void + { + $properties = $this->propertiesRepository->getByUserId($user->getUserid()); + $user->setProperties($properties); + } + + protected function applyPasswordDefinition(UserModel $user): void + { + if ($this->passwordDefinition !== null) { + $user->withPasswordDefinition($this->passwordDefinition); + } + } + + protected function resolveUserFieldValue(UserModel $user, string $fieldName, ?string $default = null): mixed + { + $camel = str_replace(' ', '', ucwords(str_replace(['_', '-'], ' ' , $fieldName))); + $method = 'get' . $camel; + if (method_exists($user, $method)) { + $value = $user->$method(); + } else { + $value = $user->get($fieldName); + } + + return empty($value) ? $default : $value; + } + + /** + * @inheritDoc + */ + #[\Override] + public function removeByLogin(string $login): bool + { + $user = $this->getByLogin($login); + if ($user !== null) { + return $this->removeById($user->getUserid()); + } + return false; + } + + /** + * @inheritDoc + */ + #[\Override] + public function removeById(string|Literal|int $userid): bool + { + try { + // Delete properties first + $this->propertiesRepository->deleteByUserId($userid); + // Delete user + $this->usersRepository->deleteById($userid); + return true; + } catch (Exception $e) { + return false; + } + } + + /** + * @inheritDoc + */ + #[\Override] + public function isValidUser(string $login, string $password): ?UserModel + { + $user = $this->getByLogin($login); + + if ($user === null) { + return null; + } + + // Hash the password for comparison using the model's configured password mapper + $passwordFieldMapping = $this->usersRepository->getMapper()->getFieldMap(UserField::Password->value); + $hashedPassword = $passwordFieldMapping->getUpdateFunctionValue($password, null); + + if ($user->getPassword() === $hashedPassword) { + return $user; + } + + return null; + } + + /** + * @inheritDoc + */ + #[\Override] + public function hasProperty(string|int|Literal $userId, string $propertyName, ?string $value = null): bool + { + $user = $this->getById($userId); + + if (empty($user)) { + return false; + } + + $values = $user->get($propertyName); + + if ($values === null) { + return false; + } + + if ($value === null) { + return true; + } + + return in_array($value, (array)$values); + } + + /** + * @inheritDoc + */ + #[\Override] + public function getProperty(string|Literal|int $userId, string $propertyName): array|string|UserPropertiesModel|null + { + $properties = $this->propertiesRepository->getByUserIdAndName($userId, $propertyName); + + if (count($properties) === 0) { + return null; + } + + $result = []; + foreach ($properties as $property) { + $result[] = $property->getValue(); + } + + if (count($result) === 1) { + return $result[0]; + } + + return $result; + } + + /** + * @inheritDoc + */ + #[\Override] + public function addProperty(string|Literal|int $userId, string $propertyName, ?string $value): bool + { + $user = $this->getById($userId); + if (empty($user)) { + return false; + } + + if (!$this->hasProperty($userId, $propertyName, $value)) { + $propMapper = $this->propertiesRepository->getMapper(); + $property = $propMapper->getEntity([ + $propMapper->getFieldMap(UserPropertyField::Userid->value)->getFieldName() => $userId, + $propMapper->getFieldMap(UserPropertyField::Name->value)->getFieldName() => $propertyName, + $propMapper->getFieldMap(UserPropertyField::Value->value)->getFieldName() => $value + ]); + $this->propertiesRepository->save($property); + } + + return true; + } + + /** + * @inheritDoc + */ + #[\Override] + public function setProperty(string|Literal|int $userId, string $propertyName, ?string $value): bool + { + $properties = $this->propertiesRepository->getByUserIdAndName($userId, $propertyName); + + if (empty($properties)) { + $propMapper = $this->propertiesRepository->getMapper(); + $property = $propMapper->getEntity([ + $propMapper->getFieldMap(UserPropertyField::Userid->value)->getFieldName() => $userId, + $propMapper->getFieldMap(UserPropertyField::Name->value)->getFieldName() => $propertyName, + $propMapper->getFieldMap(UserPropertyField::Value->value)->getFieldName() => $value + ]); + } else { + $property = $properties[0]; + $property->setValue($value); + } + + $this->propertiesRepository->save($property); + return true; + } + + /** + * @inheritDoc + */ + #[\Override] + public function removeProperty(string|Literal|int $userId, string $propertyName, ?string $value = null): bool + { + $user = $this->getById($userId); + if ($user !== null) { + $this->propertiesRepository->deleteByUserIdAndName($userId, $propertyName, $value); + return true; + } + return false; + } + + /** + * @inheritDoc + */ + #[\Override] + public function removeAllProperties(string $propertyName, ?string $value = null): void + { + $this->propertiesRepository->deleteByName($propertyName, $value); + } + + /** + * @inheritDoc + */ + #[\Override] + public function getUsersByProperty(string $propertyName, string $value): array + { + return $this->getUsersByPropertySet([$propertyName => $value]); + } + + /** + * @inheritDoc + */ + #[\Override] + public function getUsersByPropertySet(array $propertiesArray): array + { + $userTable = $this->usersRepository->getTableName(); + $propTable = $this->propertiesRepository->getTableName(); + $userPk = $this->usersRepository->getPrimaryKeyName(); + + // Handle composite keys - use first key if array + if (is_array($userPk)) { + $userPk = $userPk[0]; + } + + $propUserIdField = $this->propertiesRepository->getMapper()->getFieldMap(UserPropertyField::Userid->value)->getFieldName(); + $propNameField = $this->propertiesRepository->getMapper()->getFieldMap(UserPropertyField::Name->value)->getFieldName(); + $propValueField = $this->propertiesRepository->getMapper()->getFieldMap(UserPropertyField::Value->value)->getFieldName(); + + $query = Query::getInstance() + ->field("u.*") + ->table($userTable, "u"); + + $count = 0; + foreach ($propertiesArray as $propertyName => $value) { + $count++; + $query->join($propTable, "p$count.$propUserIdField = u.$userPk", "p$count") + ->where("p$count.$propNameField = :name$count", ["name$count" => $propertyName]) + ->where("p$count.$propValueField = :value$count", ["value$count" => $value]); + } + + $users = $this->usersRepository->getRepository()->getByQuery($query); + if ($this->passwordDefinition !== null) { + foreach ($users as $user) { + $this->applyPasswordDefinition($user); + } + } + return $users; + } + + #[\Override] + public function createInsecureAuthToken( + string|UserModel $login, + JwtWrapper $jwtWrapper, + int $expires = 1200, + array $updateUserInfo = [], + array $updateTokenInfo = [], + array $tokenUserFields = [UserField::Userid, UserField::Name, UserField::Role] + ): ?UserToken + { + if (is_string($login)) { + $user = $this->getByLogin($login); + } else { + $user = $login; + } + + if (is_null($user)) { + throw new UserNotFoundException('User not found'); + } + + foreach ($updateUserInfo as $key => $value) { + $user->set($key, $value); + } + + foreach ($tokenUserFields as $key => $field) { + $default = null; + if (!is_numeric($key)) { + $default = $field; + $field = $key; + } + $fieldName = $field instanceof UserField ? $field->value : $field; + if (!is_string($fieldName) || $fieldName === '') { + continue; + } + + $value = $this->resolveUserFieldValue($user, $fieldName, $default); + if ($value !== null) { + $updateTokenInfo[$fieldName] = $value; + } + } + + $jwtData = $jwtWrapper->createJwtData($updateTokenInfo, $expires); + + $token = $jwtWrapper->generateToken($jwtData); + + $user->set('TOKEN_HASH', sha1($token)); + $this->save($user); + + return new UserToken( + user: $user, + token: $token, + data: $updateTokenInfo + ); + } + + /** + * @inheritDoc + */ + #[\Override] + public function createAuthToken( + string $login, + string $password, + JwtWrapper $jwtWrapper, + int $expires = 1200, + array $updateUserInfo = [], + array $updateTokenInfo = [], + array $tokenUserFields = [UserField::Userid, UserField::Name, UserField::Role] + ): ?UserToken + { + $user = $this->isValidUser($login, $password); + if (is_null($user)) { + throw new UserNotFoundException('User not found'); + } + + return $this->createInsecureAuthToken( + $user, + $jwtWrapper, + $expires, + $updateUserInfo, + $updateTokenInfo, + $tokenUserFields + ); + } + + /** + * @inheritDoc + * @throws JwtWrapperException + * @throws NotAuthenticatedException + * @throws UserNotFoundException + */ + #[\Override] + public function isValidToken(string $login, JwtWrapper $jwtWrapper, string $token): UserToken + { + $user = $this->getByLogin($login); + + if (is_null($user)) { + throw new UserNotFoundException('User not found!'); + } + + if ($user->get('TOKEN_HASH') !== sha1($token)) { + throw new NotAuthenticatedException('Token does not match'); + } + + $data = $jwtWrapper->extractData($token); + + $this->save($user); + + return new UserToken( + user: $user, + token: $token, + data: (array)$data->data + ); + } + + #[\Override] + public function getUsersEntity(array $fields): UserModel + { + $entity = $this->getUsersRepository()->getMapper()->getEntity($fields); + $this->applyPasswordDefinition($entity); + return $entity; + } +} diff --git a/src/SessionContext.php b/src/SessionContext.php index fe7a115..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,6 +40,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 +54,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 +66,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 +88,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 +115,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 +143,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 deleted file mode 100644 index 64a253b..0000000 --- a/src/UsersAnyDataset.php +++ /dev/null @@ -1,317 +0,0 @@ -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; - }); - } - $this->propertiesTable = $propertiesTable; - } - - /** - * Save the current UsersAnyDataset - * - * @param UserModel $model - * @return UserModel - * @throws DatabaseException - * @throws InvalidArgumentException - * @throws UserExistsException - * @throws FileException - */ - public function save(UserModel $model): UserModel - { - $new = true; - if (!empty($model->getUserid())) { - $new = !$this->removeUserById($model->getUserid()); - } - - $new && $this->canAddUser($model); - - $this->anyDataSet->appendRow(); - - $propertyDefinition = $this->getUserDefinition()->toArray(); - foreach ($propertyDefinition as $property => $map) { - $closure = $this->getUserDefinition()->getClosureForUpdate($property); - $value = $closure($model->{"get$property"}(), $model); - if ($value !== false) { - $this->anyDataSet->addField($map, $value); - } - } - - $properties = $model->getProperties(); - foreach ($properties as $value) { - $this->anyDataSet->addField($value->getName(), $value->getValue()); - } - - $this->anyDataSet->save(); - - return $model; - } - - /** - * Get the user based on a filter. - * Return Row if user was found; null, otherwise - * - * @param IteratorFilter $filter Filter to find user - * @return UserModel|null - * @throws InvalidArgumentException - */ - public function getUser(IteratorFilter $filter): UserModel|null - { - $iterator = $this->anyDataSet->getIterator($filter); - if (!$iterator->hasNext()) { - return null; - } - - return $this->createUserModel($iterator->moveNext()); - } - - /** - * Get the user based on his login. - * Return Row if user was found; null, otherwise - * - * @param string $login - * @return boolean - * @throws DatabaseException - * @throws FileException - * @throws InvalidArgumentException - */ - public function removeByLoginField(string $login): bool - { - //anydataset.Row - $iteratorFilter = new IteratorFilter(); - $iteratorFilter->and($this->getUserDefinition()->loginField(), Relation::EQUAL, $login); - $iterator = $this->anyDataSet->getIterator($iteratorFilter); - - if ($iterator->hasNext()) { - $oldRow = $iterator->moveNext(); - $this->anyDataSet->removeRow($oldRow); - $this->anyDataSet->save(); - return true; - } - - return false; - } - - /** - * Get an Iterator based on a filter - * - * @param IteratorFilter|null $filter - * @return IteratorInterface - */ - public function getIterator(IteratorFilter $filter = null): IteratorInterface - { - return $this->anyDataSet->getIterator($filter); - } - - /** - * @throws InvalidArgumentException - */ - public function getUsersByProperty(string $propertyName, string $value): array - { - return $this->getUsersByPropertySet([$propertyName => $value]); - } - - /** - * @throws InvalidArgumentException - */ - public function getUsersByPropertySet(array $propertiesArray): array - { - $filter = new IteratorFilter(); - foreach ($propertiesArray as $propertyName => $value) { - $filter->and($propertyName, Relation::EQUAL, $value); - } - $result = []; - foreach ($this->getIterator($filter) as $model) { - $result[] = $this->createUserModel($model); - } - return $result; - } - - /** - * @param string|int|HexUuidLiteral $userId - * @param string $propertyName - * @param string|null $value - * @return boolean - * @throws DatabaseException - * @throws FileException - * @throws InvalidArgumentException - * @throws UserExistsException - * @throws UserNotFoundException - */ - public function addProperty(string|HexUuidLiteral|int $userId, string $propertyName, string|null $value): bool - { - //anydataset.Row - $user = $this->getById($userId); - if ($user !== null) { - if (!$this->hasProperty($user->getUserid(), $propertyName, $value)) { - $user->addProperty(new UserPropertiesModel($propertyName, $value)); - $this->save($user); - } - return true; - } - - return false; - } - - /** - * @throws InvalidArgumentException - * @throws DatabaseException - * @throws UserExistsException - * @throws FileException - */ - public function setProperty(string|HexUuidLiteral|int $userId, string $propertyName, string|null $value): bool - { - $user = $this->getById($userId); - if ($user !== null) { - $user->set($propertyName, $value); - $this->save($user); - return true; - } - return false; - } - - /** - * @param string|int|HexUuidLiteral $userId - * @param string $propertyName - * @param string|null $value - * @return boolean - * @throws DatabaseException - * @throws FileException - * @throws InvalidArgumentException - * @throws UserExistsException - */ - public function removeProperty(string|HexUuidLiteral|int $userId, string $propertyName, string|null $value = null): bool - { - $user = $this->getById($userId); - if (!empty($user)) { - $properties = $user->getProperties(); - foreach ($properties as $key => $property) { - if ($property->getName() == $propertyName && (empty($value) || $property->getValue() == $value)) { - unset($properties[$key]); - } - } - $user->setProperties($properties); - $this->save($user); - return true; - } - - return false; - } - - /** - * Remove a specific site from all users - * Return True or false - * - * @param string $propertyName Property name - * @param string|null $value Property value with a site - * @return void - * @throws DatabaseException - * @throws FileException - * @throws InvalidArgumentException - * @throws UserExistsException - */ - public function removeAllProperties(string $propertyName, string|null $value = null): void - { - $iterator = $this->getIterator(null); - while ($iterator->hasNext()) { - //anydataset.Row - $user = $iterator->moveNext(); - $this->removeProperty($user->get($this->getUserDefinition()->getUserid()), $propertyName, $value); - } - } - - /** - * @param Row $row - * @return UserModel - * @throws InvalidArgumentException - */ - private function createUserModel(Row $row): UserModel - { - $allProp = $row->toArray(); - $userModel = new UserModel(); - - $userTableProp = $this->getUserDefinition()->toArray(); - foreach ($userTableProp as $prop => $mapped) { - if (isset($allProp[$mapped])) { - $userModel->{"set" . ucfirst($prop)}($allProp[$mapped]); - unset($allProp[$mapped]); - } - } - - foreach (array_keys($allProp) as $property) { - foreach ($row->getAsArray($property) as $eachValue) { - $userModel->addProperty(new UserPropertiesModel($property, $eachValue)); - } - } - - return $userModel; - } - - /** - * @param string|HexUuidLiteral|int $userid - * @return bool - * @throws InvalidArgumentException - */ - public function removeUserById(string|HexUuidLiteral|int $userid): bool - { - $iteratorFilter = new IteratorFilter(); - $iteratorFilter->and($this->getUserDefinition()->getUserid(), Relation::EQUAL, $userid); - $iterator = $this->anyDataSet->getIterator($iteratorFilter); - - if ($iterator->hasNext()) { - $oldRow = $iterator->moveNext(); - $this->anyDataSet->removeRow($oldRow); - return true; - } - - return false; - } -} diff --git a/src/UsersBase.php b/src/UsersBase.php deleted file mode 100644 index e8ed42d..0000000 --- a/src/UsersBase.php +++ /dev/null @@ -1,402 +0,0 @@ -userTable === null) { - $this->userTable = new UserDefinition(); - } - return $this->userTable; - } - - /** - * @return UserPropertiesDefinition - */ - public function getUserPropertiesDefinition(): UserPropertiesDefinition - { - if ($this->propertiesTable === null) { - $this->propertiesTable = new UserPropertiesDefinition(); - } - return $this->propertiesTable; - } - - /** - * Save the current UsersAnyDataset - * - * @param UserModel $model - */ - abstract public function save(UserModel $model): UserModel; - - /** - * Add new user in database - * - * @param string $name - * @param string $userName - * @param string $email - * @param string $password - * @return UserModel - */ - public function addUser(string $name, string $userName, string $email, string $password): UserModel - { - $model = $this->getUserDefinition()->modelInstance(); - $model->setName($name); - $model->setEmail($email); - $model->setUsername($userName); - $model->setPassword($password); - - return $this->save($model); - } - - /** - * @param UserModel $model - * @return bool - * @throws UserExistsException - * @throws InvalidArgumentException - */ - public function canAddUser(UserModel $model): bool - { - if ($this->getByEmail($model->getEmail()) !== null) { - throw new UserExistsException('Email already exists'); - } - $filter = new IteratorFilter(); - $filter->and($this->getUserDefinition()->getUsername(), Relation::EQUAL, $model->getUsername()); - if ($this->getUser($filter) !== null) { - throw new UserExistsException('Username already exists'); - } - - return false; - } - - /** - * Get the user based on a filter. - * Return Row if user was found; null, otherwise - * - * @param IteratorFilter $filter Filter to find user - * @return UserModel|null - * */ - 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 - */ - 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. - * Return Row if user was found; null, otherwise - * - * @param string $username - * @return UserModel|null - * @throws InvalidArgumentException - */ - public function getByUsername(string $username): 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 - */ - public function getByLoginField(string $login): UserModel|null - { - $filter = new IteratorFilter(); - $filter->and($this->getUserDefinition()->loginField(), Relation::EQUAL, strtolower($login)); - - return $this->getUser($filter); - } - - /** - * 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 - */ - public function getById(string|HexUuidLiteral|int $userid): UserModel|null - { - $filter = new IteratorFilter(); - $filter->and($this->getUserDefinition()->getUserid(), Relation::EQUAL, $userid); - return $this->getUser($filter); - } - - /** - * Remove the user based on his login. - * - * @param string $login - * @return bool - * */ - abstract public function removeByLoginField(string $login): bool; - - /** - * Validate if the user and password exists in the file - * Return Row if user exists; null, otherwise - * - * @param string $userName User login - * @param string $password Plain text password - * @return UserModel|null - * @throws InvalidArgumentException - */ - public function isValidUser(string $userName, string $password): UserModel|null - { - $filter = new IteratorFilter(); - $passwordGenerator = $this->getUserDefinition()->getClosureForUpdate(UserDefinition::FIELD_PASSWORD); - $filter->and($this->getUserDefinition()->loginField(), Relation::EQUAL, strtolower($userName)); - $filter->and( - $this->getUserDefinition()->getPassword(), - Relation::EQUAL, - $passwordGenerator($password, null) - ); - return $this->getUser($filter); - } - - /** - * Check if the user have a property and it has a specific value. - * Return True if you have rights; false, otherwise - * - * @param string|int|HexUuidLiteral|null $userId User identification - * @param string $propertyName - * @param string|null $value Property value - * @return bool - * @throws UserNotFoundException - * @throws InvalidArgumentException - */ - public function hasProperty(string|int|HexUuidLiteral|null $userId, string $propertyName, string $value = null): bool - { - //anydataset.Row - $user = $this->getById($userId); - - if (empty($user)) { - return false; - } - - if ($this->isAdmin($userId)) { - return true; - } - - $values = $user->get($propertyName); - - if ($values === null) { - return false; - } - - if ($value === null) { - return true; - } - - return in_array($value, (array)$values); - } - - /** - * Return all sites from a specific user - * Return String vector with all sites - * - * @param string|int|HexUuidLiteral $userId User ID - * @param string $propertyName Property name - * @return array|string|UserPropertiesModel|null - * @throws UserNotFoundException - * @throws InvalidArgumentException - */ - public function getProperty(string|HexUuidLiteral|int $userId, string $propertyName): array|string|UserPropertiesModel|null - { - $user = $this->getById($userId); - if ($user !== null) { - $values = $user->get($propertyName); - - if ($this->isAdmin($userId)) { - return array(UserDefinition::FIELD_ADMIN => "admin"); - } - - return $values; - } - - return null; - } - - abstract public function getUsersByProperty(string $propertyName, string $value): array; - - abstract public function getUsersByPropertySet(array $propertiesArray): array; - - /** - * - * @param string|int|HexUuidLiteral $userId - * @param string $propertyName - * @param string|null $value - */ - abstract public function addProperty(string|HexUuidLiteral|int $userId, string $propertyName, string|null $value): bool; - - /** - * Remove a specific site from user - * Return True or false - * - * @param string|int|HexUuidLiteral $userId User login - * @param string $propertyName Property name - * @param string|null $value Property value with a site - * @return bool - * */ - abstract public function removeProperty(string|HexUuidLiteral|int $userId, string $propertyName, string|null $value = null): bool; - - /** - * Remove a specific site from all users - * Return True or false - * - * @param string $propertyName Property name - * @param string|null $value Property value with a site - * @return void - * */ - abstract public function removeAllProperties(string $propertyName, string|null $value = null): void; - - /** - * @param string|int|HexUuidLiteral $userId - * @return bool - * @throws UserNotFoundException - * @throws InvalidArgumentException - */ - 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 - * - * @param string $login - * @param string $password - * @param JwtWrapper $jwtWrapper - * @param int $expires - * @param array $updateUserInfo - * @param array $updateTokenInfo - * @return string|null the TOKEN or false if you don't. - * @throws UserNotFoundException - * @throws InvalidArgumentException - */ - public function createAuthToken( - string $login, - string $password, - JwtWrapper $jwtWrapper, - int $expires = 1200, - array $updateUserInfo = [], - array $updateTokenInfo = [] - ): string|null - { - $user = $this->isValidUser($login, $password); - if (is_null($user)) { - throw new UserNotFoundException('User not found'); - } - - foreach ($updateUserInfo as $key => $value) { - $user->set($key, $value); - } - - $updateTokenInfo['login'] = $login; - $updateTokenInfo[UserDefinition::FIELD_USERID] = $user->getUserid(); - $jwtData = $jwtWrapper->createJwtData( - $updateTokenInfo, - $expires - ); - - $token = $jwtWrapper->generateToken($jwtData); - - $user->set('TOKEN_HASH', sha1($token)); - $this->save($user); - - return $token; - } - - /** - * Check if the Auth Token is valid - * - * @param string $login - * @param JwtWrapper $jwtWrapper - * @param string $token - * @return array|null - * @throws JwtWrapperException - * @throws NotAuthenticatedException - * @throws UserNotFoundException - */ - public function isValidToken(string $login, JwtWrapper $jwtWrapper, string $token): array|null - { - $user = $this->getByLoginField($login); - - if (is_null($user)) { - throw new UserNotFoundException('User not found!'); - } - - if ($user->get('TOKEN_HASH') !== sha1($token)) { - throw new NotAuthenticatedException('Token does not match'); - } - - $data = $jwtWrapper->extractData($token); - - $this->save($user); - - return [ - 'user' => $user, - 'data' => $data->data - ]; - } - - /** - * @param string|int|HexUuidLiteral $userid - */ - abstract public function removeUserById(string|HexUuidLiteral|int $userid): bool; -} diff --git a/src/UsersDBDataset.php b/src/UsersDBDataset.php deleted file mode 100644 index 8c29126..0000000 --- a/src/UsersDBDataset.php +++ /dev/null @@ -1,424 +0,0 @@ -model(), - $userTable->table(), - $userTable->getUserid() - ); - $seed = $userTable->getGenerateKeyClosure(); - if (!empty($seed)) { - $userMapper->withPrimaryKeySeedFunction($seed); - } - - $propertyDefinition = $userTable->toArray(); - - foreach ($propertyDefinition as $property => $map) { - $userMapper->addFieldMapping(FieldMapping::create($property) - ->withFieldName($map) - ->withUpdateFunction($userTable->getClosureForUpdate($property)) - ->withSelectFunction($userTable->getClosureForSelect($property)) - ); - } - $this->userRepository = new Repository($dbDriver, $userMapper); - - $propertiesMapper = new Mapper( - UserPropertiesModel::class, - $propertiesTable->table(), - $propertiesTable->getId() - ); - $propertiesMapper->addFieldMapping(FieldMapping::create('id')->withFieldName($propertiesTable->getId())); - $propertiesMapper->addFieldMapping(FieldMapping::create('name')->withFieldName($propertiesTable->getName())); - $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)) - ); - $this->propertiesRepository = new Repository($dbDriver, $propertiesMapper); - - $this->userTable = $userTable; - $this->propertiesTable = $propertiesTable; - } - - /** - * Save the current UsersAnyDataset - * - * @param UserModel $model - * @return UserModel - * @throws UserExistsException - * @throws OrmBeforeInvalidException - * @throws OrmInvalidFieldsException - * @throws Exception - */ - public function save(UserModel $model): UserModel - { - $newUser = false; - if (empty($model->getUserid())) { - $this->canAddUser($model); - $newUser = true; - } - - $this->userRepository->setBeforeUpdate($this->userTable->getBeforeUpdate()); - $this->userRepository->setBeforeInsert($this->userTable->getBeforeInsert()); - $this->userRepository->save($model); - - foreach ($model->getProperties() as $property) { - $property->setUserid($model->getUserid()); - $this->propertiesRepository->save($property); - } - - if ($newUser) { - $model = $this->getByEmail($model->getEmail()); - } - - if ($model === null) { - throw new UserExistsException("User not found"); - } - - return $model; - } - - /** - * Get the users database information based on a filter. - * - * @param IteratorFilter|null $filter Filter to find user - * @return UserModel[] - */ - public function getIterator(IteratorFilter $filter = null): array - { - if (is_null($filter)) { - $filter = new IteratorFilter(); - } - - $param = []; - $formatter = new IteratorFilterSqlFormatter(); - $sql = $formatter->getFilter($filter->getRawFilters(), $param); - - $query = Query::getInstance() - ->table($this->getUserDefinition()->table()) - ->where($sql, $param); - - return $this->userRepository->getByQuery($query); - } - - /** - * Get the user based on a filter. - * Return Row if user was found; null, otherwise - * - * @param IteratorFilter $filter Filter to find user - * @return UserModel|null - */ - public function getUser(IteratorFilter $filter): UserModel|null - { - $result = $this->getIterator($filter); - if (count($result) === 0) { - return null; - } - - $model = $result[0]; - - $this->setPropertiesInUser($model); - - return $model; - } - - /** - * Remove the user based on his user login. - * - * @param string $login - * @return bool - * @throws Exception - */ - public function removeByLoginField(string $login): bool - { - $user = $this->getByLoginField($login); - - if ($user !== null) { - return $this->removeUserById($user->getUserid()); - } - - return false; - } - - /** - * Remove the user based on his user id. - * - * @param string|HexUuidLiteral|int $userid - * @return bool - * @throws Exception - */ - public function removeUserById(string|HexUuidLiteral|int $userid): bool - { - $updateTableProperties = DeleteQuery::getInstance() - ->table($this->getUserPropertiesDefinition()->table()) - ->where( - "{$this->getUserPropertiesDefinition()->getUserid()} = :id", - [ - "id" => $userid - ] - ); - $this->propertiesRepository->deleteByQuery($updateTableProperties); - - $this->userRepository->delete($userid); - - return true; - } - - /** - * Get the user based on his property. - * - * @param string $propertyName - * @param string $value - * @return array - * @throws InvalidArgumentException - * @throws ExceptionInvalidArgumentException - */ - public function getUsersByProperty(string $propertyName, string $value): array - { - return $this->getUsersByPropertySet([$propertyName => $value]); - } - - /** - * Get the user based on his property and value. e.g. [ 'key' => 'value', 'key2' => 'value2' ]. - * - * @param array $propertiesArray - * @return array - * @throws InvalidArgumentException - * @throws ExceptionInvalidArgumentException - */ - public function getUsersByPropertySet(array $propertiesArray): array - { - $query = Query::getInstance() - ->field("u.*") - ->table($this->getUserDefinition()->table(), "u"); - - $count = 0; - foreach ($propertiesArray as $propertyName => $value) { - $count++; - $query->join($this->getUserPropertiesDefinition()->table(), "p$count.{$this->getUserPropertiesDefinition()->getUserid()} = u.{$this->getUserDefinition()->getUserid()}", "p$count") - ->where("p$count.{$this->getUserPropertiesDefinition()->getName()} = :name$count", ["name$count" => $propertyName]) - ->where("p$count.{$this->getUserPropertiesDefinition()->getValue()} = :value$count", ["value$count" => $value]); - } - - return $this->userRepository->getByQuery($query); - } - - /** - * @param string|int|HexUuidLiteral $userId - * @param string $propertyName - * @param string|null $value - * @return bool - * @throws UserNotFoundException - * @throws OrmBeforeInvalidException - * @throws OrmInvalidFieldsException - * @throws Exception - */ - public function addProperty(string|HexUuidLiteral|int $userId, string $propertyName, string|null $value): bool - { - //anydataset.Row - $user = $this->getById($userId); - if (empty($user)) { - return false; - } - - if (!$this->hasProperty($userId, $propertyName, $value)) { - $propertiesModel = new UserPropertiesModel($propertyName, $value); - $propertiesModel->setUserid($userId); - $this->propertiesRepository->save($propertiesModel); - } - - return true; - } - - /** - * @throws UpdateConstraintException - * @throws RepositoryReadOnlyException - * @throws InvalidArgumentException - * @throws OrmBeforeInvalidException - * @throws ExceptionInvalidArgumentException - * @throws OrmInvalidFieldsException - */ - public function setProperty(string|HexUuidLiteral|int $userId, string $propertyName, string|null $value): bool - { - $query = Query::getInstance() - ->table($this->getUserPropertiesDefinition()->table()) - ->where("{$this->getUserPropertiesDefinition()->getUserid()} = :id", ["id" => $userId]) - ->where("{$this->getUserPropertiesDefinition()->getName()} = :name", ["name" => $propertyName]); - - $userProperty = $this->propertiesRepository->getByQuery($query); - if (empty($userProperty)) { - $userProperty = new UserPropertiesModel($propertyName, $value); - $userProperty->setUserid($userId); - } else { - $userProperty = $userProperty[0]; - $userProperty->setValue($value); - } - - $this->propertiesRepository->save($userProperty); - - return true; - } - - /** - * Remove a specific site from user - * Return True or false - * - * @param string|int|HexUuidLiteral $userId User Id - * @param string $propertyName Property name - * @param string|null $value Property value with a site - * @return bool - * @throws ExceptionInvalidArgumentException - * @throws InvalidArgumentException - * @throws RepositoryReadOnlyException - */ - public function removeProperty(string|HexUuidLiteral|int $userId, string $propertyName, string|null $value = null): bool - { - $user = $this->getById($userId); - if ($user !== null) { - - $updateable = DeleteQuery::getInstance() - ->table($this->getUserPropertiesDefinition()->table()) - ->where("{$this->getUserPropertiesDefinition()->getUserid()} = :id", ["id" => $userId]) - ->where("{$this->getUserPropertiesDefinition()->getName()} = :name", ["name" => $propertyName]); - - if (!empty($value)) { - $updateable->where("{$this->getUserPropertiesDefinition()->getValue()} = :value", ["value" => $value]); - } - - $this->propertiesRepository->deleteByQuery($updateable); - - return true; - } - - return false; - } - - /** - * Remove a specific site from all users - * Return True or false - * - * @param string $propertyName Property name - * @param string|null $value Property value with a site - * @return void - * @throws ExceptionInvalidArgumentException - * @throws RepositoryReadOnlyException - */ - public function removeAllProperties(string $propertyName, string|null $value = null): void - { - $updateable = DeleteQuery::getInstance() - ->table($this->getUserPropertiesDefinition()->table()) - ->where("{$this->getUserPropertiesDefinition()->getName()} = :name", ["name" => $propertyName]); - - if (!empty($value)) { - $updateable->where("{$this->getUserPropertiesDefinition()->getValue()} = :value", ["value" => $value]); - } - - $this->propertiesRepository->deleteByQuery($updateable); - } - - public function getProperty(string|HexUuidLiteral|int $userId, string $propertyName): array|string|UserPropertiesModel|null - { - $query = Query::getInstance() - ->table($this->getUserPropertiesDefinition()->table()) - ->where("{$this->getUserPropertiesDefinition()->getUserid()} = :id", ['id' =>$userId]) - ->where("{$this->getUserPropertiesDefinition()->getName()} = :name", ['name' =>$propertyName]); - - $result = []; - foreach ($this->propertiesRepository->getByQuery($query) as $model) { - $result[] = $model->getValue(); - } - - if (count($result) === 0) { - return null; - } - - if (count($result) === 1) { - return $result[0]; - } - - return $result; - } - - /** - * Return all property's fields from this user - * - * @param UserModel $userRow - * @throws RepositoryReadOnlyException - */ - protected function setPropertiesInUser(UserModel $userRow): void - { - $value = $this->propertiesRepository->getMapper()->getFieldMap(UserDefinition::FIELD_USERID)->getUpdateFunctionValue($userRow->getUserid(), $userRow, $this->propertiesRepository->getDbDriverWrite()->getDbHelper()); - $query = Query::getInstance() - ->table($this->getUserPropertiesDefinition()->table()) - ->where("{$this->getUserPropertiesDefinition()->getUserid()} = :id", ['id' => $value]); - $userRow->setProperties($this->propertiesRepository->getByQuery($query)); - } -} diff --git a/tests/CustomUserModel.php b/tests/CustomUserModel.php new file mode 100644 index 0000000..58f1767 --- /dev/null +++ b/tests/CustomUserModel.php @@ -0,0 +1,41 @@ +setOtherfield($field); + } + + public function getOtherfield() + { + return $this->otherfield; + } + + public function setOtherfield($otherfield): void + { + $this->otherfield = $otherfield; + } +} diff --git a/tests/Fixture/MyUserPropertiesModel.php b/tests/Fixture/MyUserPropertiesModel.php new file mode 100644 index 0000000..93d1bc8 --- /dev/null +++ b/tests/Fixture/MyUserPropertiesModel.php @@ -0,0 +1,23 @@ +isPasswordEncrypted($value)) { + return $value; + } + + // Leave null + if (empty($value)) { + return null; + } + + // Return the MD5 hash + return strtolower(md5($value)); + } + + #[\Override] + public function isPasswordEncrypted(mixed $password): bool + { + return is_string($password) && strlen($password) === 32 && ctype_xdigit($password); + } +} diff --git a/tests/Fixture/TestUniqueIdGenerator.php b/tests/Fixture/TestUniqueIdGenerator.php new file mode 100644 index 0000000..19fa231 --- /dev/null +++ b/tests/Fixture/TestUniqueIdGenerator.php @@ -0,0 +1,23 @@ +prefix = $prefix; + } + + #[Override] + public function processedValue(mixed $value, mixed $instance, ?DatabaseExecutor $executor = null): mixed + { + return $this->prefix . uniqid(); + } +} diff --git a/tests/Fixture/UserModelMd5.php b/tests/Fixture/UserModelMd5.php new file mode 100644 index 0000000..ed2ef98 --- /dev/null +++ b/tests/Fixture/UserModelMd5.php @@ -0,0 +1,41 @@ + 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, @@ -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(): void + { + 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(): void + { + 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(): void + { + 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/PasswordMd5MapperTest.php b/tests/PasswordMd5MapperTest.php new file mode 100644 index 0000000..6257082 --- /dev/null +++ b/tests/PasswordMd5MapperTest.php @@ -0,0 +1,193 @@ +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_at datetime default (datetime(\'2017-12-04\')), + updated_at datetime, + deleted_at datetime, + role varchar(20));' + ); + + $this->db->execute('create table users_property ( + id integer primary key autoincrement, + userid integer, + name varchar(45), + value varchar(45));' + ); + + // Create repositories and service with custom MD5 password mapper via UserModelMd5 + $executor = DatabaseExecutor::using($this->db); + $usersRepository = new UsersRepository($executor, UserModelMd5::class); + $propertiesRepository = new UserPropertiesRepository($executor, UserPropertiesModel::class); + $this->service = new UsersService( + $usersRepository, + $propertiesRepository, + LoginField::Username + ); + } + + #[\Override] + public function tearDown(): void + { + $uri = new Uri(self::CONNECTION_STRING); + if (file_exists($uri->getPath())) { + unlink($uri->getPath()); + } + $this->db = null; + $this->service = null; + } + + public function testPasswordIsHashedWithMd5OnSave(): void + { + // Add a user with a plain text password + $plainPassword = 'mySecretPassword123'; + $user = $this->service->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(): void + { + // Create a user + $user = $this->service->addUser('Jane Doe', 'janedoe', 'jane@example.com', 'password123'); + $originalHash = $user->getPassword(); + + // Update the user without changing password + $user->setName('Jane Smith'); + $updatedUser = $this->service->save($user); + + // Password hash should remain the same + $this->assertEquals($originalHash, $updatedUser->getPassword()); + } + + public function testPasswordIsHashedWhenUpdating(): void + { + // Create a user + $user = $this->service->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 = $this->service->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 = $this->service->isValidUser('janedoe', $newPlainPassword); + $this->assertNotNull($authenticatedUser); + + // Verify user cannot login with old password + $authenticatedUserOld = $this->service->isValidUser('janedoe', 'oldPassword'); + $this->assertNull($authenticatedUserOld); + } + + public function testPasswordRemainsUnchangedWhenUpdatingOtherFields(): void + { + // Create a user + $originalPassword = 'myPassword123'; + $user = $this->service->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->setRole('admin'); + $updatedUser = $this->service->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('admin', $updatedUser->getRole()); + + // Verify user can still login with original password + $authenticatedUser = $this->service->isValidUser('johnsmith', $originalPassword); + $this->assertNotNull($authenticatedUser); + $this->assertEquals('John Updated', $authenticatedUser->getName()); + } + + public function testUserCanLoginWithMd5HashedPassword(): void + { + // Add a user + $plainPassword = 'testPassword456'; + $this->service->addUser('Test User', 'testuser', 'test@example.com', $plainPassword); + + // Verify user can login with the plain text password + $authenticatedUser = $this->service->isValidUser('testuser', $plainPassword); + + $this->assertNotNull($authenticatedUser); + $this->assertEquals('Test User', $authenticatedUser->getName()); + $this->assertEquals('testuser', $authenticatedUser->getUsername()); + } + + public function testUserCannotLoginWithWrongPassword(): void + { + // Add a user + $this->service->addUser('Test User', 'testuser', 'test@example.com', 'correctPassword'); + + // Try to login with wrong password + $authenticatedUser = $this->service->isValidUser('testuser', 'wrongPassword'); + + $this->assertNull($authenticatedUser); + } + + public function testEmptyPasswordReturnsNull(): void + { + $mapper = new PasswordMd5Mapper(); + + $this->assertNull($mapper->processedValue('', null)); + $this->assertNull($mapper->processedValue(null, null)); + } + + public function testExistingMd5HashIsNotRehashed(): void + { + $mapper = new PasswordMd5Mapper(); + $existingHash = '5f4dcc3b5aa765d61d8327deb882cf99'; // MD5 of 'password' + + $result = $mapper->processedValue($existingHash, null); + + $this->assertEquals($existingHash, $result); + } +} diff --git a/tests/SessionContextTest.php b/tests/SessionContextTest.php index 33b1067..f09f672 100644 --- a/tests/SessionContextTest.php +++ b/tests/SessionContextTest.php @@ -14,17 +14,19 @@ 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; } - public function testUserContext() + public function testUserContext(): void { $this->assertFalse($this->object->isAuthenticated()); @@ -44,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/TestUsersBase.php b/tests/TestUsersBase.php new file mode 100644 index 0000000..e58447d --- /dev/null +++ b/tests/TestUsersBase.php @@ -0,0 +1,746 @@ +loginField) { + LoginField::Email => $forEmail, + LoginField::Username => $forUsername, + }; + } + + #[\Override] + public function setUp(): void + { + $this->__setUp(LoginField::Username); + } + + /** + * @return void + */ + abstract public function testAddUser(); + + public function testAddUserError(): void + { + $this->expectException(UserExistsException::class); + $this->object->addUser('some user with same username', 'user2', 'user2@gmail.com', 'mypassword'); + } + + public function testAddProperty(): void + { + // Check state + $user = $this->object->getById($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'); + $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'); + $this->assertEquals(['Rio de Janeiro', 'Belo Horizonte'], $user->get('city')); + + // Get Property + $this->assertEquals(['Rio de Janeiro', 'Belo Horizonte'], $this->object->getProperty($this->prefix . '2', 'city')); + + // Add another property + $this->object->addProperty($this->prefix . '2', 'state', 'RJ'); + $user = $this->object->getById($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'); + $this->assertEmpty($user->get('state')); + + // Remove Property Again + $this->object->removeProperty($this->prefix . '2', 'city', 'Rio de Janeiro'); + $this->assertEquals('Belo Horizonte', $this->object->getProperty($this->prefix . '2', 'city')); + + } + + public function testRemoveAllProperties(): void + { + // Add the properties + $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'); + $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'); + $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'); + $this->assertEquals(['Rio de Janeiro', 'Niteroi'], $user->get('city')); + $this->assertEmpty($user->get('state')); + $user = $this->object->getById($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'); + $this->assertEquals('Rio de Janeiro', $user->get('city')); + $this->assertEmpty($user->get('state')); + $user = $this->object->getById($this->prefix . '1'); + $this->assertEmpty($user->get('city')); + $this->assertEmpty($user->get('state')); + + } + + public function testRemoveByLoginField(): void + { + $login = $this->__chooseValue('user1', 'user1@gmail.com'); + + $user = $this->object->getByLogin($login); + $this->assertNotNull($user); + + $result = $this->object->removeByLogin($login); + $this->assertTrue($result); + + $user = $this->object->getByLogin($login); + $this->assertNull($user); + } + + public function testEditUser(): void + { + $login = $this->__chooseValue('user1', 'user1@gmail.com'); + + // Getting data + $user = $this->object->getByLogin($login); + $this->assertEquals('User 1', $user->getName()); + + // Change and Persist data + $user->setName('Other name'); + $this->object->save($user); + + // Check if data persists + $user = $this->object->getById($this->prefix . '1'); + $this->assertEquals('Other name', $user->getName()); + } + + public function testIsValidUser(): void + { + $login = $this->__chooseValue('user3', 'user3@gmail.com'); + $loginFalse = $this->__chooseValue('user3@gmail.com', 'user3'); + + // User Exists! + $user = $this->object->isValidUser($login, 'pwd3'); + $this->assertEquals('User 3', $user->getName()); + + // User Does not Exists! + $user = $this->object->isValidUser($loginFalse, 'pwd5'); + $this->assertNull($user); + } + + public function testIsAdmin(): void + { + // Check user has no role initially + $user3 = $this->object->getById($this->prefix . '3'); + $this->assertFalse($user3->hasRole('admin')); + $this->assertEquals("foobar", $user3->getRole()); + + // Set a role + $login = $this->__chooseValue('user3', 'user3@gmail.com'); + $user = $this->object->getByLogin($login); + $user->setRole('admin'); + $this->object->save($user); + + // Check user has the role + $user3 = $this->object->getById($this->prefix . '3'); + $this->assertTrue($user3->hasRole('admin')); + $this->assertTrue($user3->hasRole('ADMIN')); // Case insensitive + $this->assertFalse($user3->hasRole('user')); + $this->assertEquals('admin', $user3->getRole()); + + // Test different roles + $user->setRole('moderator'); + $this->object->save($user); + + $user3 = $this->object->getById($this->prefix . '3'); + $this->assertFalse($user3->hasRole('admin')); + $this->assertTrue($user3->hasRole('moderator')); + $this->assertEquals('moderator', $user3->getRole()); + } + + protected function expectedToken(string $tokenData, string $login, int $userId): void + { + $loginCreated = $this->__chooseValue('user2', 'user2@gmail.com'); + + $jwtWrapper = new JwtWrapper('api.test.com', new JwtHashHmacSecret('12345678', false)); + + $userToken = $this->object->createAuthToken( + $loginCreated, + 'pwd2', + $jwtWrapper, + 1200, + ['userData'=>'userValue'], + ['tokenData'=>$tokenData] + ); + + $user = $this->object->getByLogin($login); + + $dataFromToken = [ + 'tokenData' => $tokenData, + 'userid' => $userId, + 'name' => $user->getName(), + ]; + + $tokenResult = $this->object->isValidToken($loginCreated, $jwtWrapper, $userToken->token); + + // Compare data object + $this->assertEquals($dataFromToken, $tokenResult->data); + + // Compare user fields (excluding timestamps which may differ) + $this->assertEquals($user->getUserid(), $tokenResult->user->getUserid()); + $this->assertEquals($user->get("userData"), "userValue"); + $this->assertEquals($user->get("userData"), $tokenResult->user->get("userData")); + } + + /** + * @return void + */ + abstract public function testCreateAuthToken(); + + public function testCreateAuthTokenReturnsUserToken(): void + { + $login = $this->__chooseValue('user2', 'user2@gmail.com'); + $jwtWrapper = new JwtWrapper('api.test.com', new JwtHashHmacSecret('12345678', false)); + + $userToken = $this->object->createAuthToken( + $login, + 'pwd2', + $jwtWrapper, + 1200, + tokenUserFields: [ + UserField::Username->value, + UserField::Email->value, + UserField::Role->value => "nobody" + ] + ); + + $this->assertInstanceOf(UserToken::class, $userToken); + $this->assertNotEmpty($userToken->token); + $this->assertInstanceOf(UserModel::class, $userToken->user); + $this->assertEquals(2, $userToken->user->getUserid()); + $this->assertEquals('user2', $userToken->data["username"]); + $this->assertEquals('user2@gmail.com', $userToken->data["email"]); + $this->assertEquals('nobody', $userToken->data["role"]); + } + + public function testCreateAuthTokenWithInvalidPassword(): void + { + $this->expectException(UserNotFoundException::class); + $login = $this->__chooseValue('user2', 'user2@gmail.com'); + $jwtWrapper = new JwtWrapper('api.test.com', new JwtHashHmacSecret('12345678', false)); + + $this->object->createAuthToken( + $login, + 'wrongpassword', + $jwtWrapper, + 1200 + ); + } + + public function testCreateAuthTokenWithUpdateUserInfo(): void + { + $login = $this->__chooseValue('user2', 'user2@gmail.com'); + $jwtWrapper = new JwtWrapper('api.test.com', new JwtHashHmacSecret('12345678', false)); + + $userToken = $this->object->createAuthToken( + $login, + 'pwd2', + $jwtWrapper, + 1200, + ['last_login' => '2024-01-01 12:00:00'] + ); + + $this->assertInstanceOf(UserToken::class, $userToken); + $this->assertEquals('2024-01-01 12:00:00', $userToken->user->get('last_login')); + } + + public function testCreateAuthTokenWithCustomTokenData(): void + { + $login = $this->__chooseValue('user2', 'user2@gmail.com'); + $jwtWrapper = new JwtWrapper('api.test.com', new JwtHashHmacSecret('12345678', false)); + + $userToken = $this->object->createAuthToken( + $login, + 'pwd2', + $jwtWrapper, + 1200, + [], + ['custom_field' => 'custom_value', 'another_field' => 123] + ); + + $this->assertInstanceOf(UserToken::class, $userToken); + $this->assertEquals('custom_value', $userToken->data['custom_field']); + $this->assertEquals(123, $userToken->data['another_field']); + } + + public function testCreateAuthTokenIncludesDefaultUserFields(): void + { + $login = $this->__chooseValue('user2', 'user2@gmail.com'); + $jwtWrapper = new JwtWrapper('api.test.com', new JwtHashHmacSecret('12345678', false)); + + $userToken = $this->object->createAuthToken( + $login, + 'pwd2', + $jwtWrapper, + 1200 + ); + + $this->assertArrayHasKey('userid', $userToken->data); + $this->assertArrayHasKey('name', $userToken->data); + $this->assertArrayNotHasKey('role', $userToken->data); // Role isn't set because is null + $this->assertEquals(2, $userToken->data['userid']); + } + + public function testCreateAuthTokenStoresTokenHash(): void + { + $login = $this->__chooseValue('user2', 'user2@gmail.com'); + $jwtWrapper = new JwtWrapper('api.test.com', new JwtHashHmacSecret('12345678', false)); + + $userToken = $this->object->createAuthToken( + $login, + 'pwd2', + $jwtWrapper, + 1200 + ); + + $expectedHash = sha1($userToken->token); + $this->assertEquals($expectedHash, $userToken->user->get('TOKEN_HASH')); + } + + public function testCreateInsecureAuthTokenWithLoginString(): void + { + $login = $this->__chooseValue('user2', 'user2@gmail.com'); + $jwtWrapper = new JwtWrapper('api.test.com', new JwtHashHmacSecret('12345678', false)); + + $userToken = $this->object->createInsecureAuthToken( + $login, + $jwtWrapper, + 1200 + ); + + $this->assertInstanceOf(UserToken::class, $userToken); + $this->assertNotEmpty($userToken->token); + $this->assertInstanceOf(UserModel::class, $userToken->user); + $this->assertEquals(2, $userToken->user->getUserid()); + } + + public function testCreateInsecureAuthTokenWithUserModel(): void + { + $login = $this->__chooseValue('user2', 'user2@gmail.com'); + $user = $this->object->getByLogin($login); + $jwtWrapper = new JwtWrapper('api.test.com', new JwtHashHmacSecret('12345678', false)); + + $userToken = $this->object->createInsecureAuthToken( + $user, + $jwtWrapper, + 1200 + ); + + $this->assertInstanceOf(UserToken::class, $userToken); + $this->assertNotEmpty($userToken->token); + $this->assertEquals($user->getUserid(), $userToken->user->getUserid()); + } + + public function testCreateInsecureAuthTokenWithInvalidLogin(): void + { + $this->expectException(UserNotFoundException::class); + $jwtWrapper = new JwtWrapper('api.test.com', new JwtHashHmacSecret('12345678', false)); + + $this->object->createInsecureAuthToken( + 'nonexistent@example.com', + $jwtWrapper, + 1200 + ); + } + + public function testCreateInsecureAuthTokenWithCustomFields(): void + { + $login = $this->__chooseValue('user2', 'user2@gmail.com'); + $jwtWrapper = new JwtWrapper('api.test.com', new JwtHashHmacSecret('12345678', false)); + + $userToken = $this->object->createInsecureAuthToken( + $login, + $jwtWrapper, + 1200, + ['session_id' => 'abc123'], + ['ip_address' => '192.168.1.1'] + ); + + $this->assertInstanceOf(UserToken::class, $userToken); + $this->assertEquals('abc123', $userToken->user->get('session_id')); + $this->assertEquals('192.168.1.1', $userToken->data['ip_address']); + } + + public function testCreateInsecureAuthTokenStoresTokenHash(): void + { + $login = $this->__chooseValue('user2', 'user2@gmail.com'); + $jwtWrapper = new JwtWrapper('api.test.com', new JwtHashHmacSecret('12345678', false)); + + $userToken = $this->object->createInsecureAuthToken( + $login, + $jwtWrapper, + 1200 + ); + + $expectedHash = sha1($userToken->token); + $this->assertEquals($expectedHash, $userToken->user->get('TOKEN_HASH')); + } + + public function testValidateTokenWithAnotherUser(): void + { + $this->expectException(NotAuthenticatedException::class); + $login = $this->__chooseValue('user2', 'user2@gmail.com'); + $loginToFail = $this->__chooseValue('user1', 'user1@gmail.com'); + + $jwtWrapper = new JwtWrapper('api.test.com', new JwtHashHmacSecret('1234567')); + $userToken = $this->object->createAuthToken( + $login, + 'pwd2', + $jwtWrapper, + 1200, + ['userData'=>'userValue'], + ['tokenData'=>'tokenValue'] + ); + + $this->object->isValidToken($loginToFail, $jwtWrapper, $userToken->token); + } + + /** + * @return void + */ + abstract public function testSaveAndSave(); + + public function testRemoveUserById(): void + { + $user = $this->object->getById($this->prefix . '1'); + $this->assertNotNull($user); + + $this->object->removeById($this->prefix . '1'); + + $user2 = $this->object->getById($this->prefix . '1'); + $this->assertNull($user2); + } + + public function testGetByUsername(): void + { + $user = $this->object->getByUsername('user2'); + + $this->assertEquals($this->prefix . '2', $user->getUserid()); + $this->assertEquals('User 2', $user->getName()); + $this->assertEquals('user2', $user->getUsername()); + $this->assertEquals('user2@gmail.com', $user->getEmail()); + $this->assertEquals('c88b5c841897dafe75cdd9f8ba98b32f007d6bc3', $user->getPassword()); + } + + public function testGetByUserProperty(): void + { + // Add property to user1 + $user = $this->object->getById($this->prefix . '1'); + $user->set('property1', 'somevalue'); + $this->object->save($user); + + // Add property to user2 + $user = $this->object->getById($this->prefix . '2'); + $user->set('property1', 'value1'); + $user->set('property2', 'value2'); + $this->object->save($user); + + // Get user by property + $user = $this->object->getUsersByProperty('property1', 'value2'); + $this->assertCount(0, $user); + + // Get user by property + $user = $this->object->getUsersByProperty('property1', 'somevalue'); + $this->assertCount(1, $user); + $this->assertEquals($this->prefix . '1', $user[0]->getUserid()); + + // Only one property is valid, so no results + $user = $this->object->getUsersByPropertySet(['property1'=>'xyz', 'property2'=>'value2']); + $this->assertCount(0, $user); + + // Get user2 by property using method getUsersByPropertySet + $user = $this->object->getUsersByPropertySet(['property1'=>'value1', 'property2'=>'value2']); + $this->assertCount(1, $user); + $this->assertEquals($this->prefix . '2', $user[0]->getUserid()); + + } + + public function testSetProperty(): void + { + $this->assertFalse($this->object->hasProperty($this->prefix . '1', 'propertySet')); + $this->object->setProperty($this->prefix . '1', 'propertySet', 'somevalue'); + $this->assertTrue($this->object->hasProperty($this->prefix . '1', 'propertySet')); + $this->assertTrue($this->object->hasProperty($this->prefix . '1', 'propertySet', 'somevalue')); + $this->assertEquals('somevalue', $this->object->getProperty($this->prefix . '1', 'propertySet')); + } + + public function testPasswordDefinitionValidOnSave(): void + { + // Create a password definition requiring uppercase, lowercase, and numbers + $passwordDef = new \ByJG\Authenticate\Definition\PasswordDefinition([ + \ByJG\Authenticate\Definition\PasswordDefinition::MINIMUM_CHARS => 8, + \ByJG\Authenticate\Definition\PasswordDefinition::REQUIRE_UPPERCASE => 1, + \ByJG\Authenticate\Definition\PasswordDefinition::REQUIRE_LOWERCASE => 1, + \ByJG\Authenticate\Definition\PasswordDefinition::REQUIRE_NUMBERS => 1, + ]); + + // Create a user model with valid password + $user = new \ByJG\Authenticate\Model\UserModel(); + $user->setName('Test User'); + $user->setUsername('testuser_pwd'); + $user->setEmail('testpwd@example.com'); + $user->setPassword('ValidPass8642'); // Valid: uppercase, lowercase, numbers, no sequential, 12 chars + $user->withPasswordDefinition($passwordDef); + + // Should save successfully + $savedUser = $this->object->save($user); + $this->assertNotNull($savedUser->getUserid()); + $this->assertEquals('Test User', $savedUser->getName()); + + // Verify user was saved + $retrievedUser = $this->object->getByUsername('testuser_pwd'); + $this->assertNotNull($retrievedUser); + $this->assertEquals('testpwd@example.com', $retrievedUser->getEmail()); + } + + public function testPasswordDefinitionInvalidOnSave(): void + { + // Create a password definition requiring uppercase, lowercase, and numbers + $passwordDef = new \ByJG\Authenticate\Definition\PasswordDefinition([ + \ByJG\Authenticate\Definition\PasswordDefinition::MINIMUM_CHARS => 8, + \ByJG\Authenticate\Definition\PasswordDefinition::REQUIRE_UPPERCASE => 1, + \ByJG\Authenticate\Definition\PasswordDefinition::REQUIRE_LOWERCASE => 1, + \ByJG\Authenticate\Definition\PasswordDefinition::REQUIRE_NUMBERS => 1, + ]); + + // Create a user model with invalid password (no uppercase) + $user = new \ByJG\Authenticate\Model\UserModel(); + $user->setName('Test User'); + $user->setUsername('testuser_invalid'); + $user->setEmail('invalid@example.com'); + $user->withPasswordDefinition($passwordDef); + + // Should throw exception because password doesn't match definition + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Password does not match the password definition'); + $user->setPassword('weakpass123'); // Invalid: no uppercase + } + + public function testPasswordDefinitionValidOnUpdate(): void + { + // Get existing user + $user = $this->object->getById($this->prefix . '1'); + $this->assertNotNull($user); + + // Create a password definition + $passwordDef = new \ByJG\Authenticate\Definition\PasswordDefinition([ + \ByJG\Authenticate\Definition\PasswordDefinition::MINIMUM_CHARS => 10, + \ByJG\Authenticate\Definition\PasswordDefinition::REQUIRE_UPPERCASE => 1, + \ByJG\Authenticate\Definition\PasswordDefinition::REQUIRE_LOWERCASE => 1, + \ByJG\Authenticate\Definition\PasswordDefinition::REQUIRE_NUMBERS => 2, + \ByJG\Authenticate\Definition\PasswordDefinition::REQUIRE_SYMBOLS => 1, + ]); + + // Update password with valid value + $user->setPassword('StrongPass84!'); // Valid: uppercase, lowercase, 2 numbers, symbol, no sequential, 13 chars + $user->withPasswordDefinition($passwordDef); + + // Should update successfully + $savedUser = $this->object->save($user); + + // Verify password was updated (by checking authentication works with login field) + $login = $this->__chooseValue($user->getUsername(), $user->getEmail()); + $validUser = $this->object->isValidUser($login, 'StrongPass84!'); + $this->assertNotNull($validUser); + $this->assertEquals($user->getUserid(), $validUser->getUserid()); + $this->assertEquals($user->getPassword(), $validUser->getPassword()); + } + + public function testPasswordDefinitionInvalidOnUpdate(): void + { + // Get existing user + $user = $this->object->getById($this->prefix . '2'); + $this->assertNotNull($user); + + // Create a strict password definition + $passwordDef = new \ByJG\Authenticate\Definition\PasswordDefinition([ + \ByJG\Authenticate\Definition\PasswordDefinition::MINIMUM_CHARS => 12, + \ByJG\Authenticate\Definition\PasswordDefinition::REQUIRE_UPPERCASE => 2, + \ByJG\Authenticate\Definition\PasswordDefinition::REQUIRE_LOWERCASE => 2, + \ByJG\Authenticate\Definition\PasswordDefinition::REQUIRE_NUMBERS => 2, + \ByJG\Authenticate\Definition\PasswordDefinition::REQUIRE_SYMBOLS => 1, + ]); + + $user->withPasswordDefinition($passwordDef); + + // Should throw exception because password doesn't have 2 uppercase letters + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Password does not match the password definition'); + $user->setPassword('weak1!'); // Invalid: too short, not enough uppercase/lowercase/numbers + } + + public function testPasswordDefinitionMultipleFailures(): void + { + // Create a strict password definition + $passwordDef = new \ByJG\Authenticate\Definition\PasswordDefinition([ + \ByJG\Authenticate\Definition\PasswordDefinition::MINIMUM_CHARS => 12, + \ByJG\Authenticate\Definition\PasswordDefinition::REQUIRE_UPPERCASE => 1, + \ByJG\Authenticate\Definition\PasswordDefinition::REQUIRE_LOWERCASE => 1, + \ByJG\Authenticate\Definition\PasswordDefinition::REQUIRE_NUMBERS => 1, + \ByJG\Authenticate\Definition\PasswordDefinition::REQUIRE_SYMBOLS => 1, + \ByJG\Authenticate\Definition\PasswordDefinition::ALLOW_WHITESPACE => 0, + ]); + + $user = new \ByJG\Authenticate\Model\UserModel(); + $user->setName('Test User'); + $user->setUsername('testuser_multi'); + $user->setEmail('multi@example.com'); + $user->withPasswordDefinition($passwordDef); + + // Test multiple failure scenarios + try { + $user->setPassword('short'); // Fails: too short, no uppercase, no numbers, no symbols + $this->fail('Expected InvalidArgumentException was not thrown'); + } catch (\InvalidArgumentException $e) { + $this->assertStringContainsString('Password does not match the password definition', $e->getMessage()); + } + + try { + $user->setPassword('with space 123A!'); // Fails: has whitespace + $this->fail('Expected InvalidArgumentException was not thrown'); + } catch (\InvalidArgumentException $e) { + $this->assertStringContainsString('Password does not match the password definition', $e->getMessage()); + } + } + + public function testPasswordDefinitionViaServiceAddUser(): void + { + // Create a new service instance with password definition + $passwordDef = new \ByJG\Authenticate\Definition\PasswordDefinition([ + \ByJG\Authenticate\Definition\PasswordDefinition::MINIMUM_CHARS => 10, + \ByJG\Authenticate\Definition\PasswordDefinition::REQUIRE_UPPERCASE => 1, + \ByJG\Authenticate\Definition\PasswordDefinition::REQUIRE_LOWERCASE => 1, + \ByJG\Authenticate\Definition\PasswordDefinition::REQUIRE_NUMBERS => 1, + \ByJG\Authenticate\Definition\PasswordDefinition::REQUIRE_SYMBOLS => 1, + ]); + + // Get the repositories from existing service + $usersRepo = $this->object->getUsersRepository(); + $propsRepo = $this->object->getPropertiesRepository(); + + // Create new service with password definition + $usersWithPwdDef = new \ByJG\Authenticate\Service\UsersService( + $usersRepo, + $propsRepo, + LoginField::Username, + $passwordDef + ); + + // Valid password should work + $user = $usersWithPwdDef->addUser( + 'Valid User', + 'validuser', + 'valid@example.com', + 'StrongPass8!' // Valid: 12 chars, uppercase, lowercase, number, symbol, no sequential + ); + $this->assertNotNull($user->getUserid()); + $this->assertEquals('Valid User', $user->getName()); + + // Invalid password should fail + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Password does not match the password definition'); + $usersWithPwdDef->addUser( + 'Invalid User', + 'invaliduser', + 'invalid@example.com', + 'weak' // Invalid: too short, no uppercase, no numbers, no symbols + ); + } + + public function testPasswordHashRemainsUnchangedOnSave(): void + { + // Create a user with a password + $originalPassword = 'mySecretPassword'; + $user = $this->object->addUser('Test User', 'testuser', 'test@example.com', $originalPassword); + + // Store the original password hash + $originalHash = $user->getPassword(); + $this->assertEquals($originalHash, sha1($originalPassword)); + + // Verify the user can authenticate with the original password + $login = $this->__chooseValue('testuser', 'test@example.com'); + $authenticatedUser = $this->object->isValidUser($login, $originalPassword); + $this->assertNotNull($authenticatedUser); + $this->assertEquals($user->getUserid(), $authenticatedUser->getUserid()); + + // Get the user from database + $retrievedUser = $this->object->getById($user->getUserid()); + $this->assertEquals($originalHash, $retrievedUser->getPassword()); + + // Update other fields WITHOUT touching password + $retrievedUser->setName('Updated Name'); + $retrievedUser->setEmail('updated@example.com'); + $retrievedUser->setRole('moderator'); + + // Save the user + $updatedUser = $this->object->save($retrievedUser); + + // Verify the password hash remained exactly the same + $this->assertEquals($originalHash, $updatedUser->getPassword()); + $this->assertEquals('Updated Name', $updatedUser->getName()); + $this->assertEquals('updated@example.com', $updatedUser->getEmail()); + $this->assertEquals('moderator', $updatedUser->getRole()); + + // Verify user can still authenticate with original password + $login = $this->__chooseValue('testuser', 'updated@example.com'); + $authenticatedUser = $this->object->isValidUser($login, $originalPassword); + $this->assertNotNull($authenticatedUser); + $this->assertEquals($user->getUserid(), $authenticatedUser->getUserid()); + + // Get user again from database to confirm persistence + $finalUser = $this->object->getById($user->getUserid()); + $this->assertEquals($originalHash, $finalUser->getPassword()); + $this->assertEquals('Updated Name', $finalUser->getName()); + } +} diff --git a/tests/UserModelTest.php b/tests/UserModelTest.php index a63b081..c5b0be9 100644 --- a/tests/UserModelTest.php +++ b/tests/UserModelTest.php @@ -15,17 +15,19 @@ class UserModelTest extends TestCase */ protected $object; + #[\Override] public function setUp(): void { $this->object = new UserModel(); } + #[\Override] public function tearDown(): void { $this->object = null; } - public function testUserModel() + public function testUserModel(): void { $this->object->setUserid("10"); $this->object->setName('John'); @@ -40,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'); @@ -64,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, @@ -77,12 +79,18 @@ public function testPasswordDefinition() 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() + public function testPasswordDefinitionError(): void { $this->expectException(InvalidArgumentException::class); diff --git a/tests/UsersAnyDataset2ByEmailTest.php b/tests/UsersAnyDataset2ByEmailTest.php deleted file mode 100644 index 19ba913..0000000 --- a/tests/UsersAnyDataset2ByEmailTest.php +++ /dev/null @@ -1,13 +0,0 @@ -__setUp(UserDefinition::LOGIN_IS_EMAIL); - } -} diff --git a/tests/UsersAnyDataset2ByUsernameTest.php b/tests/UsersAnyDataset2ByUsernameTest.php deleted file mode 100644 index 22d41e9..0000000 --- a/tests/UsersAnyDataset2ByUsernameTest.php +++ /dev/null @@ -1,57 +0,0 @@ -prefix = "user"; - - $this->userDefinition = new UserDefinition( - 'mytable', - UserModel::class, - $loginField, - [ - UserDefinition::FIELD_USERID => 'myuserid', - UserDefinition::FIELD_NAME => 'myname', - UserDefinition::FIELD_EMAIL => 'myemail', - UserDefinition::FIELD_USERNAME => 'myusername', - UserDefinition::FIELD_PASSWORD => 'mypassword', - UserDefinition::FIELD_CREATED => 'mycreated', - UserDefinition::FIELD_ADMIN => 'myadmin' - ] - ); - $this->userDefinition->markPropertyAsReadOnly(UserDefinition::FIELD_CREATED); - - $this->propertyDefinition = new UserPropertiesDefinition( - 'theirproperty', - 'theirid', - 'theirname', - 'theirvalue', - 'theiruserid' - ); - - $anydataset = new AnyDataset('php://memory'); - $this->object = new UsersAnyDataset( - $anydataset, - $this->userDefinition, - $this->propertyDefinition - ); - $this->object->addUser('User 1', 'user1', 'user1@gmail.com', 'pwd1'); - $this->object->addUser('User 2', 'user2', 'user2@gmail.com', 'pwd2'); - $this->object->addUser('User 3', 'user3', 'user3@gmail.com', 'pwd3'); - } - - public function setUp(): void - { - $this->__setUp(UserDefinition::LOGIN_IS_USERNAME); - } -} diff --git a/tests/UsersAnyDatasetByEmailTest.php b/tests/UsersAnyDatasetByEmailTest.php deleted file mode 100644 index 560ba99..0000000 --- a/tests/UsersAnyDatasetByEmailTest.php +++ /dev/null @@ -1,13 +0,0 @@ -__setUp(UserDefinition::LOGIN_IS_EMAIL); - } -} diff --git a/tests/UsersAnyDatasetByUsernameTest.php b/tests/UsersAnyDatasetByUsernameTest.php deleted file mode 100644 index c6b957c..0000000 --- a/tests/UsersAnyDatasetByUsernameTest.php +++ /dev/null @@ -1,350 +0,0 @@ -prefix = "user"; - - $this->userDefinition = new UserDefinition('users', UserModel::class, $loginField); - $this->propertyDefinition = new UserPropertiesDefinition(); - - $anydataSet = new AnyDataset('php://memory'); - $this->object = new UsersAnyDataset( - $anydataSet, - $this->userDefinition, - $this->propertyDefinition - ); - $this->object->addUser('User 1', 'user1', 'user1@gmail.com', 'pwd1'); - $this->object->addUser('User 2', 'user2', 'user2@gmail.com', 'pwd2'); - $this->object->addUser('User 3', 'user3', 'user3@gmail.com', 'pwd3'); - } - - public function __chooseValue($forUsername, $forEmail) - { - $searchForList = [ - $this->userDefinition->getUsername() => $forUsername, - $this->userDefinition->getEmail() => $forEmail, - ]; - return $searchForList[$this->userDefinition->loginField()]; - } - - public function setUp(): void - { - $this->__setUp(UserDefinition::LOGIN_IS_USERNAME); - } - - public function testAddUser() - { - $this->object->addUser('John Doe', 'john', 'johndoe@gmail.com', 'mypassword'); - - $user = $this->object->getByLoginField($this->__chooseValue('john', 'johndoe@gmail.com')); - - $this->assertEquals('john', $user->getUserid()); - $this->assertEquals('John Doe', $user->getName()); - $this->assertEquals('john', $user->getUsername()); - $this->assertEquals('johndoe@gmail.com', $user->getEmail()); - $this->assertEquals('91dfd9ddb4198affc5c194cd8ce6d338fde470e2', $user->getPassword()); - } - - public function testAddUserError() - { - $this->expectException(UserExistsException::class); - $this->object->addUser('some user with same username', 'user2', 'user2@gmail.com', 'mypassword'); - } - - public function testAddProperty() - { - // Check state - $user = $this->object->getById($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'); - $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'); - $this->assertEquals(['Rio de Janeiro', 'Belo Horizonte'], $user->get('city')); - - // Get Property - $this->assertEquals(['Rio de Janeiro', 'Belo Horizonte'], $this->object->getProperty($this->prefix . '2', 'city')); - - // Add another property - $this->object->addProperty($this->prefix . '2', 'state', 'RJ'); - $user = $this->object->getById($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'); - $this->assertEmpty($user->get('state')); - - // Remove Property Again - $this->object->removeProperty($this->prefix . '2', 'city', 'Rio de Janeiro'); - $this->assertEquals('Belo Horizonte', $this->object->getProperty($this->prefix . '2', 'city')); - - } - - public function testRemoveAllProperties() - { - // Add the properties - $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'); - $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'); - $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'); - $this->assertEquals(['Rio de Janeiro', 'Niteroi'], $user->get('city')); - $this->assertEmpty($user->get('state')); - $user = $this->object->getById($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'); - $this->assertEquals('Rio de Janeiro', $user->get('city')); - $this->assertEmpty($user->get('state')); - $user = $this->object->getById($this->prefix . '1'); - $this->assertEmpty($user->get('city')); - $this->assertEmpty($user->get('state')); - - } - - public function testRemoveByLoginField() - { - $login = $this->__chooseValue('user1', 'user1@gmail.com'); - - $user = $this->object->getByLoginField($login); - $this->assertNotNull($user); - - $result = $this->object->removeByLoginField($login); - $this->assertTrue($result); - - $user = $this->object->getByLoginField($login); - $this->assertNull($user); - } - - public function testEditUser() - { - $login = $this->__chooseValue('user1', 'user1@gmail.com'); - - // Getting data - $user = $this->object->getByLoginField($login); - $this->assertEquals('User 1', $user->getName()); - - // Change and Persist data - $user->setName('Other name'); - $this->object->save($user); - - // Check if data persists - $user = $this->object->getById($this->prefix . '1'); - $this->assertEquals('Other name', $user->getName()); - } - - public function testIsValidUser() - { - $login = $this->__chooseValue('user3', 'user3@gmail.com'); - $loginFalse = $this->__chooseValue('user3@gmail.com', 'user3'); - - // User Exists! - $user = $this->object->isValidUser($login, 'pwd3'); - $this->assertEquals('User 3', $user->getName()); - - // User Does not Exists! - $user = $this->object->isValidUser($loginFalse, 'pwd5'); - $this->assertNull($user); - } - - public function testIsAdmin() - { - // Check is Admin - $this->assertFalse($this->object->isAdmin($this->prefix . '3')); - - // Set the Admin Flag - $login = $this->__chooseValue('user3', 'user3@gmail.com'); - $user = $this->object->getByLoginField($login); - $user->setAdmin('Y'); - $this->object->save($user); - - // Check is Admin - $this->assertTrue($this->object->isAdmin($this->prefix . '3')); - } - - protected function expectedToken($tokenData, $login, $userId) - { - $loginCreated = $this->__chooseValue('user2', 'user2@gmail.com'); - - $jwtWrapper = new JwtWrapper('api.test.com', new JwtHashHmacSecret('12345678', false)); - - $token = $this->object->createAuthToken( - $loginCreated, - 'pwd2', - $jwtWrapper, - 1200, - ['userData'=>'userValue'], - ['tokenData'=>$tokenData] - ); - - $user = $this->object->getByLoginField($login); - - $dataFromToken = new \stdClass(); - $dataFromToken->tokenData = $tokenData; - $dataFromToken->login = $loginCreated; - $dataFromToken->userid = $userId; - - $this->assertEquals( - [ - 'user' => $user, - 'data' => $dataFromToken - ], - $this->object->isValidToken($loginCreated, $jwtWrapper, $token) - ); - } - - public function testCreateAuthToken() - { - $login = $this->__chooseValue('user2', 'user2@gmail.com'); - - $this->expectedToken('tokenValue', $login, 'user2'); - } - - public function testValidateTokenWithAnotherUser() - { - $this->expectException(NotAuthenticatedException::class); - $login = $this->__chooseValue('user2', 'user2@gmail.com'); - $loginToFail = $this->__chooseValue('user1', 'user1@gmail.com'); - - $jwtWrapper = new JwtWrapper('api.test.com', new JwtHashHmacSecret('1234567')); - $token = $this->object->createAuthToken( - $login, - 'pwd2', - $jwtWrapper, - 1200, - ['userData'=>'userValue'], - ['tokenData'=>'tokenValue'] - ); - - $this->object->isValidToken($loginToFail, $jwtWrapper, $token); - } - - public function testSaveAndSave() - { - $user = $this->object->getById('user1'); - $this->object->save($user); - - $user2 = $this->object->getById('user1'); - - $this->assertEquals($user, $user2); - } - - public function testRemoveUserById() - { - $user = $this->object->getById($this->prefix . '1'); - $this->assertNotNull($user); - - $this->object->removeUserById($this->prefix . '1'); - - $user2 = $this->object->getById($this->prefix . '1'); - $this->assertNull($user2); - } - - public function testGetByUsername() - { - $user = $this->object->getByUsername('user2'); - - $this->assertEquals($this->prefix . '2', $user->getUserid()); - $this->assertEquals('User 2', $user->getName()); - $this->assertEquals('user2', $user->getUsername()); - $this->assertEquals('user2@gmail.com', $user->getEmail()); - $this->assertEquals('c88b5c841897dafe75cdd9f8ba98b32f007d6bc3', $user->getPassword()); - } - - public function testGetByUserProperty() - { - // Add property to user1 - $user = $this->object->getById($this->prefix . '1'); - $user->set('property1', 'somevalue'); - $this->object->save($user); - - // Add property to user2 - $user = $this->object->getById($this->prefix . '2'); - $user->set('property1', 'value1'); - $user->set('property2', 'value2'); - $this->object->save($user); - - // Get user by property - $user = $this->object->getUsersByProperty('property1', 'value2'); - $this->assertCount(0, $user); - - // Get user by property - $user = $this->object->getUsersByProperty('property1', 'somevalue'); - $this->assertCount(1, $user); - $this->assertEquals($this->prefix . '1', $user[0]->getUserid()); - - // Only one property is valid, so no results - $user = $this->object->getUsersByPropertySet(['property1'=>'xyz', 'property2'=>'value2']); - $this->assertCount(0, $user); - - // Get user2 by property using method getUsersByPropertySet - $user = $this->object->getUsersByPropertySet(['property1'=>'value1', 'property2'=>'value2']); - $this->assertCount(1, $user); - $this->assertEquals($this->prefix . '2', $user[0]->getUserid()); - - } - - public function testSetProperty() - { - $this->assertFalse($this->object->hasProperty($this->prefix . '1', 'propertySet')); - $this->object->setProperty($this->prefix . '1', 'propertySet', 'somevalue'); - $this->assertTrue($this->object->hasProperty($this->prefix . '1', 'propertySet')); - $this->assertTrue($this->object->hasProperty($this->prefix . '1', 'propertySet', 'somevalue')); - $this->assertEquals('somevalue', $this->object->getProperty($this->prefix . '1', 'propertySet')); - } -} diff --git a/tests/UsersDBDataset2ByEmailTest.php b/tests/UsersDBDataset2ByEmailTest.php index 694f024..4c3e692 100644 --- a/tests/UsersDBDataset2ByEmailTest.php +++ b/tests/UsersDBDataset2ByEmailTest.php @@ -2,12 +2,13 @@ namespace Tests; -use ByJG\Authenticate\Definition\UserDefinition; +use ByJG\Authenticate\Enum\LoginField; -class UsersDBDataset2ByEmailTest extends UsersDBDatasetByUsernameTest +class UsersDBDataset2ByEmailTest extends UsersDBDataset2ByUserNameTestUsersBase { + #[\Override] public function setUp(): void { - $this->__setUp(UserDefinition::LOGIN_IS_EMAIL); + $this->__setUp(LoginField::Email); } } diff --git a/tests/UsersDBDataset2ByUserNameTest.php b/tests/UsersDBDataset2ByUserNameTest.php deleted file mode 100644 index 382179c..0000000 --- a/tests/UsersDBDataset2ByUserNameTest.php +++ /dev/null @@ -1,75 +0,0 @@ -prefix = ""; - - $this->db = Factory::getDbRelationalInstance(self::CONNECTION_STRING); - $this->db->execute('create table mytable ( - myuserid integer primary key autoincrement, - myname varchar(45), - myemail varchar(200), - myusername varchar(20), - mypassword varchar(40), - mycreated datetime default (datetime(\'2017-12-04\')), - myadmin char(1));' - ); - - $this->db->execute('create table theirproperty ( - theirid integer primary key autoincrement, - theiruserid integer, - theirname varchar(45), - theirvalue varchar(45));' - ); - - $this->userDefinition = new UserDefinition( - 'mytable', - UserModel::class, - $loginField, - [ - UserDefinition::FIELD_USERID => 'myuserid', - UserDefinition::FIELD_NAME => 'myname', - UserDefinition::FIELD_EMAIL => 'myemail', - UserDefinition::FIELD_USERNAME => 'myusername', - UserDefinition::FIELD_PASSWORD => 'mypassword', - UserDefinition::FIELD_CREATED => 'mycreated', - UserDefinition::FIELD_ADMIN => 'myadmin' - ] - ); - $this->userDefinition->markPropertyAsReadOnly(UserDefinition::FIELD_CREATED); - - $this->propertyDefinition = new UserPropertiesDefinition('theirproperty', 'theirid', 'theirname', 'theirvalue', 'theiruserid'); - - $this->object = new UsersDBDataset( - $this->db, - $this->userDefinition, - $this->propertyDefinition - ); - - $this->object->addUser('User 1', 'user1', 'user1@gmail.com', 'pwd1'); - $this->object->addUser('User 2', 'user2', 'user2@gmail.com', 'pwd2'); - $this->object->addUser('User 3', 'user3', 'user3@gmail.com', 'pwd3'); - } - - public function setUp(): void - { - $this->__setUp(UserDefinition::LOGIN_IS_USERNAME); - } -} diff --git a/tests/UsersDBDataset2ByUserNameTestUsersBase.php b/tests/UsersDBDataset2ByUserNameTestUsersBase.php new file mode 100644 index 0000000..25c9687 --- /dev/null +++ b/tests/UsersDBDataset2ByUserNameTestUsersBase.php @@ -0,0 +1,132 @@ +prefix = ""; + $this->loginField = $loginField; + + $this->db = Factory::getDbRelationalInstance(self::CONNECTION_STRING); + $this->db->execute('create table mytable ( + myuserid integer primary key autoincrement, + myname varchar(45), + myemail varchar(200), + myusername varchar(20), + mypassword varchar(40), + mycreated_at datetime default (datetime(\'2017-12-04\')), + myupdated_at datetime, + mydeleted_at datetime, + myrole varchar(20));' + ); + + $this->db->execute('create table theirproperty ( + theirid integer primary key autoincrement, + theiruserid integer, + theirname varchar(45), + theirvalue varchar(45));' + ); + + $executor = DatabaseExecutor::using($this->db); + $usersRepository = new UsersRepository($executor, CustomUserModel::class); + $propertiesRepository = new UserPropertiesRepository($executor, CustomUserPropertiesModel::class); + $this->object = new UsersService( + $usersRepository, + $propertiesRepository, + $loginField + ); + + $this->object->addUser('User 1', 'user1', 'user1@gmail.com', 'pwd1', "role3"); + $this->object->addUser('User 2', 'user2', 'user2@gmail.com', 'pwd2'); + $this->object->addUser('User 3', 'user3', 'user3@gmail.com', 'pwd3', "foobar"); + } + + #[\Override] + public function setUp(): void + { + $this->__setUp(LoginField::Username); + } + + #[\Override] + public function tearDown(): void + { + $uri = new Uri(self::CONNECTION_STRING); + unlink($uri->getPath()); + $this->object = null; + } + + /** + * @return void + */ + #[\Override] + public function testAddUser() + { + $this->object->addUser('John Doe', 'john', 'johndoe@gmail.com', 'mypassword'); + + $login = $this->__chooseValue('john', 'johndoe@gmail.com'); + + $user = $this->object->getByLogin($login); + $this->assertEquals('4', $user->getUserid()); + $this->assertEquals('John Doe', $user->getName()); + $this->assertEquals('john', $user->getUsername()); + $this->assertEquals('johndoe@gmail.com', $user->getEmail()); + $this->assertEquals('91dfd9ddb4198affc5c194cd8ce6d338fde470e2', $user->getPassword()); + $this->assertEquals('', $user->getRole()); + $this->assertNotNull($user->getCreatedAt()); + $this->assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $user->getCreatedAt()); + + // Setting role + $user->setRole('admin'); + $this->object->save($user); + + $user2 = $this->object->getByLogin($login); + $this->assertEquals('admin', $user2->getRole()); + } + + /** + * @return void + */ + #[\Override] + public function testCreateAuthToken() + { + $login = $this->__chooseValue('user2', 'user2@gmail.com'); + $this->expectedToken('tokenValue', $login, 2); + } + + /** + * @return void + */ + #[\Override] + public function testSaveAndSave() + { + $user = $this->object->getById("1"); + $this->object->save($user); + + $user2 = $this->object->getById("1"); + + // Compare all fields except updated_at which changes on each save + $this->assertEquals($user->getUserid(), $user2->getUserid()); + $this->assertEquals($user->getName(), $user2->getName()); + $this->assertEquals($user->getEmail(), $user2->getEmail()); + $this->assertEquals($user->getUsername(), $user2->getUsername()); + $this->assertEquals($user->getPassword(), $user2->getPassword()); + $this->assertEquals($user->getRole(), $user2->getRole()); + $this->assertEquals($user->getCreatedAt(), $user2->getCreatedAt()); + // updated_at is expected to be different due to the save operation + } +} diff --git a/tests/UsersDBDatasetByEmailTest.php b/tests/UsersDBDatasetByEmailTest.php index edaa39b..2f166e0 100644 --- a/tests/UsersDBDatasetByEmailTest.php +++ b/tests/UsersDBDatasetByEmailTest.php @@ -2,12 +2,13 @@ namespace Tests; -use ByJG\Authenticate\Definition\UserDefinition; +use ByJG\Authenticate\Enum\LoginField; -class UsersDBDatasetByEmailTest extends UsersAnyDatasetByUsernameTest +class UsersDBDatasetByEmailTest extends UsersDBDatasetByUsernameTestUsersBase { + #[\Override] public function setUp(): void { - $this->__setUp(UserDefinition::LOGIN_IS_EMAIL); + $this->__setUp(LoginField::Email); } } diff --git a/tests/UsersDBDatasetByUsernameTest.php b/tests/UsersDBDatasetByUsernameTest.php deleted file mode 100644 index 52df602..0000000 --- a/tests/UsersDBDatasetByUsernameTest.php +++ /dev/null @@ -1,165 +0,0 @@ -prefix = ""; - - $this->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));' - ); - - $this->userDefinition = new UserDefinition('users', UserModel::class, $loginField); - $this->userDefinition->markPropertyAsReadOnly(UserDefinition::FIELD_CREATED); - $this->propertyDefinition = new UserPropertiesDefinition(); - $this->object = new UsersDBDataset( - $this->db, - $this->userDefinition, - $this->propertyDefinition - ); - - $user = $this->object->addUser('User 1', 'user1', 'user1@gmail.com', 'pwd1'); - $this->assertEquals(1, $user->getUserid()); - $this->assertEquals('User 1', $user->getName()); - $this->assertEquals('user1', $user->getUsername()); - $this->assertEquals('a63d4b132a9a1d3430f9ae507825f572449e0d17', $user->getPassword()); - $this->assertEquals('no', $user->getAdmin()); - $this->assertEquals('2017-12-04 00:00:00', $user->getCreated()); - - $this->object->addUser('User 2', 'user2', 'user2@gmail.com', 'pwd2'); - $this->object->addUser('User 3', 'user3', 'user3@gmail.com', 'pwd3'); - } - - public function setUp(): void - { - $this->__setUp(UserDefinition::LOGIN_IS_USERNAME); - } - - public function tearDown(): void - { - $uri = new Uri(self::CONNECTION_STRING); - unlink($uri->getPath()); - $this->object = null; - $this->userDefinition = null; - $this->propertyDefinition = null; - } - - public function testAddUser() - { - $this->object->addUser('John Doe', 'john', 'johndoe@gmail.com', 'mypassword'); - - $login = $this->__chooseValue('john', 'johndoe@gmail.com'); - - $user = $this->object->getByLoginField($login); - $this->assertEquals('4', $user->getUserid()); - $this->assertEquals('John Doe', $user->getName()); - $this->assertEquals('john', $user->getUsername()); - $this->assertEquals('johndoe@gmail.com', $user->getEmail()); - $this->assertEquals('91dfd9ddb4198affc5c194cd8ce6d338fde470e2', $user->getPassword()); - $this->assertEquals('no', $user->getAdmin()); - $this->assertEquals('2017-12-04 00:00:00', $user->getCreated()); - - // Setting as Admin - $user->setAdmin('y'); - $this->object->save($user); - - $user2 = $this->object->getByLoginField($login); - $this->assertEquals('y', $user2->getAdmin()); - } - - public function testCreateAuthToken() - { - $login = $this->__chooseValue('user2', 'user2@gmail.com'); - $this->expectedToken('tokenValue', $login, 2); - } - - public function testWithUpdateValue() - { - // For Update Definitions - $this->userDefinition->defineClosureForUpdate(UserDefinition::FIELD_NAME, function ($value, $instance) { - return '[' . $value . ']'; - }); - $this->userDefinition->defineClosureForUpdate(UserDefinition::FIELD_USERNAME, function ($value, $instance) { - return ']' . $value . '['; - }); - $this->userDefinition->defineClosureForUpdate(UserDefinition::FIELD_EMAIL, function ($value, $instance) { - return '-' . $value . '-'; - }); - $this->userDefinition->defineClosureForUpdate(UserDefinition::FIELD_PASSWORD, function ($value, $instance) { - return "@" . $value . "@"; - }); - $this->userDefinition->markPropertyAsReadOnly(UserDefinition::FIELD_CREATED); - - // For Select Definitions - $this->userDefinition->defineClosureForSelect(UserDefinition::FIELD_NAME, function ($value, $instance) { - return '(' . $value . ')'; - }); - $this->userDefinition->defineClosureForSelect(UserDefinition::FIELD_USERNAME, function ($value, $instance) { - return ')' . $value . '('; - }); - $this->userDefinition->defineClosureForSelect(UserDefinition::FIELD_EMAIL, function ($value, $instance) { - return '#' . $value . '#'; - }); - $this->userDefinition->defineClosureForSelect(UserDefinition::FIELD_PASSWORD, function ($value, $instance) { - return '%'. $value . '%'; - }); - - // Test it! - $newObject = new UsersDBDataset( - $this->db, - $this->userDefinition, - $this->propertyDefinition - ); - - $newObject->addUser('User 4', 'user4', 'user4@gmail.com', 'pwd4'); - - $login = $this->__chooseValue(']user4[', '-user4@gmail.com-'); - - $user = $newObject->getByLoginField($login); - $this->assertEquals('4', $user->getUserid()); - $this->assertEquals('([User 4])', $user->getName()); - $this->assertEquals(')]user4[(', $user->getUsername()); - $this->assertEquals('#-user4@gmail.com-#', $user->getEmail()); - $this->assertEquals('%@pwd4@%', $user->getPassword()); - $this->assertEquals('2017-12-04 00:00:00', $user->getCreated()); - } - - public function testSaveAndSave() - { - $user = $this->object->getById("1"); - $this->object->save($user); - - $user2 = $this->object->getById("1"); - - $this->assertEquals($user, $user2); - } -} \ No newline at end of file diff --git a/tests/UsersDBDatasetByUsernameTestUsersBase.php b/tests/UsersDBDatasetByUsernameTestUsersBase.php new file mode 100644 index 0000000..4a5b266 --- /dev/null +++ b/tests/UsersDBDatasetByUsernameTestUsersBase.php @@ -0,0 +1,159 @@ +prefix = ""; + $this->loginField = $loginField; + + $this->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_at datetime default (datetime(\'2017-12-04\')), + updated_at datetime, + deleted_at datetime, + role varchar(20));' + ); + + $this->db->execute('create table users_property ( + id integer primary key autoincrement, + userid integer, + name varchar(45), + value varchar(45));' + ); + + $executor = DatabaseExecutor::using($this->db); + $usersRepository = new UsersRepository($executor, UserModel::class); + $propertiesRepository = new UserPropertiesRepository($executor, UserPropertiesModel::class); + $this->object = new UsersService( + $usersRepository, + $propertiesRepository, + $loginField + ); + + $user = $this->object->addUser('User 1', 'user1', 'user1@gmail.com', 'pwd1'); + $this->assertEquals(1, $user->getUserid()); + $this->assertEquals('User 1', $user->getName()); + $this->assertEquals('user1', $user->getUsername()); + $this->assertEquals('a63d4b132a9a1d3430f9ae507825f572449e0d17', $user->getPassword()); + $this->assertEquals('', $user->getRole()); + $this->assertNotNull($user->getCreatedAt()); + $this->assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $user->getCreatedAt()); + + $this->object->addUser('User 2', 'user2', 'user2@gmail.com', 'pwd2'); + $this->object->addUser('User 3', 'user3', 'user3@gmail.com', 'pwd3', 'foobar'); + } + + #[\Override] + public function setUp(): void + { + $this->__setUp(LoginField::Username); + } + + #[\Override] + public function tearDown(): void + { + $uri = new Uri(self::CONNECTION_STRING); + unlink($uri->getPath()); + $this->object = null; + } + + /** + * @return void + */ + #[\Override] + public function testAddUser() + { + $this->object->addUser('John Doe', 'john', 'johndoe@gmail.com', 'mypassword'); + + $login = $this->__chooseValue('john', 'johndoe@gmail.com'); + + $user = $this->object->getByLogin($login); + $this->assertEquals('4', $user->getUserid()); + $this->assertEquals('John Doe', $user->getName()); + $this->assertEquals('john', $user->getUsername()); + $this->assertEquals('johndoe@gmail.com', $user->getEmail()); + $this->assertEquals('91dfd9ddb4198affc5c194cd8ce6d338fde470e2', $user->getPassword()); + $this->assertEquals('', $user->getRole()); + $this->assertNotNull($user->getCreatedAt()); + $this->assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $user->getCreatedAt()); + + // Setting role + $user->setRole('admin'); + $this->object->save($user); + + $user2 = $this->object->getByLogin($login); + $this->assertEquals('admin', $user2->getRole()); + } + + /** + * @return void + */ + #[\Override] + public function testCreateAuthToken() + { + $login = $this->__chooseValue('user2', 'user2@gmail.com'); + $this->expectedToken('tokenValue', $login, 2); + } + + /** + * @return void + */ + // TODO: This test is currently disabled because the new architecture uses + // compile-time attributes instead of runtime mapper definitions. + // To achieve custom mappers, users should create a custom UserModel subclass + // with different mapper classes in the FieldAttribute annotations. + /* + public function testWithUpdateValue() + { + // This test was checking the old UserDefinition runtime mapper customization. + // In the new architecture, mappers are defined in the model's #[FieldAttribute] + // annotations at compile time, not modified at runtime. + } + */ + + /** + * @return void + */ + #[\Override] + public function testSaveAndSave() + { + $user = $this->object->getById("1"); + $this->object->save($user); + + $user2 = $this->object->getById("1"); + + // Compare all fields except updated_at which changes on each save + $this->assertEquals($user->getUserid(), $user2->getUserid()); + $this->assertEquals($user->getName(), $user2->getName()); + $this->assertEquals($user->getEmail(), $user2->getEmail()); + $this->assertEquals($user->getUsername(), $user2->getUsername()); + $this->assertEquals($user->getPassword(), $user2->getPassword()); + $this->assertEquals($user->getRole(), $user2->getRole()); + $this->assertEquals($user->getCreatedAt(), $user2->getCreatedAt()); + // updated_at is expected to be different due to the save operation + } +} \ No newline at end of file diff --git a/tests/UsersDBDatasetDefinitionTest.php b/tests/UsersDBDatasetDefinitionTest.php index a568842..e3479d5 100644 --- a/tests/UsersDBDatasetDefinitionTest.php +++ b/tests/UsersDBDatasetDefinitionTest.php @@ -3,40 +3,23 @@ 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; +use ByJG\Authenticate\Enum\LoginField; use ByJG\Authenticate\Exception\UserExistsException; -use ByJG\Authenticate\Model\UserModel; -use ByJG\Authenticate\UsersDBDataset; +use ByJG\Authenticate\Repository\UserPropertiesRepository; +use ByJG\Authenticate\Repository\UsersRepository; +use ByJG\Authenticate\Service\UsersService; use ByJG\MicroOrm\Exception\OrmBeforeInvalidException; use ByJG\MicroOrm\Exception\OrmInvalidFieldsException; use ByJG\MicroOrm\Exception\OrmModelInvalidException; -use Exception; +use ByJG\Util\Uri; +use Override; use ReflectionException; +use Tests\Fixture\MyUserModel; +use Tests\Fixture\MyUserPropertiesModel; -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) - { - $this->otherfield = $otherfield; - } -} - -class UsersDBDatasetDefinitionTest extends UsersDBDatasetByUsernameTest +class UsersDBDatasetDefinitionTest extends UsersDBDatasetByUsernameTestUsersBase { protected $db; @@ -48,61 +31,50 @@ class UsersDBDatasetDefinitionTest extends UsersDBDatasetByUsernameTest * @throws UserExistsException * @throws ReflectionException */ + #[Override] public function __setUp($loginField) { $this->prefix = ""; + $this->loginField = $loginField; $this->db = Factory::getDbInstance(self::CONNECTION_STRING); $this->db->execute('create table mytable ( - myuserid integer primary key autoincrement, - myname varchar(45), - myemail varchar(200), - myusername varchar(20), - mypassword varchar(40), - myotherfield varchar(40), - mycreated datetime default (datetime(\'2017-12-04\')), - myadmin char(1));' + myuserid integer primary key autoincrement, + myname varchar(45), + myemail varchar(200), + myusername varchar(20), + mypassword varchar(40), + myotherfield varchar(40), + mycreated_at datetime default (datetime(\'2017-12-04\')), + myupdated_at datetime, + mydeleted_at datetime, + myrole varchar(20));' ); $this->db->execute('create table theirproperty ( - theirid integer primary key autoincrement, - theiruserid integer, - theirname varchar(45), + theirid integer primary key autoincrement, + theiruserid integer, + theirname varchar(45), theirvalue varchar(45));' ); - $this->userDefinition = new UserDefinition( - 'mytable', - MyUserModel::class, - $loginField, - [ - UserDefinition::FIELD_USERID => 'myuserid', - UserDefinition::FIELD_NAME => 'myname', - UserDefinition::FIELD_EMAIL => 'myemail', - UserDefinition::FIELD_USERNAME => 'myusername', - UserDefinition::FIELD_PASSWORD => 'mypassword', - UserDefinition::FIELD_CREATED => 'mycreated', - UserDefinition::FIELD_ADMIN => 'myadmin', - 'otherfield' => 'myotherfield' - ] - ); - - $this->propertyDefinition = new UserPropertiesDefinition('theirproperty', 'theirid', 'theirname', 'theirvalue', 'theiruserid'); - - $this->object = new UsersDBDataset( - $this->db, - $this->userDefinition, - $this->propertyDefinition + $executor = DatabaseExecutor::using($this->db); + $usersRepository = new UsersRepository($executor, MyUserModel::class); + $propertiesRepository = new UserPropertiesRepository($executor, MyUserPropertiesModel::class); + $this->object = new UsersService( + $usersRepository, + $propertiesRepository, + $loginField ); $this->object->save( - new MyUserModel('User 1', 'user1@gmail.com', 'user1', 'pwd1', 'no', 'other 1') + new MyUserModel('User 1', 'user1@gmail.com', 'user1', 'pwd1', '', 'other 1') ); $this->object->save( - new MyUserModel('User 2', 'user2@gmail.com', 'user2', 'pwd2', 'no', 'other 2') + new MyUserModel('User 2', 'user2@gmail.com', 'user2', 'pwd2', '', 'other 2') ); $this->object->save( - new MyUserModel('User 3', 'user3@gmail.com', 'user3', 'pwd3', 'no', 'other 3') + new MyUserModel('User 3', 'user3@gmail.com', 'user3', 'pwd3', 'foobar', 'other 3') ); } @@ -113,102 +85,83 @@ public function __setUp($loginField) * @throws ReflectionException * @throws UserExistsException */ + #[Override] public function setUp(): void { - $this->__setUp(UserDefinition::LOGIN_IS_USERNAME); + $this->__setUp(LoginField::Username); + } + + #[\Override] + public function tearDown(): void + { + $uri = new Uri(self::CONNECTION_STRING); + unlink($uri->getPath()); + $this->object = null; } /** * @throws UserExistsException * @throws DatabaseException * @throws \ByJG\Serializer\Exception\InvalidArgumentException + * + * @return void */ + #[Override] public function testAddUser() { - $this->object->save(new MyUserModel('John Doe', 'johndoe@gmail.com', 'john', 'mypassword', 'no', 'other john')); + $this->object->save(new MyUserModel('John Doe', 'johndoe@gmail.com', 'john', 'mypassword', '', 'other john')); $login = $this->__chooseValue('john', 'johndoe@gmail.com'); - $user = $this->object->getByLoginField($login); + $user = $this->object->getByLogin($login); $this->assertEquals('4', $user->getUserid()); $this->assertEquals('John Doe', $user->getName()); $this->assertEquals('john', $user->getUsername()); $this->assertEquals('johndoe@gmail.com', $user->getEmail()); $this->assertEquals('91dfd9ddb4198affc5c194cd8ce6d338fde470e2', $user->getPassword()); - $this->assertEquals('no', $user->getAdmin()); + $this->assertEquals('', $user->getRole()); /** @psalm-suppress UndefinedMethod Check UserModel::__call */ $this->assertEquals('other john', $user->getOtherfield()); - $this->assertEquals('', $user->getCreated()); // There is no default action for it + $this->assertNotNull($user->getCreatedAt()); + $this->assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $user->getCreatedAt()); - // Setting as Admin - $user->setAdmin('y'); + // Setting role + $user->setRole('admin'); $this->object->save($user); - $user2 = $this->object->getByLoginField($login); - $this->assertEquals('y', $user2->getAdmin()); + $user2 = $this->object->getByLogin($login); + $this->assertEquals('admin', $user2->getRole()); } - /** - * @throws Exception - */ + // TODO: These tests are currently disabled because the new architecture uses + // compile-time attributes instead of runtime mapper definitions. + // To achieve custom mappers, users should create a custom UserModel subclass + // with different mapper classes in the FieldAttribute annotations. + /* public function testWithUpdateValue() { - // For Update Definitions - $this->userDefinition->defineClosureForUpdate(UserDefinition::FIELD_NAME, function ($value, $instance) { - return '[' . $value . ']'; - }); - $this->userDefinition->defineClosureForUpdate(UserDefinition::FIELD_USERNAME, function ($value, $instance) { - return ']' . $value . '['; - }); - $this->userDefinition->defineClosureForUpdate(UserDefinition::FIELD_EMAIL, function ($value, $instance) { - return '-' . $value . '-'; - }); - $this->userDefinition->defineClosureForUpdate(UserDefinition::FIELD_PASSWORD, function ($value, $instance) { - return "@" . $value . "@"; - }); - $this->userDefinition->markPropertyAsReadOnly(UserDefinition::FIELD_CREATED); - $this->userDefinition->defineClosureForUpdate('otherfield', function ($value, $instance) { - return "*" . $value . "*"; - }); - - // For Select Definitions - $this->userDefinition->defineClosureForSelect(UserDefinition::FIELD_NAME, function ($value, $instance) { - return '(' . $value . ')'; - }); - $this->userDefinition->defineClosureForSelect(UserDefinition::FIELD_USERNAME, function ($value, $instance) { - return ')' . $value . '('; - }); - $this->userDefinition->defineClosureForSelect(UserDefinition::FIELD_EMAIL, function ($value, $instance) { - return '#' . $value . '#'; - }); - $this->userDefinition->defineClosureForSelect(UserDefinition::FIELD_PASSWORD, function ($value, $instance) { - return '%' . $value . '%'; - }); - $this->userDefinition->defineClosureForSelect('otherfield', function ($value, $instance) { - return ']' . $value . '['; - }); - - // Test it! - $newObject = new UsersDBDataset( - $this->db, - $this->userDefinition, - $this->propertyDefinition - ); + // This test was checking the old UserDefinition runtime mapper customization. + // In the new architecture, mappers are defined in the model's #[FieldAttribute] + // annotations at compile time, not modified at runtime. + } - $newObject->save( - new MyUserModel('User 4', 'user4@gmail.com', 'user4', 'pwd4', 'no', 'other john') - ); + public function testDefineGenerateKeyWithInterface() + { + // This test was checking custom key generation via UserDefinition. + // In the new architecture, custom key generation should be done via + // MicroOrm's FieldAttribute primaryKey with a custom generator. + } - $login = $this->__chooseValue(']user4[', '-user4@gmail.com-'); + public function testDefineGenerateKeyWithString() + { + // This test was checking custom key generation via UserDefinition. + // In the new architecture, custom key generation should be done via + // MicroOrm's FieldAttribute primaryKey with a custom generator. + } - $user = $newObject->getByLoginField($login); - $this->assertEquals('4', $user->getUserid()); - $this->assertEquals('([User 4])', $user->getName()); - $this->assertEquals(')]user4[(', $user->getUsername()); - $this->assertEquals('#-user4@gmail.com-#', $user->getEmail()); - $this->assertEquals('%@pwd4@%', $user->getPassword()); - /** @psalm-suppress UndefinedMethod Check UserModel::__call */ - $this->assertEquals(']*other john*[', $user->getOtherfield()); - $this->assertEquals('2017-12-04 00:00:00', $user->getCreated()); + public function testDefineGenerateKeyClosureThrowsException() + { + // UserDefinition has been removed in the new architecture. } + */ }