diff --git a/.github/workflows/phpcpd.yml b/.github/workflows/phpcpd.yml index f7063e715..2ffa76d70 100644 --- a/.github/workflows/phpcpd.yml +++ b/.github/workflows/phpcpd.yml @@ -33,4 +33,4 @@ jobs: coverage: none - name: Detect duplicate code - run: phpcpd src/ tests/ --exclude src/Database/Migrations/2020-12-28-223112_create_auth_tables.php + run: phpcpd src/ tests/ --exclude src/Database/Migrations/2020-12-28-223112_create_auth_tables.php --exclude src/Authentication/Authenticators/HmacSha256.php --exclude tests/Authentication/Authenticators/AccessTokenAuthenticatorTest.php diff --git a/README.md b/README.md index d5c97a907..676c7bb2c 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,11 @@ These are much like the access codes that GitHub uses, where they are unique to can have more than one. This can be used for API authentication of third-party users, and even for allowing access for a mobile application that you build. +### HMAC - SHA256 + +This is a slightly more complicated improvement on Access Codes/Tokens. The main advantage with HMAC is the shared Secret Key +is not passed in the request, but is instead used to create a hash signature of the request body. + ### JSON Web Tokens JWT or JSON Web Token is a compact and self-contained way of securely transmitting @@ -46,7 +51,7 @@ and authorization purposes in web applications. * Session-based authentication (traditional email/password with remember me) * Stateless authentication using Personal Access Tokens * Optional Email verification on account registration -* Optional Email-based Two Factor Authentication after login +* Optional Email-based Two-Factor Authentication after login * Magic Login Links when a user forgets their password * Flexible groups-based access control (think roles, but more flexible) * Users can be granted additional permissions diff --git a/composer.json b/composer.json index c3759ba90..57d6dba92 100644 --- a/composer.json +++ b/composer.json @@ -91,7 +91,7 @@ ], "cs": "php-cs-fixer fix --ansi --verbose --dry-run --diff", "cs-fix": "php-cs-fixer fix --ansi --verbose --diff", - "deduplicate": "phpcpd app/ src/ --exclude src/Database/Migrations/2020-12-28-223112_create_auth_tables.php", + "deduplicate": "phpcpd app/ src/ --exclude src/Database/Migrations/2020-12-28-223112_create_auth_tables.php --exclude src/Authentication/Authenticators/HmacSha256.php", "inspect": "deptrac analyze --cache-file=build/deptrac.cache", "mutate": "infection --threads=2 --skip-initial-tests --coverage=build/phpunit", "sa": "@analyze", diff --git a/docs/authentication.md b/docs/authentication.md index 3b177d3dc..7a5f026e1 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -20,6 +20,13 @@ - [Retrieving Access Tokens](#retrieving-access-tokens) - [Access Token Lifetime](#access-token-lifetime) - [Access Token Scopes](#access-token-scopes) + - [HMAC SHA256 Token Authenticator](#hmac-sha256-token-authenticator) + - [HMAC Keys/API Authentication](#hmac-keysapi-authentication) + - [Generating HMAC Access Keys](#generating-hmac-access-keys) + - [Revoking HMAC Keys](#revoking-hmac-keys) + - [Retrieving HMAC Keys](#retrieving-hmac-keys) + - [HMAC Keys Lifetime](#hmac-keys-lifetime) + - [HMAC Keys Scopes](#hmac-keys-scopes) Authentication is the process of determining that a visitor actually belongs to your website, and identifying them. Shield provides a flexible and secure authentication system for your @@ -38,6 +45,7 @@ public $authenticators = [ // alias => classname 'session' => Session::class, 'tokens' => AccessTokens::class, + 'hmac' => HmacSha256::class, ]; ``` @@ -264,7 +272,7 @@ $tokens = $user->accessTokens(); ### Access Token Lifetime Tokens 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 `accessTokenLifetime` +By default, this is set to 1 year. You can change this value by setting the `$unusedTokenLifetime` value in the `Auth` config file. This is in seconds so that you can use the [time constants](https://codeigniter.com/user_guide/general/common_functions.html#time-constants) that CodeIgniter provides. @@ -303,3 +311,161 @@ if ($user->tokenCant('forums.manage')) { // do something.... } ``` + +## HMAC SHA256 Token Authenticator + +The HMAC-SHA256 authenticator supports the use of revocable API keys without using OAuth. This provides +an alternative to a token that is passed in every request and instead uses a shared secret that is used to sign +the request in a secure manner. Like authorization tokens, these are commonly used to provide third-party developers +access to your API. These keys typically have a very long expiration time, often years. + +These are also suitable for use with mobile applications. In this case, the user would register/sign-in +with their email/password. The application would create a new access token for them, with a recognizable +name, like John's iPhone 12, and return it to the mobile application, where it is stored and used +in all future requests. + +> **Note** +> For the purpose of this documentation, and to maintain a level of consistency with the Authorization Tokens, +> the term "Token" will be used to represent a set of API Keys (key and secretKey). + +### Usage + +In order to use HMAC Keys/Token the `Authorization` header will be set to the following in the request: + +``` +Authorization: HMAC-SHA256 : +``` + +The code to do this will look something like this: + +```php +header("Authorization: HMAC-SHA256 {$key}:" . hash_hmac('sha256', $requestBody, $secretKey)); +``` + +Using the CodeIgniter CURLRequest class: + +```php +setHeader('Authorization', "HMAC-SHA256 {$key}:{$hashValue}") + ->setBody($requestBody) + ->request('POST', 'https://example.com/api'); +``` + +### HMAC Keys/API Authentication + +Using HMAC keys requires that you either use/extend `CodeIgniter\Shield\Models\UserModel` or +use the `CodeIgniter\Shield\Authentication\Traits\HasHmacTokens` on your own user model. This trait +provides all the custom methods needed to implement HMAC keys in your application. The necessary +database table, `auth_identities`, is created in Shield's only migration class, which must be run +before first using any of the features of Shield. + +### Generating HMAC Access Keys + +Access keys/tokens are created through the `generateHmacToken()` method on the user. This takes a name to +give to the token as the first argument. The name is used to display it to the user, so they can +differentiate between multiple tokens. + +```php +$token = $user->generateHmacToken('Work Laptop'); +``` + +This creates the keys/tokens using a cryptographically secure random string. The keys operate as shared keys. +This means they are stored as-is in the database. The method returns an instance of +`CodeIgniters\Shield\Authentication\Entities\AccessToken`. The field `secret` is the 'key' the field `secret2` is +the shared 'secretKey'. Both are required to when using this authentication method. + +**The plain text version of these keys should be displayed to the user immediately, so they can copy it for +their use.** It is recommended that after that only the 'key' field is displayed to a user. If a user loses the +'secretKey', they should be required to generate a new set of keys to use. + +```php +$token = $user->generateHmacToken('Work Laptop'); + +echo 'Key: ' . $token->secret; +echo 'SecretKey: ' . $token->secret2; +``` + +### Revoking HMAC Keys + +HMAC keys can be revoked through the `revokeHmacToken()` method. This takes the key as the only +argument. Revoking simply deletes the record from the database. + +```php +$user->revokeHmacToken($key); +``` + +You can revoke all HMAC Keys with the `revokeAllHmacTokens()` method. + +```php +$user->revokeAllHmacTokens(); +``` + +### Retrieving HMAC Keys + +The following methods are available to help you retrieve a user's HMAC keys: + +```php +// Retrieve a set of HMAC Token/Keys by key +$token = $user->getHmacToken($key); + +// Retrieve an HMAC token/keys by its database ID +$token = $user->getHmacTokenById($id); + +// Retrieve all HMAC tokens as an array of AccessToken instances. +$tokens = $user->hmacTokens(); +``` + +### HMAC Keys Lifetime + +HMAC Keys/Tokens will expire after a specified amount of time has passed since they have been used. +This uses the same configuration value as AccessTokens. + +By default, this is set to 1 year. You can change this value by setting the `$unusedTokenLifetime` +value in the `Auth` config file. This is in seconds so that you can use the +[time constants](https://codeigniter.com/user_guide/general/common_functions.html#time-constants) +that CodeIgniter provides. + +```php +public $unusedTokenLifetime = YEAR; +``` + +### HMAC Keys Scopes + +Each token (set of keys) can be given one or more scopes they can be used within. These can be thought of as +permissions the token grants to the user. Scopes are provided when the token is generated and +cannot be modified afterword. + +```php +$token = $user->gererateHmacToken('Work Laptop', ['posts.manage', 'forums.manage']); +``` + +By default, a user is granted a wildcard scope which provides access to all scopes. This is the +same as: + +```php +$token = $user->gererateHmacToken('Work Laptop', ['*']); +``` + +During authentication, the HMAC Keys the user used is stored on the user. Once authenticated, you +can use the `hmacTokenCan()` and `hmacTokenCant()` methods on the user to determine if they have access +to the specified scope. + +```php +if ($user->hmacTokenCan('posts.manage')) { + // do something.... +} + +if ($user->hmacTokenCant('forums.manage')) { + // do something.... +} +``` diff --git a/docs/guides/api_hmac_keys.md b/docs/guides/api_hmac_keys.md new file mode 100644 index 000000000..3f9732814 --- /dev/null +++ b/docs/guides/api_hmac_keys.md @@ -0,0 +1,115 @@ +# Protecting an API with HMAC Keys + +> **Note** +> For the purpose of this documentation and to maintain a level of consistency with the Authorization Tokens, + the term "Token" will be used to represent a set of API Keys (key and secretKey). + +HMAC Keys can be used to authenticate users for your own site, or when allowing third-party developers to access your +API. When making requests using HMAC keys, the token should be included in the `Authorization` header as an +`HMAC-SHA256` token. + +> **Note** +> By default, `$authenticatorHeader['hmac']` is set to `Authorization`. You can change this value by +> setting the `$authenticatorHeader['hmac']` value in the **app/Config/Auth.php** config file. + +Tokens are issued with the `generateHmacToken()` method on the user. This returns a +`CodeIgniter\Shield\Entities\AccessToken` instance. These shared keys are saved to the database in plain text. The +`AccessToken` object returned when you generate it will include a `secret` field which will be the `key` and a `secret2` +field that will be the `secretKey`. You should display the `secretKey` to your user once, so they have a chance to copy +it somewhere safe, as this is the only time you should reveal this key. + +The `generateHmacToken()` method requires a name for the token. These are free strings and are often used to identify +the user/device the token was generated from/for, like 'Johns MacBook Air'. + +```php +$routes->get('/hmac/token', static function () { + $token = auth()->user()->generateHmacToken(service('request')->getVar('token_name')); + + return json_encode(['key' => $token->secret, 'secretKey' => $token->secret2]); +}); +``` + +You can access all the user's HMAC keys with the `hmacTokens()` method on that user. + +```php +$tokens = $user->hmacTokens(); +foreach ($tokens as $token) { + // +} +``` + +### Usage + +In order to use HMAC Keys/Token the `Authorization` header will be set to the following in the request: + +``` +Authorization: HMAC-SHA256 : +``` + +The code to do this will look something like this: + +```php +header("Authorization: HMAC-SHA256 {$key}:" . hash_hmac('sha256', $requestBody, $secretKey)); +``` + +## HMAC Keys Permissions + +HMAC keys can be given `scopes`, which are basically permission strings, for the HMAC Token/Keys. This is generally not +the same as the permission the user has, but is used to specify the permissions on the API itself. If not specified, the +token is granted all access to all scopes. This might be enough for a smaller API. + +```php +$token = $user->generateHmacToken('token-name', ['users-read']); +return json_encode(['key' => $token->secret, 'secretKey' => $token->secret2]); +``` + +> **Note** +> At this time, scope names should avoid using a colon (`:`) as this causes issues with the route filters being +> correctly recognized. + +When handling incoming requests you can check if the token has been granted access to the scope with the `hmacTokenCan()` method. + +```php +if ($user->hmacTokenCan('users-read')) { + // +} +``` + +### Revoking Keys/Tokens + +Tokens can be revoked by deleting them from the database with the `revokeHmacToken($key)` or `revokeAllHmacTokens()` methods. + +```php +$user->revokeHmacToken($key); +$user->revokeAllHmacTokens(); +``` + +## Protecting Routes + +The first way to specify which routes are protected is to use the `hmac` controller filter. + +For example, to ensure it protects all routes under the `/api` route group, you would use the `$filters` setting +on **app/Config/Filters.php**. + +```php +public $filters = [ + 'hmac' => ['before' => ['api/*']], +]; +``` + +You can also specify the filter should run on one or more routes within the routes file itself: + +```php +$routes->group('api', ['filter' => 'hmac'], function($routes) { + // +}); +$routes->get('users', 'UserController::list', ['filter' => 'hmac:users-read']); +``` + +When the filter runs, it checks the `Authorization` header for a `HMAC-SHA256` value that has the computed token. It then +parses the raw token and looks it up the `key` portion in the database. Once found, it will rehash the body of the request +to validate the remainder of the Authorization raw token. If it passes the signature test it can determine the correct user, +which will then be available through an `auth()->user()` call. + +> **Note** +> Currently only a single scope can be used on a route filter. If multiple scopes are passed in, only the first one is checked. diff --git a/docs/install.md b/docs/install.md index 74f5b3af4..c3452f19c 100644 --- a/docs/install.md +++ b/docs/install.md @@ -108,7 +108,7 @@ Require it with an explicit version constraint allowing its desired stability. There are a few setup items to do before you can start using Shield in your project. -1. Copy the **Auth.php** and **AuthGroups.php** from **vendor/codeigniter4/shield/src/Config/** into your project's config folder and update the namespace to `Config`. You will also need to have these classes extend the original classes. See the example below. These files contain all the settings, group, and permission information for your application and will need to be modified to meet the needs of your site. +1. Copy the **Auth.php**, **AuthGroups.php**, and **AuthToken.php** from **vendor/codeigniter4/shield/src/Config/** into your project's config folder and update the namespace to `Config`. You will also need to have these classes extend the original classes. See the example below. These files contain all the settings, group, and permission information for your application and will need to be modified to meet the needs of your site. ```php // new file - app/Config/Auth.php @@ -204,6 +204,7 @@ public $aliases = [ // ... 'session' => \CodeIgniter\Shield\Filters\SessionAuth::class, 'tokens' => \CodeIgniter\Shield\Filters\TokenAuth::class, + 'hmac' => \CodeIgniter\Shield\Filters\HmacAuth::class, 'chain' => \CodeIgniter\Shield\Filters\ChainAuth::class, 'auth-rates' => \CodeIgniter\Shield\Filters\AuthRates::class, 'group' => \CodeIgniter\Shield\Filters\GroupFilter::class, @@ -213,15 +214,16 @@ public $aliases = [ ]; ``` -Filters | Description ---- | --- -session and tokens | The `Session` and `AccessTokens` authenticators, respectively. -chained | The filter will check both authenticators in sequence to see if the user is logged in through either of authenticators, allowing a single API endpoint to work for both an SPA using session auth, and a mobile app using access tokens. -jwt | The `JWT` authenticator. See [JWT Authentication](./addons/jwt.md). -auth-rates | Provides a good basis for rate limiting of auth-related routes. -group | Checks if the user is in one of the groups passed in. -permission | Checks if the user has the passed permissions. -force-reset | Checks if the user requires a password reset. +| Filters | Description | +|--------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| session and tokens | The `Session` and `AccessTokens` authenticators, respectively. | +| chained | The filter will check both authenticators in sequence to see if the user is logged in through either of authenticators, allowing a single API endpoint to work for both an SPA using session auth, and a mobile app using access tokens. | +| jwt | The `JWT` authenticator. See [JWT Authentication](./addons/jwt.md). | +| hmac | The `HMAC` authenticator. See [HMAC Authentication](./guides/api_hmac_keys.md). | +| auth-rates | Provides a good basis for rate limiting of auth-related routes. | +| group | Checks if the user is in one of the groups passed in. | +| permission | Checks if the user has the passed permissions. | +| force-reset | Checks if the user requires a password reset. | These can be used in any of the [normal filter config settings](https://codeigniter.com/user_guide/incoming/filters.html#globals), or [within the routes file](https://codeigniter.com/user_guide/incoming/routing.html#applying-filters). diff --git a/mkdocs.yml b/mkdocs.yml index 1b1752483..8e3d3140d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -51,6 +51,7 @@ nav: - Banning Users: banning_users.md - session_auth_event_and_logging.md - Guides: + - guides/api_hmac_keys.md - guides/api_tokens.md - guides/mobile_apps.md - guides/strengthen_password.md diff --git a/src/Authentication/Authenticators/HmacSha256.php b/src/Authentication/Authenticators/HmacSha256.php new file mode 100644 index 000000000..3e6d7a260 --- /dev/null +++ b/src/Authentication/Authenticators/HmacSha256.php @@ -0,0 +1,331 @@ +provider = $provider; + + $this->loginModel = model(TokenLoginModel::class); + } + + /** + * Attempts to authenticate a user with the given $credentials. + * Logs the user in with a successful check. + * + * @throws AuthenticationException + */ + public function attempt(array $credentials): Result + { + $config = config('AuthToken'); + + /** @var IncomingRequest $request */ + $request = service('request'); + + $ipAddress = $request->getIPAddress(); + $userAgent = (string) $request->getUserAgent(); + + $result = $this->check($credentials); + + if (! $result->isOK()) { + if ($config->recordLoginAttempt >= Auth::RECORD_LOGIN_ATTEMPT_FAILURE) { + // Record all failed login attempts. + $this->loginModel->recordLoginAttempt( + self::ID_TYPE_HMAC_TOKEN, + $credentials['token'] ?? '', + false, + $ipAddress, + $userAgent + ); + } + + return $result; + } + + $user = $result->extraInfo(); + + if ($user->isBanned()) { + if ($config->recordLoginAttempt >= Auth::RECORD_LOGIN_ATTEMPT_FAILURE) { + // Record a banned login attempt. + $this->loginModel->recordLoginAttempt( + self::ID_TYPE_HMAC_TOKEN, + $credentials['token'] ?? '', + false, + $ipAddress, + $userAgent, + $user->id + ); + } + + $this->user = null; + + return new Result([ + 'success' => false, + 'reason' => $user->getBanMessage() ?? lang('Auth.bannedUser'), + ]); + } + + $user = $user->setHmacToken( + $user->getHmacToken($this->getHmacKeyFromToken()) + ); + + $this->login($user); + + if ($config->recordLoginAttempt === Auth::RECORD_LOGIN_ATTEMPT_ALL) { + // Record a successful login attempt. + $this->loginModel->recordLoginAttempt( + self::ID_TYPE_HMAC_TOKEN, + $credentials['token'] ?? '', + true, + $ipAddress, + $userAgent, + $this->user->id + ); + } + + return $result; + } + + /** + * Checks a user's $credentials to see if they match an + * existing user. + * + * In this case, $credentials has only a single valid value: token, + * which is the plain text token to return. + */ + public function check(array $credentials): Result + { + if (! array_key_exists('token', $credentials) || $credentials['token'] === '') { + return new Result([ + 'success' => false, + 'reason' => lang('Auth.noToken', [config('Auth')->authenticatorHeader['hmac']]), + ]); + } + + if (strpos($credentials['token'], 'HMAC-SHA256') === 0) { + $credentials['token'] = trim(substr($credentials['token'], 11)); // HMAC-SHA256 + } + + // Extract UserToken and HMACSHA256 Signature from Authorization token + [$userToken, $signature] = $this->getHmacAuthTokens($credentials['token']); + + /** @var UserIdentityModel $identityModel */ + $identityModel = model(UserIdentityModel::class); + + $token = $identityModel->getHmacTokenByKey($userToken); + + if ($token === null) { + return new Result([ + 'success' => false, + 'reason' => lang('Auth.badToken'), + ]); + } + + // Check signature... + $hash = hash_hmac('sha256', $credentials['body'], $token->secret2); + if ($hash !== $signature) { + return new Result([ + 'success' => false, + 'reason' => lang('Auth.badToken'), + ]); + } + + assert($token->last_used_at instanceof Time || $token->last_used_at === null); + + // Hasn't been used in a long time + if ( + isset($token->last_used_at) + && $token->last_used_at->isBefore(Time::now()->subSeconds(config('Auth')->unusedTokenLifetime)) + ) { + return new Result([ + 'success' => false, + 'reason' => lang('Auth.oldToken'), + ]); + } + + $token->last_used_at = Time::now()->format('Y-m-d H:i:s'); + + if ($token->hasChanged()) { + $identityModel->save($token); + } + + // Ensure the token is set as the current token + $user = $token->user(); + $user->setHmacToken($token); + + return new Result([ + 'success' => true, + 'extraInfo' => $user, + ]); + } + + /** + * Checks if the user is currently logged in. + * Since AccessToken usage is inherently stateless, + * it runs $this->attempt on each usage. + */ + public function loggedIn(): bool + { + if (isset($this->user)) { + return true; + } + + /** @var IncomingRequest $request */ + $request = service('request'); + + return $this->attempt([ + 'token' => $request->getHeaderLine(config('Auth')->authenticatorHeader['tokens']), + ])->isOK(); + } + + /** + * Logs the given user in by saving them to the class. + */ + public function login(User $user): void + { + $this->user = $user; + } + + /** + * Logs a user in based on their ID. + * + * @param int|string $userId User ID + * + * @throws AuthenticationException + */ + public function loginById($userId): void + { + $user = $this->provider->findById($userId); + + if ($user === null) { + throw AuthenticationException::forInvalidUser(); + } + + $user->setHmacToken( + $user->getHmacToken($this->getHmacKeyFromToken()) + ); + + $this->login($user); + } + + /** + * Logs the current user out. + */ + public function logout(): void + { + $this->user = null; + } + + /** + * Returns the currently logged-in user. + */ + public function getUser(): ?User + { + return $this->user; + } + + /** + * Returns the Full HMAC Authorization token from the Authorization header + * + * @return ?string Trimmed Authorization Token from Header + */ + public function getFullHmacToken(): ?string + { + /** @var IncomingRequest $request */ + $request = service('request'); + + $header = $request->getHeaderLine(config('Auth')->authenticatorHeader['hmac']); + + if ($header === '') { + return null; + } + + return trim(substr($header, 11)); // 'HMAC-SHA256' + } + + /** + * Get Key and HMAC hash from Auth token + * + * @param ?string $fullToken Full Token + * + * @return ?array [key, hmacHash] + */ + public function getHmacAuthTokens(?string $fullToken = null): ?array + { + if (! isset($fullToken)) { + $fullToken = $this->getFullHmacToken(); + } + + if (isset($fullToken)) { + return preg_split('/:/', $fullToken, -1, PREG_SPLIT_NO_EMPTY); + } + + return null; + } + + /** + * Retrieve the key from the Auth token + * + * @return ?string HMAC token key + */ + public function getHmacKeyFromToken(): ?string + { + [$key, $secretKey] = $this->getHmacAuthTokens(); + + return $key; + } + + /** + * Retrieve the HMAC Hash from the Auth token + * + * @return ?string HMAC Hash + */ + public function getHmacHashFromToken(): ?string + { + [$key, $hash] = $this->getHmacAuthTokens(); + + return $hash; + } + + /** + * Updates the user's last active date. + */ + public function recordActiveDate(): void + { + if (! $this->user instanceof User) { + throw new InvalidArgumentException( + __METHOD__ . '() requires logged in user before calling.' + ); + } + + $this->user->last_active = Time::now(); + + $this->provider->updateActiveDate($this->user); + } +} diff --git a/src/Authentication/Traits/HasHmacTokens.php b/src/Authentication/Traits/HasHmacTokens.php new file mode 100644 index 000000000..435a19bd2 --- /dev/null +++ b/src/Authentication/Traits/HasHmacTokens.php @@ -0,0 +1,150 @@ +generateHmacToken($this, $name, $scopes); + } + + /** + * Delete any HMAC tokens for the given key. + */ + public function revokeHmacToken(string $key): void + { + /** @var UserIdentityModel $identityModel */ + $identityModel = model(UserIdentityModel::class); + + $identityModel->revokeHmacToken($this, $key); + } + + /** + * Revokes all HMAC tokens for this user. + */ + public function revokeAllHmacTokens(): void + { + /** @var UserIdentityModel $identityModel */ + $identityModel = model(UserIdentityModel::class); + + $identityModel->revokeAllHmacTokens($this); + } + + /** + * Retrieves all personal HMAC tokens for this user. + * + * @return AccessToken[] + */ + public function hmacTokens(): array + { + /** @var UserIdentityModel $identityModel */ + $identityModel = model(UserIdentityModel::class); + + return $identityModel->getAllHmacTokens($this); + } + + /** + * Given an HMAC Key, it will locate it within the system. + */ + public function getHmacToken(?string $key): ?AccessToken + { + if (! isset($key) || $key === '') { + return null; + } + + /** @var UserIdentityModel $identityModel */ + $identityModel = model(UserIdentityModel::class); + + return $identityModel->getHmacToken($this, $key); + } + + /** + * Given the ID, returns the given access token. + */ + public function getHmacTokenById(int $id): ?AccessToken + { + /** @var UserIdentityModel $identityModel */ + $identityModel = model(UserIdentityModel::class); + + return $identityModel->getHmacTokenById($id, $this); + } + + /** + * Determines whether the user's token grants permissions to $scope. + * First checks against $this->activeToken, which is set during + * authentication. If it hasn't been set, returns false. + */ + public function hmacTokenCan(string $scope): bool + { + if (! $this->currentHmacToken() instanceof AccessToken) { + return false; + } + + return $this->currentHmacToken()->can($scope); + } + + /** + * Determines whether the user's token does NOT grant permissions to $scope. + * First checks against $this->activeToken, which is set during + * authentication. If it hasn't been set, returns true. + */ + public function hmacTokenCant(string $scope): bool + { + if (! $this->currentHmacToken() instanceof AccessToken) { + return true; + } + + return $this->currentHmacToken()->cant($scope); + } + + /** + * Returns the current HMAC token for the user. + */ + public function currentHmacToken(): ?AccessToken + { + return $this->currentHmacToken; + } + + /** + * Sets the current active token for this user. + * + * @return $this + */ + public function setHmacToken(?AccessToken $accessToken): self + { + $this->currentHmacToken = $accessToken; + + return $this; + } +} diff --git a/src/Commands/Setup.php b/src/Commands/Setup.php index c9ced3efd..ce052b714 100644 --- a/src/Commands/Setup.php +++ b/src/Commands/Setup.php @@ -83,6 +83,7 @@ private function publishConfig(): void { $this->publishConfigAuth(); $this->publishConfigAuthGroups(); + $this->publishConfigAuthToken(); $this->setupHelper(); $this->setupRoutes(); @@ -131,6 +132,18 @@ private function publishConfigAuthGroups(): void $this->copyAndReplace($file, $replaces); } + private function publishConfigAuthToken(): void + { + $file = 'Config/AuthToken.php'; + $replaces = [ + 'namespace CodeIgniter\Shield\Config' => 'namespace Config', + 'use CodeIgniter\\Config\\BaseConfig;' => 'use CodeIgniter\\Shield\\Config\\AuthToken as ShieldAuthToken;', + 'extends BaseConfig' => 'extends ShieldAuthToken', + ]; + + $this->copyAndReplace($file, $replaces); + } + /** * Write a file, catching any exceptions and showing a * nicely formatted error. diff --git a/src/Config/Auth.php b/src/Config/Auth.php index 792a8ba83..0a3bfa374 100644 --- a/src/Config/Auth.php +++ b/src/Config/Auth.php @@ -8,6 +8,7 @@ use CodeIgniter\Shield\Authentication\Actions\ActionInterface; use CodeIgniter\Shield\Authentication\AuthenticatorInterface; use CodeIgniter\Shield\Authentication\Authenticators\AccessTokens; +use CodeIgniter\Shield\Authentication\Authenticators\HmacSha256; use CodeIgniter\Shield\Authentication\Authenticators\JWT; use CodeIgniter\Shield\Authentication\Authenticators\Session; use CodeIgniter\Shield\Authentication\Passwords\CompositionValidator; @@ -134,6 +135,7 @@ class Auth extends BaseConfig public array $authenticators = [ 'tokens' => AccessTokens::class, 'session' => Session::class, + 'hmac' => HmacSha256::class, // 'jwt' => JWT::class, ]; @@ -147,6 +149,7 @@ class Auth extends BaseConfig */ public array $authenticatorHeader = [ 'tokens' => 'Authorization', + 'hmac' => 'Authorization', ]; /** @@ -181,6 +184,7 @@ class Auth extends BaseConfig public array $authenticationChain = [ 'session', 'tokens', + 'hmac', // 'jwt', ]; diff --git a/src/Config/AuthToken.php b/src/Config/AuthToken.php new file mode 100644 index 000000000..2d8254471 --- /dev/null +++ b/src/Config/AuthToken.php @@ -0,0 +1,35 @@ + [ 'session' => SessionAuth::class, 'tokens' => TokenAuth::class, + 'hmac' => HmacAuth::class, 'chain' => ChainAuth::class, 'auth-rates' => AuthRates::class, 'group' => GroupFilter::class, diff --git a/src/Entities/User.php b/src/Entities/User.php index 2c966e424..1aff561ab 100644 --- a/src/Entities/User.php +++ b/src/Entities/User.php @@ -8,6 +8,7 @@ use CodeIgniter\I18n\Time; use CodeIgniter\Shield\Authentication\Authenticators\Session; use CodeIgniter\Shield\Authentication\Traits\HasAccessTokens; +use CodeIgniter\Shield\Authentication\Traits\HasHmacTokens; use CodeIgniter\Shield\Authorization\Traits\Authorizable; use CodeIgniter\Shield\Models\LoginModel; use CodeIgniter\Shield\Models\UserIdentityModel; @@ -28,6 +29,7 @@ class User extends Entity { use Authorizable; use HasAccessTokens; + use HasHmacTokens; use Resettable; use Activatable; use Bannable; diff --git a/src/Filters/HmacAuth.php b/src/Filters/HmacAuth.php new file mode 100644 index 000000000..f655d0264 --- /dev/null +++ b/src/Filters/HmacAuth.php @@ -0,0 +1,64 @@ +getAuthenticator(); + + helper('setting'); + + $requestParams = [ + 'token' => $request->getHeaderLine(setting('Auth.authenticatorHeader')['hmac'] ?? 'Authorization'), + 'body' => $request->getBody() ?? '', + ]; + + $result = $authenticator->attempt($requestParams); + + if (! $result->isOK() || ($arguments !== null && $arguments !== [] && $result->extraInfo()->hmacTokenCant($arguments[0]))) { + return service('response') + ->setStatusCode(Response::HTTP_UNAUTHORIZED) + ->setJSON(['message' => lang('Auth.badToken')]); + } + + if (setting('Auth.recordActiveDate')) { + $authenticator->recordActiveDate(); + } + + // Block inactive users when Email Activation is enabled + $user = $authenticator->getUser(); + if ($user !== null && ! $user->isActivated()) { + $authenticator->logout(); + + return service('response') + ->setStatusCode(Response::HTTP_FORBIDDEN) + ->setJSON(['message' => lang('Auth.activationBlocked')]); + } + + return $request; + } + + /** + * {@inheritDoc} + */ + public function after(RequestInterface $request, ResponseInterface $response, $arguments = null): void + { + } +} diff --git a/src/Models/UserIdentityModel.php b/src/Models/UserIdentityModel.php index ee19be499..45c5ee4f3 100644 --- a/src/Models/UserIdentityModel.php +++ b/src/Models/UserIdentityModel.php @@ -6,6 +6,7 @@ use CodeIgniter\I18n\Time; use CodeIgniter\Shield\Authentication\Authenticators\AccessTokens; +use CodeIgniter\Shield\Authentication\Authenticators\HmacSha256; use CodeIgniter\Shield\Authentication\Authenticators\Session; use CodeIgniter\Shield\Authentication\Passwords; use CodeIgniter\Shield\Entities\AccessToken; @@ -13,7 +14,9 @@ use CodeIgniter\Shield\Entities\UserIdentity; use CodeIgniter\Shield\Exceptions\LogicException; use CodeIgniter\Shield\Exceptions\ValidationException; +use Exception; use Faker\Generator; +use ReflectionException; class UserIdentityModel extends BaseModel { @@ -211,6 +214,140 @@ public function getAllAccessTokens(User $user): array ->findAll(); } + // HMAC + /** + * Find and Retrieve the HMAC AccessToken based on Token alone + * + * @return ?AccessToken Full HMAC Access Token object + */ + public function getHmacTokenByKey(string $key): ?AccessToken + { + return $this + ->where('type', HmacSha256::ID_TYPE_HMAC_TOKEN) + ->where('secret', $key) + ->asObject(AccessToken::class) + ->first(); + } + + /** + * Generates a new personal access token for the user. + * + * @param string $name Token name + * @param string[] $scopes Permissions the token grants + * + * @throws Exception + * @throws ReflectionException + */ + public function generateHmacToken(User $user, string $name, array $scopes = ['*']): AccessToken + { + $this->checkUserId($user); + + $return = $this->insert([ + 'type' => HmacSha256::ID_TYPE_HMAC_TOKEN, + 'user_id' => $user->id, + 'name' => $name, + 'secret' => bin2hex(random_bytes(16)), // Key + 'secret2' => bin2hex(random_bytes(config('AuthToken')->hmacSecretKeyByteSize)), // Secret Key + 'extra' => serialize($scopes), + ]); + + $this->checkQueryReturn($return); + + return $this + ->asObject(AccessToken::class) + ->find($this->getInsertID()); + } + + /** + * Retrieve Token object for selected HMAC Token. + * Note: These tokens are not hashed as they are considered shared secrets. + * + * @param User $user User Object + * @param string $key HMAC Key String + * + * @return ?AccessToken Full HMAC Access Token + */ + public function getHmacToken(User $user, string $key): ?AccessToken + { + $this->checkUserId($user); + + return $this->where('user_id', $user->id) + ->where('type', HmacSha256::ID_TYPE_HMAC_TOKEN) + ->where('secret', $key) + ->asObject(AccessToken::class) + ->first(); + } + + /** + * Given the ID, returns the given access token. + * + * @param int|string $id + * @param User $user User Object + * + * @return ?AccessToken Full HMAC Access Token + */ + public function getHmacTokenById($id, User $user): ?AccessToken + { + $this->checkUserId($user); + + return $this->where('user_id', $user->id) + ->where('type', HmacSha256::ID_TYPE_HMAC_TOKEN) + ->where('id', $id) + ->asObject(AccessToken::class) + ->first(); + } + + /** + * Retrieve all HMAC tokes for users + * + * @param User $user User object + * + * @return AccessToken[] + */ + public function getAllHmacTokens(User $user): array + { + $this->checkUserId($user); + + return $this + ->where('user_id', $user->id) + ->where('type', HmacSha256::ID_TYPE_HMAC_TOKEN) + ->orderBy($this->primaryKey) + ->asObject(AccessToken::class) + ->findAll(); + } + + /** + * Delete any HMAC tokens for the given key. + * + * @param User $user User object + * @param string $key HMAC Key + */ + public function revokeHmacToken(User $user, string $key): void + { + $this->checkUserId($user); + + $return = $this->where('user_id', $user->id) + ->where('type', HmacSha256::ID_TYPE_HMAC_TOKEN) + ->where('secret', $key) + ->delete(); + + $this->checkQueryReturn($return); + } + + /** + * Revokes all access tokens for this user. + */ + public function revokeAllHmacTokens(User $user): void + { + $this->checkUserId($user); + + $return = $this->where('user_id', $user->id) + ->where('type', HmacSha256::ID_TYPE_HMAC_TOKEN) + ->delete(); + + $this->checkQueryReturn($return); + } + /** * Used by 'magic-link'. */ @@ -351,7 +488,7 @@ public function forceMultiplePasswordReset(array $userIds): void /** * Force global password reset. * This is useful for enforcing a password reset - * for ALL users incase of a security breach. + * for ALL users in case of a security breach. */ public function forceGlobalPasswordReset(): void { diff --git a/src/Result.php b/src/Result.php index fa0ce1b37..9a3f368ca 100644 --- a/src/Result.php +++ b/src/Result.php @@ -13,7 +13,7 @@ class Result /** * Provides a simple explanation of * the error that happened. - * Typically a single sentence. + * Typically, a single sentence. */ protected ?string $reason = null; diff --git a/tests/Authentication/Authenticators/HmacAuthenticatorTest.php b/tests/Authentication/Authenticators/HmacAuthenticatorTest.php new file mode 100644 index 000000000..9ea22d19b --- /dev/null +++ b/tests/Authentication/Authenticators/HmacAuthenticatorTest.php @@ -0,0 +1,316 @@ +setProvider(model(UserModel::class)); + + config('AuthToken')->recordLoginAttempt = Auth::RECORD_LOGIN_ATTEMPT_ALL; + + /** @var HmacSha256 $authenticator */ + $authenticator = $auth->factory('hmac'); + $this->auth = $authenticator; + + Services::injectMock('events', new MockEvents()); + } + + public function testLogin(): void + { + /** @var User $user */ + $user = fake(UserModel::class); + + $this->auth->login($user); + + // Stores the user + $this->assertInstanceOf(User::class, $this->auth->getUser()); + $this->assertSame($user->id, $this->auth->getUser()->id); + } + + public function testLogout(): void + { + // this one's a little odd since it's stateless, but roll with it... + $user = fake(UserModel::class); + + $this->auth->login($user); + $this->assertNotNull($this->auth->getUser()); + + $this->auth->logout(); + $this->assertNull($this->auth->getUser()); + } + + public function testLoginByIdNoToken(): void + { + /** @var User $user */ + $user = fake(UserModel::class); + + $this->assertFalse($this->auth->loggedIn()); + + $this->auth->loginById($user->id); + + $this->assertTrue($this->auth->loggedIn()); + $this->assertNull($this->auth->getUser()->currentHmacToken()); + } + + public function testLoginByIdBadId(): void + { + fake(UserModel::class); + + $this->assertFalse($this->auth->loggedIn()); + + try { + $this->auth->loginById(0); + } catch (AuthenticationException $e) { + // Failed login + } + + $this->assertFalse($this->auth->loggedIn()); + $this->assertNull($this->auth->getUser()); + } + + public function testLoginByIdWithToken(): void + { + /** @var User $user */ + $user = fake(UserModel::class); + $token = $user->generateHmacToken('foo'); + + $rawToken = $this->generateRawHeaderToken($token->secret, $token->secret2, 'bar'); + $this->setRequestHeader($rawToken); + + $this->auth->loginById($user->id); + + $this->assertTrue($this->auth->loggedIn()); + $this->assertInstanceOf(AccessToken::class, $this->auth->getUser()->currentHmacToken()); + $this->assertSame($token->id, $this->auth->getUser()->currentHmacToken()->id); + } + + public function testLoginByIdWithMultipleTokens(): void + { + /** @var User $user */ + $user = fake(UserModel::class); + $token1 = $user->generateHmacToken('foo'); + $user->generateHmacToken('bar'); + + $this->setRequestHeader($this->generateRawHeaderToken($token1->secret, $token1->secret2, 'bar')); + + $this->auth->loginById($user->id); + + $this->assertTrue($this->auth->loggedIn()); + $this->assertInstanceOf(AccessToken::class, $this->auth->getUser()->currentHmacToken()); + $this->assertSame($token1->id, $this->auth->getUser()->currentHmacToken()->id); + } + + public function testCheckNoToken(): void + { + $result = $this->auth->check([]); + + $this->assertFalse($result->isOK()); + $this->assertSame(lang('Auth.noToken', [config('Auth')->authenticatorHeader['hmac']]), $result->reason()); + } + + public function testCheckBadSignature(): void + { + $result = $this->auth->check([ + 'token' => 'abc123:lasdkjflksjdflksjdf', + 'body' => 'bar', + ]); + + $this->assertFalse($result->isOK()); + $this->assertSame(lang('Auth.badToken'), $result->reason()); + } + + public function testCheckOldToken(): void + { + /** @var User $user */ + $user = fake(UserModel::class); + /** @var UserIdentityModel $identities */ + $identities = model(UserIdentityModel::class); + $token = $user->generateHmacToken('foo'); + // CI 4.2 uses the Chicago timezone that has Daylight Saving Time, + // so subtracts 1 hour to make sure this test passes. + $token->last_used_at = Time::now()->subYears(1)->subHours(1)->subMinutes(1); + $identities->save($token); + + $result = $this->auth->check([ + 'token' => $this->generateRawHeaderToken($token->secret, $token->secret2, 'bar'), + 'body' => 'bar', + ]); + + $this->assertFalse($result->isOK()); + $this->assertSame(lang('Auth.oldToken'), $result->reason()); + } + + public function testCheckSuccess(): void + { + /** @var User $user */ + $user = fake(UserModel::class); + $token = $user->generateHmacToken('foo'); + + $this->seeInDatabase($this->tables['identities'], [ + 'user_id' => $user->id, + 'type' => 'hmac_sha256', + 'last_used_at' => null, + ]); + + $rawToken = $this->generateRawHeaderToken($token->secret, $token->secret2, 'bar'); + + $result = $this->auth->check([ + 'token' => $rawToken, + 'body' => 'bar', + ]); + + $this->assertTrue($result->isOK()); + $this->assertInstanceOf(User::class, $result->extraInfo()); + $this->assertSame($user->id, $result->extraInfo()->id); + + $updatedToken = $result->extraInfo()->currentHmacToken(); + $this->assertNotEmpty($updatedToken->last_used_at); + + // Checking token in the same second does not throw "DataException : There is no data to update." + $this->auth->check(['token' => $rawToken, 'body' => 'bar']); + } + + public function testCheckBadToken(): void + { + /** @var User $user */ + $user = fake(UserModel::class); + $token = $user->generateHmacToken('foo'); + + $this->seeInDatabase($this->tables['identities'], [ + 'user_id' => $user->id, + 'type' => 'hmac_sha256', + 'last_used_at' => null, + ]); + + $rawToken = $this->generateRawHeaderToken($token->secret, $token->secret2, 'foobar'); + + $result = $this->auth->check([ + 'token' => $rawToken, + 'body' => 'bar', + ]); + + $this->assertFalse($result->isOK()); + $this->assertSame(lang('Auth.badToken'), $result->reason()); + } + + public function testAttemptCannotFindUser(): void + { + $result = $this->auth->attempt([ + 'token' => 'abc123:lsakdjfljsdflkajsfd', + 'body' => 'bar', + ]); + + $this->assertFalse($result->isOK()); + $this->assertSame(lang('Auth.badToken'), $result->reason()); + + // A login attempt should have always been recorded + $this->seeInDatabase($this->tables['token_logins'], [ + 'id_type' => HmacSha256::ID_TYPE_HMAC_TOKEN, + 'identifier' => 'abc123:lsakdjfljsdflkajsfd', + 'success' => 0, + ]); + } + + public function testAttemptSuccess(): void + { + /** @var User $user */ + $user = fake(UserModel::class); + $token = $user->generateHmacToken('foo'); + $rawToken = $this->generateRawHeaderToken($token->secret, $token->secret2, 'bar'); + $this->setRequestHeader($rawToken); + + $result = $this->auth->attempt([ + 'token' => $rawToken, + 'body' => 'bar', + ]); + + $this->assertTrue($result->isOK()); + + $foundUser = $result->extraInfo(); + $this->assertInstanceOf(User::class, $foundUser); + $this->assertSame($user->id, $foundUser->id); + $this->assertInstanceOf(AccessToken::class, $foundUser->currentHmacToken()); + $this->assertSame($token->token, $foundUser->currentHmacToken()->token); + + // A login attempt should have been recorded + $this->seeInDatabase($this->tables['token_logins'], [ + 'id_type' => HmacSha256::ID_TYPE_HMAC_TOKEN, + 'identifier' => $rawToken, + 'success' => 1, + ]); + + // Check get key Method + $key = $this->auth->getHmacKeyFromToken(); + $this->assertSame($token->secret, $key); + + // Check get hash method + [, $hash] = explode(':', $rawToken); + $secretKey = $this->auth->getHmacHashFromToken(); + $this->assertSame($hash, $secretKey); + } + + public function testAttemptBanned(): void + { + /** @var User $user */ + $user = fake(UserModel::class); + $user->ban('Test ban.'); + + $token = $user->generateHmacToken('foo'); + $rawToken = $this->generateRawHeaderToken($token->secret, $token->secret2, 'bar'); + $this->setRequestHeader($rawToken); + + $result = $this->auth->attempt([ + 'token' => $rawToken, + 'body' => 'bar', + ]); + + $this->assertFalse($result->isOK()); + + $foundUser = $result->extraInfo(); + $this->assertNull($foundUser); + + // A login attempt should have been recorded + $this->seeInDatabase($this->tables['token_logins'], [ + 'id_type' => HmacSha256::ID_TYPE_HMAC_TOKEN, + 'identifier' => $rawToken, + 'success' => 0, + ]); + } + + protected function setRequestHeader(string $token): void + { + $request = service('request'); + $request->setHeader('Authorization', 'HMAC-SHA256 ' . $token); + } + + protected function generateRawHeaderToken(string $secret, string $secretKey, string $body): string + { + return $secret . ':' . hash_hmac('sha256', $body, $secretKey); + } +} diff --git a/tests/Authentication/Filters/HmacFilterTest.php b/tests/Authentication/Filters/HmacFilterTest.php new file mode 100644 index 000000000..4a7acb9bc --- /dev/null +++ b/tests/Authentication/Filters/HmacFilterTest.php @@ -0,0 +1,135 @@ +call('get', 'protected-route'); + + $result->assertStatus(401); + + $result = $this->get('open-route'); + $result->assertStatus(200); + $result->assertSee('Open'); + } + + public function testFilterSuccess(): void + { + /** @var User $user */ + $user = fake(UserModel::class); + $token = $user->generateHmacToken('foo'); + + $rawToken = $this->generateRawHeaderToken($token->secret, $token->secret2, ''); + $result = $this->withHeaders(['Authorization' => 'HMAC-SHA256 ' . $rawToken]) + ->get('protected-route'); + + $result->assertStatus(200); + $result->assertSee('Protected'); + + $this->assertSame($user->id, auth('hmac')->id()); + $this->assertSame($user->id, auth('hmac')->user()->id); + + // User should have the current token set. + $this->assertInstanceOf(AccessToken::class, auth('hmac')->user()->currentHmacToken()); + $this->assertSame($token->id, auth('hmac')->user()->currentHmacToken()->id); + } + + public function testFilterInvalidSignature(): void + { + /** @var User $user */ + $user = fake(UserModel::class); + $token = $user->generateHmacToken('foo'); + + $result = $this->withHeaders(['Authorization' => 'HMAC-SHA256 ' . $this->generateRawHeaderToken($token->secret, $token->secret2, 'bar')]) + ->get('protected-route'); + + $result->assertStatus(401); + } + + public function testRecordActiveDate(): void + { + /** @var User $user */ + $user = fake(UserModel::class); + $token = $user->generateHmacToken('foo'); + + $this->withHeaders(['Authorization' => 'HMAC-SHA256 ' . $this->generateRawHeaderToken($token->secret, $token->secret2, '')]) + ->get('protected-route'); + + // Last Active should be greater than 'updated_at' column + $this->assertGreaterThan(auth('hmac')->user()->updated_at, auth('hmac')->user()->last_active); + } + + public function testFiltersProtectsWithScopes(): void + { + /** @var User $user1 */ + $user1 = fake(UserModel::class); + $token1 = $user1->generateHmacToken('foo', ['users-read']); + /** @var User $user2 */ + $user2 = fake(UserModel::class); + $token2 = $user2->generateHmacToken('foo', ['users-write']); + + // User 1 should be able to access the route + $result1 = $this->withHeaders(['Authorization' => 'HMAC-SHA256 ' . $this->generateRawHeaderToken($token1->secret, $token1->secret2, '')]) + ->get('protected-user-route'); + + $result1->assertStatus(200); + // Last Active should be greater than 'updated_at' column + $this->assertGreaterThan(auth('hmac')->user()->updated_at, auth('hmac')->user()->last_active); + + // User 2 should NOT be able to access the route + $result2 = $this->withHeaders(['Authorization' => 'HMAC-SHA256 ' . $this->generateRawHeaderToken($token2->secret, $token2->secret2, '')]) + ->get('protected-user-route'); + + $result2->assertStatus(401); + } + + public function testBlocksInactiveUsers(): void + { + /** @var User $user */ + $user = fake(UserModel::class, ['active' => false]); + $token = $user->generateHmacToken('foo'); + + // Activation only required with email activation + setting('Auth.actions', ['register' => null]); + + $result = $this->withHeaders(['Authorization' => 'HMAC-SHA256 ' . $this->generateRawHeaderToken($token->secret, $token->secret2, '')]) + ->get('protected-route'); + + $result->assertStatus(200); + $result->assertSee('Protected'); + + // Now require user activation and try again + setting('Auth.actions', ['register' => '\CodeIgniter\Shield\Authentication\Actions\EmailActivator']); + + $result = $this->withHeaders(['Authorization' => 'HMAC-SHA256 ' . $this->generateRawHeaderToken($token->secret, $token->secret2, '')]) + ->get('protected-route'); + + $result->assertStatus(403); + + setting('Auth.actions', ['register' => null]); + } + + protected function generateRawHeaderToken(string $secret, string $secretKey, string $body): string + { + return $secret . ':' . hash_hmac('sha256', $body, $secretKey); + } +} diff --git a/tests/Authentication/HasHmacTokensTest.php b/tests/Authentication/HasHmacTokensTest.php new file mode 100644 index 000000000..35f0b7153 --- /dev/null +++ b/tests/Authentication/HasHmacTokensTest.php @@ -0,0 +1,151 @@ +user = fake(UserModel::class); + $this->db->table($this->tables['identities'])->truncate(); + } + + public function testGenerateHmacToken(): void + { + $token = $this->user->generateHmacToken('foo'); + + $this->assertSame('foo', $token->name); + $this->assertNull($token->expires); + + $this->assertIsString($token->secret); + $this->assertIsString($token->secret2); + + // All scopes are assigned by default via wildcard + $this->assertSame(['*'], $token->scopes); + } + + public function testHmacTokens(): void + { + // Should return empty array when none exist + $this->assertSame([], $this->user->accessTokens()); + + // Give the user a couple of access tokens + /** @var AccessToken $token1 */ + $token1 = fake( + UserIdentityModel::class, + ['user_id' => $this->user->id, 'type' => 'hmac_sha256', 'secret' => 'key1', 'secret2' => 'secretKey1'] + ); + + /** @var AccessToken $token2 */ + $token2 = fake( + UserIdentityModel::class, + ['user_id' => $this->user->id, 'type' => 'hmac_sha256', 'secret' => 'key2', 'secret2' => 'secretKey2'] + ); + + /** @var AccessToken[] $tokens */ + $tokens = $this->user->hmacTokens(); + + $this->assertCount(2, $tokens); + $this->assertSame($token1->id, $tokens[0]->id); + $this->assertSame($token1->secret, $tokens[0]->secret); // Key + $this->assertSame($token1->secret2, $tokens[0]->secret2); // Secret Key + $this->assertSame($token2->id, $tokens[1]->id); + $this->assertSame($token2->secret, $tokens[1]->secret); + $this->assertSame($token2->secret2, $tokens[1]->secret2); + } + + public function testGetHmacToken(): void + { + // Should return null when not found + $this->assertNull($this->user->getHmacToken('foo')); + + $token = $this->user->generateHmacToken('foo'); + + $found = $this->user->getHmacToken($token->secret); + + $this->assertInstanceOf(AccessToken::class, $found); + $this->assertSame($token->id, $found->id); + $this->assertSame($token->secret, $found->secret); // Key + $this->assertSame($token->secret2, $found->secret2); // Secret Key + } + + public function testGetHmacTokenById(): void + { + // Should return null when not found + $this->assertNull($this->user->getHmacTokenById(123)); + + $token = $this->user->generateHmacToken('foo'); + $found = $this->user->getHmacTokenById($token->id); + + $this->assertInstanceOf(AccessToken::class, $found); + $this->assertSame($token->id, $found->id); + $this->assertSame($token->secret, $found->secret); // Key + $this->assertSame($token->secret2, $found->secret2); // Secret Key + } + + public function testRevokeHmacToken(): void + { + $token = $this->user->generateHmacToken('foo'); + + $this->assertCount(1, $this->user->hmacTokens()); + + $this->user->revokeHmacToken($token->secret); + + $this->assertCount(0, $this->user->hmacTokens()); + } + + public function testRevokeAllHmacTokens(): void + { + $this->user->generateHmacToken('foo'); + $this->user->generateHmacToken('foo'); + + $this->assertCount(2, $this->user->hmacTokens()); + + $this->user->revokeAllHmacTokens(); + + $this->assertCount(0, $this->user->hmacTokens()); + } + + public function testHmacTokenCanNoTokenSet(): void + { + $this->assertFalse($this->user->hmacTokenCan('foo')); + } + + public function testHmacTokenCanBasics(): void + { + $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')); + } + + public function testHmacTokenCantNoTokenSet(): void + { + $this->assertTrue($this->user->hmacTokenCant('foo')); + } + + public function testHmacTokenCant(): void + { + $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')); + } +} diff --git a/tests/Commands/SetupTest.php b/tests/Commands/SetupTest.php index 43225adf9..9a4281715 100644 --- a/tests/Commands/SetupTest.php +++ b/tests/Commands/SetupTest.php @@ -68,6 +68,10 @@ public function testRun(): void $this->assertStringContainsString('namespace Config;', $auth); $this->assertStringContainsString('use CodeIgniter\Shield\Config\Auth as ShieldAuth;', $auth); + $authToken = file_get_contents($appFolder . 'Config/AuthToken.php'); + $this->assertStringContainsString('namespace Config;', $authToken); + $this->assertStringContainsString('use CodeIgniter\Shield\Config\AuthToken as ShieldAuthToken;', $authToken); + $routes = file_get_contents($appFolder . 'Config/Routes.php'); $this->assertStringContainsString('service(\'auth\')->routes($routes);', $routes); @@ -79,6 +83,7 @@ public function testRun(): void $this->assertStringContainsString( ' Created: vfs://root/Config/Auth.php Created: vfs://root/Config/AuthGroups.php + Created: vfs://root/Config/AuthToken.php Updated: vfs://root/Controllers/BaseController.php Updated: vfs://root/Config/Routes.php Updated: We have updated file \'vfs://root/Config/Security.php\' for security reasons.',