From c110877cafe659237c6163acb070af185919afee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Polykanine?= Date: Sat, 20 Apr 2024 01:24:29 +0200 Subject: [PATCH] SplitToken: Move to private constructor model (#94) * Move to private constructor model * Remove PHPCS from CI --- .github/workflows/phpcs.yml | 28 -- README.md | 112 +++---- composer.json | 19 +- composer.lock | 228 +++++--------- phpcs.xml.dist | 13 - psalm.xml.dist | 2 +- src/Base64.php | 15 +- src/Crypt.php | 25 +- src/Exception/Base64Exception.php | 9 +- src/Exception/CryptException.php | 2 + src/Exception/DecryptionException.php | 6 +- src/Exception/EncryptionException.php | 6 +- src/Exception/InvalidTokenException.php | 4 +- src/Exception/IridiumException.php | 4 +- src/Exception/PasswordException.php | 6 +- src/Exception/SharedKeyException.php | 2 + src/Exception/SplitTokenException.php | 36 +-- src/Key/DerivedKeys.php | 8 +- src/Key/SharedKey.php | 14 +- src/Password.php | 22 +- src/SplitToken.php | 399 +++++++++--------------- tests/Base64Test.php | 4 +- tests/CryptTest.php | 2 + tests/PasswordTest.php | 2 + tests/SharedKeyTest.php | 1 + tests/SplitTokenTest.php | 173 ++++------ 26 files changed, 436 insertions(+), 706 deletions(-) delete mode 100644 .github/workflows/phpcs.yml delete mode 100644 phpcs.xml.dist diff --git a/.github/workflows/phpcs.yml b/.github/workflows/phpcs.yml deleted file mode 100644 index c24fbcf..0000000 --- a/.github/workflows/phpcs.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: check coding style - -on: [push, pull_request] - -jobs: - phpcs: - runs-on: ubuntu-latest - strategy: - fail-fast: true - - steps: - - name: Checkout code - uses: actions/checkout@v2 - with: - ref: ${{ github.head_ref }} - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: 8.3 - extensions: mbstring, pdo, pdo_sqlite, openssl - coverage: none - - - name: Install dependencies - run: composer install - - - name: run PHPCS - run: vendor/bin/phpcs src tests diff --git a/README.md b/README.md index d3d1d80..5c6fcfd 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ This library consists of several classes, or modules, and can be used for hashin ## Requirements -Requires PHP 7.4 or later with _PDO_, _Mbstring_ and _OpenSSL_ enabled. +Requires PHP 8.1 or later with _PDO_, _Mbstring_ and _OpenSSL_ enabled. ## Installation @@ -225,8 +225,7 @@ You can read everything about the split tokens authentication in [this 2017 arti ### Usage Examples -SplitToken uses fluent interface, i.e., all necessary methods can be chained. -Each time you instantiate a new SplitToken object, you need to provide a database connection as a PDO instance. If you don’t use PDO yet, consider using it, it’s convenient. If you use an ORM, you most likely have a `getPDO()` or a similar method. +Each time you use `SplitToken::create()` to generate a new token or `SplitToken::fromString()` to instantiate a new SplitToken object from a user-provided token, you need to provide a database connection as a PDO instance. If you don’t use PDO yet, consider using it, it’s convenient. If you use an ORM, you most likely have a `getPDO()` or a similar method. Support for popular ORMs is planned for a future version. #### Create a Table @@ -237,59 +236,63 @@ First you need to create the `iridium_tokens` table. For mySQL the statement is ```sql CREATE TABLE `iridium_tokens` ( `id` INT UNSIGNED NULL AUTO_INCREMENT PRIMARY KEY, - `user_id` INT UNSIGNED NOT NULL, - `token_type` INT NULL , + `user_id` INT UNSIGNED NULL, + `token_type` TINYINT UNSIGNED NULL , `selector` VARCHAR(25) NOT NULL, `verifier` VARCHAR(70) NOT NULL, - `additional_info` TEXT(300) NULL, - `expiration_time` BIGINT(20) UNSIGNED NOT NULL, - UNIQUE `token` (`selector`, `verifier`), - CONSTRAINT `fk_token_user_id` + `additional_info` TEXT NULL, + `expires_at` BIGINT(20) UNSIGNED NULL, + CONSTRAINT `fk_iridium_token_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) - ON DELETE CASCADE ON UPDATE RESTRICT + ON DELETE CASCADE ) ENGINE = InnoDB; ``` You may need to adjust the syntax to suit your particular database driver (see for example the SQLite statement in the tests), as well as the name of your `users` table. -The field lengths are optimal, the only one you may need to adjust is `additional_info`, if you are planning to use it for larger sets of data. +The field lengths are optimal. Please remember though, that you need to adjust the length and sign (`UNSIGNED` or not) of the `user_id` field in the `FOREIGN KEY` constraint, otherwise you’ll get very cryptic errors from MySQL or MariaDB. #### Create a Token -First you need to create a token. There are some **required** properties marked in bold and some *optional* ones marked in italic you can set. If you don’t set one or more of the required properties, a `SplitTokenException` will be thrown. +First you need to create a token. There are some parameters you can set, but only the database connection is required, all the other parameters have default values. -* `userId`, **required** — ID of the user the token belongs to, as an unsigned integer. -* `expirationTime`, *optional* — Time when the token expires. Stored as timestamp (big integer), but can be set in various ways, see below. If not set or set to `0`, the token is eternal, i.e., it never expires. -* `tokenType`, *optional* — If you want to perform an additional check of the token (say, separate password recovery tokens from e-mail change tokens), you may set a token type as an integer. In the examples throughout this file we’ll use plain numbers, but we suggest using constants or enums instead. -* `additionalInfo`, *optional* — Any additional information you want to convey with the token, as string. For instance, you can pass some JSON data here. The information can be additionally encrypted, see below. +* `dbConnection` — Database connection, as a PDO instance. +* `expirationTime` — Time when the token expires. Stored as timestamp (big integer), but can be set either as an integer or as a string. If you provide a string, it will be fed to the `DateTimeImmutable` constructor. There is also a special value `0` (zero). If you set the expiration time to 0, the default expiration time will be used, it is equal to current time plus one hour. If `expirationTime` is set to `null`, the token is eternal, i.e., it never expires. The default value is `0`, i.e., expiration in one hour. +* `userId` — ID of the user the token belongs to, as an unsigned integer. If it is set and is 0 or less, an exception will be thrown. +* `tokenType` — If you want to perform an additional check of the token (say, separate password recovery tokens from e-mail change tokens), you may set a token type as an integer. In the examples throughout this file we’ll use plain numbers, but we suggest using an enum instead. +* `additionalInfo` — Any additional information you want to convey with the token, as string. For instance, you can pass some JSON data here. The information can be additionally encrypted. **Note again!** Do not use this to store passwords, even obsolete ones, this can be decrypted. +* `additionalInfoKey` — an Iridium shared key used to encrypt the additional info. -To create a token for user with ID of `123` and with token type of `3` expiring in an hour, and store it into the database, do the following: +To create a token for user with ID of `123` and with token type of `3` expiring in half an hour, and store it into the database, do the following. You can of course use named arguments: ```php use Oire\Iridium\SplitToken; // You should have set your $dbConnection first as a PDO instance -$splitToken = (new SplitToken($dbConnection)) - ->setUserId(123) - ->setExpirationTime(time() + 3600) - ->setTokenType(3) - ->setAdditionalInfo('{"some": "data"}') +$splitToken = SplitToken::create( + dbConnection: $dbConnection, + expirationTime: time() + 1800, + userId: 123, + tokenType: 3, + additionalInfo: '{"some": "data"} + ) ->persist(); ``` Use `$splitToken->getToken()` to actually get the newly created token as a string. -If you want to create a non-expirable token, either use `makeEternal()` instead of `setExpirationTime()` for code readability, or skip this call altogether. +If you want to create a non-expirable token, explicitly set `expirationTime` to `null`. #### Set and Validate a User-Provided Token -If you received an Iridium token from the user, you also need to instantiate SplitToken and validate the token. You don't need to set all the properties as their values are taken from the database. +If you received an Iridium token from the user, you also need to instantiate SplitToken and validate the token. To do this, use `SplitToken::fromString()` instead of `create()`. You don't need to set all the properties as their values are taken from the database. +This method takes three parameters: database connection as PDO instance, the token as string, and optionally the additional info decryption key as Iridium shared key. ```php use Oire\Iridium\Exception\InvalidTokenException; use Oire\Iridium\SplitToken; try { - $splitToken = new SplitToken($dbConnection, $token); + $splitToken = SplitToken::fromString($token, $dbConnection); } catch (InvalidTokenException $e) { // Something went wrong with the token: either it is invalid, not found or has been tampered with } @@ -303,14 +306,14 @@ if ($splitToken->isExpired()) { #### Revoke a Token -After a token is used once for authentication, password reset and other sensitive operation, is expired or compromised, you must revoke, i.e., invalidate it. If you use Iridium tokens as API keys, tokens for unsubscribing from email lists and so on, you can make your token eternal or set the expiration time far in the future and not revoke the token after first use, certainly. If an eternal token is compromised, you must revoke it, also. There are two ways of revoking a token: +After a token is used once for authentication, password reset and other sensitive operation, is expired or compromised, you must revoke, i.e., invalidate it. If you use Iridium tokens as API keys, tokens for unsubscribing from email lists and so on, you can make your token eternal or set the expiration time far in the future and not revoke the token after first use, certainly. If an eternal token is compromised, you must revoke it, also. The `revokeToken()` method returns a `SplitToken` instance with the token-related parameters set to `null`. When revoking a token, you have two possibilities: * Setting the expiration time for the token in the past (default); * Deleting the token from the database whatsoever. To do this, pass `true` as the parameter to the `revokeToken()` method: ```php // Given that $splitToken contains a valid token -$splitToken->revokeToken(true); +$splitToken = $splitToken->revokeToken(true); ``` #### Clear Expired Tokens @@ -321,74 +324,36 @@ From time to time you will need to delete all expired tokens from the database t $deletedTokens = SplitToken::clearExpiredTokens($dbConnection); ``` -#### Three Ways of Setting Expiration Time - -You may set expiration time in three different ways, as you like: - -* `setExpirationTime()` — Accepts a raw timestamp as integer. If set to `null` or `0`, the token is eternal and never expires. -* `setExpirationDate()` — Accepts a `DateTimeImmutable` object. -* `setExpirationOffset()` — Accepts a [relative datetime format](https://www.php.net/manual/en/datetime.formats.relative.php). Default is `+1 hour`. - #### Notes on Expiration Times * All expiration times are internally stored as UTC timestamps. -* Expiration times are set, compared and formatted according to the time of the PHP server, so you won't be in trouble even if your PHP and database server times are different for some reason. -* Expiration time with value `0` makes your token eternal, so it never expires until you revoke it manually. +* Expiration times are set, compared and formatted according to the time of the PHP server, so you won't be in trouble even if your database server time is slightly off for some reason. +* Expiration time with value `0` (zero) sets the default value, i.e., the token will expire in an hour. +* If expiration time is set to `null`, the token is eternal and never expires. * Microseconds for expiration times are ignored for now, their support is planned for a future version. -#### Encrypt Additional Information - -You may store some sensitive data in the additional information for the token such as old and new e-mail address and similar things. -**Note**! Do **not** store plain-text passwords in this property, it can be decrypted! Passwords must not be decryptable, they must be *hashed* instead. If you need to handle passwords, use the Password class, it is suitable for proper password hashing (see above). You may store password hashes in this property, though. -If your additional info contains sensitive data, you can encrypt it. To do this, you first need to have an Iridium key (see above): - -```php -use Oire\Iridium\Key\SharedKey; -use Oire\Iridium\SplitToken; - -$key = new SharedKey(); -// Store the key somewhere safe, i.e., in an environment variable. You can safely cast it to string for that (see above) -$additionalInfo = '{"oldEmail": "john@example.com", "newEmail": "john.doe@example.com"}'; -$splitToken = (new SplitToken($dbConnection)) - ->setUserId($user->getId()) - ->setExpirationOffset('+30 minutes') - ->setTokenType(self::TOKEN_TYPE_CHANGE_EMAIL) - ->setAdditionalInfo($additionalInfo, $key) - ->persist(); -``` - -That's it. I.e., if the second parameter of `setAdditionalInfo()` is not empty and is a valid Iridium key, your additional information will be encrypted. If something is wrong, a `SplitTokenException` will be thrown. -If you received a user-provided token whose additional info is encrypted, pass the key as the third parameter to the SplitToken constructor. - ### Error Handling SplitToken throws two types of exceptions: * `InvalidTokenException` is thrown when something really wrong happens to the token itself or to SQL queries related to the token (for example, a token is not found, it has been tampered with, its length is invalid or a PDO statement cannot be executed); -* `SplitTokenException` is thrown in most cases when you do something erroneously (for example, try to store an empty token into the database, forget to set a required property or try to set such a property when validating a user-provided token, try to set expiration time which is in the past etc.). +* `SplitTokenException` is thrown in most cases when you do something erroneously (for example, try to store an empty token into the database, try to set a negative user ID etc.). ### Methods -Below all of the SplitToken methods are outlined. +Below all of the SplitToken public methods are outlined. -* `__construct(PDO $dbConnection, string|null $token, Oire\Iridium\Key\SharedKey|null $additionalInfoDecryptionKey)` — Instantiate a new SplitToken object. Provide a PDO instance as the first parameter, the user-provided token as the second one, and the Iridium key for decrypting additional info as the third one. **Note**! Provide the token only if you received it from the user. If you want to create a fresh token, the second and third parameters must not be set. -* `getDbConnection(): PDO` — Get the database connection for the current SplitToken instance as a PDO object. +* `static create(PDO $dbConnection, int|string|null $expirationTime = 0, int|null $userId = null, int|null $tokenType = null, string|null $additionalInfo = null, Oire\Iridium\Key\SharedKey|null $additionalInfoKey = null): self` — Generate a new token. All the parameters are described above, only the database connection is required. Expiration time is by default set to `0` which means the token expires in one hour. If `$additionalInfoKey` is not null, the additional info is encrypted with this key. Throws `SplitTokenException` if trying to set a non-positive user ID. +* `static fromString(string $token, PDO $dbConnection, Oire\Iridium\SharedKey|null $additionalInfoKey): self` — Set and validate a user-provided token. If `$additionalInfoKey` is not null, decrypts the additional info stored in the database with this key. * `getToken(): string` — Get the token for the current SplitToken instance as a string. Throws `SplitTokenException` if the token was not created or set before. * `getUserId(): int` — Get the ID of the user the token belongs to, as an integer. -* `setUserId(int $userId): self` — Set the user ID for the newly created token. Do not use this method and similar methods when validating a user-provided token, use them only when creating a new token. Returns `$this` for chainability. * `getExpirationTime(): int` — Get expiration time for the token as raw timestamp. Returns integer. * `getExpirationDate(): DateTimeImmutable` — Get expiration time for the token as a DateTimeImmutable object. Returns the date in the current time zone of your PHP server. * `getExpirationDateFormatted(string $format = 'Y-m-d H:i:s'): string` — Get expiration time for the token as date string. The default format is `2020-11-15 12:34:56`. The `$format` parameter must be a valid [date format](https://www.php.net/manual/en/function.date.php). -* `setExpirationTime(int|null $timestamp = null): self` — Set expiration time for the token as a raw timestamp. If the timestamp is set to `null` or `0`, the token never expires. -* `makeEternal(): self` — A convenience method that makes the token eternal, so it will never expire until you revoke it manually. Returns `$this` for chainability. -* `setExpirationOffset(string $offset = '+1 hour'): self` — Set expiration time for the token as a relative time offset. The default value is `+1 hour`. The `$offset` parameter must be a valid [relative time format](https://www.php.net/manual/en/datetime.formats.relative.php). Returns `$this` for chainability. -* `setExpirationDate(DateTimeImmutable $expirationDate): self` — Set expiration time for the token as a [DateTimeImmutable](https://www.php.net/manual/en/class.datetimeimmutable.php) object. Returns `$this` for chainability. * `isEternal(): bool` — check if the token is eternal and never expires. Returns `true` if the token is eternal, `false` if it has expiration time set in the future or already expired. * `isExpired(): bool` — Check if the token is expired. Returns `true` if the token has already expired, `false` otherwise. * `getTokenType(): int|null` — Get the type for the current token. Returns integer if the token type was set before, or null if the token has no type. -* `setTokenType(int|null $tokenType): self` — Set the type for the current token, as integer or null. Returns `$this` for chainability. * `getAdditionalInfo(): string|null` — Get additional info for the token. Returns string or null, if additional info was not set before. -* `setAdditionalInfo(string|null $additionalInfo, Oire\Iridium\Key\SharedKey|null $encryptionKey = null): self` — Set additional info for the current token. If the `$encryptionKey` parameter is not empty, tries to encrypt the additional information using the Crypt class. Returns `$this` for chainability. * `persist(): self` — Store the token into the database. Returns `$this` for chainability. * `revokeToken(bool $deleteToken = false): void` — Revoke. i.e., invalidate the current token after it is used. If the `$deleteToken` parameter is set to `true`, the token will be deleted from the database, and `getToken()` will return `null`. If it is set to `false` (default), the expiration time for the token will be updated and set to a value in the past. The method returns no value. * `static clearExpiredTokens(PDO $dbConnection): int` — Delete all expired tokens from the database. As it is a static method, it receives the database connection as a PDO object. Returns the number of deleted tokens, as integer. @@ -407,7 +372,6 @@ Before committing, don’t forget to run all the needed checks, otherwise the CI ./vendor/bin/phpunit ./vendor/bin/psalm ./vendor/bin/php-cs-fixer fix -./vendor/bin/phpcs . ``` If PHPCodeSniffer finds any code style errors, fix them in your code. @@ -415,5 +379,5 @@ When your pull request is submitted, make sure all checks passed on CI. ## License -Copyright © 2021-2022 Andre Polykanine also known as Menelion Elensúlë, [The Magical Kingdom of Oirë](https://github.com/Oire/). +Copyright © 2021-2024 Andre Polykanine also known as Menelion Elensúlë, [The Magical Kingdom of Oirë](https://github.com/Oire/). This software is licensed under an MIT license. diff --git a/composer.json b/composer.json index 229396e..55d1380 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,6 @@ "oire/php-code-style": "dev-master", "phpunit/phpunit": "*", "psalm/plugin-phpunit": "*", - "squizlabs/php_codesniffer": "*", "vimeo/psalm": "dev-master" }, "license": "MIT", @@ -48,13 +47,17 @@ "*": "dist" }, "allow-plugins": { - "composer/package-versions-deprecated": true, - "captainhook/plugin-composer": false + "composer/package-versions-deprecated": true } }, - "scripts": { - "tests": "vendor/bin/phpunit", - "coding-style": "vendor/bin/php-cs-fixer fix --dry-run --diff --config=.php_cs.dist", - "clear": "rm -rf vendor/" - } + "funding": [ + { + "type": "PayPal", + "url": "https://paypal.me/MenelionFr" + }, + { + "type": "Ko-fi", + "url": "https://ko-fi/Menelion" + } + ] } diff --git a/composer.lock b/composer.lock index 165a86e..e886a7e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,21 +4,21 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "9b8dfd64c12e7763bd05552d272ffb27", + "content-hash": "9ea6dd362b5b67d512761da46c31c14d", "packages": [], "packages-dev": [ { "name": "amphp/amp", - "version": "v3.0.0", + "version": "v3.0.1", "source": { "type": "git", "url": "https://github.com/amphp/amp.git", - "reference": "aaf0ec1d5a2c20b523258995a10e80c1fb765871" + "reference": "ff63f10210adb6e83335e0e25522bac5cd7dc4e2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/amp/zipball/aaf0ec1d5a2c20b523258995a10e80c1fb765871", - "reference": "aaf0ec1d5a2c20b523258995a10e80c1fb765871", + "url": "https://api.github.com/repos/amphp/amp/zipball/ff63f10210adb6e83335e0e25522bac5cd7dc4e2", + "reference": "ff63f10210adb6e83335e0e25522bac5cd7dc4e2", "shasum": "" }, "require": { @@ -28,7 +28,7 @@ "require-dev": { "amphp/php-cs-fixer-config": "^2", "phpunit/phpunit": "^9", - "psalm/phar": "^4.13" + "psalm/phar": "5.23.1" }, "type": "library", "autoload": { @@ -78,7 +78,7 @@ ], "support": { "issues": "https://github.com/amphp/amp/issues", - "source": "https://github.com/amphp/amp/tree/v3.0.0" + "source": "https://github.com/amphp/amp/tree/v3.0.1" }, "funding": [ { @@ -86,7 +86,7 @@ "type": "github" } ], - "time": "2022-12-18T16:52:44+00:00" + "time": "2024-04-18T15:24:36+00:00" }, { "name": "amphp/byte-stream", @@ -964,16 +964,16 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.52.1", + "version": "v3.54.0", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "6e77207f0d851862ceeb6da63e6e22c01b1587bc" + "reference": "2aecbc8640d7906c38777b3dcab6f4ca79004d08" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/6e77207f0d851862ceeb6da63e6e22c01b1587bc", - "reference": "6e77207f0d851862ceeb6da63e6e22c01b1587bc", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/2aecbc8640d7906c38777b3dcab6f4ca79004d08", + "reference": "2aecbc8640d7906c38777b3dcab6f4ca79004d08", "shasum": "" }, "require": { @@ -997,6 +997,7 @@ }, "require-dev": { "facile-it/paraunit": "^1.3 || ^2.0", + "infection/infection": "^0.27.11", "justinrainbow/json-schema": "^5.2", "keradus/cli-executor": "^2.1", "mikey179/vfsstream": "^1.6.11", @@ -1044,7 +1045,7 @@ ], "support": { "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", - "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.52.1" + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.54.0" }, "funding": [ { @@ -1052,7 +1053,7 @@ "type": "github" } ], - "time": "2024-03-19T21:02:43+00:00" + "time": "2024-04-17T08:12:13+00:00" }, { "name": "myclabs/deep-copy", @@ -1228,12 +1229,12 @@ "source": { "type": "git", "url": "https://github.com/Oire/php-code-style.git", - "reference": "6b8651a5dcd08b8c32bf7f9a968beca9427b0458" + "reference": "6a08469cab5e1ad63feeaaa12fccbef48da39685" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Oire/php-code-style/zipball/6b8651a5dcd08b8c32bf7f9a968beca9427b0458", - "reference": "6b8651a5dcd08b8c32bf7f9a968beca9427b0458", + "url": "https://api.github.com/repos/Oire/php-code-style/zipball/6a08469cab5e1ad63feeaaa12fccbef48da39685", + "reference": "6a08469cab5e1ad63feeaaa12fccbef48da39685", "shasum": "" }, "require": { @@ -1263,7 +1264,7 @@ "issues": "https://github.com/Oire/php-code-style/issues", "source": "https://github.com/Oire/php-code-style" }, - "time": "2024-04-01T23:26:52+00:00" + "time": "2024-04-17T22:59:07+00:00" }, { "name": "phar-io/manifest", @@ -1438,28 +1439,35 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "5.3.0", + "version": "5.4.0", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "622548b623e81ca6d78b721c5e029f4ce664f170" + "reference": "298d2febfe79d03fe714eb871d5538da55205b1a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/622548b623e81ca6d78b721c5e029f4ce664f170", - "reference": "622548b623e81ca6d78b721c5e029f4ce664f170", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/298d2febfe79d03fe714eb871d5538da55205b1a", + "reference": "298d2febfe79d03fe714eb871d5538da55205b1a", "shasum": "" }, "require": { + "doctrine/deprecations": "^1.1", "ext-filter": "*", - "php": "^7.2 || ^8.0", + "php": "^7.4 || ^8.0", "phpdocumentor/reflection-common": "^2.2", - "phpdocumentor/type-resolver": "^1.3", + "phpdocumentor/type-resolver": "^1.7", + "phpstan/phpdoc-parser": "^1.7", "webmozart/assert": "^1.9.1" }, "require-dev": { - "mockery/mockery": "~1.3.2", - "psalm/phar": "^4.8" + "mockery/mockery": "~1.3.5", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-mockery": "^1.1", + "phpstan/phpstan-webmozart-assert": "^1.2", + "phpunit/phpunit": "^9.5", + "vimeo/psalm": "^5.13" }, "type": "library", "extra": { @@ -1483,15 +1491,15 @@ }, { "name": "Jaap van Otterdijk", - "email": "account@ijaap.nl" + "email": "opensource@ijaap.nl" } ], "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "support": { "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.3.0" + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.4.0" }, - "time": "2021-10-19T17:43:47+00:00" + "time": "2024-04-09T21:13:58+00:00" }, { "name": "phpdocumentor/type-resolver", @@ -1553,16 +1561,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "1.27.0", + "version": "1.28.0", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "86e4d5a4b036f8f0be1464522f4c6b584c452757" + "reference": "cd06d6b1a1b3c75b0b83f97577869fd85a3cd4fb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/86e4d5a4b036f8f0be1464522f4c6b584c452757", - "reference": "86e4d5a4b036f8f0be1464522f4c6b584c452757", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/cd06d6b1a1b3c75b0b83f97577869fd85a3cd4fb", + "reference": "cd06d6b1a1b3c75b0b83f97577869fd85a3cd4fb", "shasum": "" }, "require": { @@ -1594,9 +1602,9 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/1.27.0" + "source": "https://github.com/phpstan/phpdoc-parser/tree/1.28.0" }, - "time": "2024-03-21T13:14:53+00:00" + "time": "2024-04-03T18:51:33+00:00" }, { "name": "phpunit/php-code-coverage", @@ -1923,16 +1931,16 @@ }, { "name": "phpunit/phpunit", - "version": "11.0.9", + "version": "11.1.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "591bbfe416400385527d5086b346b92c06de404b" + "reference": "51e342a0bc987e0ea8418105d0711f08ae116de3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/591bbfe416400385527d5086b346b92c06de404b", - "reference": "591bbfe416400385527d5086b346b92c06de404b", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/51e342a0bc987e0ea8418105d0711f08ae116de3", + "reference": "51e342a0bc987e0ea8418105d0711f08ae116de3", "shasum": "" }, "require": { @@ -1971,7 +1979,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "11.0-dev" + "dev-main": "11.1-dev" } }, "autoload": { @@ -2003,7 +2011,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.0.9" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.1.2" }, "funding": [ { @@ -2019,7 +2027,7 @@ "type": "tidelift" } ], - "time": "2024-03-28T10:09:42+00:00" + "time": "2024-04-14T07:13:56+00:00" }, { "name": "psalm/plugin-phpunit", @@ -3292,98 +3300,18 @@ ], "time": "2024-02-07T10:39:02+00:00" }, - { - "name": "squizlabs/php_codesniffer", - "version": "3.9.1", - "source": { - "type": "git", - "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "267a4405fff1d9c847134db3a3c92f1ab7f77909" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/267a4405fff1d9c847134db3a3c92f1ab7f77909", - "reference": "267a4405fff1d9c847134db3a3c92f1ab7f77909", - "shasum": "" - }, - "require": { - "ext-simplexml": "*", - "ext-tokenizer": "*", - "ext-xmlwriter": "*", - "php": ">=5.4.0" - }, - "require-dev": { - "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" - }, - "bin": [ - "bin/phpcbf", - "bin/phpcs" - ], - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.x-dev" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Greg Sherwood", - "role": "Former lead" - }, - { - "name": "Juliette Reinders Folmer", - "role": "Current lead" - }, - { - "name": "Contributors", - "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors" - } - ], - "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", - "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer", - "keywords": [ - "phpcs", - "standards", - "static analysis" - ], - "support": { - "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues", - "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy", - "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer", - "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki" - }, - "funding": [ - { - "url": "https://github.com/PHPCSStandards", - "type": "github" - }, - { - "url": "https://github.com/jrfnl", - "type": "github" - }, - { - "url": "https://opencollective.com/php_codesniffer", - "type": "open_collective" - } - ], - "time": "2024-03-31T21:03:09+00:00" - }, { "name": "symfony/console", - "version": "v7.0.4", + "version": "v7.0.6", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "6b099f3306f7c9c2d2786ed736d0026b2903205f" + "reference": "fde915cd8e7eb99b3d531d3d5c09531429c3f9e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/6b099f3306f7c9c2d2786ed736d0026b2903205f", - "reference": "6b099f3306f7c9c2d2786ed736d0026b2903205f", + "url": "https://api.github.com/repos/symfony/console/zipball/fde915cd8e7eb99b3d531d3d5c09531429c3f9e5", + "reference": "fde915cd8e7eb99b3d531d3d5c09531429c3f9e5", "shasum": "" }, "require": { @@ -3447,7 +3375,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.0.4" + "source": "https://github.com/symfony/console/tree/v7.0.6" }, "funding": [ { @@ -3463,7 +3391,7 @@ "type": "tidelift" } ], - "time": "2024-02-22T20:27:20+00:00" + "time": "2024-04-01T11:04:53+00:00" }, { "name": "symfony/deprecation-contracts", @@ -3614,16 +3542,16 @@ }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.4.0", + "version": "v3.4.2", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "a76aed96a42d2b521153fb382d418e30d18b59df" + "reference": "4e64b49bf370ade88e567de29465762e316e4224" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/a76aed96a42d2b521153fb382d418e30d18b59df", - "reference": "a76aed96a42d2b521153fb382d418e30d18b59df", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/4e64b49bf370ade88e567de29465762e316e4224", + "reference": "4e64b49bf370ade88e567de29465762e316e4224", "shasum": "" }, "require": { @@ -3670,7 +3598,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.4.0" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.4.2" }, "funding": [ { @@ -3686,20 +3614,20 @@ "type": "tidelift" } ], - "time": "2023-05-23T14:45:45+00:00" + "time": "2024-01-23T14:51:35+00:00" }, { "name": "symfony/filesystem", - "version": "v7.0.3", + "version": "v7.0.6", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "2890e3a825bc0c0558526c04499c13f83e1b6b12" + "reference": "408105dff4c104454100730bdfd1a9cdd993f04d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/2890e3a825bc0c0558526c04499c13f83e1b6b12", - "reference": "2890e3a825bc0c0558526c04499c13f83e1b6b12", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/408105dff4c104454100730bdfd1a9cdd993f04d", + "reference": "408105dff4c104454100730bdfd1a9cdd993f04d", "shasum": "" }, "require": { @@ -3733,7 +3661,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.0.3" + "source": "https://github.com/symfony/filesystem/tree/v7.0.6" }, "funding": [ { @@ -3749,7 +3677,7 @@ "type": "tidelift" } ], - "time": "2024-01-23T15:02:46+00:00" + "time": "2024-03-21T19:37:36+00:00" }, { "name": "symfony/finder", @@ -4419,16 +4347,16 @@ }, { "name": "symfony/service-contracts", - "version": "v3.4.1", + "version": "v3.4.2", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "fe07cbc8d837f60caf7018068e350cc5163681a0" + "reference": "11bbf19a0fb7b36345861e85c5768844c552906e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/fe07cbc8d837f60caf7018068e350cc5163681a0", - "reference": "fe07cbc8d837f60caf7018068e350cc5163681a0", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/11bbf19a0fb7b36345861e85c5768844c552906e", + "reference": "11bbf19a0fb7b36345861e85c5768844c552906e", "shasum": "" }, "require": { @@ -4481,7 +4409,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.4.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.4.2" }, "funding": [ { @@ -4497,7 +4425,7 @@ "type": "tidelift" } ], - "time": "2023-12-26T14:02:43+00:00" + "time": "2023-12-19T21:51:00+00:00" }, { "name": "symfony/stopwatch", @@ -4703,12 +4631,12 @@ "source": { "type": "git", "url": "https://github.com/vimeo/psalm.git", - "reference": "ef3b018e89c4ffc157332c13e2ebf9cf22320d17" + "reference": "08afc45a81d1f7c5145341ddf4c3c2c8b1985ed2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vimeo/psalm/zipball/ef3b018e89c4ffc157332c13e2ebf9cf22320d17", - "reference": "ef3b018e89c4ffc157332c13e2ebf9cf22320d17", + "url": "https://api.github.com/repos/vimeo/psalm/zipball/08afc45a81d1f7c5145341ddf4c3c2c8b1985ed2", + "reference": "08afc45a81d1f7c5145341ddf4c3c2c8b1985ed2", "shasum": "" }, "require": { @@ -4804,7 +4732,7 @@ "issues": "https://github.com/vimeo/psalm/issues", "source": "https://github.com/vimeo/psalm" }, - "time": "2024-03-09T19:31:51+00:00" + "time": "2024-04-11T20:02:02+00:00" }, { "name": "webmozart/assert", diff --git a/phpcs.xml.dist b/phpcs.xml.dist deleted file mode 100644 index f401985..0000000 --- a/phpcs.xml.dist +++ /dev/null @@ -1,13 +0,0 @@ - - - */.git/* - */vendor/* - */src/* - */tests/* - - - - - - - diff --git a/psalm.xml.dist b/psalm.xml.dist index 0795439..0c91a4e 100644 --- a/psalm.xml.dist +++ b/psalm.xml.dist @@ -5,7 +5,7 @@ strictBinaryOperands="true" findUnusedVariablesAndParams="true" findUnusedCode="true" - ensureArrayStringOffsetsExist="true" + ensureArrayStringOffsetsExist="false" ensureArrayIntOffsetsExist="true" phpVersion="8.3" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" diff --git a/src/Base64.php b/src/Base64.php index cea9bc2..2d2ac8b 100644 --- a/src/Base64.php +++ b/src/Base64.php @@ -1,4 +1,5 @@ getMessage()), $e); } - - /** @psalm-suppress PossiblyUnusedReturnValue */ - final public static function tokenNeverExpires(): self - { - return new self('The token never expires.'); - } } diff --git a/src/Key/DerivedKeys.php b/src/Key/DerivedKeys.php index 1a4d15a..67cbf78 100644 --- a/src/Key/DerivedKeys.php +++ b/src/Key/DerivedKeys.php @@ -1,4 +1,5 @@ salt && $this->encryptionKey && $this->authenticationKey) - && mb_strlen($this->salt, Crypt::STRING_ENCODING_8BIT) === self::SALT_SIZE; + && self::SALT_SIZE === mb_strlen($this->salt, Crypt::STRING_ENCODING_8BIT); } } diff --git a/src/Key/SharedKey.php b/src/Key/SharedKey.php index e983db8..ce6134a 100644 --- a/src/Key/SharedKey.php +++ b/src/Key/SharedKey.php @@ -1,5 +1,7 @@ getMessage()), $e); } - if (mb_strlen($this->rawKey, Crypt::STRING_ENCODING_8BIT) !== self::KEY_SIZE) { + if (self::KEY_SIZE !== mb_strlen($this->rawKey, Crypt::STRING_ENCODING_8BIT)) { throw new SharedKeyException('Invalid key given.'); } @@ -66,6 +69,7 @@ public function __construct(?string $key = null) /** * Get the key in raw binary form. + * * @return string The key in binary form as a string */ public function getRawKey(): string @@ -75,6 +79,7 @@ public function getRawKey(): string /** * Get the key in readable and storable form. + * * @return string The key in readable form as a string */ public function getKey(): string @@ -84,13 +89,15 @@ public function getKey(): string /** * Derive encryption and authentication keys for encrypt-then-MAC. - * @param string|null $salt Salt for key derivation. Provide this only for decryption! + * + * @param string|null $salt Salt for key derivation. Provide this only for decryption! + * * @return DerivedKeys A derived keys object containing salt, encryptionKey and authenticationKey */ public function deriveKeys(?string $salt = null): DerivedKeys { if ($salt !== null) { - if (mb_strlen($salt, Crypt::STRING_ENCODING_8BIT) !== DerivedKeys::SALT_SIZE) { + if (DerivedKeys::SALT_SIZE !== mb_strlen($salt, Crypt::STRING_ENCODING_8BIT)) { throw new SharedKeyException('Given salt is of incorrect length.'); } } else { @@ -116,6 +123,7 @@ public function deriveKeys(?string $salt = null): DerivedKeys /** * Get key object as string. + * * @return string Returns the key in readable and storable form */ public function __toString(): string diff --git a/src/Password.php b/src/Password.php index 385ff38..8028453 100644 --- a/src/Password.php +++ b/src/Password.php @@ -1,5 +1,7 @@ dbConnection = $dbConnection; - try { $this->dbConnection->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $this->dbConnection->setAttribute(PDO::ATTR_EMULATE_PREPARES, false); @@ -78,61 +78,98 @@ public function __construct( } catch (PDOException $e) { throw InvalidTokenException::sqlError($e); } - - if ($token !== null && $token !== '') { - $this->setToken($token, $additionalInfoKey); - } else { - $rawToken = random_bytes(self::TOKEN_SIZE); - $this->token = Base64::encode($rawToken); - $this->selector = Base64::encode(mb_substr($rawToken, 0, self::SELECTOR_SIZE, Crypt::STRING_ENCODING_8BIT)); - $this->hashedVerifier = Base64::encode( - hash( - Crypt::HASH_FUNCTION, - mb_substr($rawToken, self::SELECTOR_SIZE, self::VERIFIER_SIZE, Crypt::STRING_ENCODING_8BIT), - true - ) - ); - } } /** - * Get the connection to the database. - * @return PDO Returns the connection to the database as a PDO object + * Create a new split token. + * + * @param PDO $dbConnection Connection to the database + * @param int|string|null $expirationTime expiration time of the token. Set to null if the token should not expire + * @param int|null $userId The ID of the user in the database + * @param int|null $tokenType A custom type for the token, most likely taken from an enum + * @param string|null $additionalInfo Some supplementary information attached to the token, like a JSON object + * @param SharedKey|null $additionalInfoKey An Iridium key to encrypt the additional info or decrypt it if it was encrypted before + * + * @return self Returns a newly created SplitToken */ - public function getDbConnection(): PDO - { - return $this->dbConnection; + public static function create( + PDO $dbConnection, + int|string|null $expirationTime = 0, + ?int $userId = null, + ?int $tokenType = null, + ?string $additionalInfo = null, + ?SharedKey $additionalInfoKey = null + ): self { + $splitToken = new self($dbConnection); + $rawToken = random_bytes(self::TOKEN_SIZE); + $splitToken->token = Base64::encode($rawToken); + $splitToken->selector = Base64::encode(mb_substr($rawToken, 0, self::SELECTOR_SIZE, Crypt::STRING_ENCODING_8BIT)); + $splitToken->hashedVerifier = Base64::encode( + hash( + Crypt::HASH_FUNCTION, + mb_substr($rawToken, self::SELECTOR_SIZE, self::VERIFIER_SIZE, Crypt::STRING_ENCODING_8BIT), + true + ) + ); + + $splitToken->expirationTime = is_string($expirationTime) + ? (new DateTimeImmutable($expirationTime))->getTimestamp() + : ($expirationTime === 0 ? (new DateTimeImmutable(self::DEFAULT_EXPIRATION_TIME_OFFSET))->getTimestamp() : $expirationTime); + + if ($userId !== null && $userId <= 0) { + throw SplitTokenException::invalidUserId($userId); + } + + $splitToken->userId = $userId; + $splitToken->tokenType = $tokenType; + + if ($additionalInfo !== null && $additionalInfoKey !== null) { + try { + $splitToken->additionalInfo = Crypt::encrypt($additionalInfo, $additionalInfoKey); + } catch (CryptException $e) { + throw SplitTokenException::additionalInfoEncryptionError($e); + } + } else { + $splitToken->additionalInfo = $additionalInfo; + } + + return $splitToken; } /** * Get the token. - * @throws SplitTokenException If the token was not set or created beforehand - * @return string Returns the token + * + * @return string|null Returns the token */ - public function getToken(): string + public function getToken(): ?string { - if ($this->token === null || $this->token === '') { - throw SplitTokenException::tokenNotSet(); - } - return $this->token; } /** * Set and validate a user-provided token. - * @param string $token The token provided by the user - * @param SharedKey|null $additionalInfoKey If not empty, the encrypted additional info will be decrypted + * + * @param string|null $token The token provided by the user + * @param PDO $dbConnection Connection to the database + * @param SharedKey|null $additionalInfoKey If not empty, the encrypted additional info will be decrypted + * * @throws InvalidTokenException */ - private function setToken(string $token, ?SharedKey $additionalInfoKey = null): void + public static function fromString(?string $token, PDO $dbConnection, ?SharedKey $additionalInfoKey = null): self { + if ($token === null) { + throw InvalidTokenException::invalidTokenLength(); + } + + $splitToken = new self($dbConnection); + try { $rawToken = Base64::decode($token); } catch (Base64Exception $e) { throw InvalidTokenException::invalidTokenFormat($e->getMessage(), $e); } - if (mb_strlen($rawToken, Crypt::STRING_ENCODING_8BIT) !== self::TOKEN_SIZE) { + if (self::TOKEN_SIZE !== mb_strlen($rawToken, Crypt::STRING_ENCODING_8BIT)) { throw InvalidTokenException::invalidTokenLength(); } @@ -145,10 +182,10 @@ private function setToken(string $token, ?SharedKey $additionalInfoKey = null): WHERE selector = :selector', self::TABLE_NAME ); - $statement = $this->dbConnection->prepare($sql); + $statement = $splitToken->dbConnection->prepare($sql); if (!$statement) { - $errorMessage = $this->dbConnection->errorInfo()[2] ?? 'Unknown PDO error'; + $errorMessage = $splitToken->dbConnection->errorInfo()[2] ?? 'Unknown PDO error'; throw InvalidTokenException::pdoStatementError($errorMessage); } @@ -158,7 +195,7 @@ private function setToken(string $token, ?SharedKey $additionalInfoKey = null): throw InvalidTokenException::sqlError($e); } - /** @var string[] $result */ + /** @var array */ $result = $statement->fetch(); if (!$result) { @@ -173,98 +210,72 @@ private function setToken(string $token, ?SharedKey $additionalInfoKey = null): ) ); - if (isset($result['verifier'])) { - $validVerifier = $result['verifier']; - } else { - throw InvalidTokenException::verifierError(); - } - - if (!hash_equals($verifier, $validVerifier)) { + if ($result['verifier'] === null || !hash_equals($verifier, $result['verifier'])) { throw InvalidTokenException::verifierError(); } - $this->token = $token; - $this->selector = $selector; - $this->hashedVerifier = $verifier; + $splitToken->token = $token; + $splitToken->selector = $selector; + $splitToken->hashedVerifier = $verifier; + $splitToken->userId = $result['user_id'] !== null ? (int) $result['user_id'] : null; + $splitToken->expirationTime = $result['expiration_time'] !== null ? (int) $result['expiration_time'] : null; + $splitToken->tokenType = $result['token_type'] !== null ? (int) $result['token_type'] : null; - if (isset($result['user_id'])) { - $this->userId = (int) $result['user_id']; - } else { - throw SplitTokenException::invalidUserId(0); - } - - $this->expirationTime = isset($result['expiration_time']) ? (int) $result['expiration_time'] : 0; - $this->tokenType = isset($result['token_type']) ? (int) $result['token_type'] : null; - - if (isset($result['additional_info'])) { - if ($additionalInfoKey) { + if ($result['additional_info'] !== null) { + if ($additionalInfoKey !== null) { try { - $this->additionalInfo = Crypt::decrypt($result['additional_info'], $additionalInfoKey); + $splitToken->additionalInfo = Crypt::decrypt($result['additional_info'], $additionalInfoKey); } catch (CryptException $e) { throw SplitTokenException::additionalInfoDecryptionError($e); } } else { - $this->additionalInfo = $result['additional_info']; + $splitToken->additionalInfo = $result['additional_info']; } + } else { + $splitToken->additionalInfo = null; } + + return $splitToken; } /** * Get the ID of the user the token belongs to. */ - public function getUserId(): int + public function getUserId(): ?int { return $this->userId; } - /** - * Set the ID of the user the token belongs to. - * @param int $userId The ID of the user the token belongs to. Must be a positive integer. - * @throws SplitTokenException - * @return $this - */ - public function setUserId(int $userId): self - { - if ($this->userId) { - throw SplitTokenException::propertyAlreadySet('User ID'); - } - - if ($userId <= 0) { - throw SplitTokenException::invalidUserId($userId); - } - - $this->userId = $userId; - - return $this; - } - /** * Get the expiration time of the token as timestamp. + * + * @return int|null Returns null if the token never expires */ - public function getExpirationTime(): int + public function getExpirationTime(): ?int { return $this->expirationTime; } /** * Check if the token is eternal, i.e., never expires. - * @throws SplitTokenException If the expiration time is empty - * @return bool True if the token never expires, false otherwise or if the token was revoked + * + * @return bool True if the token never expires, false otherwise or if the token was revoked */ public function isEternal(): bool { - return $this->expirationTime === 0 && !$this->isExpired(); + return $this->expirationTime === null; } /** * Get the expiration time of the token as a DateTime immutable object. - * @throws SplitTokenException If the token never expires - * @return DateTimeImmutable Returns the expiration time in the default time zone + * + * @return DateTimeImmutable|null Returns the expiration time in the default time zone. If the token is eternal, returns null + * @psalm-suppress PossiblyUnusedMethod */ - public function getExpirationDate(): DateTimeImmutable + public function getExpirationDate(): ?DateTimeImmutable { - if ($this->isEternal()) { - throw SplitTokenException::tokenNeverExpires(); + if ($this->expirationTime === null) { + return null; } return (new DateTimeImmutable(sprintf('@%s', $this->expirationTime))) @@ -273,15 +284,19 @@ public function getExpirationDate(): DateTimeImmutable /** * Get the expiration time of the token in a given format. + * * @param string $format A valid date format. Defaults to `'Y-m-d H:i:s'` + * * @see https://www.php.net/manual/en/function.date.php - * @throws SplitTokenException if the date formatting fails or the token never expires - * @return string Returns the expiration time as date string in given format + * + * @throws SplitTokenException if the date formatting fails + * @return string|null Returns the expiration time as date string in given format. If the token is eternal, returns null + * @psalm-suppress PossiblyUnusedMethod */ - public function getExpirationDateFormatted(string $format = self::DEFAULT_EXPIRATION_DATE_FORMAT): string + public function getExpirationDateFormatted(string $format = self::DEFAULT_EXPIRATION_DATE_FORMAT): ?string { - if ($this->isEternal()) { - throw SplitTokenException::tokenNeverExpires(); + if ($this->expirationTime === null) { + return null; } try { @@ -293,106 +308,21 @@ public function getExpirationDateFormatted(string $format = self::DEFAULT_EXPIRA } } - /** - * Set the expiration time for the token using timestamp. - * @param int $timestamp The timestamp when the token should expire, defaults to +1 hour - * @throws SplitTokenException - * @return $this - */ - public function setExpirationTime(?int $timestamp = null): self - { - if ($this->expirationTime) { - throw SplitTokenException::propertyAlreadySet('Expiration time'); - } - - $timestamp ??= time() + self::DEFAULT_EXPIRATION_TIME_OFFSET; - - if ($timestamp !== 0 && $timestamp <= time()) { - throw SplitTokenException::expirationTimeInPast($timestamp); - } - - $this->expirationTime = $timestamp; - - return $this; - } - - /** - * Set the expiration time for the token using relative time. - * @param string $offset The time interval the token expires in. Defaults to +1 hour - * @see https://www.php.net/manual/en/datetime.formats.relative.php - * @throws SplitTokenException - * @return $this - */ - public function setExpirationOffset(string $offset = self::DEFAULT_EXPIRATION_DATE_OFFSET): self - { - if ($this->expirationTime) { - throw SplitTokenException::propertyAlreadySet('Expiration time'); - } - - if (!$offset) { - throw SplitTokenException::emptyExpirationOffset(); - } - - try { - /** @var DateTimeImmutable|false $expirationDate */ - $expirationDate = (new DateTimeImmutable())->modify($offset); - - if (!$expirationDate) { - throw new SplitTokenException('Invalid expiration date'); - } - - $this->expirationTime = $expirationDate->getTimestamp(); - - if ($this->expirationTime <= time()) { - throw SplitTokenException::expirationTimeInPast($this->expirationTime); - } - } catch (Throwable $e) { - throw new SplitTokenException(sprintf('Invalid expiration offset "%s": %s', $offset, $e->getMessage()), $e); - } - - return $this; - } - - /** - * Set the expiration time for the token using DateTime immutable object. - * @param DateTimeImmutable $expirationDate The date the token should expire at - * @throws SplitTokenException - * @return $this - */ - public function setExpirationDate(DateTimeImmutable $expirationDate): self - { - $this->expirationTime = $expirationDate->getTimestamp(); - - if ($this->expirationTime <= time()) { - throw SplitTokenException::expirationTimeInPast($this->expirationTime); - } - - return $this; - } - - /** - * Makes the token eternal, so it will never expire. - * @return $this - */ - public function makeEternal(): self - { - $this->expirationTime = 0; - - return $this; - } - /** * Check if the token is expired. + * * @throws SplitTokenException if the expiration time is empty * @return bool True if the token is expired, false otherwise + * */ public function isExpired(): bool { - return $this->expirationTime !== 0 && $this->expirationTime <= time(); + return null !== $this->expirationTime && $this->expirationTime <= time(); } /** * Get the token type. + * * @return int|null The token type or null if it was not set before */ public function getTokenType(): ?int @@ -400,20 +330,9 @@ public function getTokenType(): ?int return $this->tokenType; } - /** - * Set the token type. - * @param int|null $tokenType Set this if you want to categorize your tokens by type. The default value is null - * @return $this - */ - public function setTokenType(?int $tokenType): self - { - $this->tokenType = $tokenType; - - return $this; - } - /** * Get the additional info for the token. + * * @return string|null The additional info or null if it was not set before */ public function getAdditionalInfo(): ?string @@ -421,45 +340,16 @@ public function getAdditionalInfo(): ?string return $this->additionalInfo; } - /** - * Set the additional info for the token. - * @param string|null $additionalInfo Any additional info you want to convey along with the token, as string - * @param SharedKey|null $encryptionKey If not empty, the data will be encrypted - * @return $this - */ - public function setAdditionalInfo(?string $additionalInfo, ?SharedKey $encryptionKey = null): self - { - if ($additionalInfo !== null && $additionalInfo !== '') { - if ($encryptionKey !== null) { - try { - $this->additionalInfo = Crypt::encrypt($additionalInfo, $encryptionKey); - } catch (CryptException $e) { - throw SplitTokenException::additionalInfoEncryptionError($e); - } - } else { - $this->additionalInfo = $additionalInfo; - } - } - - return $this; - } - /** * Store the token in the database. + * * @throws InvalidTokenException If SQL error occurs * @throws SplitTokenException if not enough data are provided * @return $this + * */ public function persist(): self { - if ($this->token === null || $this->token === '') { - throw SplitTokenException::tokenNotSet(); - } - - if ($this->userId <= 0) { - throw SplitTokenException::invalidUserId($this->userId); - } - $sql = sprintf( 'INSERT INTO %s ( user_id, token_type, selector, verifier, additional_info, expiration_time @@ -482,7 +372,7 @@ public function persist(): self ':selector' => $this->selector, ':verifier' => $this->hashedVerifier, ':additional' => $this->additionalInfo, - ':expires' => $this->expirationTime + ':expires' => $this->expirationTime, ]); } catch (PDOException $e) { throw InvalidTokenException::sqlError($e); @@ -493,16 +383,17 @@ public function persist(): self /** * Revoke the token. - * @param bool $deleteToken If true, token is deleted. If false (default), it is expired + * + * @param bool $deleteToken If true, token is deleted. If false (default), it is expired + * * @throws SplitTokenException + * @return $this + * */ - public function revokeToken(bool $deleteToken = false): void + public function revokeToken(bool $deleteToken = false): self { - if ($this->token === null || $this->token === '') { - throw SplitTokenException::tokenNotSet(); - } - - $this->expirationTime = time() - self::DEFAULT_EXPIRATION_TIME_OFFSET; + $oneDayInSeconds = 86400; + $this->expirationTime = time() - $oneDayInSeconds; if ($deleteToken) { $statement = $this->dbConnection->prepare( @@ -532,7 +423,7 @@ public function revokeToken(bool $deleteToken = false): void try { $statement->execute([ ':expires' => $this->expirationTime, - ':selector' => $this->selector + ':selector' => $this->selector, ]); } catch (PDOException $e) { throw InvalidTokenException::sqlError($e); @@ -542,11 +433,15 @@ public function revokeToken(bool $deleteToken = false): void $this->token = null; $this->selector = null; $this->hashedVerifier = null; + + return $this; } /** * Delete all expired tokens from database. - * @param PDO $dbConnection Connection to the database + * + * @param PDO $dbConnection Connection to the database + * * @return int Returns the number of deleted tokens */ public static function clearExpiredTokens(PDO $dbConnection): int diff --git a/tests/Base64Test.php b/tests/Base64Test.php index f7c01b4..7dade67 100644 --- a/tests/Base64Test.php +++ b/tests/Base64Test.php @@ -1,5 +1,7 @@ modify(SplitToken::DEFAULT_EXPIRATION_DATE_OFFSET)->getTimestamp(); + $expirationTime = (new DateTimeImmutable(SplitToken::DEFAULT_EXPIRATION_TIME_OFFSET))->getTimestamp(); $statement = self::$db->prepare( sprintf( 'INSERT INTO %s ( @@ -84,12 +86,11 @@ public function testSetKnownToken(): void ':selector' => self::TEST_SELECTOR, ':verifier' => self::TEST_HASHED_VERIFIER, ':additional' => self::TEST_ADDITIONAL_INFO, - ':expires' => $expirationTime + ':expires' => $expirationTime, ]); - $splittoken = new SplitToken(self::$db, self::TEST_TOKEN); + $splittoken = SplitToken::fromString(self::TEST_TOKEN, self::$db); - self::assertSame(self::$db, $splittoken->getDbConnection()); self::assertSame(self::TEST_TOKEN, $splittoken->getToken()); self::assertSame(self::TEST_USER_ID, $splittoken->getUserId()); self::assertSame(self::TEST_TOKEN_TYPE, $splittoken->getTokenType()); @@ -97,18 +98,20 @@ public function testSetKnownToken(): void self::assertSame(self::TEST_ADDITIONAL_INFO, $splittoken->getAdditionalInfo()); } - public function testCreateTokenAndSetExpirationTime(): void + public function testCreateToken(): void { - $startSplitToken = new SplitToken(self::$db); - $expirationTime = time() + 3600; - $token = $startSplitToken->getToken(); - $startSplitToken - ->setUserId(self::TEST_USER_ID) - ->setExpirationTime($expirationTime) - ->setAdditionalInfo(self::TEST_ADDITIONAL_INFO) + $expirationTime = time() + 10800; + $startSplitToken = SplitToken::create( + dbConnection: self::$db, + expirationTime: $expirationTime, + userId: self::TEST_USER_ID, + tokenType: null, + additionalInfo: self::TEST_ADDITIONAL_INFO + ) ->persist(); + $token = $startSplitToken->getToken(); - $splittoken = new SplitToken(self::$db, $token); + $splittoken = SplitToken::fromString($token, self::$db); self::assertSame($token, $splittoken->getToken()); self::assertSame(self::TEST_USER_ID, $splittoken->getUserId()); @@ -118,84 +121,45 @@ public function testCreateTokenAndSetExpirationTime(): void self::assertSame(self::TEST_ADDITIONAL_INFO, $splittoken->getAdditionalInfo()); } - public function testCreateTokenAndSetExpirationOffset(): void - { - $startSplitToken = new SplitToken(self::$db); - $expirationTime = (new DateTimeImmutable())->modify(SplitToken::DEFAULT_EXPIRATION_DATE_OFFSET)->getTimestamp(); - $token = $startSplitToken->getToken(); - $startSplitToken - ->setUserId(self::TEST_USER_ID) - ->setTokenType(self::TEST_TOKEN_TYPE) - ->setExpirationOffset(SplitToken::DEFAULT_EXPIRATION_DATE_OFFSET) - ->persist(); - - $splittoken = new SplitToken(self::$db, $token); - - self::assertSame($token, $splittoken->getToken()); - self::assertSame(self::TEST_USER_ID, $splittoken->getUserId()); - self::assertSame(self::TEST_TOKEN_TYPE, $splittoken->getTokenType()); - self::assertSame($expirationTime, $splittoken->getExpirationTime()); - self::assertFalse($splittoken->isEternal()); - self::assertNull($splittoken->getAdditionalInfo()); - } - - public function testCreateTokenAndSetExpirationDate(): void + public function testCreateEternalToken(): void { - $startSplittoken = new SplitToken(self::$db); - $expirationDate = (new DateTimeImmutable())->modify(SplitToken::DEFAULT_EXPIRATION_DATE_OFFSET); + $startSplittoken = SplitToken::create(self::$db, null)->persist(); $token = $startSplittoken->getToken(); - $startSplittoken - ->setUserId(self::TEST_USER_ID) - ->setTokenType(self::TEST_TOKEN_TYPE) - ->setExpirationDate($expirationDate) - ->persist(); - - $splittoken = new SplitToken(self::$db, $token); + $splittoken = SplitToken::fromString($token, self::$db); self::assertSame($token, $splittoken->getToken()); - self::assertSame(self::TEST_USER_ID, $splittoken->getUserId()); - self::assertSame(self::TEST_TOKEN_TYPE, $splittoken->getTokenType()); - self::assertSame($expirationDate->getTimestamp(), $splittoken->getExpirationDate()->getTimestamp()); - self::assertSame( - $expirationDate->format(SplitToken::DEFAULT_EXPIRATION_DATE_FORMAT), - $splittoken->getExpirationDateFormatted() - ); - self::assertFalse($splittoken->isEternal()); + self::assertNull($splittoken->getUserId()); + self::assertNull($splittoken->getExpirationTime()); + self::assertTrue($splittoken->isEternal()); + self::assertNull($splittoken->getTokenType()); self::assertNull($splittoken->getAdditionalInfo()); } - public function testCreateEternalToken(): void + public function testSetDefaultExpirationTime(): void { - $startSplittoken = new SplitToken(self::$db); - $token = $startSplittoken->getToken(); - $startSplittoken - ->setUserId(self::TEST_USER_ID) - // ->makeEternal() - ->setAdditionalInfo(self::TEST_ADDITIONAL_INFO) - ->persist(); - - $splittoken = new SplitToken(self::$db, $token); + $startSplitToken = SplitToken::create(self::$db)->persist(); + $token = $startSplitToken->getToken(); + $splitToken = SplitToken::fromString($token, self::$db); - self::assertSame($token, $splittoken->getToken()); - self::assertSame(self::TEST_USER_ID, $splittoken->getUserId()); - self::assertSame(0, $splittoken->getExpirationTime()); - self::assertTrue($splittoken->isEternal()); - self::assertNull($splittoken->getTokenType()); - self::assertSame(self::TEST_ADDITIONAL_INFO, $splittoken->getAdditionalInfo()); + self::assertSame($token, $splitToken->getToken()); + self::assertNotNull($splitToken->getExpirationTime()); + self::assertGreaterThan(time(), $splitToken->getExpirationTime()); + self::assertFalse($splitToken->isEternal()); + self::assertFalse($splitToken->isExpired()); } public function testRevokeToken(): void { - $startSplittoken = new SplitToken(self::$db); - $expirationDate = (new DateTimeImmutable())->modify(SplitToken::DEFAULT_EXPIRATION_DATE_OFFSET); - $token = $startSplittoken->getToken(); - $startSplittoken - ->setUserId(self::TEST_USER_ID) - ->setTokenType(self::TEST_TOKEN_TYPE) - ->setExpirationDate($expirationDate) + $startSplittoken = SplitToken::create( + self::$db, + time() + 3600, + self::TEST_USER_ID, + self::TEST_TOKEN_TYPE + ) ->persist(); + $token = $startSplittoken->getToken(); - $splittoken = new SplitToken(self::$db, $token); + $splittoken = SplitToken::fromString($token, self::$db); self::assertSame($token, $splittoken->getToken()); self::assertFalse($splittoken->isExpired()); @@ -206,49 +170,48 @@ public function testRevokeToken(): void public function testRevokeEternalToken(): void { - $startSplittoken = new SplitToken(self::$db); + $startSplittoken = SplitToken::create( + self::$db, + null, + self::TEST_USER_ID, + self::TEST_TOKEN_TYPE + ) + ->persist(); $token = $startSplittoken->getToken(); - $startSplittoken->setUserId(self::TEST_USER_ID)->setTokenType(self::TEST_TOKEN_TYPE)->makeEternal()->persist(); - $splittoken = new SplitToken(self::$db, $token); + $splitToken = SplitToken::fromString($token, self::$db); - self::assertSame($token, $splittoken->getToken()); - self::assertFalse($splittoken->isExpired()); - self::assertTrue($splittoken->isEternal()); + self::assertSame($token, $splitToken->getToken()); + self::assertFalse($splitToken->isExpired(), 'the token should not be expired as the time is null'); + self::assertNull($splitToken->getExpirationTime()); + self::assertTrue($splitToken->isEternal()); - $splittoken->revokeToken(); - self::assertTrue($splittoken->isExpired()); - self::assertFalse($splittoken->isEternal()); + $splitToken = $splitToken->revokeToken(); + self::assertTrue($splitToken->isExpired(), 'Now the token should be expired'); + self::assertNotNull($splitToken->getExpirationTime()); + self::assertFalse($splitToken->isEternal()); } public function testClearExpiredTokens(): void { self::$db->query(sprintf('DELETE FROM %s', SplitToken::TABLE_NAME)); - $splittoken1 = (new SplitToken(self::$db))->setUserId(1)->setExpirationTime(time() + 3600)->persist(); - $splittoken2 = (new SplitToken(self::$db))->setUserId(2)->setExpirationTime(time() + 3660)->persist(); - $splittoken3 = (new SplitToken(self::$db))->setUserId(3)->setExpirationTime(time() + 3720)->persist(); + $splitToken1 = SplitToken::create(self::$db, time() + 3600, 1)->persist(); + $splitToken2 = SplitToken::create(self::$db, time() + 3660, 2)->persist(); + $splitToken3 = SplitToken::create(self::$db, time() + 3720, 3)->persist(); - $splittoken1->revokeToken(); - $splittoken2->revokeToken(); - $splittoken3->revokeToken(true); + $splitToken1->revokeToken(); + $splitToken2->revokeToken(); + $splitToken3->revokeToken(true); self::assertSame(2, SplitToken::clearExpiredTokens(self::$db)); } - public function testTrySetExpirationTimeInPast(): void - { - self::expectException(SplitTokenException::class); - self::expectExceptionMessage('Expiration time cannot be in the past'); - - (new SplitToken(self::$db))->setUserId(123)->setExpirationTime(time() - 3600)->persist(); - } - public function testTryPersistWithInvalidUserId(): void { self::expectException(SplitTokenException::class); self::expectExceptionMessage('Invalid user ID'); - (new SplitToken(self::$db))->persist(); + SplitToken::create(self::$db, time() + 3600, -3)->persist(); } public function testInvalidTokenLength(): void @@ -256,6 +219,6 @@ public function testInvalidTokenLength(): void self::expectException(InvalidTokenException::class); self::expectExceptionMessage('Invalid token length'); - new SplitToken(self::$db, 'abc'); + SplitToken::fromString('abc', self::$db); } }