diff --git a/docs/references/authentication/hmac.md b/docs/references/authentication/hmac.md index 737199cbf..7a7b37d3e 100644 --- a/docs/references/authentication/hmac.md +++ b/docs/references/authentication/hmac.md @@ -97,6 +97,63 @@ 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 reach the HMAC keys' lifetime expiration after their last use date. + +HMAC keys can be set to expire through the `generateHmacToken()` method. This takes the expiration date as the `$expiresAt` argument. To update an existing HMAC key expiration date, use `updateHmacTokenExpiration($hmacTokenID, $expiresAt)`. To remove it, use `removeHmacTokenExpiration($hmacTokenID)`. + +`$expiresAt` [Time](https://codeigniter.com/user_guide/libraries/time.html) object + +```php +// Expiration date = 2024-11-03 12:00:00 +$expiresAt = Time::parse('2024-11-03 12:00:00'); +$token = $this->user->generateHmacToken('foo', ['foo.bar'], $expiresAt); + +// Expiration date = 2024-11-15 00:00:00 +$expiresAt = Time::parse('2024-11-15 00:00:00'); +$user->updateHmacTokenExpiration($token->id, $expiresAt); + +// Expiration date = 1 month + 15 days into the future +$expiresAt = Time::now()->addMonths(1)->addDays(15); +$user->updateHmacTokenExpiration($token->id, $expiresAt); + +// Remove the expiration date +$user->removeHmacTokenExpiration($token->id); +``` + +The following support methods are also available: + +`isHmacTokenExpired(AccessToken $hmacToken)` - Checks if the HMAC key is expired. Returns `true` if the HMAC key is expired; otherwise, returns `false`. + +```php +$expiresAt = Time::parse('2024-11-03 12:00:00'); +$token = $this->user->generateHmacToken('foo', ['foo.bar'], $expiresAt); + +$this->user->isHmacTokenExpired($token); // Returns true +``` + +`canHmacTokenExpire(AccessToken $hmacToken)` - Checks if HMAC key has an expiration set. Returns `true` if the HMAC key is expired; otherwise, returns `false`. + +```php +$expiresAt = Time::parse('2024-11-03 12:00:00'); + +$token = $this->user->generateHmacToken('foo', ['foo.bar'], $expiresAt); +$this->user->canHmacTokenExpire($token); // Returns true + +$token2 = $this->user->generateHmacToken('bar'); +$this->user->canHmacTokenExpire($token2); // Returns false +``` + +You can also easily set all existing HMAC keys/tokens as expired with the `spark` command: +```console +php spark shield:hmac invalidateAll +``` + +!!! warning + + This command invalidates _all_ keys for _all_ users. + ## Retrieving HMAC Keys The following methods are available to help you retrieve a user's HMAC keys: @@ -217,7 +274,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 +285,10 @@ that CodeIgniter provides. public $unusedTokenLifetime = YEAR; ``` +### HMAC Keys Expiration vs Lifetime + +Expiration and lifetime are two 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..4f1b9e1c9 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 have last 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,61 @@ that CodeIgniter provides. public $unusedTokenLifetime = YEAR; ``` + +## Expiring Access Tokens + +By default, the Access Tokens don't expire unless they reach the Access Token's lifetime expiration after their last use date. + +Access Tokens can be set to expire through the `generateAccessToken()` method. This takes the expiration date as the `$expiresAt` argument. To update an existing HMAC key expiration date, use `updateAcessTokenExpiration($accessTokenID, $expiresAt)`. To remove it, use `removeAccessTokenExpiration($accessTokenID)`. + +`$expiresAt` [Time](https://codeigniter.com/user_guide/libraries/time.html) object + +```php +// Expiration date = 2024-11-03 12:00:00 +$expiresAt = Time::parse('2024-11-03 12:00:00'); +$token = $this->user->generateAccessToken('foo', ['foo.bar'], $expiresAt); + +// Expiration date = 2024-11-15 00:00:00 +$expiresAt = Time::parse('2024-11-15 00:00:00'); +$user->updateAcessTokenExpiration($token->id, $expiresAt); + +// Or Expiration date = 1 month + 15 days into the future +$expiresAt = Time::now()->addMonths(1)->addDays(15); +$user->updateAcessTokenExpiration($token->id, $expiresAt); + +// Remove the expiration date +$user->removeAccessTokenExpiration($token->id); +``` + +The following support methods are also available: + +`isAccessTokenExpired(AccessToken $accessToken)` - Checks if Access Token is expired. Returns `true` if the Access Token is expired; otherwise, returns `false`. + +```php +$expiresAt = Time::parse('2024-11-03 12:00:00'); + +$token = $this->user->generateAccessToken('foo', ['foo.bar'], $expiresAt); + +$this->user->isAccessTokenExpired($token); // Returns true +``` + +`canAccessTokenExpire(AccessToken $accessToken)` - Returns `true` if the Access Token has a set expiration date; otherwise, returns `false`. + +```php +$expiresAt = Time::parse('2024-11-03 12:00:00'); + +$token = $this->user->generateAccessToken('foo', ['foo.bar'], $expiresAt); +$this->user->canAccessTokenExpire($token2); // Returns false + +$token2 = $this->user->generateAccessToken('bar'); +$this->user->canAccessTokenExpire($token); // Returns true +``` + + +### Access Token Expiration vs Lifetime + +Expiration and lifetime are two 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/phpstan-baseline.php b/phpstan-baseline.php index 575162eb4..59cc29d60 100644 --- a/phpstan-baseline.php +++ b/phpstan-baseline.php @@ -168,7 +168,13 @@ $ignoreErrors[] = [ 'message' => '#^Cannot access property \\$id on array\\\\|object\\.$#', 'identifier' => 'property.nonObject', - 'count' => 7, + 'count' => 9, + 'path' => __DIR__ . '/src/Commands/Hmac.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Cannot access property \\$expires on array\\\\|object\\.$#', + 'identifier' => 'property.nonObject', + 'count' => 3, 'path' => __DIR__ . '/src/Commands/Hmac.php', ]; $ignoreErrors[] = [ @@ -259,7 +265,7 @@ $ignoreErrors[] = [ 'message' => '#^Call to function model with CodeIgniter\\\\Shield\\\\Models\\\\UserIdentityModel\\:\\:class is discouraged\\.$#', 'identifier' => 'codeigniter.factoriesClassConstFetch', - 'count' => 19, + 'count' => 23, 'path' => __DIR__ . '/src/Entities/User.php', ]; $ignoreErrors[] = [ diff --git a/psalm.xml b/psalm.xml index 5b3a32593..61677a083 100644 --- a/psalm.xml +++ b/psalm.xml @@ -11,6 +11,7 @@ errorBaseline="psalm-baseline.xml" findUnusedBaselineEntry="false" findUnusedCode="false" + ensureOverrideAttribute="false" > diff --git a/src/Authentication/Authenticators/AccessTokens.php b/src/Authentication/Authenticators/AccessTokens.php index ce86155a9..4b471ada4 100644 --- a/src/Authentication/Authenticators/AccessTokens.php +++ b/src/Authentication/Authenticators/AccessTokens.php @@ -154,6 +154,19 @@ public function check(array $credentials): Result assert($token->last_used_at instanceof Time || $token->last_used_at === null); + // Is expired ? + if ( + $token->expires instanceof Time + && $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..b4fa52cbb 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 Time|null $expiresAt Expiration date + * + * @throws InvalidArgumentException */ - public function generateAccessToken(string $name, array $scopes = ['*']): AccessToken + public function generateAccessToken(string $name, array $scopes = ['*'], ?Time $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,63 @@ public function setAccessToken(?AccessToken $accessToken): self return $this; } + + /** + * Checks if the provided Access Token is expired. + */ + public function isAccessTokenExpired(AccessToken $accessToken): bool + { + return $accessToken->expires instanceof Time && $accessToken->expires->isBefore(Time::now()); + } + + /** + * Sets an expiration for Access Tokens by ID. + * + * @param int $id AccessTokens ID + * @param Time $expiresAt Expiration date + * + * @return bool Returns true if expiration date is set or updated. + */ + public function updateAccessTokenExpiration(int $id, Time $expiresAt): bool + { + /** @var UserIdentityModel $identityModel */ + $identityModel = model(UserIdentityModel::class); + $result = $identityModel->setIdentityExpirationById($id, $this, $expiresAt); + + if ($result) { + // refresh currentAccessToken with updated data + $this->currentAccessToken = $identityModel->getAccessTokenById($id, $this); + } + + return $result; + } + + /** + * Removes the expiration date for Access Tokens by ID. + * + * @param int $id AccessTokens ID + * + * @return bool Returns true if expiration date is set or updated. + */ + public function removeAccessTokenExpiration(int $id): bool + { + /** @var UserIdentityModel $identityModel */ + $identityModel = model(UserIdentityModel::class); + $result = $identityModel->setIdentityExpirationById($id, $this); + + if ($result) { + // refresh currentAccessToken with updated data + $this->currentAccessToken = $identityModel->getAccessTokenById($id, $this); + } + + return $result; + } + + /** + * Checks if the access token has a set expiration date + */ + public function canAccessTokenExpire(AccessToken $accessToken): bool + { + return $accessToken->expires !== null; + } } diff --git a/src/Authentication/Traits/HasHmacTokens.php b/src/Authentication/Traits/HasHmacTokens.php index bfaab7d65..137cea9a3 100644 --- a/src/Authentication/Traits/HasHmacTokens.php +++ b/src/Authentication/Traits/HasHmacTokens.php @@ -13,8 +13,10 @@ namespace CodeIgniter\Shield\Authentication\Traits; +use CodeIgniter\I18n\Time; use CodeIgniter\Shield\Entities\AccessToken; use CodeIgniter\Shield\Models\UserIdentityModel; +use InvalidArgumentException; use ReflectionException; /** @@ -35,17 +37,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 Time|null $expiresAt Expiration date * + * @throws InvalidArgumentException * @throws ReflectionException */ - public function generateHmacToken(string $name, array $scopes = ['*']): AccessToken + public function generateHmacToken(string $name, array $scopes = ['*'], ?Time $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 +160,63 @@ public function setHmacToken(?AccessToken $accessToken): self return $this; } + + /** + * Checks if the provided HMAC Token is expired. + */ + public function isHmacTokenExpired(AccessToken $hmacToken): bool + { + return $hmacToken->expires instanceof Time && $hmacToken->expires->isBefore(Time::now()); + } + + /** + * Sets an expiration for HMAC token by ID. + * + * @param int $id HMAC Token ID + * @param Time $expiresAt Expiration date + * + * @return bool Returns true if expiration date is set or updated. + */ + public function updateHmacTokenExpiration(int $id, Time $expiresAt): bool + { + /** @var UserIdentityModel $identityModel */ + $identityModel = model(UserIdentityModel::class); + $result = $identityModel->setIdentityExpirationById($id, $this, $expiresAt); + + if ($result) { + // refresh currentHmacToken with updated data + $this->currentHmacToken = $identityModel->getHmacTokenById($id, $this); + } + + return $result; + } + + /** + * Removes the expiration date for HMAC token by ID. + * + * @param int $id HMAC Token ID + * + * @return bool Returns true if expiration date is removed + */ + public function removeHmacTokenExpiration(int $id): bool + { + /** @var UserIdentityModel $identityModel */ + $identityModel = model(UserIdentityModel::class); + $result = $identityModel->setIdentityExpirationById($id, $this); + + if ($result) { + // refresh currentHmacToken with updated data + $this->currentHmacToken = $identityModel->getHmacTokenById($id, $this); + } + + return $result; + } + + /** + * Checks if the current HMAC token has a set expiration date + */ + public function canHmacTokenExpire(AccessToken $hmacToken): bool + { + return $hmacToken->expires !== null; + } } diff --git a/src/Commands/Hmac.php b/src/Commands/Hmac.php index d3961b07f..38421cb67 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, ]; @@ -87,10 +91,12 @@ public function run(array $params): int try { match ($action) { - 'encrypt' => $this->encrypt(), - 'decrypt' => $this->decrypt(), - 'reencrypt' => $this->reEncrypt(), - default => throw new BadInputException('Unrecognized Command'), + 'encrypt' => $this->encrypt(), + 'decrypt' => $this->decrypt(), + 'reencrypt' => $this->reEncrypt(), + 'invalidateAll' => $this->invalidateAll(), + + default => throw new BadInputException('Unrecognized Command'), }; } catch (Exception $e) { $this->write($e->getMessage(), 'red'); @@ -196,4 +202,31 @@ static function ($identity) use ($uIdModelSub, $encrypter, $that): void { }, ); } + + /** + * Invalidates all HMAC Keys/Tokens for every user. + */ + public function invalidateAll(): void + { + $uIdModel = new UserIdentityModel(); + $uIdModelSub = new UserIdentityModel(); + + $uIdModel->where('type', 'hmac_sha256')->orderBy('id')->chunk( + 100, + function ($identity) use ($uIdModelSub): void { + $timeNow = Time::now(); + + if (null !== $identity->expires && $identity->expires->isBefore($timeNow)) { + $this->write('HMAC Token ID: ' . $identity->id . ', already expired, skipped.'); + + return; + } + + $identity->expires = $timeNow; + $uIdModelSub->save($identity); + + $this->write('HMAC Token ID: ' . $identity->id . ', set as expired.'); + }, + ); + } } 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 69f9af62a..c3f6b42e5 100644 --- a/src/Models/UserIdentityModel.php +++ b/src/Models/UserIdentityModel.php @@ -26,6 +26,7 @@ use CodeIgniter\Shield\Exceptions\ValidationException; use Exception; use Faker\Generator; +use InvalidArgumentException; use ReflectionException; class UserIdentityModel extends BaseModel @@ -144,10 +145,13 @@ 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 Time $expiresAt Expiration date + * + * @throws InvalidArgumentException */ - public function generateAccessToken(User $user, string $name, array $scopes = ['*']): AccessToken + public function generateAccessToken(User $user, string $name, array $scopes = ['*'], ?Time $expiresAt = null): AccessToken { $this->checkUserId($user); @@ -158,6 +162,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 +229,24 @@ public function getAllAccessTokens(User $user): array ->findAll(); } + /** + * Updates or sets expiration date of users' AccessToken or HMAC Token by ID. + * + * @param Time $expiresAt Expiration date + * @param mixed $id + * + * @return bool Returns true if expiration date was set or updated. + */ + public function setIdentityExpirationById($id, User $user, ?Time $expiresAt = null): bool + { + $this->checkUserId($user); + + return $this->where('user_id', $user->id) + ->where('id', $id) + ->set(['expires' => $expiresAt]) + ->update(); + } + // HMAC /** * Find and Retrieve the HMAC AccessToken based on Token alone @@ -242,13 +265,15 @@ 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 Time $expiresAt Expiration date * * @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 = ['*'], ?Time $expiresAt = null): AccessToken { $this->checkUserId($user); @@ -262,6 +287,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 e00154f89..d864860c0 100644 --- a/tests/Authentication/HasAccessTokensTest.php +++ b/tests/Authentication/HasAccessTokensTest.php @@ -13,6 +13,7 @@ namespace Tests\Authentication; +use CodeIgniter\I18n\Time; use CodeIgniter\Shield\Entities\AccessToken; use CodeIgniter\Shield\Entities\User; use CodeIgniter\Shield\Models\UserIdentityModel; @@ -140,11 +141,11 @@ public function testTokenCanNoTokenSet(): void public function testTokenCanBasics(): void { - $token = $this->user->generateAccessToken('foo', ['foo:bar']); + $token = $this->user->generateAccessToken('foo', ['foo.bar']); $this->user->setAccessToken($token); - $this->assertTrue($this->user->tokenCan('foo:bar')); - $this->assertFalse($this->user->tokenCan('foo:baz')); + $this->assertTrue($this->user->tokenCan('foo.bar')); + $this->assertFalse($this->user->tokenCan('foo.baz')); } public function testTokenCantNoTokenSet(): void @@ -152,12 +153,97 @@ 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']); + $tokenExpiration = Time::parse('2024-11-03 12:00:00'); + $token = $this->user->generateAccessToken('foo', ['foo.bar'], $tokenExpiration); $this->user->setAccessToken($token); - $this->assertFalse($this->user->tokenCant('foo:bar')); - $this->assertTrue($this->user->tokenCant('foo:baz')); + $this->assertSame($tokenExpiration->format('Y-m-d h:i:s'), $this->user->currentAccessToken()->expires->format('Y-m-d h:i:s')); + + $tokenExpiration = $tokenExpiration->addMonths(1)->addYears(1); + $token = $this->user->generateAccessToken('foo', ['foo.bar'], $tokenExpiration); + $this->user->setAccessToken($token); + + $this->assertSame($tokenExpiration->format('Y-m-d h:i:s'), $this->user->currentAccessToken()->expires->format('Y-m-d h:i:s')); + } + + /** + * 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); + + $tokenExpiration = Time::parse('2024-11-03 12:00:00'); + + $this->assertTrue($this->user->updateAccessTokenExpiration($token->id, $tokenExpiration)); + $this->assertSame($tokenExpiration->format('Y-m-d h:i:s'), $this->user->currentAccessToken()->expires->format('Y-m-d h:i:s')); + } + + /** + * See https://github.com/codeigniter4/shield/issues/926 + */ + public function testIsTokenExpired(): void + { + $tokenExpiration = Time::parse('2024-11-03 12:00:00'); + $token = $this->user->generateAccessToken('foo', ['foo.bar'], $tokenExpiration); + $this->user->setAccessToken($token); + + $this->assertTrue($this->user->isAccessTokenExpired($this->user->currentAccessToken())); + } + + /** + * See https://github.com/codeigniter4/shield/issues/926 + */ + public function testTokenTimeToExpired(): void + { + $tokenExpiration = Time::now()->addYears(1); + + $token = $this->user->generateAccessToken('foo', ['foo.bar'], $tokenExpiration); + $this->user->setAccessToken($token); + + $this->assertSame('in 1 year', $this->user->currentAccessToken()->expires->humanize()); + } + + /** + * See https://github.com/codeigniter4/shield/issues/926 + */ + public function testCanHmacTokenExpire(): void + { + $tokenExpiration = Time::now()->addYears(1); + + $token = $this->user->generateAccessToken('foo', ['foo.bar'], $tokenExpiration); + + $this->assertTrue($this->user->canAccessTokenExpire($token)); + + $token = $this->user->generateAccessToken('foo', ['foo.bar']); + + $this->assertFalse($this->user->canAccessTokenExpire($token)); + } + + /** + * See https://github.com/codeigniter4/shield/issues/926 + */ + public function testAccessTokenRemoveExpiration(): void + { + $tokenExpiration = Time::now()->addYears(1); + + $token = $this->user->generateAccessToken('foo', ['foo.bar'], $tokenExpiration); + + $this->user->setAccessToken($token); + + $this->assertTrue($this->user->canAccessTokenExpire($token)); + + $this->assertTrue($this->user->removeAccessTokenExpiration($token->id)); + + $this->assertFalse($this->user->canAccessTokenExpire($this->user->currentAccessToken())); } } diff --git a/tests/Authentication/HasHmacTokensTest.php b/tests/Authentication/HasHmacTokensTest.php index e9d6a3451..33909c1de 100644 --- a/tests/Authentication/HasHmacTokensTest.php +++ b/tests/Authentication/HasHmacTokensTest.php @@ -13,6 +13,7 @@ namespace Tests\Authentication; +use CodeIgniter\I18n\Time; use CodeIgniter\Shield\Entities\AccessToken; use CodeIgniter\Shield\Entities\User; use CodeIgniter\Shield\Models\UserIdentityModel; @@ -135,11 +136,11 @@ public function testHmacTokenCanNoTokenSet(): void public function testHmacTokenCanBasics(): void { - $token = $this->user->generateHmacToken('foo', ['foo:bar']); + $token = $this->user->generateHmacToken('foo', ['foo.bar']); $this->user->setHmacToken($token); - $this->assertTrue($this->user->hmacTokenCan('foo:bar')); - $this->assertFalse($this->user->hmacTokenCan('foo:baz')); + $this->assertTrue($this->user->hmacTokenCan('foo.bar')); + $this->assertFalse($this->user->hmacTokenCan('foo.baz')); } public function testHmacTokenCantNoTokenSet(): void @@ -149,10 +150,110 @@ public function testHmacTokenCantNoTokenSet(): void public function testHmacTokenCant(): void { - $token = $this->user->generateHmacToken('foo', ['foo:bar']); + $token = $this->user->generateHmacToken('foo', ['foo.bar']); $this->user->setHmacToken($token); - $this->assertFalse($this->user->hmacTokenCant('foo:bar')); - $this->assertTrue($this->user->hmacTokenCant('foo:baz')); + $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 + { + $tokenExpiration = Time::parse('2024-11-03 12:00:00'); + + $token = $this->user->generateHmacToken('foo', ['foo.bar'], $tokenExpiration); + $this->user->setHmacToken($token); + + $this->assertSame($tokenExpiration->format('Y-m-d h:i:s'), $this->user->currentHmacToken()->expires->format('Y-m-d h:i:s')); + + $tokenExpiration = $tokenExpiration->addMonths(1)->addYears(1); + + $token = $this->user->generateHmacToken('foo', ['foo.bar'], $tokenExpiration); + $this->user->setHmacToken($token); + + $this->assertSame($tokenExpiration->format('Y-m-d h:i:s'), $this->user->currentHmacToken()->expires->format('Y-m-d h:i:s')); + } + + /** + * See https://github.com/codeigniter4/shield/issues/926 + */ + public function testSetTokenExpirationById(): void + { + $token = $this->user->generateHmacToken('foo', ['foo.bar']); + + $this->user->setHmacToken($token); + + $this->assertNull($this->user->currentHmacToken()->expires); + + $tokenExpiration = Time::parse('2024-11-03 12:00:00'); + + $this->assertTrue($this->user->updateHmacTokenExpiration($token->id, $tokenExpiration)); + + $this->user->setHmacToken($this->user->getHmacTokenById($token->id)); + $this->assertSame($tokenExpiration->format('Y-m-d h:i:s'), $this->user->currentHmacToken()->expires->format('Y-m-d h:i:s')); + } + + /** + * See https://github.com/codeigniter4/shield/issues/926 + */ + public function testIsHmacTokenExpired(): void + { + $tokenExpiration = Time::parse('2024-11-03 12:00:00'); + + $token = $this->user->generateHmacToken('foo', ['foo.bar'], $tokenExpiration); + $this->user->setHmacToken($token); + + $this->assertTrue($this->user->isHmacTokenExpired($token)); + } + + /** + * See https://github.com/codeigniter4/shield/issues/926 + */ + public function testHmacTokenTimeToExpired(): void + { + $tokenExpiration = Time::now(); + $tokenExpiration = $tokenExpiration->addYears(1); + + $token = $this->user->generateHmacToken('foo', ['foo.bar'], $tokenExpiration); + + $this->assertSame('in 1 year', $token->expires->humanize()); + } + + /** + * See https://github.com/codeigniter4/shield/issues/926 + */ + public function testcanHmacTokenExpire(): void + { + $tokenExpiration = Time::now(); + $tokenExpiration = $tokenExpiration->addYears(1); + + $token = $this->user->generateHmacToken('foo', ['foo.bar'], $tokenExpiration); + + $this->assertTrue($this->user->canHmacTokenExpire($token)); + + $token = $this->user->generateHmacToken('foo', ['foo.bar']); + + $this->assertFalse($this->user->canHmacTokenExpire($token)); + } + + /** + * See https://github.com/codeigniter4/shield/issues/926 + */ + public function testHmacTokenRemoveExpiration(): void + { + $tokenExpiration = Time::now()->addYears(1); + + $token = $this->user->generateHmacToken('hmac', ['foo.bar'], $tokenExpiration); + + $this->user->setHmacToken($token); + + $this->assertTrue($this->user->canHmacTokenExpire($token)); + + $this->assertTrue($this->user->removeHmacTokenExpiration($token->id)); + + $this->assertFalse($this->user->canHmacTokenExpire($this->user->currentHmacToken())); } } diff --git a/tests/Commands/HmacTest.php b/tests/Commands/HmacTest.php index 9027599f3..270aa6f5e 100644 --- a/tests/Commands/HmacTest.php +++ b/tests/Commands/HmacTest.php @@ -13,6 +13,7 @@ namespace Tests\Commands; +use CodeIgniter\I18n\Time; use CodeIgniter\Shield\Authentication\HMAC\HmacEncrypter; use CodeIgniter\Shield\Commands\Hmac; use CodeIgniter\Shield\Config\AuthToken; @@ -141,6 +142,29 @@ public function testBadCommand(): void $this->assertSame('Unrecognized Command', $resultsString); } + /** + * See https://github.com/codeigniter4/shield/issues/926 + */ + public function testExpireAll(): void + { + $tokenExpiration = Time::parse('2024-11-03 12:00:00'); + + /** @var User $user */ + $user = fake(UserModel::class); + $user->generateHmacToken('foo', ['*'], $tokenExpiration); + $user->generateHmacToken('bar'); + + $this->setMockIo([]); + $this->assertNotFalse(command('shield:hmac invalidateAll')); + + $resultsString = $this->io->getOutputs(); + $results = explode("\n", trim($resultsString)); + + $this->assertCount(2, $results); + $this->assertSame('HMAC Token ID: 1, already expired, skipped.', trim($results[0])); + $this->assertSame('HMAC Token ID: 2, set as expired.', trim($results[1])); + } + /** * Set MockInputOutput and user inputs. *