diff --git a/README.md b/README.md index 3b148ebdd..73b4f360c 100644 --- a/README.md +++ b/README.md @@ -15,20 +15,29 @@ The primary goals for Shield are: ## Authentication Methods -Shield provides two primary methods of authentication out of the box: +Shield provides two primary methods **Session-based** and **Personal Access Codes** +of authentication out of the box. -**Session-based** +It also provides **JSON Web Tokens** authentication. + +### Session-based This is your typical email/username/password system you see everywhere. It includes a secure "remember me" functionality. This can be used for standard web applications, as well as for single page applications. Includes full controllers and basic views for all standard functionality, like registration, login, forgot password, etc. -**Personal Access Codes** +### Personal Access Codes These are much like the access codes that GitHub uses, where they are unique to a single user, and a single user 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. +### JSON Web Tokens + +JWT or JSON Web Token is a compact and self-contained way of securely transmitting +information between parties as a JSON object. It is commonly used for authentication +and authorization purposes in web applications. + ## Some Important Features * Session-based authentication (traditional email/password with remember me) diff --git a/composer.json b/composer.json index 8fb7cb750..42ee9f2d5 100644 --- a/composer.json +++ b/composer.json @@ -25,13 +25,15 @@ "codeigniter4/devkit": "^1.0", "codeigniter4/framework": "^4.2.7", "mikey179/vfsstream": "^1.6.7", - "mockery/mockery": "^1.0" + "mockery/mockery": "^1.0", + "firebase/php-jwt": "^6.4" }, "provide": { "codeigniter4/authentication-implementation": "1.0" }, "suggest": { - "ext-curl": "Required to use the password validation rule via PwnedValidator class." + "ext-curl": "Required to use the password validation rule via PwnedValidator class.", + "ext-openssl": "Required to use the JWT Authenticator." }, "minimum-stability": "dev", "prefer-stable": true, diff --git a/docs/addons/jwt.md b/docs/addons/jwt.md new file mode 100644 index 000000000..44bf4a198 --- /dev/null +++ b/docs/addons/jwt.md @@ -0,0 +1,356 @@ +# JWT Authentication + +> **Note** +> Shield now supports only JWS (Singed JWT). JWE (Encrypted JWT) is not supported. + +## What is JWT? + +JWT or JSON Web Token is a compact and self-contained way of securely transmitting +information between parties as a JSON object. It is commonly used for authentication +and authorization purposes in web applications. + +For example, when a user logs in to a web application, the server generates a JWT +token and sends it to the client. The client then includes this token in the header +of subsequent requests to the server. The server verifies the authenticity of the +token and grants access to protected resources accordingly. + +If you are not familiar with JWT, we recommend that you check out +[Introduction to JSON Web Tokens](https://jwt.io/introduction) before continuing. + +## Setup + +To use JWT Authentication, you need additional setup and configuration. + +### Manual Setup + +1. Install "firebase/php-jwt" via Composer. + + ```console + composer require firebase/php-jwt:^6.4 + ``` + +2. Copy the **AuthJWT.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. + + ```php + // new file - app/Config/AuthJWT.php + AccessTokens::class, + 'session' => Session::class, + 'jwt' => JWT::class, + ]; + ``` + + If you want to use JWT Authenticator in Authentication Chain, add `jwt`: + ```php + public array $authenticationChain = [ + 'session', + 'tokens', + 'jwt' + ]; + ``` + +## Configuration + +Configure **app/Config/AuthJWT.php** for your needs. + +### Set the Default Claims + +> **Note** +> A payload contains the actual data being transmitted, such as user ID, role, +> or expiration time. Items in a payload is called *claims*. + +Set the default payload items to the property `$defaultClaims`. + +E.g.: +```php + public array $defaultClaims = [ + 'iss' => 'https://codeigniter.com/', + ]; +``` + +The default claims will be included in all tokens issued by Shield. + +### Set Secret Key + +Set your secret key in the `$keys` property, or set it in your `.env` file. + +E.g.: +```dotenv +authjwt.keys.default.0.secret = 8XBFsF6HThIa7OV/bSynahEch+WbKrGcuiJVYPiwqPE= +``` + +It needs at least 256 bits random string. The length of the secret depends on the +algorithm we use. The default one is `HS256`, so to ensure that the hash value is +secure and not easily guessable, the secret key should be at least as long as the +hash function's output - 256 bits (32 bytes). You can get a secure random string +with the following command: + +```console +php -r 'echo base64_encode(random_bytes(32));' +``` + +> **Note** +> The secret key is used for signing and validating tokens. + +## Issuing JWTs + +To use JWT Authentication, you need a controller that issues JWTs. + +Here is a sample controller. When a client posts valid credentials (email/password), +it returns a new JWT. + +```php +// app/Config/Routes.php +$routes->post('auth/jwt', '\App\Controllers\Auth\LoginController::jwtLogin'); +``` + +```php +// app/Controllers/Auth/LoginController.php +declare(strict_types=1); + +namespace App\Controllers\Auth; + +use App\Controllers\BaseController; +use CodeIgniter\API\ResponseTrait; +use CodeIgniter\HTTP\ResponseInterface; +use CodeIgniter\Shield\Authentication\Authenticators\Session; +use CodeIgniter\Shield\Authentication\JWTManager; +use CodeIgniter\Shield\Authentication\Passwords; +use CodeIgniter\Shield\Config\AuthSession; + +class LoginController extends BaseController +{ + use ResponseTrait; + + /** + * Authenticate Existing User and Issue JWT. + */ + public function jwtLogin(): ResponseInterface + { + // Get the validation rules + $rules = $this->getValidationRules(); + + // Validate credentials + if (! $this->validateData($this->request->getJSON(true), $rules)) { + return $this->fail( + ['errors' => $this->validator->getErrors()], + $this->codes['unauthorized'] + ); + } + + // Get the credentials for login + $credentials = $this->request->getJsonVar(setting('Auth.validFields')); + $credentials = array_filter($credentials); + $credentials['password'] = $this->request->getJsonVar('password'); + + /** @var Session $authenticator */ + $authenticator = auth('session')->getAuthenticator(); + + // Check the credentials + $result = $authenticator->check($credentials); + + // Credentials mismatch. + if (! $result->isOK()) { + // @TODO Record a failed login attempt + + return $this->failUnauthorized($result->reason()); + } + + // Credentials match. + // @TODO Record a successful login attempt + + $user = $result->extraInfo(); + + /** @var JWTManager $manager */ + $manager = service('jwtmanager'); + + // Generate JWT and return to client + $jwt = $manager->generateToken($user); + + return $this->respond([ + 'access_token' => $jwt, + ]); + } + + /** + * Returns the rules that should be used for validation. + * + * @return array|string>> + * @phpstan-return array>> + */ + protected function getValidationRules(): array + { + return setting('Validation.login') ?? [ + 'email' => [ + 'label' => 'Auth.email', + 'rules' => config(AuthSession::class)->emailValidationRules, + ], + 'password' => [ + 'label' => 'Auth.password', + 'rules' => 'required|' . Passwords::getMaxLenghtRule(), + 'errors' => [ + 'max_byte' => 'Auth.errorPasswordTooLongBytes', + ], + ], + ]; + } +} +``` + +You could send a request with the existing user's credentials by curl like this: + +```console +$ curl --location 'http://localhost:8080/auth/jwt' \ +--header 'Content-Type: application/json' \ +--data-raw '{"email" : "admin@example.jp" , "password" : "passw0rd!"}' +``` + +When making all future requests to the API, the client should send the JWT in +the `Authorization` header as a `Bearer` token. + +You could send a request with the `Authorization` header by curl like this: + +```console +curl --location --request GET 'http://localhost:8080/api/users' \ +--header 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTaGllbGQgVGVzdCBBcHAiLCJzdWIiOiIxIiwiaWF0IjoxNjgxODA1OTMwLCJleHAiOjE2ODE4MDk1MzB9.DGpOmRPOBe45whVtEOSt53qJTw_CpH0V8oMoI_gm2XI' +``` + +## Protecting Routes + +The first way to specify which routes are protected is to use the `jwt` 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 = [ + 'jwt' => ['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' => 'jwt'], static function ($routes) { + // ... +}); +$routes->get('users', 'UserController::list', ['filter' => 'jwt']); +``` + +When the filter runs, it checks the `Authorization` header for a `Bearer` value +that has the JWT. It then validates the token. If the token is valid, it can +determine the correct user, which will then be available through an `auth()->user()` +call. + +## Method References + +### Generating Signed JWTs + +#### JWT to a Specific User + +JWTs are created through the `JWTManager::generateToken()` method. +This takes a User object to give to the token as the first argument. +It can also take optional additional claims array, time to live in seconds, +a key group (an array key) in the `Config\AuthJWT::$keys`, and additional header +array: + +```php +public function generateToken( + User $user, + array $claims = [], + ?int $ttl = null, + $keyset = 'default', + ?array $headers = null +): string +``` + +The following code generates a JWT to the user. + +```php +use CodeIgniter\Shield\Authentication\JWTManager; + +/** @var JWTManager $manager */ +$manager = service('jwtmanager'); + +$user = auth()->user(); +$claims = [ + 'email' => $user->email, +]; +$jwt = $manager->generateToken($user, $claims); +``` + +It sets the `Config\AuthJWT::$defaultClaims` to the token, and adds +the `'email'` claim and the user ID in the `"sub"` (subject) claim. +It also sets `"iat"` (Issued At) and `"exp"` (Expiration Time) claims automatically +if you don't specify. + +#### Arbitrary JWT + +You can generate arbitrary JWT with the ``JWTManager::issue()`` method. + +It takes a JWT claims array, and can take time to live in seconds, a key group +(an array key) in the `Config\AuthJWT::$keys`, and additional header array: + +```php +public function issue( + array $claims, + ?int $ttl = null, + $keyset = 'default', + ?array $headers = null +): string +``` + +The following code generates a JWT. + +```php +use CodeIgniter\Shield\Authentication\JWTManager; + +/** @var JWTManager $manager */ +$manager = service('jwtmanager'); + +$payload = [ + 'user_id' => '1', + 'email' => 'admin@example.jp', +]; +$jwt = $manager->issue($payload, DAY); +``` + +It uses the `secret` and `alg` in the `Config\AuthJWT::$keys['default']`. + +It sets the `Config\AuthJWT::$defaultClaims` to the token, and sets +`"iat"` (Issued At) and `"exp"` (Expiration Time) claims automatically even if +you don't pass them. diff --git a/docs/index.md b/docs/index.md index a0fa90810..8d1103e7a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -13,6 +13,11 @@ * [Banning Users](banning_users.md) ## Guides + * [Protecting an API with Access Tokens](guides/api_tokens.md) * [Mobile Authentication with Access Tokens](guides/mobile_apps.md) * [How to Strengthen the Password](guides/strengthen_password.md) + +## Addons + +* [JWT Authentication](addons/jwt.md) diff --git a/docs/install.md b/docs/install.md index 6514e936b..74f5b3af4 100644 --- a/docs/install.md +++ b/docs/install.md @@ -196,18 +196,20 @@ your project. ``` ## Controller Filters + The [Controller Filters](https://codeigniter.com/user_guide/incoming/filters.html) you can use to protect your routes the shield provides are: ```php public $aliases = [ // ... - 'session' => \CodeIgniter\Shield\Filters\SessionAuth::class, - 'tokens' => \CodeIgniter\Shield\Filters\TokenAuth::class, - 'chain' => \CodeIgniter\Shield\Filters\ChainAuth::class, - 'auth-rates' => \CodeIgniter\Shield\Filters\AuthRates::class, - 'group' => \CodeIgniter\Shield\Filters\GroupFilter::class, - 'permission' => \CodeIgniter\Shield\Filters\PermissionFilter::class, + 'session' => \CodeIgniter\Shield\Filters\SessionAuth::class, + 'tokens' => \CodeIgniter\Shield\Filters\TokenAuth::class, + 'chain' => \CodeIgniter\Shield\Filters\ChainAuth::class, + 'auth-rates' => \CodeIgniter\Shield\Filters\AuthRates::class, + 'group' => \CodeIgniter\Shield\Filters\GroupFilter::class, + 'permission' => \CodeIgniter\Shield\Filters\PermissionFilter::class, 'force-reset' => \CodeIgniter\Shield\Filters\ForcePasswordResetFilter::class, + 'jwt' => \CodeIgniter\Shield\Filters\JWTAuth::class, ]; ``` @@ -215,6 +217,7 @@ 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. diff --git a/mkdocs.yml b/mkdocs.yml index 023b20886..1b1752483 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -54,3 +54,5 @@ nav: - guides/api_tokens.md - guides/mobile_apps.md - guides/strengthen_password.md + - Addons: + - JWT Authentication: addons/jwt.md diff --git a/src/Authentication/Authenticators/JWT.php b/src/Authentication/Authenticators/JWT.php new file mode 100644 index 000000000..c223bdbc7 --- /dev/null +++ b/src/Authentication/Authenticators/JWT.php @@ -0,0 +1,279 @@ +provider = $provider; + + $this->jwtManager = service('jwtmanager'); + $this->tokenLoginModel = model(TokenLoginModel::class); + } + + /** + * Attempts to authenticate a user with the given $credentials. + * Logs the user in with a successful check. + * + * @param array{token?: string} $credentials + */ + public function attempt(array $credentials): Result + { + $config = config(AuthJWT::class); + + /** @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 a failed login attempt. + $this->tokenLoginModel->recordLoginAttempt( + self::ID_TYPE_JWT, + $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->tokenLoginModel->recordLoginAttempt( + self::ID_TYPE_JWT, + $credentials['token'] ?? '', + false, + $ipAddress, + $userAgent, + $user->id + ); + } + + $this->user = null; + + return new Result([ + 'success' => false, + 'reason' => $user->getBanMessage() ?? lang('Auth.bannedUser'), + ]); + } + + $this->login($user); + + if ($config->recordLoginAttempt === Auth::RECORD_LOGIN_ATTEMPT_ALL) { + // Record a successful login attempt. + $this->tokenLoginModel->recordLoginAttempt( + self::ID_TYPE_JWT, + $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. + * + * @param array{token?: string} $credentials + */ + public function check(array $credentials): Result + { + if (! array_key_exists('token', $credentials) || $credentials['token'] === '') { + return new Result([ + 'success' => false, + 'reason' => lang( + 'Auth.noToken', + [config(AuthJWT::class)->authenticatorHeader] + ), + ]); + } + + // Check JWT + try { + $this->payload = $this->jwtManager->parse($credentials['token'], $this->keyset); + } catch (RuntimeException $e) { + return new Result([ + 'success' => false, + 'reason' => $e->getMessage(), + ]); + } + + $userId = $this->payload->sub ?? null; + + if ($userId === null) { + return new Result([ + 'success' => false, + 'reason' => 'Invalid JWT: no user_id', + ]); + } + + // Find User + $user = $this->provider->findById($userId); + + if ($user === null) { + return new Result([ + 'success' => false, + 'reason' => lang('Auth.invalidUser'), + ]); + } + + 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 ($this->user !== null) { + return true; + } + + /** @var IncomingRequest $request */ + $request = service('request'); + + $config = config(AuthJWT::class); + + return $this->attempt([ + 'token' => $request->getHeaderLine($config->authenticatorHeader), + ])->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 + * + * @throws AuthenticationException + */ + public function loginById($userId): void + { + $user = $this->provider->findById($userId); + + if ($user === null) { + throw AuthenticationException::forInvalidUser(); + } + + $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; + } + + /** + * 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->save($this->user); + } + + /** + * @param string $keyset The key group. The array key of Config\AuthJWT::$keys. + */ + public function setKeyset($keyset): void + { + $this->keyset = $keyset; + } + + /** + * Returns payload + */ + public function getPayload(): ?stdClass + { + return $this->payload; + } +} diff --git a/src/Authentication/JWT/Adapters/FirebaseAdapter.php b/src/Authentication/JWT/Adapters/FirebaseAdapter.php new file mode 100644 index 000000000..3a246ed3f --- /dev/null +++ b/src/Authentication/JWT/Adapters/FirebaseAdapter.php @@ -0,0 +1,156 @@ +createKeysForDecode($keyset); + + return JWT::decode($encodedToken, $keys); + } catch (InvalidArgumentException $e) { + // provided key/key-array is empty or malformed. + throw new ShieldInvalidArgumentException( + 'Invalid Keyset: "' . $keyset . '". ' . $e->getMessage(), + 0, + $e + ); + } catch (DomainException $e) { + // provided algorithm is unsupported OR + // provided key is invalid OR + // unknown error thrown in openSSL or libsodium OR + // libsodium is required but not available. + throw new ShieldLogicException('Cannot decode JWT: ' . $e->getMessage(), 0, $e); + } catch (SignatureInvalidException $e) { + // provided JWT signature verification failed. + throw InvalidTokenException::forInvalidToken($e); + } catch (BeforeValidException $e) { + // provided JWT is trying to be used before "nbf" claim OR + // provided JWT is trying to be used before "iat" claim. + throw InvalidTokenException::forBeforeValidToken($e); + } catch (ExpiredException $e) { + // provided JWT is trying to be used after "exp" claim. + throw InvalidTokenException::forExpiredToken($e); + } catch (UnexpectedValueException $e) { + // provided JWT is malformed OR + // provided JWT is missing an algorithm / using an unsupported algorithm OR + // provided JWT algorithm does not match provided key OR + // provided key ID in key/key-array is empty or invalid. + log_message( + 'error', + '[Shield] ' . class_basename($this) . '::' . __FUNCTION__ + . '(' . __LINE__ . ') ' + . get_class($e) . ': ' . $e->getMessage() + ); + + throw InvalidTokenException::forInvalidToken($e); + } + } + + /** + * Creates keys for Decode + * + * @param string $keyset + * + * @return array|Key key or key array + */ + private function createKeysForDecode($keyset) + { + $config = config(AuthJWT::class); + + $configKeys = $config->keys[$keyset]; + + if (count($configKeys) === 1) { + $key = $configKeys[0]['secret'] ?? $configKeys[0]['public']; + $algorithm = $configKeys[0]['alg']; + + return new Key($key, $algorithm); + } + + $keys = []; + + foreach ($config->keys[$keyset] as $item) { + $key = $item['secret'] ?? $item['public']; + $algorithm = $item['alg']; + + $keys[$item['kid']] = new Key($key, $algorithm); + } + + return $keys; + } + + /** + * {@inheritDoc} + */ + public function encode(array $payload, $keyset, ?array $headers = null): string + { + try { + [$key, $keyId, $algorithm] = $this->createKeysForEncode($keyset); + + return JWT::encode($payload, $key, $algorithm, $keyId, $headers); + } catch (LogicException $e) { + // errors having to do with environmental setup or malformed JWT Keys + throw new ShieldLogicException('Cannot encode JWT: ' . $e->getMessage(), 0, $e); + } catch (UnexpectedValueException $e) { + // errors having to do with JWT signature and claims + throw new ShieldLogicException('Cannot encode JWT: ' . $e->getMessage(), 0, $e); + } + } + + /** + * Creates keys for Encode + * + * @param string $keyset + */ + private function createKeysForEncode($keyset): array + { + $config = config(AuthJWT::class); + + if (isset($config->keys[$keyset][0]['secret'])) { + $key = $config->keys[$keyset][0]['secret']; + } else { + $passphrase = $config->keys[$keyset][0]['passphrase'] ?? ''; + + if ($passphrase !== '') { + $key = openssl_pkey_get_private( + $config->keys[$keyset][0]['private'], + $passphrase + ); + } else { + $key = $config->keys[$keyset][0]['private']; + } + } + + $algorithm = $config->keys[$keyset][0]['alg']; + + $keyId = $config->keys[$keyset][0]['kid'] ?? null; + if ($keyId === '') { + $keyId = null; + } + + return [$key, $keyId, $algorithm]; + } +} diff --git a/src/Authentication/JWT/Exceptions/InvalidTokenException.php b/src/Authentication/JWT/Exceptions/InvalidTokenException.php new file mode 100644 index 000000000..b3f9b5569 --- /dev/null +++ b/src/Authentication/JWT/Exceptions/InvalidTokenException.php @@ -0,0 +1,30 @@ + $payload The payload. + * @param string $keyset The key group. + * The array key of Config\AuthJWT::$keys. + * @param array|null $headers An array with header elements to attach. + * + * @return string JWT (JWS) + */ + public function encode(array $payload, $keyset, ?array $headers = null): string; + + /** + * Decode Signed JWT (JWS) + * + * @param string $keyset The key group. The array key of Config\AuthJWT::$keys. + * + * @return stdClass Payload + */ + public function decode(string $encodedToken, $keyset): stdClass; +} diff --git a/src/Authentication/JWT/JWSDecoder.php b/src/Authentication/JWT/JWSDecoder.php new file mode 100644 index 000000000..3ba548ac6 --- /dev/null +++ b/src/Authentication/JWT/JWSDecoder.php @@ -0,0 +1,33 @@ +jwsAdapter = $jwsAdapter ?? new FirebaseAdapter(); + } + + /** + * Returns payload of the JWT + * + * @param string $keyset The key group. The array key of Config\AuthJWT::$keys. + */ + public function decode(string $encodedToken, $keyset = 'default'): stdClass + { + return $this->jwsAdapter->decode($encodedToken, $keyset); + } +} diff --git a/src/Authentication/JWT/JWSEncoder.php b/src/Authentication/JWT/JWSEncoder.php new file mode 100644 index 000000000..327d5ea03 --- /dev/null +++ b/src/Authentication/JWT/JWSEncoder.php @@ -0,0 +1,67 @@ +jwsAdapter = $jwsAdapter ?? new FirebaseAdapter(); + $this->clock = $clock ?? new Time(); + } + + /** + * Issues Signed JWT (JWS) + * + * @param array $claims The payload items. + * @param int|null $ttl Time to live in seconds. + * @param string $keyset The key group. + * The array key of Config\AuthJWT::$keys. + * @param array|null $headers An array with header elements to attach. + */ + public function encode( + array $claims, + ?int $ttl = null, + $keyset = 'default', + ?array $headers = null + ): string { + assert( + (array_key_exists('exp', $claims) && ($ttl !== null)) === false, + 'Cannot pass $claims[\'exp\'] and $ttl at the same time.' + ); + + $config = config(AuthJWT::class); + + $payload = array_merge( + $config->defaultClaims, + $claims + ); + + if (! array_key_exists('iat', $claims)) { + $payload['iat'] = $this->clock->now()->getTimestamp(); + } + + if (! array_key_exists('exp', $claims)) { + $payload['exp'] = $payload['iat'] + $config->timeToLive; + } + + if ($ttl !== null) { + $payload['exp'] = $payload['iat'] + $ttl; + } + + return $this->jwsAdapter->encode( + $payload, + $keyset, + $headers + ); + } +} diff --git a/src/Authentication/JWTManager.php b/src/Authentication/JWTManager.php new file mode 100644 index 000000000..11f1dba93 --- /dev/null +++ b/src/Authentication/JWTManager.php @@ -0,0 +1,85 @@ +clock = $clock ?? new Time(); + $this->jwsEncoder = $jwsEncoder ?? new JWSEncoder(null, $this->clock); + $this->jwsDecoder = $jwsDecoder ?? new JWSDecoder(); + } + + /** + * Issues Signed JWT (JWS) for a User + * + * @param array $claims The payload items. + * @param int|null $ttl Time to live in seconds. + * @param string $keyset The key group. + * The array key of Config\AuthJWT::$keys. + * @param array|null $headers An array with header elements to attach. + */ + public function generateToken( + User $user, + array $claims = [], + ?int $ttl = null, + $keyset = 'default', + ?array $headers = null + ): string { + $payload = array_merge( + $claims, + [ + 'sub' => (string) $user->id, // subject + ], + ); + + return $this->issue($payload, $ttl, $keyset, $headers); + } + + /** + * Issues Signed JWT (JWS) + * + * @param array $claims The payload items. + * @param int|null $ttl Time to live in seconds. + * @param string $keyset The key group. + * The array key of Config\AuthJWT::$keys. + * @param array|null $headers An array with header elements to attach. + */ + public function issue( + array $claims, + ?int $ttl = null, + $keyset = 'default', + ?array $headers = null + ): string { + return $this->jwsEncoder->encode($claims, $ttl, $keyset, $headers); + } + + /** + * Returns payload of the JWT + * + * @param string $keyset The key group. The array key of Config\AuthJWT::$keys. + */ + public function parse(string $encodedToken, $keyset = 'default'): stdClass + { + return $this->jwsDecoder->decode($encodedToken, $keyset); + } +} diff --git a/src/Config/Auth.php b/src/Config/Auth.php index 6cdd5d9b8..b4d9ebae0 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\JWT; use CodeIgniter\Shield\Authentication\Authenticators\Session; use CodeIgniter\Shield\Authentication\Passwords\CompositionValidator; use CodeIgniter\Shield\Authentication\Passwords\DictionaryValidator; @@ -18,6 +19,10 @@ class Auth extends BaseConfig { + public const RECORD_LOGIN_ATTEMPT_NONE = 0; // Do not record at all + public const RECORD_LOGIN_ATTEMPT_FAILURE = 1; // Record only failures + public const RECORD_LOGIN_ATTEMPT_ALL = 2; // Record all login attempts + /** * //////////////////////////////////////////////////////////////////// * AUTHENTICATION @@ -122,6 +127,7 @@ class Auth extends BaseConfig public array $authenticators = [ 'tokens' => AccessTokens::class, 'session' => Session::class, + // 'jwt' => JWT::class, ]; /** @@ -168,6 +174,7 @@ class Auth extends BaseConfig public array $authenticationChain = [ 'session', 'tokens', + // 'jwt', ]; /** diff --git a/src/Config/AuthJWT.php b/src/Config/AuthJWT.php new file mode 100644 index 000000000..adf9bf404 --- /dev/null +++ b/src/Config/AuthJWT.php @@ -0,0 +1,87 @@ + + */ + public array $defaultClaims = [ + 'iss' => '', + ]; + + /** + * -------------------------------------------------------------------- + * The Keys + * -------------------------------------------------------------------- + * The key of the array is the key group name. + * The first key of the group is used for signing. + * + * @var array>> + * @phpstan-var array>> + */ + public array $keys = [ + 'default' => [ + // Symmetric Key + [ + 'kid' => '', // Key ID. Optional if you have only one key. + 'alg' => 'HS256', // algorithm. + // Set secret random string. Needs at least 256 bits for HS256 algorithm. + // E.g., $ php -r 'echo base64_encode(random_bytes(32));' + 'secret' => '', + ], + // Asymmetric Key + // [ + // 'kid' => '', // Key ID. Optional if you have only one key. + // 'alg' => 'RS256', // algorithm. + // 'public' => '', // Public Key + // 'private' => '', // Private Key + // 'passphrase' => '' // Passphrase + // ], + ], + ]; + + /** + * -------------------------------------------------------------------- + * Time To Live (in seconds) + * -------------------------------------------------------------------- + * Specifies the amount of time, in seconds, that a token is valid. + */ + public int $timeToLive = HOUR; + + /** + * -------------------------------------------------------------------- + * Record Login Attempts + * -------------------------------------------------------------------- + * Whether login attempts are recorded in the database. + * + * Valid values are: + * - Auth::RECORD_LOGIN_ATTEMPT_NONE + * - Auth::RECORD_LOGIN_ATTEMPT_FAILURE + * - Auth::RECORD_LOGIN_ATTEMPT_ALL + */ + public int $recordLoginAttempt = Auth::RECORD_LOGIN_ATTEMPT_FAILURE; +} diff --git a/src/Config/Registrar.php b/src/Config/Registrar.php index d3bcf8672..290b036d5 100644 --- a/src/Config/Registrar.php +++ b/src/Config/Registrar.php @@ -10,6 +10,7 @@ use CodeIgniter\Shield\Filters\ChainAuth; use CodeIgniter\Shield\Filters\ForcePasswordResetFilter; use CodeIgniter\Shield\Filters\GroupFilter; +use CodeIgniter\Shield\Filters\JWTAuth; use CodeIgniter\Shield\Filters\PermissionFilter; use CodeIgniter\Shield\Filters\SessionAuth; use CodeIgniter\Shield\Filters\TokenAuth; @@ -30,6 +31,7 @@ public static function Filters(): array 'group' => GroupFilter::class, 'permission' => PermissionFilter::class, 'force-reset' => ForcePasswordResetFilter::class, + 'jwt' => JWTAuth::class, ], ]; } diff --git a/src/Config/Services.php b/src/Config/Services.php index 2fb696176..1e002b6a0 100644 --- a/src/Config/Services.php +++ b/src/Config/Services.php @@ -6,6 +6,7 @@ use CodeIgniter\Shield\Auth; use CodeIgniter\Shield\Authentication\Authentication; +use CodeIgniter\Shield\Authentication\JWTManager; use CodeIgniter\Shield\Authentication\Passwords; use Config\Services as BaseService; @@ -36,4 +37,16 @@ public static function passwords(bool $getShared = true): Passwords return new Passwords(config('Auth')); } + + /** + * JWT Manager. + */ + public static function jwtmanager(bool $getShared = true): JWTManager + { + if ($getShared) { + return self::getSharedInstance('jwtmanager'); + } + + return new JWTManager(); + } } diff --git a/src/Filters/JWTAuth.php b/src/Filters/JWTAuth.php new file mode 100644 index 000000000..a0a6c2a7d --- /dev/null +++ b/src/Filters/JWTAuth.php @@ -0,0 +1,84 @@ +getAuthenticator(); + + $token = $this->getTokenFromHeader($request); + + $result = $authenticator->attempt(['token' => $token]); + + if (! $result->isOK()) { + return Services::response() + ->setJSON([ + 'error' => $result->reason(), + ]) + ->setStatusCode(ResponseInterface::HTTP_UNAUTHORIZED); + } + + if (setting('Auth.recordActiveDate')) { + $authenticator->recordActiveDate(); + } + } + + private function getTokenFromHeader(RequestInterface $request): string + { + assert($request instanceof IncomingRequest); + + $config = config(AuthJWT::class); + + $tokenHeader = $request->getHeaderLine( + $config->authenticatorHeader ?? 'Authorization' + ); + + if (strpos($tokenHeader, 'Bearer') === 0) { + return trim(substr($tokenHeader, 6)); + } + + return $tokenHeader; + } + + /** + * We don't have anything to do here. + * + * @param Response|ResponseInterface $response + * @param array|null $arguments + */ + public function after(RequestInterface $request, ResponseInterface $response, $arguments = null): void + { + } +} diff --git a/src/Language/de/Auth.php b/src/Language/de/Auth.php index 8e2c2476f..a681516bd 100644 --- a/src/Language/de/Auth.php +++ b/src/Language/de/Auth.php @@ -20,6 +20,10 @@ 'unableSendEmailToUser' => 'Leider gab es ein Problem beim Senden der E-Mail. Wir konnten keine E-Mail an "{0}" senden.', 'throttled' => 'Es wurden zu viele Anfragen von dieser IP-Adresse gestellt. Sie können es in {0} Sekunden erneut versuchen.', 'notEnoughPrivilege' => 'Sie haben nicht die erforderliche Berechtigung, um den gewünschten Vorgang auszuführen.', + // JWT Exceptions + 'invalidJWT' => '(To be translated) The token is invalid.', + 'expiredJWT' => '(To be translated) The token has expired.', + 'beforeValidJWT' => '(To be translated) The token is not yet available.', 'email' => 'E-Mail-Adresse', 'username' => 'Benutzername', diff --git a/src/Language/en/Auth.php b/src/Language/en/Auth.php index 306c233d5..363fd4af7 100644 --- a/src/Language/en/Auth.php +++ b/src/Language/en/Auth.php @@ -20,6 +20,10 @@ 'unableSendEmailToUser' => 'Sorry, there was a problem sending the email. We could not send an email to "{0}".', 'throttled' => 'Too many requests made from this IP address. You may try again in {0} seconds.', 'notEnoughPrivilege' => 'You do not have the necessary permission to perform the desired operation.', + // JWT Exceptions + 'invalidJWT' => 'The token is invalid.', + 'expiredJWT' => 'The token has expired.', + 'beforeValidJWT' => 'The token is not yet available.', 'email' => 'Email Address', 'username' => 'Username', diff --git a/src/Language/es/Auth.php b/src/Language/es/Auth.php index bd99cb003..2cf2c6211 100644 --- a/src/Language/es/Auth.php +++ b/src/Language/es/Auth.php @@ -20,6 +20,10 @@ 'unableSendEmailToUser' => 'Lo siento, hubo un problema al enviar el correo electrónico. No pudimos enviar un correo electrónico a "{0}".', 'throttled' => 'Se han realizado demasiadas solicitudes desde esta dirección IP. Puedes intentarlo de nuevo en {0} segundos.', 'notEnoughPrivilege' => 'No tienes los permisos necesarios para realizar la operación deseada.', + // JWT Exceptions + 'invalidJWT' => '(To be translated) The token is invalid.', + 'expiredJWT' => '(To be translated) The token has expired.', + 'beforeValidJWT' => '(To be translated) The token is not yet available.', 'email' => 'Correo Electrónico', 'username' => 'Nombre de usuario', diff --git a/src/Language/fa/Auth.php b/src/Language/fa/Auth.php index b3d8e4fe5..26d242525 100644 --- a/src/Language/fa/Auth.php +++ b/src/Language/fa/Auth.php @@ -20,6 +20,10 @@ 'unableSendEmailToUser' => 'متاسفانه, در ارسال ایمیل مشکلی پیش آمد. ما نتوانستیم ایمیلی را به "{0}" ارسال کنیم.', 'throttled' => 'درخواست های بسیار زیادی از این آدرس IP انجام شده است. می توانید بعد از {0} ثانیه دوباره امتحان کنید.', 'notEnoughPrivilege' => 'شما مجوز لازم برای انجام عملیات مورد نظر را ندارید.', + // JWT Exceptions + 'invalidJWT' => 'توکن معتبر نمی باشد.', + 'expiredJWT' => 'توکن منقضی شده است.', + 'beforeValidJWT' => 'در حال حاضر امکان استفاده از توکن وجود ندارد.', 'email' => 'آدرس ایمیل', 'username' => 'نام کاربری', diff --git a/src/Language/fr/Auth.php b/src/Language/fr/Auth.php index 50c56881a..b43a354b0 100644 --- a/src/Language/fr/Auth.php +++ b/src/Language/fr/Auth.php @@ -20,6 +20,10 @@ 'unableSendEmailToUser' => 'Désolé, il y a eu un problème lors de l\'envoi de l\'email. Nous ne pouvons pas envoyer un email à "{0}".', 'throttled' => 'Trop de requêtes faites depuis cette adresse IP. Vous pouvez réessayer dans {0} secondes.', 'notEnoughPrivilege' => 'Vous n\'avez pas l\'autorisation nécessaire pour effectuer l\'opération souhaitée.', + // JWT Exceptions + 'invalidJWT' => '(To be translated) The token is invalid.', + 'expiredJWT' => '(To be translated) The token has expired.', + 'beforeValidJWT' => '(To be translated) The token is not yet available.', 'email' => 'Adresse email', 'username' => 'Identifiant', diff --git a/src/Language/id/Auth.php b/src/Language/id/Auth.php index ada97cf22..f2be28a35 100644 --- a/src/Language/id/Auth.php +++ b/src/Language/id/Auth.php @@ -20,6 +20,10 @@ 'unableSendEmailToUser' => 'Maaf, ada masalah saat mengirim email. Kami tidak dapat mengirim email ke "{0}".', 'throttled' => 'Terlalu banyak permintaan yang dibuat dari alamat IP ini. Anda dapat mencoba lagi dalam {0} detik.', 'notEnoughPrivilege' => 'Anda tidak memiliki izin yang diperlukan untuk melakukan operasi yang diinginkan.', + // JWT Exceptions + 'invalidJWT' => '(To be translated) The token is invalid.', + 'expiredJWT' => '(To be translated) The token has expired.', + 'beforeValidJWT' => '(To be translated) The token is not yet available.', 'email' => 'Alamat Email', 'username' => 'Nama Pengguna', diff --git a/src/Language/it/Auth.php b/src/Language/it/Auth.php index f517ac988..af2b41e24 100644 --- a/src/Language/it/Auth.php +++ b/src/Language/it/Auth.php @@ -20,6 +20,10 @@ 'unableSendEmailToUser' => 'Spiacente, c\'è stato un problema inviando l\'email. Non possiamo inviare un\'email a "{0}".', 'throttled' => 'Troppe richieste effettuate da questo indirizzo IP. Potrai riprovare tra {0} secondi.', 'notEnoughPrivilege' => 'Non si dispone dell\'autorizzazione necessaria per eseguire l\'operazione desiderata.', + // JWT Exceptions + 'invalidJWT' => '(To be translated) The token is invalid.', + 'expiredJWT' => '(To be translated) The token has expired.', + 'beforeValidJWT' => '(To be translated) The token is not yet available.', 'email' => 'Indirizzo Email', 'username' => 'Nome Utente', diff --git a/src/Language/ja/Auth.php b/src/Language/ja/Auth.php index e1d4870be..8d1ec4574 100644 --- a/src/Language/ja/Auth.php +++ b/src/Language/ja/Auth.php @@ -20,6 +20,10 @@ 'unableSendEmailToUser' => '申し訳ありませんが、メールの送信に問題がありました。 "{0}"にメールを送信できませんでした。', // 'Sorry, there was a problem sending the email. We could not send an email to "{0}".', 'throttled' => 'このIPアドレスからのリクエストが多すぎます。 {0}秒後に再試行できます。', // Too many requests made from this IP address. You may try again in {0} seconds. 'notEnoughPrivilege' => '目的の操作を実行するために必要な権限がありません。', // You do not have the necessary permission to perform the desired operation. + // JWT Exceptions + 'invalidJWT' => '(To be translated) The token is invalid.', + 'expiredJWT' => '(To be translated) The token has expired.', + 'beforeValidJWT' => '(To be translated) The token is not yet available.', 'email' => 'メールアドレス', // 'Email Address', 'username' => 'ユーザー名', // 'Username', diff --git a/src/Language/pt-BR/Auth.php b/src/Language/pt-BR/Auth.php index 4a9c6cbf4..e98ff1745 100644 --- a/src/Language/pt-BR/Auth.php +++ b/src/Language/pt-BR/Auth.php @@ -20,6 +20,10 @@ 'unableSendEmailToUser' => 'Desculpe, houve um problema ao enviar o email. Não pudemos enviar um email para {0}.', 'throttled' => 'Muitas solicitações feitas a partir deste endereço IP. Você pode tentar novamente em {0} segundos.', 'notEnoughPrivilege' => 'Você não tem a permissão necessária para realizar a operação desejada.', + // JWT Exceptions + 'invalidJWT' => '(To be translated) The token is invalid.', + 'expiredJWT' => '(To be translated) The token has expired.', + 'beforeValidJWT' => '(To be translated) The token is not yet available.', 'email' => 'Endereço de Email', 'username' => 'Nome de usuário', diff --git a/src/Language/pt/Auth.php b/src/Language/pt/Auth.php index 4a9057c3e..a5cfa8492 100644 --- a/src/Language/pt/Auth.php +++ b/src/Language/pt/Auth.php @@ -20,6 +20,10 @@ 'unableSendEmailToUser' => 'Desculpe, houve um problema ao enviar o email. Não pudemos enviar um email para {0}.', 'throttled' => 'Muitas solicitações feitas a partir deste endereço IP. Pode tentar novamente em {0} segundos.', 'notEnoughPrivilege' => 'Não tem a permissão necessária para realizar a operação desejada.', + // JWT Exceptions + 'invalidJWT' => '(To be translated) The token is invalid.', + 'expiredJWT' => '(To be translated) The token has expired.', + 'beforeValidJWT' => '(To be translated) The token is not yet available.', 'email' => 'Endereço de Email', 'username' => 'Nome de utilizador', diff --git a/src/Language/sk/Auth.php b/src/Language/sk/Auth.php index 3617e0560..3424e88c4 100644 --- a/src/Language/sk/Auth.php +++ b/src/Language/sk/Auth.php @@ -20,6 +20,10 @@ 'unableSendEmailToUser' => 'Ľutujeme, pri odosielaní e-mailu sa vyskytol problém. Nepodarilo sa nám odoslať e-mail na adresu „{0}".', 'throttled' => 'Z tejto adresy IP bolo odoslaných príliš veľa žiadostí. Môžete to skúsiť znova o {0} sekúnd.', 'notEnoughPrivilege' => 'Nemáte potrebné povolenie na vykonanie požadovanej operácie.', + // JWT Exceptions + 'invalidJWT' => '(To be translated) The token is invalid.', + 'expiredJWT' => '(To be translated) The token has expired.', + 'beforeValidJWT' => '(To be translated) The token is not yet available.', 'email' => 'Emailová adresa', 'username' => 'Používateľské meno', diff --git a/src/Language/sr/Auth.php b/src/Language/sr/Auth.php index 22b2ecb5d..6c8e71f01 100644 --- a/src/Language/sr/Auth.php +++ b/src/Language/sr/Auth.php @@ -20,6 +20,10 @@ 'unableSendEmailToUser' => 'Žao nam je ali slanje email poruke nije moguće. Nismo u mogućnosti poslati poruku na "{0}".', 'throttled' => 'Preveliki broj zahteva sa vaše IP adrese. Možete pokušati ponovo za {0} secondi.', 'notEnoughPrivilege' => 'Nemate dovoljan nivo autorizacije za zahtevanu akciju.', + // JWT Exceptions + 'invalidJWT' => '(To be translated) The token is invalid.', + 'expiredJWT' => '(To be translated) The token has expired.', + 'beforeValidJWT' => '(To be translated) The token is not yet available.', 'email' => 'E-mail Adresa', 'username' => 'Korisničko ime', diff --git a/src/Language/sv-SE/Auth.php b/src/Language/sv-SE/Auth.php index 7176f650a..a37b09665 100644 --- a/src/Language/sv-SE/Auth.php +++ b/src/Language/sv-SE/Auth.php @@ -20,6 +20,10 @@ 'unableSendEmailToUser' => 'Det var inte möjligt att skicka epost. Det gick inte att skicka till "{0}".', 'throttled' => 'För många anrop från denna IP-adress. Du kan försöka igen om {0} sekunder.', 'notEnoughPrivilege' => 'Du har inte nödvändiga rättigheter för detta kommando.', + // JWT Exceptions + 'invalidJWT' => '(To be translated) The token is invalid.', + 'expiredJWT' => '(To be translated) The token has expired.', + 'beforeValidJWT' => '(To be translated) The token is not yet available.', 'email' => 'Epostadress', 'username' => 'Användarnamn', diff --git a/src/Language/tr/Auth.php b/src/Language/tr/Auth.php index 48c7a247f..57f515e31 100644 --- a/src/Language/tr/Auth.php +++ b/src/Language/tr/Auth.php @@ -20,6 +20,10 @@ 'unableSendEmailToUser' => 'Üzgünüz, e-posta gönderilirken bir sorun oluştu. "{0}" adresine e-posta gönderemedik.', 'throttled' => 'Bu IP adresinden çok fazla istek yapıldı. {0} saniye sonra tekrar deneyebilirsiniz.', 'notEnoughPrivilege' => 'İstediğiniz işlemi gerçekleştirmek için gerekli izne sahip değilsiniz.', + // JWT Exceptions + 'invalidJWT' => '(To be translated) The token is invalid.', + 'expiredJWT' => '(To be translated) The token has expired.', + 'beforeValidJWT' => '(To be translated) The token is not yet available.', 'email' => 'E-posta Adresi', 'username' => 'Kullanıcı Adı', diff --git a/tests/Authentication/Authenticators/JWTAuthenticatorTest.php b/tests/Authentication/Authenticators/JWTAuthenticatorTest.php new file mode 100644 index 000000000..5c074df12 --- /dev/null +++ b/tests/Authentication/Authenticators/JWTAuthenticatorTest.php @@ -0,0 +1,276 @@ +authenticators['jwt'] = JWT::class; + + $auth = new Authentication($config); + $auth->setProvider(\model(UserModel::class)); + + /** @var JWT $authenticator */ + $authenticator = $auth->factory('jwt'); + $this->auth = $authenticator; + + Services::injectMock('events', new MockEvents()); + } + + private function createUser(): User + { + return \fake(UserModel::class); + } + + public function testLogin(): void + { + $user = $this->createUser(); + + $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 = $this->createUser(); + + $this->auth->login($user); + $this->assertNotNull($this->auth->getUser()); + + $this->auth->logout(); + $this->assertNull($this->auth->getUser()); + } + + public function testLoginById(): void + { + $user = $this->createUser(); + + $this->assertFalse($this->auth->loggedIn()); + + $this->auth->loginById($user->id); + + $this->assertTrue($this->auth->loggedIn()); + } + + public function testLoginByIdNoUser(): void + { + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage('Unable to locate the specified user.'); + + $this->createUser(); + + $this->assertFalse($this->auth->loggedIn()); + + $this->auth->loginById(9999); + } + + public function testCheckNoToken(): void + { + $result = $this->auth->check([]); + + $this->assertFalse($result->isOK()); + $this->assertSame( + \lang('Auth.noToken', [config(AuthJWT::class)->authenticatorHeader]), + $result->reason() + ); + } + + public function testCheckBadSignatureToken(): void + { + $result = $this->auth->check(['token' => self::BAD_JWT]); + + $this->assertFalse($result->isOK()); + $this->assertSame(lang('Auth.invalidJWT'), $result->reason()); + } + + public function testCheckNoSubToken(): void + { + /** @var AuthJWT $config */ + $config = config('AuthJWT'); + $payload = [ + 'iss' => $config->defaultClaims['iss'], // issuer + ]; + $token = FirebaseJWT::encode($payload, $config->keys['default'][0]['secret'], $config->keys['default'][0]['alg']); + + $result = $this->auth->check(['token' => $token]); + + $this->assertFalse($result->isOK()); + $this->assertSame('Invalid JWT: no user_id', $result->reason()); + } + + public function testCheckOldToken(): void + { + Time::setTestNow('-1 hour'); + $token = $this->generateJWT(); + Time::setTestNow(); + + $result = $this->auth->check(['token' => $token]); + + $this->assertFalse($result->isOK()); + $this->assertSame(lang('Auth.expiredJWT'), $result->reason()); + } + + public function testCheckNoUserInDatabase(): void + { + $token = $this->generateJWT(); + + $users = \model(UserModel::class); + $users->delete(1); + + $result = $this->auth->check(['token' => $token]); + + $this->assertFalse($result->isOK()); + $this->assertSame(\lang('Auth.invalidUser'), $result->reason()); + } + + public function testCheckSuccess(): void + { + $token = $this->generateJWT(); + + $result = $this->auth->check(['token' => $token]); + + $this->assertTrue($result->isOK()); + $this->assertInstanceOf(User::class, $result->extraInfo()); + $this->assertSame(1, $result->extraInfo()->id); + } + + public function testGetPayload(): void + { + $token = $this->generateJWT(); + + $this->auth->check(['token' => $token]); + $payload = $this->auth->getPayload(); + + $this->assertSame((string) $this->user->id, $payload->sub); + /** @var AuthJWT $config */ + $config = config('AuthJWT'); + $this->assertSame($config->defaultClaims['iss'], $payload->iss); + } + + public function testAttemptBadSignatureToken(): void + { + $result = $this->auth->attempt([ + 'token' => self::BAD_JWT, + ]); + + $this->assertInstanceOf(Result::class, $result); + $this->assertFalse($result->isOK()); + $this->assertSame(lang('Auth.invalidJWT'), $result->reason()); + + // A login attempt should have always been recorded + $this->seeInDatabase('auth_token_logins', [ + 'id_type' => JWT::ID_TYPE_JWT, + 'identifier' => self::BAD_JWT, + 'success' => 0, + ]); + } + + public function testAttemptBannedUser(): void + { + $token = $this->generateJWT(); + + $this->user->ban(); + + $result = $this->auth->attempt([ + 'token' => $token, + ]); + + $this->assertInstanceOf(Result::class, $result); + $this->assertFalse($result->isOK()); + $this->assertSame(lang('Auth.bannedUser'), $result->reason()); + + // The login attempt should have been recorded + $this->seeInDatabase('auth_token_logins', [ + 'id_type' => JWT::ID_TYPE_JWT, + 'identifier' => $token, + 'success' => 0, + 'user_id' => $this->user->id, + ]); + } + + public function testAttemptSuccess(): void + { + // Change $recordLoginAttempt in Config. + /** @var AuthJWT $config */ + $config = config('AuthJWT'); + $config->recordLoginAttempt = Auth::RECORD_LOGIN_ATTEMPT_ALL; + + $token = $this->generateJWT(); + + $result = $this->auth->attempt([ + 'token' => $token, + ]); + + $this->assertInstanceOf(Result::class, $result); + $this->assertTrue($result->isOK()); + + $foundUser = $result->extraInfo(); + + $this->assertInstanceOf(User::class, $foundUser); + $this->assertSame(1, $foundUser->id); + + // A login attempt should have been recorded + $this->seeInDatabase('auth_token_logins', [ + 'id_type' => JWT::ID_TYPE_JWT, + 'identifier' => $token, + 'success' => 1, + ]); + } + + public function testRecordActiveDateNoUser(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Authentication\Authenticators\JWT::recordActiveDate() requires logged in user before calling.' + ); + + $this->auth->recordActiveDate(); + } + + /** + * @param Time|null $clock The Time object + */ + private function generateJWT(?Time $clock = null): string + { + $this->user = \fake(UserModel::class, ['id' => 1, 'username' => 'John Smith']); + + $generator = new JWTManager($clock); + + return $generator->generateToken($this->user); + } +} diff --git a/tests/Authentication/Filters/JWTFilterTest.php b/tests/Authentication/Filters/JWTFilterTest.php new file mode 100644 index 000000000..082627888 --- /dev/null +++ b/tests/Authentication/Filters/JWTFilterTest.php @@ -0,0 +1,87 @@ +authenticators['jwt'] = JWT::class; + + // Register our filter + $filterConfig = \config('Filters'); + $filterConfig->aliases['jwtAuth'] = JWTAuth::class; + Factories::injectMock('filters', 'filters', $filterConfig); + + // Add a test route that we can visit to trigger. + $routes = \service('routes'); + $routes->group('/', ['filter' => 'jwtAuth'], static function ($routes): void { + $routes->get('protected-route', static function (): void { + echo 'Protected'; + }); + }); + $routes->get('open-route', static function (): void { + echo 'Open'; + }); + $routes->get('login', 'AuthController::login', ['as' => 'login']); + Services::injectMock('routes', $routes); + } + + public function testFilterNotAuthorized(): void + { + $result = $this->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); + + $generator = new JWTManager(); + $token = $generator->generateToken($user); + + $result = $this->withHeaders(['Authorization' => 'Bearer ' . $token]) + ->get('protected-route'); + + $result->assertStatus(200); + $result->assertSee('Protected'); + + $this->assertSame($user->id, \auth('jwt')->id()); + $this->assertSame($user->id, \auth('jwt')->user()->id); + } +} diff --git a/tests/Unit/Authentication/JWT/Adapters/FirebaseAdapaterTest.php b/tests/Unit/Authentication/JWT/Adapters/FirebaseAdapaterTest.php new file mode 100644 index 000000000..9618c151e --- /dev/null +++ b/tests/Unit/Authentication/JWT/Adapters/FirebaseAdapaterTest.php @@ -0,0 +1,145 @@ +generateJWT(); + + $adapter = new FirebaseAdapter(); + + /** @var AuthJWT $config */ + $config = config('AuthJWT'); + + $key = 'default'; + $payload = $adapter->decode($token, $key); + + $this->assertSame($config->defaultClaims['iss'], $payload->iss); + $this->assertSame('1', $payload->sub); + } + + /** + * @param Time|null $clock The Time object + */ + public static function generateJWT(?Time $clock = null): string + { + /** @var User $user */ + $user = fake(UserModel::class, ['id' => 1, 'username' => 'John Smith'], false); + + $generator = new JWTManager($clock); + + return $generator->generateToken($user); + } + + public function testDecodeSignatureInvalidException(): void + { + $this->expectException(InvalidTokenException::class); + $this->expectExceptionMessage(lang('Auth.invalidJWT')); + + $adapter = new FirebaseAdapter(); + + $key = 'default'; + $token = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJJc3N1ZXIgb2YgdGhlIEpXVCIsImF1ZCI6IkF1ZGllbmNlIG9mIHRoZSBKV1QiLCJzdWIiOiIxIiwiaWF0IjoxNjUzOTkxOTg5LCJleHAiOjE2NTM5OTU1ODl9.hgOYHEcT6RGHb3po1lspTcmjrylY1Cy1IvYmHOyx0CY'; + $adapter->decode($token, $key); + } + + public function testDecodeExpiredToken(): void + { + $this->expectException(InvalidTokenException::class); + $this->expectExceptionMessage(lang('Auth.expiredJWT')); + + $adapter = new FirebaseAdapter(); + + Time::setTestNow('-1 hour'); + $token = $this->generateJWT(); + Time::setTestNow(); + + $key = 'default'; + $adapter->decode($token, $key); + } + + public function testDecodeInvalidTokenExceptionUnexpectedValueException(): void + { + $token = $this->generateJWT(); + + // Change algorithm and it makes the key invalid. + $config = config(AuthJWT::class); + $config->keys['default'][0]['alg'] = 'ES256'; + + $adapter = new FirebaseAdapter(); + + try { + $key = 'default'; + $adapter->decode($token, $key); + } catch (InvalidTokenException $e) { + $prevException = $e->getPrevious(); + + $this->assertInstanceOf(UnexpectedValueException::class, $prevException); + $this->assertSame('Incorrect key for this algorithm', $prevException->getMessage()); + + return; + } + + $this->fail('InvalidTokenException is not thrown.'); + } + + public function testDecodeInvalidArgumentException(): void + { + $this->expectException(ShieldInvalidArgumentException::class); + $this->expectExceptionMessage('Invalid Keyset: "default". Key material must not be empty'); + + $token = $this->generateJWT(); + + // Set invalid key. + $config = config(AuthJWT::class); + $config->keys['default'][0] = [ + 'alg' => '', + 'secret' => '', + ]; + + $adapter = new FirebaseAdapter(); + + $key = 'default'; + $adapter->decode($token, $key); + } + + public function testEncodeLogicExceptionLogicException(): void + { + $this->expectException(ShieldLogicException::class); + $this->expectExceptionMessage('Cannot encode JWT: Algorithm not supported'); + + // Set unsupported algorithm. + $config = config(AuthJWT::class); + $config->keys['default'][0]['alg'] = 'PS256'; + + $adapter = new FirebaseAdapter(); + + $key = 'default'; + $payload = [ + 'iss' => 'http://example.org', + 'aud' => 'http://example.com', + 'iat' => 1_356_999_524, + 'nbf' => 1_357_000_000, + ]; + $adapter->encode($payload, $key); + } +} diff --git a/tests/Unit/Authentication/JWT/JWTManagerTest.php b/tests/Unit/Authentication/JWT/JWTManagerTest.php new file mode 100644 index 000000000..7f5adfc1a --- /dev/null +++ b/tests/Unit/Authentication/JWT/JWTManagerTest.php @@ -0,0 +1,383 @@ + 1, 'username' => 'John Smith'], false); + + // Fix the current time for testing. + Time::setTestNow('now'); + + $clock = new Time(); + $manager = $this->createJWTManager($clock); + + $currentTime = $clock->now(); + + $token = $manager->generateToken($user); + + // Reset the current time. + Time::setTestNow(); + + $this->assertIsString($token); + $this->assertStringStartsWith('eyJ', $token); + + return [$token, $currentTime]; + } + + /** + * @depends testGenerateToken + */ + public function testGenerateTokenPayload(array $data): void + { + [$token, $currentTime] = $data; + + $manager = $this->createJWTManager(); + $payload = $manager->parse($token); + + $config = config(AuthJWT::class); + $expected = [ + 'iss' => $config->defaultClaims['iss'], + 'sub' => '1', + 'iat' => $currentTime->getTimestamp(), + 'exp' => $currentTime->getTimestamp() + $config->timeToLive, + ]; + $this->assertSame($expected, (array) $payload); + } + + public function testGenerateTokenAddClaims(): void + { + /** @var User $user */ + $user = fake(UserModel::class, ['id' => 1, 'username' => 'John Smith'], false); + + $manager = $this->createJWTManager(); + + $claims = [ + 'email' => 'admin@example.jp', + ]; + $token = $manager->generateToken($user, $claims); + + $this->assertIsString($token); + + $payload = $this->decodeJWT($token, 'payload'); + + $this->assertStringStartsWith('1', $payload['sub']); + $this->assertStringStartsWith('admin@example.jp', $payload['email']); + } + + public function testIssue() + { + // Fix the current time for testing. + Time::setTestNow('now'); + + $clock = new Time(); + $manager = $this->createJWTManager($clock); + + $currentTime = $clock->now(); + + $payload = [ + 'user_id' => '1', + 'email' => 'admin@example.jp', + ]; + + $token = $manager->issue($payload, DAY); + + // Reset the current time. + Time::setTestNow(); + + $this->assertIsString($token); + $this->assertStringStartsWith('eyJ', $token); + + return [$token, $currentTime]; + } + + /** + * @depends testIssue + */ + public function testIssuePayload(array $data): void + { + [$token, $currentTime] = $data; + + $manager = $this->createJWTManager(); + $payload = $manager->parse($token); + + $config = config(AuthJWT::class); + $expected = [ + 'iss' => $config->defaultClaims['iss'], + 'user_id' => '1', + 'email' => 'admin@example.jp', + 'iat' => $currentTime->getTimestamp(), + 'exp' => $currentTime->getTimestamp() + DAY, + ]; + $this->assertSame($expected, (array) $payload); + } + + public function testIssueSetKid(): void + { + $manager = $this->createJWTManager(); + + // Set kid + $config = config(AuthJWT::class); + $config->keys['default'][0]['kid'] = 'Key01'; + + $payload = [ + 'user_id' => '1', + ]; + $token = $manager->issue($payload, DAY); + + $this->assertIsString($token); + + $headers = $this->decodeJWT($token, 'header'); + $this->assertSame([ + 'typ' => 'JWT', + 'alg' => 'HS256', + 'kid' => 'Key01', + ], $headers); + } + + public function testIssueAddHeader(): void + { + $manager = $this->createJWTManager(); + + $payload = [ + 'user_id' => '1', + ]; + $headers = [ + 'extra_key' => 'extra_value', + ]; + $token = $manager->issue($payload, DAY, 'default', $headers); + + $this->assertIsString($token); + + $headers = $this->decodeJWT($token, 'header'); + $this->assertSame([ + 'extra_key' => 'extra_value', + 'typ' => 'JWT', + 'alg' => 'HS256', + ], $headers); + } + + public function testIssueWithAsymmetricKey(): void + { + $manager = $this->createJWTManager(); + + $config = config(AuthJWT::class); + $config->keys['default'][0] = [ + 'alg' => 'RS256', // algorithm. + 'public' => '', // Public Key + 'private' => <<<'EOD' + -----BEGIN RSA PRIVATE KEY----- + MIIEowIBAAKCAQEAuzWHNM5f+amCjQztc5QTfJfzCC5J4nuW+L/aOxZ4f8J3Frew + M2c/dufrnmedsApb0By7WhaHlcqCh/ScAPyJhzkPYLae7bTVro3hok0zDITR8F6S + JGL42JAEUk+ILkPI+DONM0+3vzk6Kvfe548tu4czCuqU8BGVOlnp6IqBHhAswNMM + 78pos/2z0CjPM4tbeXqSTTbNkXRboxjU29vSopcT51koWOgiTf3C7nJUoMWZHZI5 + HqnIhPAG9yv8HAgNk6CMk2CadVHDo4IxjxTzTTqo1SCSH2pooJl9O8at6kkRYsrZ + WwsKlOFE2LUce7ObnXsYihStBUDoeBQlGG/BwQIDAQABAoIBAFtGaOqNKGwggn9k + 6yzr6GhZ6Wt2rh1Xpq8XUz514UBhPxD7dFRLpbzCrLVpzY80LbmVGJ9+1pJozyWc + VKeCeUdNwbqkr240Oe7GTFmGjDoxU+5/HX/SJYPpC8JZ9oqgEA87iz+WQX9hVoP2 + oF6EB4ckDvXmk8FMwVZW2l2/kd5mrEVbDaXKxhvUDf52iVD+sGIlTif7mBgR99/b + c3qiCnxCMmfYUnT2eh7Vv2LhCR/G9S6C3R4lA71rEyiU3KgsGfg0d82/XWXbegJW + h3QbWNtQLxTuIvLq5aAryV3PfaHlPgdgK0ft6ocU2de2FagFka3nfVEyC7IUsNTK + bq6nhAECgYEA7d/0DPOIaItl/8BWKyCuAHMss47j0wlGbBSHdJIiS55akMvnAG0M + 39y22Qqfzh1at9kBFeYeFIIU82ZLF3xOcE3z6pJZ4Dyvx4BYdXH77odo9uVK9s1l + 3T3BlMcqd1hvZLMS7dviyH79jZo4CXSHiKzc7pQ2YfK5eKxKqONeXuECgYEAyXlG + vonaus/YTb1IBei9HwaccnQ/1HRn6MvfDjb7JJDIBhNClGPt6xRlzBbSZ73c2QEC + 6Fu9h36K/HZ2qcLd2bXiNyhIV7b6tVKk+0Psoj0dL9EbhsD1OsmE1nTPyAc9XZbb + OPYxy+dpBCUA8/1U9+uiFoCa7mIbWcSQ+39gHuECgYAz82pQfct30aH4JiBrkNqP + nJfRq05UY70uk5k1u0ikLTRoVS/hJu/d4E1Kv4hBMqYCavFSwAwnvHUo51lVCr/y + xQOVYlsgnwBg2MX4+GjmIkqpSVCC8D7j/73MaWb746OIYZervQ8dbKahi2HbpsiG + 8AHcVSA/agxZr38qvWV54QKBgCD5TlDE8x18AuTGQ9FjxAAd7uD0kbXNz2vUYg9L + hFL5tyL3aAAtUrUUw4xhd9IuysRhW/53dU+FsG2dXdJu6CxHjlyEpUJl2iZu/j15 + YnMzGWHIEX8+eWRDsw/+Ujtko/B7TinGcWPz3cYl4EAOiCeDUyXnqnO1btCEUU44 + DJ1BAoGBAJuPD27ErTSVtId90+M4zFPNibFP50KprVdc8CR37BE7r8vuGgNYXmnI + RLnGP9p3pVgFCktORuYS2J/6t84I3+A17nEoB4xvhTLeAinAW/uTQOUmNicOP4Ek + 2MsLL2kHgL8bLTmvXV4FX+PXphrDKg1XxzOYn0otuoqdAQrkK4og + -----END RSA PRIVATE KEY----- + EOD, + ]; + + $payload = [ + 'user_id' => '1', + ]; + $token = $manager->issue($payload, DAY); + + $this->assertIsString($token); + + $headers = $this->decodeJWT($token, 'header'); + $this->assertSame([ + 'typ' => 'JWT', + 'alg' => 'RS256', + ], $headers); + } + + private function decodeJWT(string $token, $part): array + { + $map = [ + 'header' => 0, + 'payload' => 1, + ]; + $index = $map[$part]; + + return json_decode( + base64_decode( + str_replace( + '_', + '/', + str_replace( + '-', + '+', + explode('.', $token)[$index] + ) + ), + true + ), + true + ); + } + + public function testParseCanDecodeTokenSignedByOldKey(): void + { + $config = config(AuthJWT::class); + $config->keys['default'] = [ + [ + 'kid' => 'Key01', + 'alg' => 'HS256', // algorithm. + 'secret' => 'Key01_Secret', + ], + ]; + + // Generate token with Key01. + $manager = $this->createJWTManager(); + $payload = [ + 'user_id' => '1', + ]; + $token = $manager->issue($payload, DAY, 'default'); + + // Add new Key02. + $config->keys['default'] = [ + [ + 'kid' => 'Key02', + 'alg' => 'HS256', // algorithm. + 'secret' => 'Key02_Secret', + ], + [ + 'kid' => 'Key01', + 'alg' => 'HS256', // algorithm. + 'secret' => 'Key01_Secret', + ], + ]; + + $payload = $manager->parse($token); + + $this->assertSame('1', $payload->user_id); + } + + public function testParseCanSpecifyKey(): void + { + $config = config(AuthJWT::class); + $config->keys['mobile'] = [ + [ + 'kid' => 'Key01', + 'alg' => 'HS256', // algorithm. + 'secret' => 'Key01_Secret', + ], + ]; + + // Generate token with the mobile key. + $manager = $this->createJWTManager(); + $payload = [ + 'user_id' => '1', + ]; + $token = $manager->issue($payload, DAY, 'mobile'); + + $payload = $manager->parse($token, 'mobile'); + + $this->assertSame('1', $payload->user_id); + } + + public function testParseCanDecodeWithAsymmetricKey(): void + { + $token = $this->generateJWTWithAsymmetricKey(); + + $manager = $this->createJWTManager(); + $payload = $manager->parse($token); + + $this->assertSame('1', $payload->user_id); + } + + private function generateJWTWithAsymmetricKey(): string + { + $manager = $this->createJWTManager(); + + $config = config(AuthJWT::class); + $config->keys['default'][0] = [ + 'alg' => 'RS256', // algorithm. + 'public' => <<<'EOD' + -----BEGIN PUBLIC KEY----- + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuzWHNM5f+amCjQztc5QT + fJfzCC5J4nuW+L/aOxZ4f8J3FrewM2c/dufrnmedsApb0By7WhaHlcqCh/ScAPyJ + hzkPYLae7bTVro3hok0zDITR8F6SJGL42JAEUk+ILkPI+DONM0+3vzk6Kvfe548t + u4czCuqU8BGVOlnp6IqBHhAswNMM78pos/2z0CjPM4tbeXqSTTbNkXRboxjU29vS + opcT51koWOgiTf3C7nJUoMWZHZI5HqnIhPAG9yv8HAgNk6CMk2CadVHDo4IxjxTz + TTqo1SCSH2pooJl9O8at6kkRYsrZWwsKlOFE2LUce7ObnXsYihStBUDoeBQlGG/B + wQIDAQAB + -----END PUBLIC KEY----- + EOD, + 'private' => <<<'EOD' + -----BEGIN RSA PRIVATE KEY----- + MIIEowIBAAKCAQEAuzWHNM5f+amCjQztc5QTfJfzCC5J4nuW+L/aOxZ4f8J3Frew + M2c/dufrnmedsApb0By7WhaHlcqCh/ScAPyJhzkPYLae7bTVro3hok0zDITR8F6S + JGL42JAEUk+ILkPI+DONM0+3vzk6Kvfe548tu4czCuqU8BGVOlnp6IqBHhAswNMM + 78pos/2z0CjPM4tbeXqSTTbNkXRboxjU29vSopcT51koWOgiTf3C7nJUoMWZHZI5 + HqnIhPAG9yv8HAgNk6CMk2CadVHDo4IxjxTzTTqo1SCSH2pooJl9O8at6kkRYsrZ + WwsKlOFE2LUce7ObnXsYihStBUDoeBQlGG/BwQIDAQABAoIBAFtGaOqNKGwggn9k + 6yzr6GhZ6Wt2rh1Xpq8XUz514UBhPxD7dFRLpbzCrLVpzY80LbmVGJ9+1pJozyWc + VKeCeUdNwbqkr240Oe7GTFmGjDoxU+5/HX/SJYPpC8JZ9oqgEA87iz+WQX9hVoP2 + oF6EB4ckDvXmk8FMwVZW2l2/kd5mrEVbDaXKxhvUDf52iVD+sGIlTif7mBgR99/b + c3qiCnxCMmfYUnT2eh7Vv2LhCR/G9S6C3R4lA71rEyiU3KgsGfg0d82/XWXbegJW + h3QbWNtQLxTuIvLq5aAryV3PfaHlPgdgK0ft6ocU2de2FagFka3nfVEyC7IUsNTK + bq6nhAECgYEA7d/0DPOIaItl/8BWKyCuAHMss47j0wlGbBSHdJIiS55akMvnAG0M + 39y22Qqfzh1at9kBFeYeFIIU82ZLF3xOcE3z6pJZ4Dyvx4BYdXH77odo9uVK9s1l + 3T3BlMcqd1hvZLMS7dviyH79jZo4CXSHiKzc7pQ2YfK5eKxKqONeXuECgYEAyXlG + vonaus/YTb1IBei9HwaccnQ/1HRn6MvfDjb7JJDIBhNClGPt6xRlzBbSZ73c2QEC + 6Fu9h36K/HZ2qcLd2bXiNyhIV7b6tVKk+0Psoj0dL9EbhsD1OsmE1nTPyAc9XZbb + OPYxy+dpBCUA8/1U9+uiFoCa7mIbWcSQ+39gHuECgYAz82pQfct30aH4JiBrkNqP + nJfRq05UY70uk5k1u0ikLTRoVS/hJu/d4E1Kv4hBMqYCavFSwAwnvHUo51lVCr/y + xQOVYlsgnwBg2MX4+GjmIkqpSVCC8D7j/73MaWb746OIYZervQ8dbKahi2HbpsiG + 8AHcVSA/agxZr38qvWV54QKBgCD5TlDE8x18AuTGQ9FjxAAd7uD0kbXNz2vUYg9L + hFL5tyL3aAAtUrUUw4xhd9IuysRhW/53dU+FsG2dXdJu6CxHjlyEpUJl2iZu/j15 + YnMzGWHIEX8+eWRDsw/+Ujtko/B7TinGcWPz3cYl4EAOiCeDUyXnqnO1btCEUU44 + DJ1BAoGBAJuPD27ErTSVtId90+M4zFPNibFP50KprVdc8CR37BE7r8vuGgNYXmnI + RLnGP9p3pVgFCktORuYS2J/6t84I3+A17nEoB4xvhTLeAinAW/uTQOUmNicOP4Ek + 2MsLL2kHgL8bLTmvXV4FX+PXphrDKg1XxzOYn0otuoqdAQrkK4og + -----END RSA PRIVATE KEY----- + EOD, + ]; + + $payload = [ + 'user_id' => '1', + ]; + + return $manager->issue($payload, DAY); + } +}