From 7e8c5f21e54747c586df03d348b785ca78042bef Mon Sep 17 00:00:00 2001 From: CosDiabos <14842802+CosDiabos@users.noreply.github.com> Date: Sun, 3 Nov 2024 23:33:58 +0100 Subject: [PATCH 1/3] feat: add expiration date to access token & hmac keys/tokens. --- docs/references/authentication/hmac.md | 57 ++++++++++- docs/references/authentication/tokens.md | 52 +++++++++- .../Authenticators/AccessTokens.php | 13 +++ src/Authentication/Traits/HasAccessTokens.php | 76 +++++++++++++- src/Authentication/Traits/HasHmacTokens.php | 77 ++++++++++++++- src/Commands/Hmac.php | 37 +++++++ src/Models/UserIdentityModel.php | 98 +++++++++++++++++-- tests/Authentication/HasAccessTokensTest.php | 56 ++++++++++- tests/Authentication/HasHmacTokensTest.php | 55 +++++++++++ tests/Commands/HmacTest.php | 21 ++++ 10 files changed, 523 insertions(+), 19 deletions(-) diff --git a/docs/references/authentication/hmac.md b/docs/references/authentication/hmac.md index fed1f0bb7..e65bc9ce4 100644 --- a/docs/references/authentication/hmac.md +++ b/docs/references/authentication/hmac.md @@ -97,6 +97,58 @@ You can revoke all HMAC Keys with the `revokeAllHmacTokens()` method. $user->revokeAllHmacTokens(); ``` +## Expiring HMAC Keys + +By default, the HMAC keys don't expire unless they meet the HMAC Keys lifetime expiration after their last used date. + +HMAC keys can be set to expire through the `generateHmacToken()` method. This takes the expiration date as the $expiresAt argument. It's also possible to update an existing HMAC key using `setHmacTokenExpirationById($HmacTokenID, $expiresAt)` + +`$expiresAt` Accepts DateTime string formatted as 'Y-m-d h:i:s' or [DateTime relative formats](https://www.php.net/manual/en/datetime.formats.php#datetime.formats.relative) unit symbols (1 day, 2 weeks, 6 months, 1 year) to be added to DateTime 'now' + +```php +// Expiration date = 2024-11-03 12:00:00 +$token = $this->user->generateHmacToken('foo', ['foo:bar'], '2024-11-03 12:00:00'); + +// Expiration date = 2024-11-15 00:00:00 +$token = $user->setHmacTokenExpirationById($token->id, '2024-11-15 00:00:00'); + +// Or Expiration date = now() + 1 month + 15 days +$token = $user->setHmacTokenExpirationById($token->id, '1 month 15 days'); +``` + +The following support methods are also available: + +`hasHmacTokenExpired(AccessToken $HmacToken)` - Checks if the given HMAC key has expired. Returns `true` if the HMAC key has expired, `false` if not, and `null` if the expire date is null. + +```php +$token = $this->user->generateAccessToken('foo', ['foo:bar'], '2024-11-03 12:00:00'); + +$this->user->hasHmacTokenExpired($token); // Returns true +``` + +`getHmacTokenTimeToExpire(AccessToken $accessToken, string $format = "date" | "human")` - Checks if the given HMAC key has expired. Returns `true` if HMAC key has expired, `false` if not, and `null` if the expire date is not set. + +```php +$token = $this->user->generateHmacToken('foo', ['foo:bar']); + +$this->user->getHmacTokenTimeToExpire($token, 'date'); // Returns null + +// Assuming current time is: 2024-11-04 20:00:00 +$token = $this->user->generateHmacToken('foo', ['foo:bar'], '2024-11-03 12:00:00'); + +$this->user->getHmacTokenTimeToExpire($token, 'date'); // 2024-11-03 12:00:00 +$this->user->getHmacTokenTimeToExpire($token, 'human'); // 1 day ago + +$token = $this->user->generateHmacToken('foo', ['foo:bar'], '2026-01-06 12:00:00'); +$this->user->getHmacTokenTimeToExpire($token, 'human'); // in 1 year +``` + +You can also easily set all existing HMAC keys/tokens as expired with the `spark` command: +``` +php spark shield:hmac expireAll +``` +**Careful!** This command 'expires' _all_ keys for _all_ users. + ## Retrieving HMAC Keys The following methods are available to help you retrieve a user's HMAC keys: @@ -217,7 +269,7 @@ Configure **app/Config/AuthToken.php** for your needs. ### HMAC Keys Lifetime -HMAC Keys/Tokens will expire after a specified amount of time has passed since they have been used. +HMAC Keys will expire after a specified amount of time has passed since they have been used. By default, this is set to 1 year. You can change this value by setting the `$unusedTokenLifetime` value. This is in seconds so that you can use the @@ -228,6 +280,9 @@ that CodeIgniter provides. public $unusedTokenLifetime = YEAR; ``` +### HMAC Keys Expiration vs Lifetime +Expiration and Lifetime are different concepts. The lifetime is the maximum time allowed for the HMAC Key to exist since its last use. HMAC Key expiration, on the other hand, is a set date in which the HMAC Key will cease to function. + ### Login Attempt Logging By default, only failed login attempts are recorded in the `auth_token_logins` table. diff --git a/docs/references/authentication/tokens.md b/docs/references/authentication/tokens.md index 65df886d0..daaadb3ba 100644 --- a/docs/references/authentication/tokens.md +++ b/docs/references/authentication/tokens.md @@ -125,7 +125,7 @@ Configure **app/Config/AuthToken.php** for your needs. ### Access Token Lifetime -Tokens will expire after a specified amount of time has passed since they have been used. +Tokens will expire after a specified amount of time has passed since they last have been used. By default, this is set to 1 year. You can change this value by setting the `$unusedTokenLifetime` value. This is @@ -137,6 +137,56 @@ that CodeIgniter provides. public $unusedTokenLifetime = YEAR; ``` + +## Expiring Access Tokens + +By default, the Access Tokens don't expire unless they meet the Access Token lifetime expiration after their last used date. + +Access Tokens can be set to expire through the `generateAccessToken()` method. This takes the expiration date as the $expiresAt argument. It's also possible to update an existing HMAC key using `setAccessTokenById($HmacTokenID, $expiresAt)` + +`$expiresAt` Accepts DateTime string formatted as 'Y-m-d h:i:s' or [DateTime relative formats](https://www.php.net/manual/en/datetime.formats.php#datetime.formats.relative) unit symbols (1 day, 2 weeks, 6 months, 1 year) to be added to DateTime 'now' + +```php +// Expiration date = 2024-11-03 12:00:00 +$token = $this->user->generateAccessToken('foo', ['foo:bar'], '2024-11-03 12:00:00'); + +// Expiration date = 2024-11-15 00:00:00 +$user->setAccessTokenExpirationById($token->id, '2024-11-15 00:00:00'); + +// Or Expiration date = now() + 1 month + 15 days +$user->setAccessTokenExpirationById($token->id, '1 month 15 days'); +``` + +The following support methods are also available: + +`hasAccessTokenExpired(AccessToken $accessToken)` - Checks if the given Access Token has expired. Returns `true` if the Access Token has expired, `false` if not, and `null` if the expire date is not set. + +```php +$token = $this->user->generateAccessToken('foo', ['foo:bar'], '2024-11-03 12:00:00'); + +$this->user->hasAccessTokenExpired($token); // Returns true +``` + +`getAccessTokenTimeToExpire(AccessToken $accessToken, string $format = "date" | "human")` - Checks if the given Access Token has expired. Returns `true` if Access Token has expired, `false` if not, and `null` if the expire date is null. + +```php +$token = $this->user->generateAccessToken('foo', ['foo:bar']); + +$this->user->getAccessTokenTimeToExpire($token, 'date'); // Returns null + +// Assuming current time is: 2024-11-04 20:00:00 +$token = $this->user->generateAccessToken('foo', ['foo:bar'], '2024-11-03 12:00:00'); + +$this->user->getAccessTokenTimeToExpire($token, 'date'); // 2024-11-03 12:00:00 +$this->user->getAccessTokenTimeToExpire($token, 'human'); // 1 day ago + +$token = $this->user->generateAccessToken('foo', ['foo:bar'], '2026-01-06 12:00:00'); +$this->user->getAccessTokenTimeToExpire($token, 'human'); // in 1 year +``` + +### Access Token Expiration vs Lifetime +Expiration and Lifetime are different concepts. The lifetime is the maximum time allowed for the token to exist since its last use. Token expiration, on the other hand, is a set date in which the Access Token will cease to function. + ### Login Attempt Logging By default, only failed login attempts are recorded in the `auth_token_logins` table. diff --git a/src/Authentication/Authenticators/AccessTokens.php b/src/Authentication/Authenticators/AccessTokens.php index 6356ab1a8..99b72af41 100644 --- a/src/Authentication/Authenticators/AccessTokens.php +++ b/src/Authentication/Authenticators/AccessTokens.php @@ -157,6 +157,19 @@ public function check(array $credentials): Result assert($token->last_used_at instanceof Time || $token->last_used_at === null); + // Is expired ? + if ( + $token->expires + && $token->expires->isBefore( + Time::now() + ) + ) { + return new Result([ + 'success' => false, + 'reason' => lang('Auth.oldToken'), + ]); + } + // Hasn't been used in a long time if ( $token->last_used_at diff --git a/src/Authentication/Traits/HasAccessTokens.php b/src/Authentication/Traits/HasAccessTokens.php index bb9d47975..bd2eb5245 100644 --- a/src/Authentication/Traits/HasAccessTokens.php +++ b/src/Authentication/Traits/HasAccessTokens.php @@ -13,8 +13,11 @@ namespace CodeIgniter\Shield\Authentication\Traits; +use CodeIgniter\I18n\Time; +use CodeIgniter\Shield\Authentication\Authenticators\AccessTokens; use CodeIgniter\Shield\Entities\AccessToken; use CodeIgniter\Shield\Models\UserIdentityModel; +use InvalidArgumentException; /** * Trait HasAccessTokens @@ -34,15 +37,18 @@ trait HasAccessTokens /** * Generates a new personal access token for this user. * - * @param string $name Token name - * @param list $scopes Permissions the token grants + * @param string $name Token name + * @param list $scopes Permissions the token grants + * @param string $expiresAt Sets token expiration date. Accepts DateTime string formatted as 'Y-m-d h:i:s' or DateTime relative formats (1 day, 2 weeks, 6 months, 1 year) to be added to DateTime 'now' + * + * @throws InvalidArgumentException */ - public function generateAccessToken(string $name, array $scopes = ['*']): AccessToken + public function generateAccessToken(string $name, array $scopes = ['*'], ?string $expiresAt = null): AccessToken { /** @var UserIdentityModel $identityModel */ $identityModel = model(UserIdentityModel::class); - return $identityModel->generateAccessToken($this, $name, $scopes); + return $identityModel->generateAccessToken($this, $name, $scopes, $expiresAt); } /** @@ -165,4 +171,66 @@ public function setAccessToken(?AccessToken $accessToken): self return $this; } + + /** + * Checks if the provided Access Token has expired. + * + * @return false|true|null Returns true if Access Token has expired, false if not, and null if the expire field is null + */ + public function hasAccessTokenExpired(?AccessToken $accessToken): bool|null + { + if (null === $accessToken->expires) { + return null; + } + + return $accessToken->expires->isBefore(Time::now()); + } + + /** + * Returns formatted date to expiration for provided AccessToken + * + * @param AcessToken $accessToken AccessToken + * @param string $format The return format - "date" or "human". Date is 'Y-m-d h:i:s', human is 'in 2 weeks' + * + * @return false|true|null Returns true if Access Token has expired, false if not and null if the expire field is null + * + * @throws InvalidArgumentException + */ + public function getAccessTokenTimeToExpire(?AccessToken $accessToken, string $format = 'date'): string|null + { + if (null === $accessToken->expires) { + return null; + } + + switch ($format) { + case 'date': + return $accessToken->expires->toLocalizedString(); + + case 'human': + return $accessToken->expires->humanize(); + + default: + throw new InvalidArgumentException('getAccessTokenTimeToExpire(): $format argument is invalid. Expects string with "date" or "human".'); + } + } + + /** + * Sets an expiration for Access Tokens by ID. + * + * @param int $id AccessTokens ID + * @param string $expiresAt Expiration date. Accepts DateTime string formatted as 'Y-m-d h:i:s' or DateTime relative formats (1 day, 2 weeks, 6 months, 1 year) to be added to DateTime 'now' + */ + public function setAccessTokenExpirationById(int $id, string $expiresAt): bool + { + /** @var UserIdentityModel $identityModel */ + $identityModel = model(UserIdentityModel::class); + $result = $identityModel->setIdentityExpirationById($id, $this, $expiresAt, AccessTokens::ID_TYPE_ACCESS_TOKEN); + + if ($result) { + // refresh currentAccessToken with updated data + $this->currentAccessToken = $identityModel->getAccessTokenById($id, $this); + } + + return $result; + } } diff --git a/src/Authentication/Traits/HasHmacTokens.php b/src/Authentication/Traits/HasHmacTokens.php index bfaab7d65..ee2dea92d 100644 --- a/src/Authentication/Traits/HasHmacTokens.php +++ b/src/Authentication/Traits/HasHmacTokens.php @@ -13,8 +13,11 @@ namespace CodeIgniter\Shield\Authentication\Traits; +use CodeIgniter\I18n\Time; +use CodeIgniter\Shield\Authentication\Authenticators\HmacSha256; use CodeIgniter\Shield\Entities\AccessToken; use CodeIgniter\Shield\Models\UserIdentityModel; +use InvalidArgumentException; use ReflectionException; /** @@ -35,17 +38,19 @@ trait HasHmacTokens /** * Generates a new personal HMAC token for this user. * - * @param string $name Token name - * @param list $scopes Permissions the token grants + * @param string $name Token name + * @param list $scopes Permissions the token grants + * @param string $expiresAt Sets token expiration date. Accepts DateTime string formatted as 'Y-m-d h:i:s' or DateTime relative formats (1 day, 2 weeks, 6 months, 1 year) to be added to DateTime 'now' * + * @throws InvalidArgumentException * @throws ReflectionException */ - public function generateHmacToken(string $name, array $scopes = ['*']): AccessToken + public function generateHmacToken(string $name, array $scopes = ['*'], ?string $expiresAt = null): AccessToken { /** @var UserIdentityModel $identityModel */ $identityModel = model(UserIdentityModel::class); - return $identityModel->generateHmacToken($this, $name, $scopes); + return $identityModel->generateHmacToken($this, $name, $scopes, $expiresAt); } /** @@ -156,4 +161,68 @@ public function setHmacToken(?AccessToken $accessToken): self return $this; } + + /** + * Checks if the provided Access Token has expired. + * + * @return false|true|null Returns true if Access Token has expired, false if not, and null if the expire field is null + */ + public function hasHmacTokenExpired(?AccessToken $accessToken): bool|null + { + if (null === $accessToken->expires) { + return null; + } + + return $accessToken->expires->isBefore(Time::now()); + } + + /** + * Returns formatted date to expiration for provided Hmac Key/Token. + * + * @param AcessToken $accessToken AccessToken + * @param string $format The return format - "date" or "human". Date is 'Y-m-d h:i:s', human is 'in 2 weeks' + * + * @return false|true|null Returns true if Access Token has expired, false if not and null if the expire field is null + * + * @throws InvalidArgumentException + */ + public function getHmacTokenTimeToExpire(?AccessToken $accessToken, string $format = 'date'): string|null + { + if (null === $accessToken->expires) { + return null; + } + + switch ($format) { + case 'date': + return $accessToken->expires->toLocalizedString(); + + case 'human': + return $accessToken->expires->humanize(); + + default: + throw new InvalidArgumentException('getHmacTokenTimeToExpire(): $format argument is invalid. Expects string with "date" or "human".'); + } + } + + /** + * Sets an expiration for Hmac Key/Token by ID. + * + * @param int $id AccessTokens ID + * @param string $expiresAt Expiration date. Accepts DateTime string formatted as 'Y-m-d h:i:s' or DateTime relative formats (1 day, 2 weeks, 6 months, 1 year) to be added to DateTime 'now' + * + * @return false|true|null Returns true if token is updated, false if not. + */ + public function setHmacTokenExpirationById(int $id, string $expiresAt): bool + { + /** @var UserIdentityModel $identityModel */ + $identityModel = model(UserIdentityModel::class); + $result = $identityModel->setIdentityExpirationById($id, $this, $expiresAt, HmacSha256::ID_TYPE_HMAC_TOKEN); + + if ($result) { + // refresh currentAccessToken with updated data + $this->currentAccessToken = $identityModel->getHmacTokenById($id, $this); + } + + return $result; + } } diff --git a/src/Commands/Hmac.php b/src/Commands/Hmac.php index 08fdcb7d8..b9f97b404 100644 --- a/src/Commands/Hmac.php +++ b/src/Commands/Hmac.php @@ -13,6 +13,7 @@ namespace CodeIgniter\Shield\Commands; +use CodeIgniter\I18n\Time; use CodeIgniter\Shield\Authentication\HMAC\HmacEncrypter; use CodeIgniter\Shield\Commands\Exceptions\BadInputException; use CodeIgniter\Shield\Exceptions\RuntimeException; @@ -46,9 +47,11 @@ class Hmac extends BaseCommand shield:hmac reencrypt shield:hmac encrypt shield:hmac decrypt + shield:hmac invalidateAll The reencrypt command should be used when rotating the encryption keys. The encrypt command should only be run on existing raw secret keys (extremely rare). + The invalidateAll command should only be run if you need to invalidate ALL HMAC Tokens (for everyone). EOL; /** @@ -61,6 +64,7 @@ class Hmac extends BaseCommand reencrypt: Re-encrypts all HMAC Secret Keys on encryption key rotation encrypt: Encrypt all raw HMAC Secret Keys decrypt: Decrypt all encrypted HMAC Secret Keys + invalidateAll: Invalidates all HMAC Keys/Tokens (for everyone) EOL, ]; @@ -99,6 +103,10 @@ public function run(array $params): int $this->reEncrypt(); break; + case 'expireAll': + $this->expireAll(); + break; + default: throw new BadInputException('Unrecognized Command'); } @@ -206,4 +214,33 @@ static function ($identity) use ($uIdModelSub, $encrypter, $that): void { } ); } + + /** + * Invalidates all HMAC Keys/Tokens for every user. + */ + public function expireAll(): void + { + $uIdModel = new UserIdentityModel(); + $uIdModelSub = new UserIdentityModel(); + + $that = $this; + + $uIdModel->where('type', 'hmac_sha256')->orderBy('id')->chunk( + 100, + static function ($identity) use ($uIdModelSub, $that): void { + $timeNow = Time::now(); // Current date/time + + if (null !== $identity->expires && $identity->expires->isBefore($timeNow)) { + $that->write('Hmac Key/Token ID: ' . $identity->id . ', already expired, skipped.'); + + return; + } + + $identity->expires = $timeNow->format('Y-m-d h:i:s'); + $uIdModelSub->save($identity); + + $that->write('Hmac Key/Token ID: ' . $identity->id . ', set as expired.'); + } + ); + } } diff --git a/src/Models/UserIdentityModel.php b/src/Models/UserIdentityModel.php index d65a1810b..a2b02ce56 100644 --- a/src/Models/UserIdentityModel.php +++ b/src/Models/UserIdentityModel.php @@ -24,8 +24,11 @@ use CodeIgniter\Shield\Entities\UserIdentity; use CodeIgniter\Shield\Exceptions\LogicException; use CodeIgniter\Shield\Exceptions\ValidationException; +use DateInterval; +use DateTime; use Exception; use Faker\Generator; +use InvalidArgumentException; use ReflectionException; class UserIdentityModel extends BaseModel @@ -101,6 +104,43 @@ private function checkUserId(User $user): void } } + private function checkExpiresAtFormat(string $expiresAt): string + { + if (! is_string($expiresAt)) { + throw new InvalidArgumentException('$expiresAt should be a string.'); + } + + $expireMatch = []; + + // Check Y-m-d h:i:s format. + preg_match('/\d{4}-\d{2}-\d{2}.\d{2}:\d{2}:\d{2}/', $expiresAt, $expireMatch, PREG_UNMATCHED_AS_NULL); + + // Format Y-m-d h:i:s not found. + if ($expireMatch === []) { + // Looking for relative format like 1 day, 2 weeks and process all + preg_match_all('/[1].(second|minute|hour|day|week|month|year)|[2,3,4,5,6,7,8,9].(seconds|minutes|hours|days|weeks|months|years)/', $expiresAt, $expireMatch, PREG_PATTERN_ORDER); + + if ($expireMatch[0] !== []) { + // Dummy DateTime to add() DateInterval + $dateTime = new DateTime(); + + // Turn the preg_match array into string splitted by ' + ' + // to be fed into DateTime->add() to generate new date + $relativeTime = implode(' + ', $expireMatch[0]); + + // add relative formats + $dateTime->add(DateInterval::createFromDateString($relativeTime)); + + // return Dummy DateTime + return $dateTime->format('Y-m-d h:i:s'); + } + + throw new InvalidArgumentException('$expiresAt should be a DateTime string formatted as "Y-m-d h:i:s" or a DateTime relative formats.'); + } + + return $expiresAt; + } + /** * Create an identity with 6 digits code for auth action * @@ -144,13 +184,20 @@ public function createCodeIdentity( /** * Generates a new personal access token for the user. * - * @param string $name Token name - * @param list $scopes Permissions the token grants + * @param string $name Token name + * @param list $scopes Permissions the token grants + * @param string $expiresAt Sets token expiration date. Accepts DateTime string formatted as 'Y-m-d h:i:s' or DateTime relative formats (1 day, 2 weeks, 6 months, 1 year) to be added to 'Time::now()' + * + * @throws InvalidArgumentException */ - public function generateAccessToken(User $user, string $name, array $scopes = ['*']): AccessToken + public function generateAccessToken(User $user, string $name, array $scopes = ['*'], ?string $expiresAt = null): AccessToken { $this->checkUserId($user); + if ($expiresAt !== null && $expiresAt !== '' && $expiresAt !== '0') { + $expiresAt = $this->checkExpiresAtFormat($expiresAt); + } + helper('text'); $return = $this->insert([ @@ -158,6 +205,7 @@ public function generateAccessToken(User $user, string $name, array $scopes = [' 'user_id' => $user->id, 'name' => $name, 'secret' => hash('sha256', $rawToken = random_string('crypto', 64)), + 'expires' => $expiresAt, 'extra' => serialize($scopes), ]); @@ -224,6 +272,37 @@ public function getAllAccessTokens(User $user): array ->findAll(); } + /** + * Updates or sets expiration date of users' AccessToken or HMAC Token by ID. Returns updated row. + * + * @param string $expiresAt Expiration date. Accepts DateTime string formatted as 'Y-m-d h:i:s' or DateTime relative formats (1 day, 2 weeks, 6 months, 1 year) to be added to 'Time::now()' + * @param mixed $id + */ + public function setIdentityExpirationById($id, User $user, ?string $expiresAt = null, ?string $type_token = null): bool + { + $this->checkUserId($user); + + if (! $expiresAt) { + throw new InvalidArgumentException("setIdentityExpirationById(): expiresAt argument can't be null."); + } + + $expiresAt = $this->checkExpiresAtFormat($expiresAt); + + $currentExpiration = $this->asObject(AccessToken::class)->find($id); + + // d($currentExpiration); + if ($currentExpiration->expires !== $expiresAt) { + // throw new InvalidArgumentException(sprintf("User-id: %d type: %s id: %d expires:%s", $user->id,$type_token,$id,$expiresAt)); + return $this->where('user_id', $user->id) + ->where('type', $type_token) + ->where('id', $id) + ->set(['expires' => $expiresAt]) + ->update(); + } + + throw new InvalidArgumentException('setIdentityExpirationById(): No data to update. ID:' . $id . ' Expires at: ' . $expiresAt . ' curr_expires:' . $currentExpiration->expires . ' Result: ' . ($currentExpiration->expires !== $expiresAt)); + } + // HMAC /** * Find and Retrieve the HMAC AccessToken based on Token alone @@ -242,16 +321,22 @@ public function getHmacTokenByKey(string $key): ?AccessToken /** * Generates a new personal access token for the user. * - * @param string $name Token name - * @param list $scopes Permissions the token grants + * @param string $name Token name + * @param list $scopes Permissions the token grants + * @param string $expiresAt Expiration date. Accepts DateTime string formatted as 'Y-m-d h:i:s' or DateTime relative formats (1 day, 2 weeks, 6 months, 1 year) to be added to 'Time::now()' * * @throws Exception + * @throws InvalidArgumentException * @throws ReflectionException */ - public function generateHmacToken(User $user, string $name, array $scopes = ['*']): AccessToken + public function generateHmacToken(User $user, string $name, array $scopes = ['*'], ?string $expiresAt = null): AccessToken { $this->checkUserId($user); + if ($expiresAt !== null && $expiresAt !== '' && $expiresAt !== '0') { + $expiresAt = $this->checkExpiresAtFormat($expiresAt); + } + $encrypter = new HmacEncrypter(); $rawSecretKey = $encrypter->generateSecretKey(); $secretKey = $encrypter->encrypt($rawSecretKey); @@ -262,6 +347,7 @@ public function generateHmacToken(User $user, string $name, array $scopes = ['*' 'name' => $name, 'secret' => bin2hex(random_bytes(16)), // Key 'secret2' => $secretKey, + 'expires' => $expiresAt, 'extra' => serialize($scopes), ]); diff --git a/tests/Authentication/HasAccessTokensTest.php b/tests/Authentication/HasAccessTokensTest.php index f86668f1d..746067508 100644 --- a/tests/Authentication/HasAccessTokensTest.php +++ b/tests/Authentication/HasAccessTokensTest.php @@ -17,6 +17,8 @@ use CodeIgniter\Shield\Entities\User; use CodeIgniter\Shield\Models\UserIdentityModel; use CodeIgniter\Shield\Models\UserModel; +use DateInterval; +use DateTime; use Tests\Support\DatabaseTestCase; /** @@ -152,12 +154,60 @@ public function testTokenCantNoTokenSet(): void $this->assertTrue($this->user->tokenCant('foo')); } - public function testTokenCant(): void + /** + * See https://github.com/codeigniter4/shield/issues/926 + */ + public function testGenerateTokenWithExpiration(): void + { + $token = $this->user->generateAccessToken('foo', ['foo:bar'], '2024-11-03 12:00:00'); + $this->user->setAccessToken($token); + + $this->assertSame('2024-11-03 12:00:00', $this->user->getAccessTokenTimeToExpire($this->user->currentAccessToken(), 'date')); + + $token = $this->user->generateAccessToken('foo', ['foo:bar'], '1 month 1 year'); + $this->user->setAccessToken($token); + + $expireDate = new DateTime(); + $expireDate->add(DateInterval::createFromDateString('1 month + 1 year')); + $this->assertSame($expireDate->format('Y-m-d h:i:s'), $this->user->getAccessTokenTimeToExpire($this->user->currentAccessToken(), 'date')); + } + + /** + * See https://github.com/codeigniter4/shield/issues/926 + */ + public function testSetTokenExpirationById(): void { $token = $this->user->generateAccessToken('foo', ['foo:bar']); + + $this->user->setAccessToken($token); + + $this->assertNull($this->user->currentAccessToken()->expires); + + // true = updated row + $this->assertTrue($this->user->setAccessTokenExpirationById($token->id, '2024-11-03 12:00:00')); + + $this->assertSame('2024-11-03 12:00:00', $this->user->getAccessTokenTimeToExpire($this->user->currentAccessToken(), 'date')); + } + + /** + * See https://github.com/codeigniter4/shield/issues/926 + */ + public function testIsTokenExpired(): void + { + $token = $this->user->generateAccessToken('foo', ['foo:bar'], '2024-11-03 12:00:00'); + $this->user->setAccessToken($token); + + $this->assertTrue($this->user->hasAccessTokenExpired($this->user->currentAccessToken())); + } + + /** + * See https://github.com/codeigniter4/shield/issues/926 + */ + public function testTokenTimeToExpired(): void + { + $token = $this->user->generateAccessToken('foo', ['foo:bar'], '1 year'); $this->user->setAccessToken($token); - $this->assertFalse($this->user->tokenCant('foo:bar')); - $this->assertTrue($this->user->tokenCant('foo:baz')); + $this->assertSame('in 11 months', $this->user->getAccessTokenTimeToExpire($this->user->currentAccessToken(), 'human')); } } diff --git a/tests/Authentication/HasHmacTokensTest.php b/tests/Authentication/HasHmacTokensTest.php index 0b5394bab..a50dc73d4 100644 --- a/tests/Authentication/HasHmacTokensTest.php +++ b/tests/Authentication/HasHmacTokensTest.php @@ -17,6 +17,8 @@ use CodeIgniter\Shield\Entities\User; use CodeIgniter\Shield\Models\UserIdentityModel; use CodeIgniter\Shield\Models\UserModel; +use DateInterval; +use DateTime; use Tests\Support\DatabaseTestCase; /** @@ -155,4 +157,57 @@ public function testHmacTokenCant(): void $this->assertFalse($this->user->hmacTokenCant('foo:bar')); $this->assertTrue($this->user->hmacTokenCant('foo:baz')); } + + /** + * See https://github.com/codeigniter4/shield/issues/926 + */ + public function testGenerateTokenWithExpiration(): void + { + $token = $this->user->generateHmacToken('foo', ['foo:bar'], '2024-11-03 12:00:00'); + + $this->assertSame('2024-11-03 12:00:00', $this->user->getHmacTokenTimeToExpire($token, 'date')); + + $token = $this->user->generateHmacToken('foo', ['foo:bar'], '1 month 1 year'); + + $expireDate = new DateTime(); + $expireDate->add(DateInterval::createFromDateString('1 month 1 year')); + + $this->assertSame($expireDate->format('Y-m-d h:i:s'), $this->user->getHmacTokenTimeToExpire($token, 'date')); + } + + /** + * See https://github.com/codeigniter4/shield/issues/926 + */ + public function testSetTokenExpirationById(): void + { + $token = $this->user->generateHmacToken('foo', ['foo:bar']); + + $this->assertNull($token->expires); + + // true = updated row + $this->assertTrue($this->user->setHmacTokenExpirationById($token->id, '2024-11-03 12:00:00')); + + $found = $this->user->getHmacTokenById($token->id); + $this->assertSame('2024-11-03 12:00:00', $this->user->getHmacTokenTimeToExpire($found, 'date')); + } + + /** + * See https://github.com/codeigniter4/shield/issues/926 + */ + public function testIsHmacTokenExpired(): void + { + $token = $this->user->generateHmacToken('foo', ['foo:bar'], '2024-11-03 12:00:00'); + + $this->assertTrue($this->user->hasHmacTokenExpired($token)); + } + + /** + * See https://github.com/codeigniter4/shield/issues/926 + */ + public function testHmacTokenTimeToExpired(): void + { + $token = $this->user->generateHmacToken('foo', ['foo:bar'], '1 year'); + + $this->assertSame('in 11 months', $this->user->getHmacTokenTimeToExpire($token, 'human')); + } } diff --git a/tests/Commands/HmacTest.php b/tests/Commands/HmacTest.php index 9027599f3..30640a00e 100644 --- a/tests/Commands/HmacTest.php +++ b/tests/Commands/HmacTest.php @@ -141,6 +141,27 @@ public function testBadCommand(): void $this->assertSame('Unrecognized Command', $resultsString); } + /** + * See https://github.com/codeigniter4/shield/issues/926 + */ + public function testExpireAll(): void + { + /** @var User $user */ + $user = fake(UserModel::class); + $user->generateHmacToken('foo', expiresAt: '2024-10-01 12:20:00'); + $user->generateHmacToken('bar'); + + $this->setMockIo([]); + $this->assertNotFalse(command('shield:hmac expireAll')); + + $resultsString = $this->io->getOutputs(); + $results = explode("\n", trim($resultsString)); + + $this->assertCount(2, $results); + $this->assertSame('Hmac Key/Token ID: 1, already expired, skipped.', trim($results[0])); + $this->assertSame('Hmac Key/Token ID: 2, set as expired.', trim($results[1])); + } + /** * Set MockInputOutput and user inputs. * From 52860a9b2c835860e453adf03e99d35e82311ad0 Mon Sep 17 00:00:00 2001 From: CosDiabos <14842802+CosDiabos@users.noreply.github.com> Date: Thu, 14 Nov 2024 10:40:07 +0100 Subject: [PATCH 2/3] fix test assertion --- tests/Authentication/HasAccessTokensTest.php | 2 +- tests/Authentication/HasHmacTokensTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Authentication/HasAccessTokensTest.php b/tests/Authentication/HasAccessTokensTest.php index 746067508..0138abbc8 100644 --- a/tests/Authentication/HasAccessTokensTest.php +++ b/tests/Authentication/HasAccessTokensTest.php @@ -208,6 +208,6 @@ public function testTokenTimeToExpired(): void $token = $this->user->generateAccessToken('foo', ['foo:bar'], '1 year'); $this->user->setAccessToken($token); - $this->assertSame('in 11 months', $this->user->getAccessTokenTimeToExpire($this->user->currentAccessToken(), 'human')); + $this->assertSame('in 1 year', $this->user->getAccessTokenTimeToExpire($this->user->currentAccessToken(), 'human')); } } diff --git a/tests/Authentication/HasHmacTokensTest.php b/tests/Authentication/HasHmacTokensTest.php index a50dc73d4..e511845f6 100644 --- a/tests/Authentication/HasHmacTokensTest.php +++ b/tests/Authentication/HasHmacTokensTest.php @@ -208,6 +208,6 @@ public function testHmacTokenTimeToExpired(): void { $token = $this->user->generateHmacToken('foo', ['foo:bar'], '1 year'); - $this->assertSame('in 11 months', $this->user->getHmacTokenTimeToExpire($token, 'human')); + $this->assertSame('in 1 year', $this->user->getHmacTokenTimeToExpire($token, 'human')); } } From eaa2b63d55bd529eefae07159dbbe32f20d4ac64 Mon Sep 17 00:00:00 2001 From: CosDiabos <14842802+CosDiabos@users.noreply.github.com> Date: Thu, 14 Nov 2024 15:23:49 +0100 Subject: [PATCH 3/3] fix test for lower PHP version and PHPDoc blocks --- phpstan-baseline.php | 2 +- .../Authenticators/AccessTokens.php | 2 +- src/Authentication/Traits/HasAccessTokens.php | 16 +++++++--------- src/Authentication/Traits/HasHmacTokens.php | 18 +++++++----------- src/Entities/AccessToken.php | 1 + src/Models/UserIdentityModel.php | 18 +++++------------- tests/Commands/HmacTest.php | 2 +- 7 files changed, 23 insertions(+), 36 deletions(-) diff --git a/phpstan-baseline.php b/phpstan-baseline.php index 94be2606f..85f23b559 100644 --- a/phpstan-baseline.php +++ b/phpstan-baseline.php @@ -253,7 +253,7 @@ $ignoreErrors[] = [ // identifier: codeigniter.factoriesClassConstFetch 'message' => '#^Call to function model with CodeIgniter\\\\Shield\\\\Models\\\\UserIdentityModel\\:\\:class is discouraged\\.$#', - 'count' => 19, + 'count' => 21, 'path' => __DIR__ . '/src/Entities/User.php', ]; $ignoreErrors[] = [ diff --git a/src/Authentication/Authenticators/AccessTokens.php b/src/Authentication/Authenticators/AccessTokens.php index 99b72af41..36688f23d 100644 --- a/src/Authentication/Authenticators/AccessTokens.php +++ b/src/Authentication/Authenticators/AccessTokens.php @@ -159,7 +159,7 @@ public function check(array $credentials): Result // Is expired ? if ( - $token->expires + $token->expires !== null && $token->expires->isBefore( Time::now() ) diff --git a/src/Authentication/Traits/HasAccessTokens.php b/src/Authentication/Traits/HasAccessTokens.php index bd2eb5245..95eb80ff5 100644 --- a/src/Authentication/Traits/HasAccessTokens.php +++ b/src/Authentication/Traits/HasAccessTokens.php @@ -175,24 +175,20 @@ public function setAccessToken(?AccessToken $accessToken): self /** * Checks if the provided Access Token has expired. * - * @return false|true|null Returns true if Access Token has expired, false if not, and null if the expire field is null + * @return bool|null Returns true if Access Token has expired, false if not, and null if the expire field is null */ public function hasAccessTokenExpired(?AccessToken $accessToken): bool|null { - if (null === $accessToken->expires) { - return null; - } - - return $accessToken->expires->isBefore(Time::now()); + return $accessToken->expires !== null ? $accessToken->expires->isBefore(Time::now()) : null; } /** * Returns formatted date to expiration for provided AccessToken * - * @param AcessToken $accessToken AccessToken - * @param string $format The return format - "date" or "human". Date is 'Y-m-d h:i:s', human is 'in 2 weeks' + * @param AccessToken $accessToken AccessToken + * @param string $format The return format - "date" or "human". Date is 'Y-m-d h:i:s', human is 'in 2 weeks' * - * @return false|true|null Returns true if Access Token has expired, false if not and null if the expire field is null + * @return string|null Returns a formatted expiration date or null if the expire field is not set. * * @throws InvalidArgumentException */ @@ -219,6 +215,8 @@ public function getAccessTokenTimeToExpire(?AccessToken $accessToken, string $fo * * @param int $id AccessTokens ID * @param string $expiresAt Expiration date. Accepts DateTime string formatted as 'Y-m-d h:i:s' or DateTime relative formats (1 day, 2 weeks, 6 months, 1 year) to be added to DateTime 'now' + * + * @return bool Returns true if expiration date is set or updated. */ public function setAccessTokenExpirationById(int $id, string $expiresAt): bool { diff --git a/src/Authentication/Traits/HasHmacTokens.php b/src/Authentication/Traits/HasHmacTokens.php index ee2dea92d..1e1653f6e 100644 --- a/src/Authentication/Traits/HasHmacTokens.php +++ b/src/Authentication/Traits/HasHmacTokens.php @@ -165,24 +165,20 @@ public function setHmacToken(?AccessToken $accessToken): self /** * Checks if the provided Access Token has expired. * - * @return false|true|null Returns true if Access Token has expired, false if not, and null if the expire field is null + * @return bool|null Returns true if Access Token has expired, false if not, and null if the expire field is null */ public function hasHmacTokenExpired(?AccessToken $accessToken): bool|null { - if (null === $accessToken->expires) { - return null; - } - - return $accessToken->expires->isBefore(Time::now()); + return $accessToken->expires !== null ? $accessToken->expires->isBefore(Time::now()) : null; } /** * Returns formatted date to expiration for provided Hmac Key/Token. * - * @param AcessToken $accessToken AccessToken - * @param string $format The return format - "date" or "human". Date is 'Y-m-d h:i:s', human is 'in 2 weeks' + * @param AccessToken $accessToken AccessToken + * @param string $format The return format - "date" or "human". Date is 'Y-m-d h:i:s', human is 'in 2 weeks' * - * @return false|true|null Returns true if Access Token has expired, false if not and null if the expire field is null + * @return string|null Returns a formatted expiration date or null if the expire field is not set. * * @throws InvalidArgumentException */ @@ -207,10 +203,10 @@ public function getHmacTokenTimeToExpire(?AccessToken $accessToken, string $form /** * Sets an expiration for Hmac Key/Token by ID. * - * @param int $id AccessTokens ID + * @param int $id AccessToken ID * @param string $expiresAt Expiration date. Accepts DateTime string formatted as 'Y-m-d h:i:s' or DateTime relative formats (1 day, 2 weeks, 6 months, 1 year) to be added to DateTime 'now' * - * @return false|true|null Returns true if token is updated, false if not. + * @return bool Returns true if expiration date is set or updated. */ public function setHmacTokenExpirationById(int $id, string $expiresAt): bool { diff --git a/src/Entities/AccessToken.php b/src/Entities/AccessToken.php index 406910f21..3cd48e6ff 100644 --- a/src/Entities/AccessToken.php +++ b/src/Entities/AccessToken.php @@ -22,6 +22,7 @@ * Represents a single Personal Access Token, used * for authenticating users for an API. * + * @property string|Time|null $expires * @property string|Time|null $last_used_at */ class AccessToken extends Entity diff --git a/src/Models/UserIdentityModel.php b/src/Models/UserIdentityModel.php index a2b02ce56..db0d3e9ae 100644 --- a/src/Models/UserIdentityModel.php +++ b/src/Models/UserIdentityModel.php @@ -106,10 +106,6 @@ private function checkUserId(User $user): void private function checkExpiresAtFormat(string $expiresAt): string { - if (! is_string($expiresAt)) { - throw new InvalidArgumentException('$expiresAt should be a string.'); - } - $expireMatch = []; // Check Y-m-d h:i:s format. @@ -277,22 +273,18 @@ public function getAllAccessTokens(User $user): array * * @param string $expiresAt Expiration date. Accepts DateTime string formatted as 'Y-m-d h:i:s' or DateTime relative formats (1 day, 2 weeks, 6 months, 1 year) to be added to 'Time::now()' * @param mixed $id + * + * @return bool Returns true if expiration date was set or updated. */ public function setIdentityExpirationById($id, User $user, ?string $expiresAt = null, ?string $type_token = null): bool { $this->checkUserId($user); - if (! $expiresAt) { - throw new InvalidArgumentException("setIdentityExpirationById(): expiresAt argument can't be null."); - } - $expiresAt = $this->checkExpiresAtFormat($expiresAt); - $currentExpiration = $this->asObject(AccessToken::class)->find($id); + $currentExpiration = $this->where('user_id', $user->id)->where('id', $id)->asObject(AccessToken::class)->first(); - // d($currentExpiration); - if ($currentExpiration->expires !== $expiresAt) { - // throw new InvalidArgumentException(sprintf("User-id: %d type: %s id: %d expires:%s", $user->id,$type_token,$id,$expiresAt)); + if ($currentExpiration->expires !== null && $currentExpiration->expires !== $expiresAt) { return $this->where('user_id', $user->id) ->where('type', $type_token) ->where('id', $id) @@ -300,7 +292,7 @@ public function setIdentityExpirationById($id, User $user, ?string $expiresAt = ->update(); } - throw new InvalidArgumentException('setIdentityExpirationById(): No data to update. ID:' . $id . ' Expires at: ' . $expiresAt . ' curr_expires:' . $currentExpiration->expires . ' Result: ' . ($currentExpiration->expires !== $expiresAt)); + return false; } // HMAC diff --git a/tests/Commands/HmacTest.php b/tests/Commands/HmacTest.php index 30640a00e..68dc7b358 100644 --- a/tests/Commands/HmacTest.php +++ b/tests/Commands/HmacTest.php @@ -148,7 +148,7 @@ public function testExpireAll(): void { /** @var User $user */ $user = fake(UserModel::class); - $user->generateHmacToken('foo', expiresAt: '2024-10-01 12:20:00'); + $user->generateHmacToken('foo', ['*'], '2024-10-01 12:20:00'); $user->generateHmacToken('bar'); $this->setMockIo([]);