Skip to content

Commit

Permalink
feat: add expiration date to access token & hmac keys/tokens.
Browse files Browse the repository at this point in the history
  • Loading branch information
CosDiabos committed Nov 3, 2024
1 parent 97439b6 commit 7e8c5f2
Show file tree
Hide file tree
Showing 10 changed files with 523 additions and 19 deletions.
57 changes: 56 additions & 1 deletion docs/references/authentication/hmac.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
52 changes: 51 additions & 1 deletion docs/references/authentication/tokens.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions src/Authentication/Authenticators/AccessTokens.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(

Check failure on line 163 in src/Authentication/Authenticators/AccessTokens.php

View workflow job for this annotation

GitHub Actions / phpstan / PHP 8.3 Static Analysis

Cannot call method isBefore() on array|float|int<min, -1>|int<1, max>|object|string|true.

Check failure on line 163 in src/Authentication/Authenticators/AccessTokens.php

View workflow job for this annotation

GitHub Actions / phpstan / PHP 8.1 Static Analysis

Cannot call method isBefore() on array|float|int<min, -1>|int<1, max>|object|string|true.

Check failure on line 163 in src/Authentication/Authenticators/AccessTokens.php

View workflow job for this annotation

GitHub Actions / phpstan / PHP 8.0 Static Analysis

Cannot call method isBefore() on array|float|int<min, -1>|int<1, max>|object|string|true.

Check failure on line 163 in src/Authentication/Authenticators/AccessTokens.php

View workflow job for this annotation

GitHub Actions / phpstan / PHP 8.2 Static Analysis

Cannot call method isBefore() on array|float|int<min, -1>|int<1, max>|object|string|true.
Time::now()
)
) {
return new Result([
'success' => false,
'reason' => lang('Auth.oldToken'),
]);
}

// Hasn't been used in a long time
if (
$token->last_used_at
Expand Down
76 changes: 72 additions & 4 deletions src/Authentication/Traits/HasAccessTokens.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -34,15 +37,18 @@ trait HasAccessTokens
/**
* Generates a new personal access token for this user.
*
* @param string $name Token name
* @param list<string> $scopes Permissions the token grants
* @param string $name Token name
* @param list<string> $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);
}

/**
Expand Down Expand Up @@ -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

Check failure on line 192 in src/Authentication/Traits/HasAccessTokens.php

View workflow job for this annotation

GitHub Actions / psalm / Psalm Analysis

UndefinedDocblockClass

src/Authentication/Traits/HasAccessTokens.php:192:15: UndefinedDocblockClass: Docblock-defined class, interface or enum named CodeIgniter\Shield\Authentication\Traits\AcessToken does not exist (see https://psalm.dev/200)
* @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;
}
}
77 changes: 73 additions & 4 deletions src/Authentication/Traits/HasHmacTokens.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -35,17 +38,19 @@ trait HasHmacTokens
/**
* Generates a new personal HMAC token for this user.
*
* @param string $name Token name
* @param list<string> $scopes Permissions the token grants
* @param string $name Token name
* @param list<string> $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);
}

/**
Expand Down Expand Up @@ -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

Check failure on line 182 in src/Authentication/Traits/HasHmacTokens.php

View workflow job for this annotation

GitHub Actions / psalm / Psalm Analysis

UndefinedDocblockClass

src/Authentication/Traits/HasHmacTokens.php:182:15: UndefinedDocblockClass: Docblock-defined class, interface or enum named CodeIgniter\Shield\Authentication\Traits\AcessToken does not exist (see https://psalm.dev/200)
* @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;
}
}
Loading

0 comments on commit 7e8c5f2

Please sign in to comment.