diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..27b765f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +/tests export-ignore +/.github export-ignore diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0f7d23f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,25 @@ +on: + push: + # Sequence of patterns matched against refs/tags + tags: + - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 + +name: Release + +jobs: + release: + name: Release + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Create Release + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref }} + release_name: Release ${{ github.ref }} + draft: false + prerelease: false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0250909 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/vendor/ +composer.lock +*.cache +*.log +.idea/ +.DS_Store diff --git a/.php_cs b/.php_cs new file mode 100755 index 0000000..bf0a627 --- /dev/null +++ b/.php_cs @@ -0,0 +1,91 @@ +setRiskyAllowed(true) + ->setRules([ + '@PSR2' => true, + '@Symfony' => true, + '@DoctrineAnnotation' => true, + '@PhpCsFixer' => true, + 'header_comment' => [ + 'commentType' => 'PHPDoc', + 'header' => $header, + 'separate' => 'none', + 'location' => 'after_declare_strict', + ], + 'array_syntax' => [ + 'syntax' => 'short' + ], + 'list_syntax' => [ + 'syntax' => 'short' + ], + 'concat_space' => [ + 'spacing' => 'one' + ], + 'blank_line_before_statement' => [ + 'statements' => [ + 'declare', + ], + ], + 'general_phpdoc_annotation_remove' => [ + 'annotations' => [ + 'author' + ], + ], + 'ordered_imports' => [ + 'imports_order' => [ + 'class', 'function', 'const', + ], + 'sort_algorithm' => 'alpha', + ], + 'single_line_comment_style' => [ + 'comment_types' => [ + ], + ], + 'yoda_style' => [ + 'always_move_variable' => false, + 'equal' => false, + 'identical' => false, + ], + 'phpdoc_align' => [ + 'align' => 'left', + ], + 'multiline_whitespace_before_semicolons' => [ + 'strategy' => 'no_multi_line', + ], + 'constant_case' => [ + 'case' => 'lower', + ], + 'class_attributes_separation' => true, + 'combine_consecutive_unsets' => true, + 'declare_strict_types' => true, + 'linebreak_after_opening_tag' => true, + 'lowercase_static_reference' => true, + 'no_useless_else' => true, + 'no_unused_imports' => true, + 'not_operator_with_successor_space' => true, + 'not_operator_with_space' => false, + 'ordered_class_elements' => true, + 'php_unit_strict' => false, + 'phpdoc_separation' => false, + 'single_quote' => true, + 'standardize_not_equals' => true, + 'multiline_comment_opening_closing' => true, + ]) + ->setFinder( + PhpCsFixer\Finder::create() + ->exclude('bin') + ->exclude('public') + ->exclude('runtime') + ->exclude('vendor') + ->in(__DIR__) + ) + ->setUsingCache(false); diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..c1ce37a --- /dev/null +++ b/.travis.yml @@ -0,0 +1,38 @@ +language: php + +sudo: required + +matrix: + include: + - php: 7.2 + env: SW_VERSION="4.5.3RC1" + - php: 7.3 + env: SW_VERSION="4.5.3RC1" + - php: 7.4 + env: SW_VERSION="4.5.3RC1" + + allow_failures: + - php: master + +services: + - docker + +before_install: + - export PHP_MAJOR="$(`phpenv which php` -r 'echo phpversion();' | cut -d '.' -f 1)" + - export PHP_MINOR="$(`phpenv which php` -r 'echo phpversion();' | cut -d '.' -f 2)" + - echo $PHP_MAJOR + - echo $PHP_MINOR + +install: + - cd $TRAVIS_BUILD_DIR + - bash .travis/swoole.install.sh + - phpenv config-rm xdebug.ini || echo "xdebug not available" + - phpenv config-add .travis/ci.ini + +before_script: + - cd $TRAVIS_BUILD_DIR + - composer config -g process-timeout 900 && composer update + +script: + - composer analyse + - composer test \ No newline at end of file diff --git a/.travis/ci.ini b/.travis/ci.ini new file mode 100644 index 0000000..101b1e3 --- /dev/null +++ b/.travis/ci.ini @@ -0,0 +1,5 @@ +[opcache] +opcache.enable_cli=1 + +[swoole] +extension = "swoole.so" diff --git a/.travis/swoole.install.sh b/.travis/swoole.install.sh new file mode 100644 index 0000000..0067690 --- /dev/null +++ b/.travis/swoole.install.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +wget https://github.com/swoole/swoole-src/archive/v"${SW_VERSION}".tar.gz -O swoole.tar.gz +mkdir -p swoole +tar -xf swoole.tar.gz -C swoole --strip-components=1 +rm swoole.tar.gz +cd swoole || exit +phpize +./configure --enable-openssl --enable-mysqlnd --enable-http2 +make -j "$(nproc)" +make install diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b716978 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) Sean Tymon +Copyright (c) Eric Zhu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b042d4e --- /dev/null +++ b/README.md @@ -0,0 +1,294 @@ +# Hyperf JWT 组件 + +该组件基于 [`tymon/jwt-auth`](https://github.com/tymondesigns/jwt-auth ),实现了完整用于 JWT 认证的能力。 + +该组件并不直接提供身份认证的能力,你可以基于该组件提供的功能特性来实现自己的身份认证。 + +如果你不想自己动手,可以同时安装 [`hyperf-ext/auth`](https://github.com/hyperf-ext/auth) 组件来获得接近开箱即用的身份认证和授权功能。 + +## 安装 + +```shell script +composer require hyperf-ext/jwt +``` + +## 发布配置 + +```shell script +php bin/hyperf.php vendor:publish hyperf-ext/jwt +``` + +> 文件位于 `config/autoload/jwt.php`。 + +## 配置 + +```php +[ + /* + |-------------------------------------------------------------------------- + | JWT 密钥 + |-------------------------------------------------------------------------- + | + | 该密钥用于签名你的令牌,切记要在 .env 文件中设置。组件提供了一个辅助命令来完成 + | 这步操作: + | `php bin/hyperf.php gen:jwt-secret` + | + | 注意:该密钥仅用于对称算法(HMAC),RSA 和 ECDSA 使用公私钥体系(见下方)。 + | + | 注意:该值必须使用 BASE64 编码。 + | + */ + + 'secret' => env('JWT_SECRET'), + + /* + |-------------------------------------------------------------------------- + | JWT 公私钥 + |-------------------------------------------------------------------------- + | + | 你使用的算法将决定你的令牌是使用随机字符串(在 `JWT_SECRET` 中定设置)还是 + | 使用以下公钥和私钥来签名。组件提供了一个辅助命令来完成这步操作: + | `php bin/hyperf.php gen:jwt-keypair` + | + | 对称算法: + | HS256、HS384 和 HS512 使用 `JWT_SECRET`。 + | + | 非对称算法: + | RS256、RS384 和 RS512 / ES256、ES384 和 ES512 使用下面的公私钥。 + | + */ + + 'keys' => [ + /* + |-------------------------------------------------------------------------- + | 公钥 + |-------------------------------------------------------------------------- + | + | 你的公钥内容。 + | + */ + + 'public' => env('JWT_PUBLIC_KEY'), + + /* + |-------------------------------------------------------------------------- + | 私钥 + |-------------------------------------------------------------------------- + | + | 你的私钥内容。 + | + */ + + 'private' => env('JWT_PRIVATE_KEY'), + + /* + |-------------------------------------------------------------------------- + | 密码 + |-------------------------------------------------------------------------- + | + | 你的私钥的密码。不需要密码可设置为 `null`。 + | + | 注意:该值必须使用 BASE64 编码。 + | + */ + + 'passphrase' => env('JWT_PASSPHRASE'), + ], + + /* + |-------------------------------------------------------------------------- + | JWT 生存时间 + |-------------------------------------------------------------------------- + | + | 指定令牌有效的时长(以秒为单位)。默认为 1 小时。 + | + | 你可以将其设置为 `null`,以产生永不过期的令牌。某些场景下有人可能想要这种行为, + | 例如在用于手机应用的情况下。 + | 不太推荐这样做,因此请确保你有适当的体系来在必要时可以撤消令牌。 + | 注意:如果将其设置为 `null`,则应从 `required_claims` 列表中删除 `exp` 元素。 + | + */ + + 'ttl' => env('JWT_TTL', 3600), + + /* + |-------------------------------------------------------------------------- + | 刷新生存时间 + |-------------------------------------------------------------------------- + | + | 指定一个时长以在其有效期内可刷新令牌(以秒为单位)。 例如,用户可以 + | 在创建原始令牌后的 2 周内刷新该令牌,直到他们必须重新进行身份验证为止。 + | 默认为 2 周。 + | + | 你可以将其设置为 `null`,以提供无限的刷新时间。某些场景下有人可能想要这种行为, + | 而不是永不过期的令牌,例如在用于手机应用的情况下。 + | 不太推荐这样做,因此请确保你有适当的体系来在必要时可以撤消令牌。 + | + */ + + 'refresh_ttl' => env('JWT_REFRESH_TTL', 3600 * 24 * 14), + + /* + |-------------------------------------------------------------------------- + | JWT 哈希算法 + |-------------------------------------------------------------------------- + | + | 用于签名你的令牌的哈希算法。 + | + | 关于算法的详细描述可参阅 https://tools.ietf.org/html/rfc7518。 + | + | 可能的值:HS256, HS384, HS512, RS256, RS384, RS512, ES256, ES384, ES512 + | + */ + + 'algo' => env('JWT_ALGO', 'HS512'), + + /* + |-------------------------------------------------------------------------- + | 必要声明 + |-------------------------------------------------------------------------- + | + | 指定在任一令牌中必须存在的必要声明。如果在有效载荷中不存在这些声明中的任意一个, + | 则将抛出 `TokenInvalidException` 异常。 + | + */ + + 'required_claims' => [ + 'iss', + 'iat', + 'exp', + 'nbf', + 'sub', + 'jti', + ], + + /* + |-------------------------------------------------------------------------- + | 保留声明 + |-------------------------------------------------------------------------- + | + | 指定在刷新令牌时要保留的声明的键名。 + | 除了这些声明之外,`sub`、`iat` 和 `prv`(如果有)声明也将自动保留。 + | + | 注意:如果有声明不存在,则会将其忽略。 + | + */ + + 'persistent_claims' => [ + // 'foo', + // 'bar', + ], + + /* + |-------------------------------------------------------------------------- + | 锁定主题声明 + |-------------------------------------------------------------------------- + | + | 这将决定是否将一个 `prv` 声明自动添加到令牌中。 + | 此目的是确保在你拥有多个身份验证模型时,例如 `App\User` 和 `App\OtherPerson`, + | 如果两个令牌在两个不同的模型中碰巧具有相同的 ID(`sub` 声明),则我们应当防止 + | 一个身份验证请求冒充另一个身份验证请求。 + | + | 在特定情况下,你可能需要禁用该行为,例如你只有一个身份验证模型的情况下, + | 这可以减少一些令牌大小。 + | + */ + + 'lock_subject' => true, + + /* + |-------------------------------------------------------------------------- + | 时间容差 + |-------------------------------------------------------------------------- + | + | 该属性为 JWT 的时间戳类声明提供了一些时间上的容差。 + | 这意味着,如果你的某些服务器上不可避免地存在轻微的时钟偏差, + | 那么这将可以为此提供一定程度的缓冲。 + | + | 该设置适用于 `iat`、`nbf` 和 `exp`声明。 + | 以秒为单位设置该值,仅在你了解你真正需要它时才指定。 + | + */ + + 'leeway' => env('JWT_LEEWAY', 0), + + /* + |-------------------------------------------------------------------------- + | 启用黑名单 + |-------------------------------------------------------------------------- + | + | 为使令牌无效,你必须启用黑名单。 + | 如果你不想或不需要此功能,请将其设置为 `false`。 + | + */ + + 'blacklist_enabled' => env('JWT_BLACKLIST_ENABLED', true), + + /* + | ------------------------------------------------------------------------- + | 黑名单宽限期 + | ------------------------------------------------------------------------- + | + | 当使用同一个 JWT 发送多个并发请求时,由于每次请求都会重新生成令牌, + | 因此其中一些可能会失败。 + | + | 设置宽限期(以秒为单位)以防止并发请求失败。 + | + */ + + 'blacklist_grace_period' => env('JWT_BLACKLIST_GRACE_PERIOD', 0), + + /* + |-------------------------------------------------------------------------- + | 黑名单存储 + |-------------------------------------------------------------------------- + | + | 指定用于实现在黑名单中存储令牌行为的类。 + | + | 自定义存储类需要实现 `HyperfExt\Jwt\Contracts\StorageInterface` 接口。 + | + */ + + 'blacklist_storage' => HyperfExt\Jwt\Storage\HyperfCache::class, +]; +``` + +## 使用 + +如果你使用 [`hyperf-ext/auth`](https://github.com/hyperf-ext/auth) 组件,则可以忽略该部分。 + +```php +manager = $manager; + $this->jwt = $jwtFactory->make(); + } +} +``` + +可阅读上述两个类来详细了解如何使用。 diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..e572cff --- /dev/null +++ b/composer.json @@ -0,0 +1,67 @@ +{ + "name": "hyperf-ext/jwt", + "type": "library", + "license": "MIT", + "keywords": [ + "php", + "hyperf", + "auth", + "jwt" + ], + "description": "The Hyperf JWT package.", + "authors": [ + { + "name": "Eric Zhu", + "email": "eric@zhu.email" + }, + { + "name": "Sean Tymon", + "email": "tymon148@gmail.com", + "homepage": "https://tymon.xyz", + "role": "Developer" + } + ], + "autoload": { + "psr-4": { + "HyperfExt\\Jwt\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "HyperfTest\\": "tests" + } + }, + "require": { + "php": ">=7.2", + "ext-swoole": ">=4.5", + "ext-json": "*", + "ext-openssl": "*", + "hyperf/cache": "^2.0", + "hyperf/command": "^2.0", + "hyperf/config": "^2.0", + "hyperf/di": "^2.0", + "hyperf/framework": "^2.0", + "lcobucci/jwt": "^3.2", + "nesbot/carbon": "^2.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.14", + "hyperf/testing": "^2.0", + "phpstan/phpstan": "^0.12", + "swoole/ide-helper": "dev-master", + "mockery/mockery": "^1.0" + }, + "config": { + "sort-packages": true + }, + "scripts": { + "test": "co-phpunit -c phpunit.xml --colors=always", + "analyse": "phpstan analyse --memory-limit 1024M -l 0 ./src", + "cs-fix": "php-cs-fixer fix $1" + }, + "extra": { + "hyperf": { + "config": "HyperfExt\\Jwt\\ConfigProvider" + } + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..d2c615a --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,15 @@ + + + + ./tests/ + + \ No newline at end of file diff --git a/publish/jwt.php b/publish/jwt.php new file mode 100644 index 0000000..be03232 --- /dev/null +++ b/publish/jwt.php @@ -0,0 +1,246 @@ + env('JWT_SECRET'), + + /* + |-------------------------------------------------------------------------- + | JWT Authentication Keys + |-------------------------------------------------------------------------- + | + | The algorithm you are using, will determine whether your tokens are + | signed with a random string (defined in `JWT_SECRET`) or using the + | following public and private keys. A helper command is provided for this: + | `php bin/hyperf.php gen:jwt-keypair` + | + | Symmetric Algorithms: + | HS256, HS384 & HS512 will use `JWT_SECRET`. + | + | Asymmetric Algorithms: + | RS256, RS384 & RS512 / ES256, ES384 & ES512 will use the keys below. + | + */ + + 'keys' => [ + /* + |-------------------------------------------------------------------------- + | Public Key + |-------------------------------------------------------------------------- + | + | Your public key content. + | + */ + + 'public' => env('JWT_PUBLIC_KEY'), + + /* + |-------------------------------------------------------------------------- + | Private Key + |-------------------------------------------------------------------------- + | + | Your private key content. + | + */ + + 'private' => env('JWT_PRIVATE_KEY'), + + /* + |-------------------------------------------------------------------------- + | Passphrase + |-------------------------------------------------------------------------- + | + | The passphrase for your private key. Can be null if none set. + | + | Note: This value must be encoded by base64. + | + */ + + 'passphrase' => env('JWT_PASSPHRASE'), + ], + + /* + |-------------------------------------------------------------------------- + | JWT time to live + |-------------------------------------------------------------------------- + | + | Specify the length of time (in seconds) that the token will be valid for. + | Defaults to 1 hour. + | + | You can also set this to null, to yield a never expiring token. + | Some people may want this behaviour for e.g. a mobile app. + | This is not particularly recommended, so make sure you have appropriate + | systems in place to revoke the token if necessary. + | Notice: If you set this to null you should remove 'exp' element from 'required_claims' list. + | + */ + + 'ttl' => env('JWT_TTL', 3600), + + /* + |-------------------------------------------------------------------------- + | Refresh time to live + |-------------------------------------------------------------------------- + | + | Specify the length of time (in seconds) that the token can be refreshed + | within. I.E. The user can refresh their token within a 2 week window of + | the original token being created until they must re-authenticate. + | Defaults to 2 weeks. + | + | You can also set this to null, to yield an infinite refresh time. + | Some may want this instead of never expiring tokens for e.g. a mobile app. + | This is not particularly recommended, so make sure you have appropriate + | systems in place to revoke the token if necessary. + | + */ + + 'refresh_ttl' => env('JWT_REFRESH_TTL', 3600 * 24 * 14), + + /* + |-------------------------------------------------------------------------- + | JWT hashing algorithm + |-------------------------------------------------------------------------- + | + | Specify the hashing algorithm that will be used to sign the token. + | + | possible values: HS256, HS384, HS512, RS256, RS384, RS512, ES256, ES384, ES512 + | + */ + + 'algo' => env('JWT_ALGO', 'HS512'), + + /* + |-------------------------------------------------------------------------- + | Required Claims + |-------------------------------------------------------------------------- + | + | Specify the required claims that must exist in any token. + | A TokenInvalidException will be thrown if any of these claims are not + | present in the payload. + | + */ + + 'required_claims' => [ + 'iss', + 'iat', + 'exp', + 'nbf', + 'sub', + 'jti', + ], + + /* + |-------------------------------------------------------------------------- + | Persistent Claims + |-------------------------------------------------------------------------- + | + | Specify the claim keys to be persisted when refreshing a token. + | `sub` and `iat` will automatically be persisted, in + | addition to the these claims. + | + | Note: If a claim does not exist then it will be ignored. + | + */ + + 'persistent_claims' => [ + // 'foo', + // 'bar', + ], + + /* + |-------------------------------------------------------------------------- + | Lock Subject + |-------------------------------------------------------------------------- + | + | This will determine whether a `prv` claim is automatically added to + | the token. The purpose of this is to ensure that if you have multiple + | authentication models e.g. `App\User` & `App\OtherPerson`, then we + | should prevent one authentication request from impersonating another, + | if 2 tokens happen to have the same id across the 2 different models. + | + | Under specific circumstances, you may want to disable this behaviour + | e.g. if you only have one authentication model, then you would save + | a little on token size. + | + */ + + 'lock_subject' => true, + + /* + |-------------------------------------------------------------------------- + | Leeway + |-------------------------------------------------------------------------- + | + | This property gives the jwt timestamp claims some "leeway". + | Meaning that if you have any unavoidable slight clock skew on + | any of your servers then this will afford you some level of cushioning. + | + | This applies to the claims `iat`, `nbf` and `exp`. + | + | Specify in seconds - only if you know you need it. + | + */ + + 'leeway' => env('JWT_LEEWAY', 0), + + /* + |-------------------------------------------------------------------------- + | Blacklist Enabled + |-------------------------------------------------------------------------- + | + | In order to invalidate tokens, you must have the blacklist enabled. + | If you do not want or need this functionality, then set this to false. + | + */ + + 'blacklist_enabled' => env('JWT_BLACKLIST_ENABLED', true), + + /* + | ------------------------------------------------------------------------- + | Blacklist Grace Period + | ------------------------------------------------------------------------- + | + | When multiple concurrent requests are made with the same JWT, + | it is possible that some of them fail, due to token regeneration + | on every request. + | + | Set grace period in seconds to prevent parallel request failure. + | + */ + + 'blacklist_grace_period' => env('JWT_BLACKLIST_GRACE_PERIOD', 0), + + /* + |-------------------------------------------------------------------------- + | Blacklist Storage + |-------------------------------------------------------------------------- + | + | Specify the handler that is used to store tokens in the blacklist. + | + */ + + 'blacklist_storage' => HyperfExt\Jwt\Storage\HyperfCache::class, +]; diff --git a/src/Blacklist.php b/src/Blacklist.php new file mode 100644 index 0000000..1c76da9 --- /dev/null +++ b/src/Blacklist.php @@ -0,0 +1,208 @@ +storage = $storage; + $this->gracePeriod = $gracePeriod; + $this->refreshTtl = $refreshTtl; + } + + /** + * Add the token (jti claim) to the blacklist. + */ + public function add(Payload $payload): bool + { + // if there is no exp claim then add the jwt to + // the blacklist indefinitely + if (! $payload->hasKey('exp')) { + return $this->addForever($payload); + } + + // if we have already added this token to the blacklist + if (! empty($this->storage->get($this->getKey($payload)))) { + return true; + } + + $this->storage->add( + $this->getKey($payload), + ['valid_until' => $this->getGraceTimestamp()], + $this->getSecondsUntilExpired($payload) + ); + + return true; + } + + /** + * Add the token (jti claim) to the blacklist indefinitely. + */ + public function addForever(Payload $payload): bool + { + $this->storage->forever($this->getKey($payload), 'forever'); + + return true; + } + + /** + * Determine whether the token has been blacklisted. + */ + public function has(Payload $payload): bool + { + $val = $this->storage->get((string) $this->getKey($payload)); + + // exit early if the token was blacklisted forever, + if ($val === 'forever') { + return true; + } + + // check whether the expiry + grace has past + return ! empty($val) and ! Utils::isFuture($val['valid_until']); + } + + /** + * Remove the token (jti claim) from the blacklist. + */ + public function remove(Payload $payload): bool + { + return $this->storage->destroy($this->getKey($payload)); + } + + /** + * Remove all tokens from the blacklist. + */ + public function clear(): bool + { + $this->storage->flush(); + + return true; + } + + /** + * Set the grace period. + * + * @return $this + */ + public function setGracePeriod(int $gracePeriod) + { + $this->gracePeriod = (int) $gracePeriod; + + return $this; + } + + /** + * Get the grace period. + */ + public function getGracePeriod(): int + { + return $this->gracePeriod; + } + + /** + * Get the unique key held within the blacklist. + * + * @return mixed + */ + public function getKey(Payload $payload) + { + return $payload($this->key); + } + + /** + * Set the unique key held within the blacklist. + * + * @return $this + */ + public function setKey(string $key) + { + $this->key = value($key); + + return $this; + } + + /** + * Set the refresh time limit. + * + * @return $this + */ + public function setRefreshTtl(?int $refreshTtl) + { + $this->refreshTtl = $refreshTtl === null ? null : (int) $refreshTtl; + + return $this; + } + + /** + * Get the refresh time limit. + */ + public function getRefreshTtl(): ?int + { + return $this->refreshTtl; + } + + /** + * Get the number of seconds until the token expiry. + */ + protected function getSecondsUntilExpired(Payload $payload): int + { + $exp = Utils::timestamp($payload['exp']); + $iat = Utils::timestamp($payload['iat']); + + // get the latter of the two expiration dates and find + // the number of seconds until the expiration date, + // plus 1 minute to avoid overlap + return $exp->max($iat->addSeconds($this->refreshTtl))->addMinute()->diffInRealSeconds(); + } + + /** + * Get the timestamp when the blacklist comes into effect + * This defaults to immediate (0 seconds). + */ + protected function getGraceTimestamp(): int + { + return Utils::now()->addSeconds($this->gracePeriod)->getTimestamp(); + } +} diff --git a/src/Claims/AbstractClaim.php b/src/Claims/AbstractClaim.php new file mode 100644 index 0000000..a5eea70 --- /dev/null +++ b/src/Claims/AbstractClaim.php @@ -0,0 +1,152 @@ +setValue($value); + } + + /** + * Get the payload as a string. + */ + public function __toString(): string + { + return $this->toJson(); + } + + /** + * Set the claim value, and call a validate method. + * + * @param mixed $value + * + * @return $this + */ + public function setValue($value) + { + $this->value = $this->validateCreate($value); + + return $this; + } + + /** + * Get the claim value. + * + * @return mixed + */ + public function getValue() + { + return $this->value; + } + + /** + * Set the claim name. + * + * @return $this + */ + public function setName(string $name) + { + $this->name = $name; + + return $this; + } + + /** + * Get the claim name. + */ + public function getName(): string + { + return $this->name; + } + + /** + * Validate the claim in a standalone Claim context. + * + * @param mixed $value + */ + public function validateCreate($value) + { + return $value; + } + + /** + * Checks if the value matches the claim. + * + * @param mixed $value + */ + public function matches($value, bool $strict = true): bool + { + return $strict ? $this->value === $value : $this->value == $value; + } + + /** + * Convert the object into something JSON serializable. + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } + + /** + * Build a key value array comprising of the claim name and value. + */ + public function toArray(): array + { + return [$this->getName() => $this->getValue()]; + } + + /** + * Get the claim as JSON. + */ + public function toJson(int $options = JSON_UNESCAPED_SLASHES): string + { + return json_encode($this->toArray(), $options); + } + + protected function getFactory(): Factory + { + if (! empty($this->factory)) { + return $this->factory; + } + return $this->factory = ApplicationContext::getContainer()->get(ManagerInterface::class)->getClaimFactory(); + } +} diff --git a/src/Claims/Audience.php b/src/Claims/Audience.php new file mode 100644 index 0000000..f13eac7 --- /dev/null +++ b/src/Claims/Audience.php @@ -0,0 +1,21 @@ +getArrayableItems($items)); + } + + /** + * Get a Claim instance by it's unique name. + * + * @param mixed $default + */ + public function getByClaimName(string $name, ?callable $callback = null, $default = null): AbstractClaim + { + return $this->filter(function (AbstractClaim $claim) use ($name) { + return $claim->getName() === $name; + })->first($callback, $default); + } + + /** + * Validate each claim. + * + * @return $this + */ + public function validate(bool $ignoreExpired = false) + { + $this->each(function ($claim) use ($ignoreExpired) { + $claim->validate($ignoreExpired); + }); + return $this; + } + + /** + * Determine if the Collection contains all of the given keys. + * + * @param mixed $claims + */ + public function hasAllClaims($claims): bool + { + return count($claims) and (new static($claims))->diff($this->keys())->isEmpty(); + } + + /** + * Get the claims as key/val array. + */ + public function toPlainArray(): array + { + return $this->map(function (AbstractClaim $claim) { + return $claim->getValue(); + })->toArray(); + } + + /** + * {@inheritdoc} + */ + protected function getArrayableItems($items): array + { + return $this->sanitizeClaims($items); + } + + /** + * Ensure that the given claims array is keyed by the claim name. + * + * @param mixed $items + */ + private function sanitizeClaims($items): array + { + $claims = []; + foreach ($items as $key => $value) { + if (! is_string($key) and $value instanceof AbstractClaim) { + $key = $value->getName(); + } + + $claims[$key] = $value; + } + + return $claims; + } +} diff --git a/src/Claims/Custom.php b/src/Claims/Custom.php new file mode 100644 index 0000000..4d2ff4f --- /dev/null +++ b/src/Claims/Custom.php @@ -0,0 +1,28 @@ +setName($name); + } + + public function validate(bool $ignoreExpired = false): bool + { + return true; + } +} diff --git a/src/Claims/DatetimeTrait.php b/src/Claims/DatetimeTrait.php new file mode 100644 index 0000000..9877b99 --- /dev/null +++ b/src/Claims/DatetimeTrait.php @@ -0,0 +1,92 @@ +add($value); + } + + if ($value instanceof DateTimeInterface) { + $value = $value->getTimestamp(); + } + + return parent::setValue($value); + } + + /** + * {@inheritdoc} + */ + public function validateCreate($value) + { + if (! is_numeric($value)) { + throw new InvalidClaimException($this); + } + + return $value; + } + + /** + * Set the leeway in seconds. + * + * @return $this + */ + public function setLeeway(int $leeway) + { + $this->leeway = $leeway; + + return $this; + } + + /** + * Determine whether the value is in the future. + * + * @param mixed $value + */ + protected function isFuture($value): bool + { + return Utils::isFuture((int) $value, (int) $this->leeway); + } + + /** + * Determine whether the value is in the past. + * + * @param mixed $value + */ + protected function isPast($value): bool + { + return Utils::isPast((int) $value, (int) $this->leeway); + } +} diff --git a/src/Claims/Expiration.php b/src/Claims/Expiration.php new file mode 100644 index 0000000..c0e7f3a --- /dev/null +++ b/src/Claims/Expiration.php @@ -0,0 +1,28 @@ +isPast($this->getValue())) { + throw new TokenExpiredException('Token has expired'); + } + return true; + } +} diff --git a/src/Claims/Factory.php b/src/Claims/Factory.php new file mode 100644 index 0000000..1ad32bc --- /dev/null +++ b/src/Claims/Factory.php @@ -0,0 +1,185 @@ + Audience::class, + 'exp' => Expiration::class, + 'iat' => IssuedAt::class, + 'iss' => Issuer::class, + 'jti' => JwtId::class, + 'nbf' => NotBefore::class, + 'sub' => Subject::class, + ]; + + public function __construct(?int $ttl, ?int $refreshTtl, int $leeway = 0) + { + $this->setTtl($ttl); + $this->setRefreshTtl($refreshTtl); + $this->setLeeway($leeway); + } + + /** + * Get the instance of the claim when passing the name and value. + * + * @param mixed $value + */ + public function get(string $name, $value): ClaimInterface + { + if ($this->has($name)) { + $claim = make($this->classMap[$name], ['factory' => $this, 'value' => $value]); + + return method_exists($claim, 'setLeeway') ? + $claim->setLeeway($this->leeway) : + $claim; + } + + return new Custom($name, $value); + } + + /** + * Check whether the claim exists. + */ + public function has(string $name): bool + { + return array_key_exists($name, $this->classMap); + } + + /** + * Generate the initial value and return the Claim instance. + */ + public function make(string $name): ClaimInterface + { + return $this->get($name, $this->{$name}()); + } + + /** + * Add a new claim mapping. + * + * @return $this + */ + public function extend(string $name, string $classPath) + { + $this->classMap[$name] = $classPath; + + return $this; + } + + /** + * Set the token ttl (in seconds). + * + * @return $this + */ + public function setTtl(?int $ttl) + { + $this->ttl = $ttl === null ? null : (int) $ttl; + + return $this; + } + + /** + * Get the token ttl. + */ + public function getTtl(): ?int + { + return $this->ttl; + } + + /** + * Set the token refresh ttl (in seconds). + * + * @return $this + */ + public function setRefreshTtl(?int $refreshTtl) + { + $this->refreshTtl = $refreshTtl === null ? null : (int) $refreshTtl; + + return $this; + } + + /** + * Get the token refresh ttl. + */ + public function getRefreshTtl(): ?int + { + return $this->refreshTtl; + } + + /** + * Set the leeway in seconds. + * + * @return $this + */ + public function setLeeway(int $leeway) + { + $this->leeway = $leeway; + + return $this; + } + + public function iss(): string + { + return ApplicationContext::getContainer()->get(ServerRequestInterface::class)->url(); + } + + public function iat(): int + { + return time(); + } + + public function exp(): int + { + return time() + $this->getTtl(); + } + + public function nbf(): int + { + return time(); + } + + public function jti(): string + { + return Str::random(16); + } +} diff --git a/src/Claims/IssuedAt.php b/src/Claims/IssuedAt.php new file mode 100644 index 0000000..6f6cd04 --- /dev/null +++ b/src/Claims/IssuedAt.php @@ -0,0 +1,51 @@ +commonValidateCreate($value); + + if ($this->isFuture($value)) { + throw new InvalidClaimException($this); + } + + return $value; + } + + public function validate(bool $ignoreExpired = false): bool + { + if ($this->isFuture($value = $this->getValue())) { + throw new TokenInvalidException('Issued At (iat) timestamp cannot be in the future'); + } + + if ( + ($refreshTtl = $this->getFactory()->getRefreshTtl()) !== null and + $this->isPast($value + $refreshTtl) + ) { + throw new TokenExpiredException('Token has expired and can no longer be refreshed'); + } + + return true; + } +} diff --git a/src/Claims/Issuer.php b/src/Claims/Issuer.php new file mode 100644 index 0000000..65acc86 --- /dev/null +++ b/src/Claims/Issuer.php @@ -0,0 +1,21 @@ +isFuture($this->getValue())) { + throw new TokenInvalidException('Not Before (nbf) timestamp cannot be in the future'); + } + return true; + } +} diff --git a/src/Claims/Subject.php b/src/Claims/Subject.php new file mode 100644 index 0000000..f25d046 --- /dev/null +++ b/src/Claims/Subject.php @@ -0,0 +1,21 @@ + HS256::class, + 'HS384' => HS384::class, + 'HS512' => HS512::class, + 'RS256' => RS256::class, + 'RS384' => RS384::class, + 'RS512' => RS512::class, + 'ES256' => ES256::class, + 'ES384' => ES384::class, + 'ES512' => ES512::class, + ]; + + protected $asymmetric = [ + 'HS256' => false, + 'HS384' => false, + 'HS512' => false, + 'RS256' => true, + 'RS384' => true, + 'RS512' => true, + 'ES256' => true, + 'ES384' => true, + 'ES512' => true, + ]; + + /** + * The secret. + * + * @var string + */ + protected $secret; + + /** + * The array of keys. + * + * @var array + */ + protected $keys; + + /** + * The used algorithm. + * + * @var string + */ + protected $algo; + + /** + * The Signer instance. + * + * @var \Lcobucci\JWT\Signer + */ + protected $signer; + + public function __construct(string $secret, string $algo, array $keys) + { + $this->secret = $secret; + $this->algo = $algo; + $this->keys = $keys; + } + + /** + * Set the algorithm used to sign the token. + * + * @return $this + */ + public function setAlgo(string $algo) + { + $this->algo = $algo; + + return $this; + } + + /** + * Get the algorithm used to sign the token. + */ + public function getAlgo(): string + { + return $this->algo; + } + + /** + * Set the secret used to sign the token. + * + * @return $this + */ + public function setSecret(string $secret) + { + $this->secret = $secret; + + return $this; + } + + /** + * Get the secret used to sign the token. + * + * @return string + */ + public function getSecret() + { + return $this->secret; + } + + /** + * Set the keys used to sign the token. + * + * @return $this + */ + public function setKeys(array $keys) + { + $this->keys = $keys; + + return $this; + } + + /** + * Get the array of keys used to sign tokens + * with an asymmetric algorithm. + */ + public function getKeys(): array + { + return $this->keys; + } + + /** + * Get the public key used to sign tokens + * with an asymmetric algorithm. + * + * @return resource|string + */ + public function getPublicKey() + { + return Arr::get($this->keys, 'public'); + } + + /** + * Get the private key used to sign tokens + * with an asymmetric algorithm. + * + * @return resource|string + */ + public function getPrivateKey() + { + return Arr::get($this->keys, 'private'); + } + + /** + * Get the passphrase used to sign tokens + * with an asymmetric algorithm. + */ + public function getPassphrase(): ?string + { + return Arr::get($this->keys, 'passphrase'); + } + + /** + * Create a JSON Web Token. + * + * @throws \HyperfExt\Jwt\Exceptions\JwtException + */ + public function encode(array $payload): string + { + $builder = $this->getBuilder(); + + try { + foreach ($payload as $key => $value) { + $builder->withClaim($key, $value); + } + return (string) $builder->getToken($this->getSigner(), $this->getSigningKey()); + } catch (Exception $e) { + throw new JwtException('Could not create token: ' . $e->getMessage(), $e->getCode(), $e); + } + } + + /** + * Decode a JSON Web Token. + * + * @throws \HyperfExt\Jwt\Exceptions\JwtException + */ + public function decode(string $token): array + { + $parser = $this->getParser(); + + try { + $jwt = $parser->parse($token); + } catch (Exception $e) { + throw new TokenInvalidException('Could not decode token: ' . $e->getMessage(), $e->getCode(), $e); + } + + if (! $jwt->verify($this->getSigner(), $this->getVerificationKey())) { + throw new TokenInvalidException('Token Signature could not be verified.'); + } + + return (new Collection($jwt->getClaims()))->map(function ($claim) { + return is_object($claim) ? $claim->getValue() : $claim; + })->toArray(); + } + + /** + * Get the signer instance. + * + * @throws \HyperfExt\Jwt\Exceptions\JwtException + */ + protected function getSigner(): Signer + { + if ($this->signer !== null) { + return $this->signer; + } + + if (! array_key_exists($this->algo, $this->signers)) { + throw new JwtException('The given algorithm could not be found'); + } + + return $this->signer = new $this->signers[$this->algo](); + } + + /** + * Get the builder instance. + */ + protected function getBuilder(): Builder + { + return new Builder(); + } + + /** + * Get the parser instance. + */ + protected function getParser(): Parser + { + return new Parser(); + } + + /** + * Determine if the algorithm is asymmetric, and thus + * requires a public/private key combo. + */ + protected function isAsymmetric(): bool + { + return $this->asymmetric[$this->algo]; + } + + /** + * Get the key used to sign the tokens. + */ + protected function getSigningKey(): Signer\Key + { + return $this->isAsymmetric() + ? new Signer\Key($this->getPrivateKey(), $this->getPassphrase()) + : new Signer\Key($this->getSecret()); + } + + /** + * Get the key used to verify the tokens. + */ + protected function getVerificationKey(): Signer\Key + { + return $this->isAsymmetric() + ? new Signer\Key($this->getPublicKey()) + : new Signer\Key($this->getSecret()); + } +} diff --git a/src/Commands/AbstractGenCommand.php b/src/Commands/AbstractGenCommand.php new file mode 100644 index 0000000..80d9093 --- /dev/null +++ b/src/Commands/AbstractGenCommand.php @@ -0,0 +1,56 @@ +config = $config; + } + + public function configure() + { + parent::configure(); + $this->setDescription($this->description); + $this->addOption('show', 's', InputOption::VALUE_NONE, 'Display the key instead of modifying files'); + $this->addOption('always-no', null, InputOption::VALUE_NONE, 'Skip generating key if it already exists'); + $this->addOption('force', 'f', InputOption::VALUE_NONE, 'Skip confirmation when overwriting an existing key'); + } + + /** + * @param null|mixed $default + * + * @return null|mixed + */ + protected function getOption(string $name, $default = null) + { + $result = $this->input->getOption($name); + return empty($result) ? $default : $result; + } + + protected function envFilePath(): string + { + return BASE_PATH . '/.env'; + } +} diff --git a/src/Commands/GenJwtKeypairCommand.php b/src/Commands/GenJwtKeypairCommand.php new file mode 100644 index 0000000..68afb9b --- /dev/null +++ b/src/Commands/GenJwtKeypairCommand.php @@ -0,0 +1,129 @@ + ['private_key_type' => OPENSSL_KEYTYPE_RSA, 'digest_alg' => 'SHA256', 'private_key_bits' => 4096], + 'RS384' => ['private_key_type' => OPENSSL_KEYTYPE_RSA, 'digest_alg' => 'SHA384', 'private_key_bits' => 4096], + 'RS512' => ['private_key_type' => OPENSSL_KEYTYPE_RSA, 'digest_alg' => 'SHA512', 'private_key_bits' => 4096], + 'ES256' => ['private_key_type' => OPENSSL_KEYTYPE_EC, 'digest_alg' => 'SHA256', 'curve_name' => 'secp256k1'], + 'ES384' => ['private_key_type' => OPENSSL_KEYTYPE_EC, 'digest_alg' => 'SHA384', 'curve_name' => 'secp384r1'], + 'ES512' => ['private_key_type' => OPENSSL_KEYTYPE_EC, 'digest_alg' => 'SHA512', 'curve_name' => 'secp521r1'], + ]; + + public function handle() + { + [, $config] = $this->choiceAlgorithm(); + $passphrase = $this->setPassphrase(); + + [$privateKey, $publicKey] = $this->generateKeypair($config, $passphrase); + + if (! empty($passphrase)) { + $passphrase = base64_encode($passphrase); + } + + if ($this->getOption('show')) { + $this->displayKey($privateKey, $publicKey, $passphrase); + return; + } + + if (file_exists($path = $this->envFilePath()) === false) { + $this->displayKey($privateKey, $publicKey, $passphrase); + return; + } + + if (Str::contains(file_get_contents($path), ['JWT_PRIVATE_KEY', 'JWT_PUBLIC_KEY', 'JWT_PASSPHRASE'])) { + if ($this->getOption('always-no')) { + $this->comment('The key pair or some part of it already exists. Skipping...'); + return; + } + + if ($this->isConfirmed() === false) { + $this->comment('Phew... No changes were made to your key pair.'); + return; + } + + $force = true; + } else { + $force = false; + } + + foreach (['privateKey', 'publicKey', 'passphrase'] as $name) { + $this->writeEnv($path, $name, ${$name}, $force); + } + + $this->info('JWT key pair set successfully.'); + } + + protected function writeEnv(string $path, string $name, ?string $value, bool $force) + { + $envKey = 'JWT_' . Str::upper(Str::snake($name)); + $envValue = empty($value) ? '(null)' : '"' . str_replace("\n", '\\n', $value) . '"'; + + if (Str::contains(file_get_contents($path), $envKey) === false) { + file_put_contents($path, "{$envKey}={$envValue}\n", FILE_APPEND); + } elseif ($force) { + file_put_contents($path, preg_replace( + "~{$envKey}=[^\n]*~", + "{$envKey}={$envValue}", + file_get_contents($path) + )); + } + } + + protected function choiceAlgorithm(): array + { + $algo = $this->choice('Select algorithm', array_keys($this->configs)); + return [$algo, $this->configs[$algo]]; + } + + protected function setPassphrase(): ?string + { + $random = $this->choice('Use random passphrase', ['Yes', 'No']); + if ($random === 'Yes') { + return random_bytes(16); + } + return $this->ask('Set passphrase (can be empty)'); + } + + protected function generateKeypair(array $config, ?string $passphrase = null): array + { + $res = openssl_pkey_new($config); + openssl_pkey_export($res, $privateKey, $passphrase); + $publicKey = openssl_pkey_get_details($res)['key']; + return [$privateKey, $publicKey]; + } + + protected function isConfirmed(): bool + { + return $this->getOption('force') ? true : $this->confirm( + 'Are you sure you want to override the key pair? This will invalidate all existing tokens.' + ); + } + + protected function displayKey(string $privateKey, string $publicKey, ?string $passphrase): void + { + $this->info('Private Key:'); + $this->comment($privateKey); + $this->info('Public Key:'); + $this->comment($publicKey); + $this->info('Passphrase (base64 encoded):'); + $this->comment(empty($passphrase) ? 'No Passphrase' : $passphrase); + } +} diff --git a/src/Commands/GenJwtSecretCommand.php b/src/Commands/GenJwtSecretCommand.php new file mode 100644 index 0000000..dddf156 --- /dev/null +++ b/src/Commands/GenJwtSecretCommand.php @@ -0,0 +1,69 @@ +getOption('show')) { + $this->comment($key); + return; + } + + if (file_exists($path = $this->envFilePath()) === false) { + $this->displayKey($key); + return; + } + + if (Str::contains(file_get_contents($path), 'JWT_SECRET') === false) { + file_put_contents($path, "\nJWT_SECRET={$key}\n", FILE_APPEND); + } else { + if ($this->getOption('always-no')) { + $this->comment('Secret key already exists. Skipping...'); + return; + } + + if ($this->isConfirmed() === false) { + $this->comment('Phew... No changes were made to your secret key.'); + return; + } + + file_put_contents($path, preg_replace( + "~JWT_SECRET=[^\n]*~", + "JWT_SECRET=\"{$key}\"", + file_get_contents($path) + )); + } + + $this->displayKey($key); + } + + protected function displayKey(string $key): void + { + $this->info("JWT secret [{$key}] (base64 encoded) set successfully."); + } + + protected function isConfirmed(): bool + { + return $this->getOption('force') ? true : $this->confirm( + 'Are you sure you want to override the key? This will invalidate all existing tokens.' + ); + } +} diff --git a/src/ConfigProvider.php b/src/ConfigProvider.php new file mode 100644 index 0000000..425861d --- /dev/null +++ b/src/ConfigProvider.php @@ -0,0 +1,50 @@ + [ + ManagerInterface::class => ManagerFactory::class, + TokenValidatorInterface::class => TokenValidator::class, + PayloadValidatorInterface::class => PayloadValidator::class, + RequestParserInterface::class => RequestParserFactory::class, + JwtFactoryInterface::class => JwtFactory::class, + ], + 'commands' => [ + GenJwtSecretCommand::class, + GenJwtKeypairCommand::class, + ], + 'publish' => [ + [ + 'id' => 'config', + 'description' => 'The config for HyperfExt\\Jwt.', + 'source' => __DIR__ . '/../publish/jwt.php', + 'destination' => BASE_PATH . '/config/autoload/jwt.php', + ], + ], + ]; + } +} diff --git a/src/Contracts/ClaimInterface.php b/src/Contracts/ClaimInterface.php new file mode 100644 index 0000000..65b4bb8 --- /dev/null +++ b/src/Contracts/ClaimInterface.php @@ -0,0 +1,49 @@ +customClaims = $customClaims; + + return $this; + } + + /** + * Get the custom claims. + */ + public function getCustomClaims(): array + { + return $this->customClaims; + } +} diff --git a/src/Exceptions/InvalidClaimException.php b/src/Exceptions/InvalidClaimException.php new file mode 100644 index 0000000..2b1cd93 --- /dev/null +++ b/src/Exceptions/InvalidClaimException.php @@ -0,0 +1,27 @@ +getName() . ']', $code, $previous); + } +} diff --git a/src/Exceptions/InvalidConfigException.php b/src/Exceptions/InvalidConfigException.php new file mode 100644 index 0000000..42b93ea --- /dev/null +++ b/src/Exceptions/InvalidConfigException.php @@ -0,0 +1,15 @@ +manager = $manager; + $this->requestParser = $requestParser; + $this->request = $request; + } + + /** + * Magically call the Jwt Manager. + * + * @throws \BadMethodCallException + * + * @return mixed + */ + public function __call(string $method, array $parameters) + { + if (method_exists($this->manager, $method)) { + return call_user_func_array([$this->manager, $method], $parameters); + } + + throw new BadMethodCallException("Method [{$method}] does not exist."); + } + + /** + * Generate a token for a given subject. + */ + public function fromSubject(JwtSubjectInterface $subject): string + { + $payload = $this->makePayload($subject); + + return $this->manager->encode($payload)->get(); + } + + /** + * Alias to generate a token for a given user. + */ + public function fromUser(JwtSubjectInterface $user): string + { + return $this->fromSubject($user); + } + + /** + * Refresh an expired token. + * + * @throws \HyperfExt\Jwt\Exceptions\JwtException + */ + public function refresh(bool $forceForever = false): string + { + $this->requireToken(); + + return $this->token = $this->manager + ->refresh($this->token, $forceForever, array_merge( + $this->getCustomClaims(), + ($prv = $this->getPayload()->get('prv')) ? ['prv' => $prv] : [] + )) + ->get(); + } + + /** + * Invalidate a token (add it to the blacklist). + * + * @throws \HyperfExt\Jwt\Exceptions\JwtException + * @return $this + */ + public function invalidate(bool $forceForever = false) + { + $this->requireToken(); + + $this->manager->invalidate($this->token, $forceForever); + + return $this; + } + + /** + * Alias to get the payload, and as a result checks that + * the token is valid i.e. not expired or blacklisted. + * + * @throws \HyperfExt\Jwt\Exceptions\JwtException + */ + public function checkOrFail(): Payload + { + return $this->getPayload(); + } + + /** + * Check that the token is valid. + * + * @return bool|\HyperfExt\Jwt\Payload + */ + public function check(bool $getPayload = false) + { + try { + $payload = $this->checkOrFail(); + } catch (JwtException $e) { + return false; + } + + return $getPayload ? $payload : true; + } + + /** + * Get the token. + */ + public function getToken(): ?Token + { + if ($this->token === null) { + try { + $this->parseToken(); + } catch (JwtException $e) { + $this->token = null; + } + } + + return $this->token; + } + + /** + * Parse the token from the request. + * + *@throws \HyperfExt\Jwt\Exceptions\JwtException + * @return $this + */ + public function parseToken() + { + if (! $token = $this->getRequestParser()->parseToken($this->request)) { + throw new JwtException('The token could not be parsed from the request'); + } + + return $this->setToken($token); + } + + /** + * Get the raw Payload instance. + * @throws \HyperfExt\Jwt\Exceptions\JwtException + */ + public function getPayload(): Payload + { + $this->requireToken(); + + return $this->manager->decode($this->token); + } + + /** + * Convenience method to get a claim value. + * + * @throws \HyperfExt\Jwt\Exceptions\JwtException + * @return mixed + */ + public function getClaim(string $claim) + { + return $this->getPayload()->get($claim); + } + + /** + * Create a Payload instance. + */ + public function makePayload(JwtSubjectInterface $subject): Payload + { + return $this->getPayloadFactory()->make($this->getClaimsArray($subject)); + } + + /** + * Check if the subject model matches the one saved in the token. + * + * @param object|string $model + * + * @throws \HyperfExt\Jwt\Exceptions\JwtException + */ + public function checkSubjectModel($model): bool + { + if (($prv = $this->getPayload()->get('prv')) === null) { + return true; + } + + return $this->hashSubjectModel($model) === $prv; + } + + /** + * Set the token. + * + * @param \HyperfExt\Jwt\Token|string $token + * + * @return $this + */ + public function setToken($token) + { + $this->token = $token instanceof Token ? $token : new Token($token); + + return $this; + } + + /** + * Unset the current token. + * + * @return $this + */ + public function unsetToken() + { + $this->token = null; + + return $this; + } + + /** + * @return $this + */ + public function setRequest(ServerRequestInterface $request) + { + $this->request = $request; + + return $this; + } + + /** + * Set whether the subject should be "locked". + * + * @return $this + */ + public function setLockSubject(bool $lock) + { + $this->lockSubject = $lock; + + return $this; + } + + /** + * Get the Manager instance. + */ + public function getManager(): Manager + { + return $this->manager; + } + + /** + * Get the Parser instance. + */ + public function getRequestParser(): RequestParserInterface + { + return $this->requestParser; + } + + /** + * Get the Payload Factory. + */ + public function getPayloadFactory(): PayloadFactory + { + return $this->manager->getPayloadFactory(); + } + + /** + * Get the Blacklist. + */ + public function getBlacklist(): Blacklist + { + return $this->manager->getBlacklist(); + } + + /** + * Build the claims array and return it. + */ + protected function getClaimsArray(JwtSubjectInterface $subject): array + { + return array_merge( + $this->getClaimsForSubject($subject), + $subject->getJwtCustomClaims(), // custom claims from JwtSubject method + $this->customClaims // custom claims from inline setter + ); + } + + /** + * Get the claims associated with a given subject. + */ + protected function getClaimsForSubject(JwtSubjectInterface $subject): array + { + return array_merge([ + 'sub' => $subject->getJwtIdentifier(), + ], $this->lockSubject ? ['prv' => $this->hashSubjectModel($subject)] : []); + } + + /** + * Hash the subject model and return it. + * + * @param object|string $model + */ + protected function hashSubjectModel($model): string + { + return sha1(is_object($model) ? get_class($model) : (string) $model); + } + + /** + * Ensure that a token is available. + * + * @throws \HyperfExt\Jwt\Exceptions\JwtException + */ + protected function requireToken() + { + if (! $this->token) { + throw new JwtException('A token is required'); + } + } +} diff --git a/src/JwtFactory.php b/src/JwtFactory.php new file mode 100644 index 0000000..d7a46e3 --- /dev/null +++ b/src/JwtFactory.php @@ -0,0 +1,29 @@ +lockSubject = (bool) $config->get('jwt.lock_subject'); + } + + public function make(): Jwt + { + return make(Jwt::class)->setLockSubject($this->lockSubject); + } +} diff --git a/src/Manager.php b/src/Manager.php new file mode 100644 index 0000000..84d5976 --- /dev/null +++ b/src/Manager.php @@ -0,0 +1,225 @@ +codec = $codec; + $this->blacklist = $blacklist; + $this->claimFactory = $claimFactory; + $this->payloadFactory = $payloadFactory; + } + + /** + * Encode a Payload and return the Token. + */ + public function encode(Payload $payload): Token + { + $token = $this->codec->encode($payload->get()); + + return new Token($token); + } + + /** + * Decode a Token and return the Payload. + * + * @throws \HyperfExt\Jwt\Exceptions\TokenBlacklistedException + */ + public function decode(Token $token, bool $checkBlacklist = true, bool $ignoreExpired = false): Payload + { + $payload = $this->payloadFactory->make($this->codec->decode($token->get()), $ignoreExpired); + + if ($checkBlacklist and $this->blacklistEnabled and $this->blacklist->has($payload)) { + throw new TokenBlacklistedException('The token has been blacklisted'); + } + + return $payload; + } + + /** + * Refresh a Token and return a new Token. + * + * @throws \HyperfExt\Jwt\Exceptions\TokenBlacklistedException + * @throws \HyperfExt\Jwt\Exceptions\JwtException + */ + public function refresh(Token $token, bool $forceForever = false, array $customClaims = []): Token + { + $claims = $this->buildRefreshClaims($this->decode($token, true, true)); + + if ($this->blacklistEnabled) { + // Invalidate old token + $this->invalidate($token, $forceForever); + } + + $claims = array_merge($claims, $customClaims); + + // Return the new token + return $this->encode($this->payloadFactory->make($claims)); + } + + /** + * Invalidate a Token by adding it to the blacklist. + * + * @throws \HyperfExt\Jwt\Exceptions\JwtException + */ + public function invalidate(Token $token, bool $forceForever = false): bool + { + if (! $this->blacklistEnabled) { + throw new JwtException('You must have the blacklist enabled to invalidate a token.'); + } + + return call_user_func( + [$this->blacklist, $forceForever ? 'addForever' : 'add'], + $this->decode($token, false) + ); + } + + /** + * Get the Claim Factory instance. + */ + public function getClaimFactory(): ClaimFactory + { + return $this->claimFactory; + } + + /** + * Get the Payload Factory instance. + */ + public function getPayloadFactory(): PayloadFactory + { + return $this->payloadFactory; + } + + /** + * Get the JWT codec instance. + */ + public function getCodec(): CodecInterface + { + return $this->codec; + } + + /** + * Get the Blacklist instance. + */ + public function getBlacklist(): Blacklist + { + return $this->blacklist; + } + + /** + * Set whether the blacklist is enabled. + * + * @return $this + */ + public function setBlacklistEnabled(bool $enabled) + { + $this->blacklistEnabled = $enabled; + + return $this; + } + + /** + * Set the claims to be persisted when refreshing a token. + * + * @return $this + */ + public function setPersistentClaims(array $claims) + { + $this->persistentClaims = $claims; + + return $this; + } + + /** + * Get the claims to be persisted when refreshing a token. + */ + public function getPersistentClaims(): array + { + return $this->persistentClaims; + } + + /** + * Build the claims to go into the refreshed token. + * + * @param \HyperfExt\Jwt\Payload $payload + * + * @return array + */ + protected function buildRefreshClaims(Payload $payload) + { + // Get the claims to be persisted from the payload + $persistentClaims = Arr::only($payload->toArray(), $this->persistentClaims); + + // persist the relevant claims + return array_merge( + $persistentClaims, + [ + 'sub' => $payload['sub'], + 'iat' => $payload['iat'], + ] + ); + } +} diff --git a/src/ManagerFactory.php b/src/ManagerFactory.php new file mode 100644 index 0000000..ba68faa --- /dev/null +++ b/src/ManagerFactory.php @@ -0,0 +1,82 @@ +get(ConfigInterface::class)->get('jwt'); + if (empty($config)) { + throw new InvalidConfigException(sprintf('JWT config is not defined.')); + } + + $this->config = $config; + + $codec = $this->resolveCodec(); + $blacklist = $this->resolveBlacklist(); + $claimFactory = $this->resolverClaimFactory(); + $payloadFactory = $this->resolverPayloadFactory($claimFactory); + + return make(Manager::class, compact('codec', 'blacklist', 'claimFactory', 'payloadFactory')) + ->setBlacklistEnabled($this->config['blacklist_enabled']); + } + + private function resolveCodec(): CodecInterface + { + $secret = base64_decode($this->config['secret'] ?? ''); + $algo = $this->config['algo'] ?? 'HS256'; + $keys = $this->config['keys'] ?? []; + if (! empty($keys)) { + $keys['passphrase'] = empty($keys['passphrase']) ? null : base64_decode($keys['passphrase']); + } + return make(Codec::class, compact('secret', 'algo', 'keys')); + } + + private function resolveBlacklist(): Blacklist + { + $storageClass = $this->config['blacklist_storage'] ?? HyperfCache::class; + $storage = make($storageClass, [ + 'tag' => 'jwt.default', + ]); + $gracePeriod = $this->config['blacklist_grace_period']; + $refreshTtl = $this->config['refresh_ttl']; + + return make(Blacklist::class, compact('storage', 'gracePeriod', 'refreshTtl')); + } + + private function resolverClaimFactory(): ClaimFactory + { + $ttl = $this->config['ttl']; + $refreshTtl = $this->config['refresh_ttl']; + $leeway = $this->config['leeway']; + + return make(ClaimFactory::class, compact('ttl', 'refreshTtl', 'leeway')); + } + + private function resolverPayloadFactory(ClaimFactory $claimFactory): PayloadFactory + { + return make(PayloadFactory::class, compact('claimFactory')) + ->setTtl($this->config['ttl']); + } +} diff --git a/src/Payload.php b/src/Payload.php new file mode 100644 index 0000000..ab3d0a5 --- /dev/null +++ b/src/Payload.php @@ -0,0 +1,248 @@ +validator = ApplicationContext::getContainer()->get(PayloadValidatorInterface::class); + $this->claims = $this->validator->check($claims, $ignoreExpired); + } + + /** + * Get the payload as a string. + */ + public function __toString(): string + { + return $this->toJson(); + } + + /** + * Invoke the Payload as a callable function. + * + * @param mixed $claim + * + * @return mixed + */ + public function __invoke($claim = null) + { + return $this->get($claim); + } + + /** + * Magically get a claim value. + * + * @throws \BadMethodCallException + * @return mixed + */ + public function __call(string $method, array $parameters) + { + if (preg_match('/get(.+)\b/i', $method, $matches)) { + foreach ($this->claims as $claim) { + if (get_class($claim) === 'HyperfExt\\Jwt\\Claims\\' . $matches[1]) { + return $claim->getValue(); + } + } + } + + throw new BadMethodCallException(sprintf('The claim [%s] does not exist on the payload.', $method)); + } + + /** + * Get the array of claim instances. + */ + public function getClaims(): Collection + { + return $this->claims; + } + + /** + * Checks if a payload matches some expected values. + */ + public function matches(array $values, bool $strict = false): bool + { + if (empty($values)) { + return false; + } + + $claims = $this->getClaims(); + + foreach ($values as $key => $value) { + if (! $claims->has($key) or ! $claims->get($key)->matches($value, $strict)) { + return false; + } + } + + return true; + } + + /** + * Checks if a payload strictly matches some expected values. + */ + public function matchesStrict(array $values): bool + { + return $this->matches($values, true); + } + + /** + * Get the payload. + * + * @param mixed $claim + * + * @return mixed + */ + public function get($claim = null) + { + $claim = value($claim); + + if ($claim !== null) { + if (is_array($claim)) { + return array_map([$this, 'get'], $claim); + } + + return Arr::get($this->toArray(), $claim); + } + + return $this->toArray(); + } + + /** + * Get the underlying Claim instance. + */ + public function getInternal(string $claim): AbstractClaim + { + return $this->claims->getByClaimName($claim); + } + + /** + * Determine whether the payload has the claim (by instance). + */ + public function has(AbstractClaim $claim): bool + { + return $this->claims->has($claim->getName()); + } + + /** + * Determine whether the payload has the claim (by key). + */ + public function hasKey(string $claim): bool + { + return $this->offsetExists($claim); + } + + /** + * Get the array of claims. + */ + public function toArray(): array + { + return $this->claims->toPlainArray(); + } + + /** + * Convert the object into something JSON serializable. + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } + + /** + * Get the payload as JSON. + */ + public function toJson(int $options = JSON_UNESCAPED_SLASHES): string + { + return json_encode($this->toArray(), $options); + } + + /** + * Determine if an item exists at an offset. + * + * @param mixed $key + */ + public function offsetExists($key): bool + { + return Arr::has($this->toArray(), $key); + } + + /** + * Get an item at a given offset. + * + * @param mixed $key + * + * @return mixed + */ + public function offsetGet($key) + { + return Arr::get($this->toArray(), $key); + } + + /** + * Don't allow changing the payload as it should be immutable. + * + * @param mixed $key + * @param mixed $value + * + * @throws \HyperfExt\Jwt\Exceptions\PayloadException + */ + public function offsetSet($key, $value) + { + throw new PayloadException('The payload is immutable'); + } + + /** + * Don't allow changing the payload as it should be immutable. + * + * @param string $key + * + * @throws \HyperfExt\Jwt\Exceptions\PayloadException + */ + public function offsetUnset($key) + { + throw new PayloadException('The payload is immutable'); + } + + /** + * Count the number of claims. + */ + public function count(): int + { + return count($this->toArray()); + } +} diff --git a/src/PayloadFactory.php b/src/PayloadFactory.php new file mode 100644 index 0000000..800084c --- /dev/null +++ b/src/PayloadFactory.php @@ -0,0 +1,129 @@ +claimFactory = $claimFactory; + } + + /** + * Create the Payload instance. + */ + public function make(array $claims, bool $ignoreExpired = false): Payload + { + return new Payload($this->resolveClaims($this->buildClaims($claims)), $ignoreExpired); + } + + /** + * Set the default claims to be added to the Payload. + * + * @return $this + */ + public function setDefaultClaims(array $claims) + { + $this->defaultClaims = $claims; + + return $this; + } + + /** + * Get the default claims. + * + * @return string[] + */ + public function getDefaultClaims(): array + { + return $this->defaultClaims; + } + + /** + * Helper to set the ttl. + * + * @return $this + */ + public function setTtl(int $ttl) + { + $this->claimFactory->setTtl($ttl); + + return $this; + } + + /** + * Helper to get the ttl. + */ + public function getTtl(): int + { + return $this->claimFactory->getTtl(); + } + + /** + * Build the default claims. + */ + protected function buildClaims(array $claims): Collection + { + $collection = new Collection(); + $defaultClaims = $this->getDefaultClaims(); + + // remove the exp claim if it exists and the ttl is null + if ($this->claimFactory->getTtl() === null and $key = array_search('exp', $defaultClaims)) { + unset($defaultClaims[$key]); + } + + // add the default claims + foreach ($defaultClaims as $claim) { + $collection->put($claim, $this->claimFactory->make($claim)); + } + + // add custom claims on top, allowing them to overwrite defaults + foreach ($claims as $name => $value) { + $collection->put($name, $value); + } + + return $collection; + } + + /** + * Build out the Claim DTO's. + */ + protected function resolveClaims(Collection $claims): Collection + { + return $claims->map(function ($value, $name) { + return $value instanceof ClaimInterface ? $value : $this->claimFactory->get($name, $value); + }); + } +} diff --git a/src/RequestParser/Handlers/AuthHeaders.php b/src/RequestParser/Handlers/AuthHeaders.php new file mode 100644 index 0000000..60b6ac8 --- /dev/null +++ b/src/RequestParser/Handlers/AuthHeaders.php @@ -0,0 +1,66 @@ +getHeaderLine($this->header); + + if ($header and preg_match('/' . $this->prefix . '\s*(\S+)\b/i', $header, $matches)) { + return $matches[1]; + } + + return null; + } + + /** + * Set the header name. + * + * @return $this + */ + public function setHeaderName(string $headerName) + { + $this->header = $headerName; + + return $this; + } + + /** + * Set the header prefix. + * + * @return $this + */ + public function setHeaderPrefix(string $headerPrefix) + { + $this->prefix = $headerPrefix; + + return $this; + } +} diff --git a/src/RequestParser/Handlers/Cookies.php b/src/RequestParser/Handlers/Cookies.php new file mode 100644 index 0000000..dda185f --- /dev/null +++ b/src/RequestParser/Handlers/Cookies.php @@ -0,0 +1,24 @@ +getCookieParams(), $this->key); + } +} diff --git a/src/RequestParser/Handlers/InputSource.php b/src/RequestParser/Handlers/InputSource.php new file mode 100644 index 0000000..785e890 --- /dev/null +++ b/src/RequestParser/Handlers/InputSource.php @@ -0,0 +1,28 @@ +getParsedBody()) ? $data : [], + $this->key + ); + return empty($data) === null ? null : (string) $data; + } +} diff --git a/src/RequestParser/Handlers/KeyTrait.php b/src/RequestParser/Handlers/KeyTrait.php new file mode 100644 index 0000000..fc920f4 --- /dev/null +++ b/src/RequestParser/Handlers/KeyTrait.php @@ -0,0 +1,45 @@ +key = $key; + + return $this; + } + + /** + * Get the key. + * + * @return string + */ + public function getKey() + { + return $this->key; + } +} diff --git a/src/RequestParser/Handlers/QueryString.php b/src/RequestParser/Handlers/QueryString.php new file mode 100644 index 0000000..7649d56 --- /dev/null +++ b/src/RequestParser/Handlers/QueryString.php @@ -0,0 +1,25 @@ +getQueryParams(), $this->key); + return empty($data) === null ? null : (string) $data; + } +} diff --git a/src/RequestParser/Handlers/RouteParams.php b/src/RequestParser/Handlers/RouteParams.php new file mode 100644 index 0000000..6939617 --- /dev/null +++ b/src/RequestParser/Handlers/RouteParams.php @@ -0,0 +1,27 @@ +route($this->key); + } +} diff --git a/src/RequestParser/RequestParser.php b/src/RequestParser/RequestParser.php new file mode 100644 index 0000000..c4a4f2e --- /dev/null +++ b/src/RequestParser/RequestParser.php @@ -0,0 +1,57 @@ +handlers = $handlers; + } + + public function getHandlers(): array + { + return $this->handlers; + } + + public function setHandlers(array $handlers) + { + $this->handlers = $handlers; + + return $this; + } + + public function parseToken(ServerRequestInterface $request): ?string + { + foreach ($this->handlers as $handler) { + if ($token = $handler->parse($request)) { + return $token; + } + } + return null; + } + + public function hasToken(ServerRequestInterface $request): bool + { + return $this->parseToken($request) !== null; + } +} diff --git a/src/RequestParser/RequestParserFactory.php b/src/RequestParser/RequestParserFactory.php new file mode 100644 index 0000000..0a87fa4 --- /dev/null +++ b/src/RequestParser/RequestParserFactory.php @@ -0,0 +1,31 @@ +setHandlers([ + new AuthHeaders(), + new QueryString(), + new InputSource(), + new RouteParams(), + new Cookies(), + ]); + } +} diff --git a/src/Storage/HyperfCache.php b/src/Storage/HyperfCache.php new file mode 100644 index 0000000..084f834 --- /dev/null +++ b/src/Storage/HyperfCache.php @@ -0,0 +1,77 @@ +cache = $cache; + $this->tag = $tag; + } + + public function add(string $key, $value, int $ttl) + { + $this->cache->set($this->resolveKey($key), $value, $ttl); + } + + public function forever(string $key, $value) + { + $this->cache->set($this->resolveKey($key), $value); + } + + public function get(string $key) + { + return $this->cache->get($this->resolveKey($key)); + } + + public function destroy(string $key): bool + { + return $this->cache->delete($this->resolveKey($key)); + } + + public function flush(): void + { + method_exists($cache = $this->cache, 'clearPrefix') + ? $cache->clearPrefix($this->tag) + : $cache->clear(); + } + + protected function cache(): CacheInterface + { + return $this->cache; + } + + protected function resolveKey(string $key) + { + return $this->tag . '.' . $key; + } +} diff --git a/src/Token.php b/src/Token.php new file mode 100644 index 0000000..4a868ed --- /dev/null +++ b/src/Token.php @@ -0,0 +1,52 @@ +validator = ApplicationContext::getContainer()->get(TokenValidatorInterface::class); + $this->value = (string) $this->validator->check($value); + } + + /** + * Get the token when casting to string. + */ + public function __toString(): string + { + return $this->get(); + } + + /** + * Get the token. + */ + public function get(): string + { + return $this->value; + } +} diff --git a/src/Utils.php b/src/Utils.php new file mode 100644 index 0000000..3cbbc81 --- /dev/null +++ b/src/Utils.php @@ -0,0 +1,56 @@ +timezone('UTC'); + } + + /** + * Checks if a timestamp is in the past. + */ + public static function isPast(int $timestamp, int $leeway = 0): bool + { + $timestamp = static::timestamp($timestamp); + + return $leeway > 0 + ? $timestamp->addSeconds($leeway)->isPast() + : $timestamp->isPast(); + } + + /** + * Checks if a timestamp is in the future. + */ + public static function isFuture(int $timestamp, int $leeway = 0): bool + { + $timestamp = static::timestamp($timestamp); + + return $leeway > 0 + ? $timestamp->subSeconds($leeway)->isFuture() + : $timestamp->isFuture(); + } +} diff --git a/src/Validators/PayloadValidator.php b/src/Validators/PayloadValidator.php new file mode 100644 index 0000000..eb88bb7 --- /dev/null +++ b/src/Validators/PayloadValidator.php @@ -0,0 +1,87 @@ +setRequiredClaims($config->get('jwt.required_claims', [])); + } + + public function check(Collection $value, bool $ignoreExpired = false): Collection + { + $this->validateStructure($value); + + return $this->validatePayload($value, $ignoreExpired); + } + + public function isValid(Collection $value, bool $ignoreExpired = false): bool + { + try { + $this->check($value, $ignoreExpired); + } catch (JwtException $e) { + return false; + } + + return true; + } + + /** + * Set the required claims. + * + * @return $this + */ + public function setRequiredClaims(array $claims) + { + $this->requiredClaims = $claims; + + return $this; + } + + /** + * Ensure the payload contains the required claims and + * the claims have the relevant type. + * + * @throws \HyperfExt\Jwt\Exceptions\TokenInvalidException + */ + protected function validateStructure(Collection $claims) + { + if ($this->requiredClaims and ! $claims->hasAllClaims($this->requiredClaims)) { + throw new TokenInvalidException('JWT payload does not contain the required claims'); + } + return $this; + } + + /** + * Validate the payload timestamps. + * + * @throws \HyperfExt\Jwt\Exceptions\TokenExpiredException + * @throws \HyperfExt\Jwt\Exceptions\TokenInvalidException + */ + protected function validatePayload(Collection $claims, bool $ignoreExpired = false): Collection + { + return $claims->validate($ignoreExpired); + } +} diff --git a/src/Validators/TokenValidator.php b/src/Validators/TokenValidator.php new file mode 100644 index 0000000..b1f6a29 --- /dev/null +++ b/src/Validators/TokenValidator.php @@ -0,0 +1,61 @@ +validateStructure($value); + return $value; + } + + /** + * Helper function to return a boolean. + */ + public function isValid(string $value): bool + { + try { + $this->check($value); + } catch (JwtException $e) { + return false; + } + + return true; + } + + /** + * @throws \HyperfExt\Jwt\Exceptions\TokenInvalidException + */ + protected function validateStructure(string $token) + { + $parts = explode('.', $token); + + if (count($parts) !== 3) { + throw new TokenInvalidException('Wrong number of segments'); + } + + $parts = array_filter(array_map('trim', $parts)); + + if (count($parts) !== 3 or implode('.', $parts) !== $token) { + throw new TokenInvalidException('Malformed token'); + } + + return $this; + } +} diff --git a/tests/AbstractTestCase.php b/tests/AbstractTestCase.php new file mode 100644 index 0000000..5ed4b4f --- /dev/null +++ b/tests/AbstractTestCase.php @@ -0,0 +1,61 @@ +testNowTimestamp = $now->getTimestamp(); + $this->container = ApplicationContext::getContainer(); + $this->container->set(ManagerInterface::class, $this->manager = Mockery::mock(ManagerFactory::class)); + $this->manager->shouldReceive('getClaimFactory')->andReturn($this->claimFactory = new Factory(3600, 3600 * 24 * 14)); + } + + public function tearDown() + { + Carbon::setTestNow(); + Mockery::close(); + + parent::tearDown(); + } +} diff --git a/tests/BlacklistTest.php b/tests/BlacklistTest.php new file mode 100644 index 0000000..67e3449 --- /dev/null +++ b/tests/BlacklistTest.php @@ -0,0 +1,313 @@ +storage = Mockery::mock(StorageInterface::class); + $this->blacklist = new Blacklist($this->storage, 0, 3600 * 24 * 14); + } + + /** @test */ + public function itShouldAddAValidTokenToTheBlacklist() + { + $claims = [ + new Subject(1), + new Issuer('http://example.com'), + new Expiration($this->testNowTimestamp + 3600), + new NotBefore($this->testNowTimestamp), + new IssuedAt($this->testNowTimestamp), + new JwtId('foo'), + ]; + + $collection = Collection::make($claims); + + $payload = new Payload($collection); + + $refreshTtl = 1209660; + + $this->storage->shouldReceive('get') + ->with('foo') + ->once() + ->andReturn([]); + + $this->storage->shouldReceive('add') + ->with('foo', ['valid_until' => $this->testNowTimestamp], $refreshTtl + 60) + ->once(); + + $this->assertTrue($this->blacklist->setRefreshTtl($refreshTtl)->add($payload)); + } + + /** @test */ + public function itShouldAddATokenWithNoExpToTheBlacklistForever() + { + $claims = [ + new Subject(1), + new Issuer('http://example.com'), + new NotBefore($this->testNowTimestamp), + new IssuedAt($this->testNowTimestamp), + new JwtId('foo'), + ]; + $collection = Collection::make($claims); + + $payload = new Payload($collection); + + $this->storage->shouldReceive('forever')->with('foo', 'forever')->once(); + + $this->assertTrue($this->blacklist->add($payload)); + } + + /** @test */ + public function itShouldReturnTrueWhenAddingAnExpiredTokenToTheBlacklist() + { + $claims = [ + new Subject(1), + new Issuer('http://example.com'), + new Expiration($this->testNowTimestamp - 3600), + new NotBefore($this->testNowTimestamp), + new IssuedAt($this->testNowTimestamp), + new JwtId('foo'), + ]; + $collection = Collection::make($claims); + + $payload = new Payload($collection, true); + + $refreshTtl = 1209660; + + $this->storage->shouldReceive('get') + ->with('foo') + ->once() + ->andReturn([]); + + $this->storage->shouldReceive('add') + ->with('foo', ['valid_until' => $this->testNowTimestamp], $refreshTtl + 60) + ->once(); + + $this->assertTrue($this->blacklist->setRefreshTtl($refreshTtl)->add($payload)); + } + + /** @test */ + public function itShouldReturnTrueEarlyWhenAddingAnItemAndItAlreadyExists() + { + $claims = [ + new Subject(1), + new Issuer('http://example.com'), + new Expiration($this->testNowTimestamp - 3600), + new NotBefore($this->testNowTimestamp), + new IssuedAt($this->testNowTimestamp), + new JwtId('foo'), + ]; + $collection = Collection::make($claims); + + $payload = new Payload($collection, true); + + $refreshTtl = 1209660; + + $this->storage->shouldReceive('get') + ->with('foo') + ->once() + ->andReturn(['valid_until' => $this->testNowTimestamp]); + + $this->storage->shouldReceive('add') + ->with('foo', ['valid_until' => $this->testNowTimestamp], $refreshTtl + 60) + ->never(); + + $this->assertTrue($this->blacklist->setRefreshTtl($refreshTtl)->add($payload)); + } + + /** @test */ + public function itShouldCheckWhetherATokenHasBeenBlacklisted() + { + $claims = [ + new Subject(1), + new Issuer('http://example.com'), + new Expiration($this->testNowTimestamp + 3600), + new NotBefore($this->testNowTimestamp), + new IssuedAt($this->testNowTimestamp), + new JwtId('foobar'), + ]; + + $collection = Collection::make($claims); + + $payload = new Payload($collection); + + $this->storage->shouldReceive('get')->with('foobar')->once()->andReturn(['valid_until' => $this->testNowTimestamp]); + + $this->assertTrue($this->blacklist->has($payload)); + } + + public function blacklist_provider() + { + return [ + [null], + [0], + [''], + [[]], + [['valid_until' => strtotime('+1day')]], + ]; + } + + /** + * @test + * @dataProvider blacklist_provider + * + * @param mixed $result + */ + public function itShouldCheckWhetherATokenHasNotBeenBlacklisted($result) + { + $claims = [ + new Subject(1), + new Issuer('http://example.com'), + new Expiration($this->testNowTimestamp + 3600), + new NotBefore($this->testNowTimestamp), + new IssuedAt($this->testNowTimestamp), + new JwtId('foobar'), + ]; + + $collection = Collection::make($claims); + + $payload = new Payload($collection); + + $this->storage->shouldReceive('get')->with('foobar')->once()->andReturn($result); + $this->assertFalse($this->blacklist->has($payload)); + } + + /** @test */ + public function itShouldCheckWhetherATokenHasBeenBlacklistedForever() + { + $claims = [ + new Subject(1), + new Issuer('http://example.com'), + new Expiration($this->testNowTimestamp + 3600), + new NotBefore($this->testNowTimestamp), + new IssuedAt($this->testNowTimestamp), + new JwtId('foobar'), + ]; + $collection = Collection::make($claims); + + $payload = new Payload($collection); + + $this->storage->shouldReceive('get')->with('foobar')->once()->andReturn('forever'); + + $this->assertTrue($this->blacklist->has($payload)); + } + + /** @test */ + public function itShouldCheckWhetherATokenHasBeenBlacklistedWhenTheTokenIsNotBlacklisted() + { + $claims = [ + new Subject(1), + new Issuer('http://example.com'), + new Expiration($this->testNowTimestamp + 3600), + new NotBefore($this->testNowTimestamp), + new IssuedAt($this->testNowTimestamp), + new JwtId('foobar'), + ]; + $collection = Collection::make($claims); + + $payload = new Payload($collection); + + $this->storage->shouldReceive('get')->with('foobar')->once()->andReturn(null); + + $this->assertFalse($this->blacklist->has($payload)); + } + + /** @test */ + public function itShouldRemoveATokenFromTheBlacklist() + { + $claims = [ + new Subject(1), + new Issuer('http://example.com'), + new Expiration($this->testNowTimestamp + 3600), + new NotBefore($this->testNowTimestamp), + new IssuedAt($this->testNowTimestamp), + new JwtId('foobar'), + ]; + $collection = Collection::make($claims); + + $payload = new Payload($collection); + + $this->storage->shouldReceive('destroy')->with('foobar')->andReturn(true); + $this->assertTrue($this->blacklist->remove($payload)); + } + + /** @test */ + public function itShouldSetACustomUniqueKeyForTheBlacklist() + { + $claims = [ + new Subject(1), + new Issuer('http://example.com'), + new Expiration($this->testNowTimestamp + 3600), + new NotBefore($this->testNowTimestamp), + new IssuedAt($this->testNowTimestamp), + new JwtId('foobar'), + ]; + $collection = Collection::make($claims); + + $payload = new Payload($collection); + + $this->storage->shouldReceive('get')->with(1)->once()->andReturn(['valid_until' => $this->testNowTimestamp]); + + $this->assertTrue($this->blacklist->setKey('sub')->has($payload)); + $this->assertSame(1, $this->blacklist->getKey($payload)); + } + + /** @test */ + public function itShouldEmptyTheBlacklist() + { + $this->storage->shouldReceive('flush'); + $this->assertTrue($this->blacklist->clear()); + } + + /** @test */ + public function itShouldSetAndGetTheBlacklistGracePeriod() + { + $this->assertInstanceOf(Blacklist::class, $this->blacklist->setGracePeriod(15)); + $this->assertSame(15, $this->blacklist->getGracePeriod()); + } + + /** @test */ + public function itShouldSetAndGetTheBlacklistRefreshTtl() + { + $this->assertInstanceOf(Blacklist::class, $this->blacklist->setRefreshTtl(15)); + $this->assertSame(15, $this->blacklist->getRefreshTtl()); + } +} diff --git a/tests/Claims/ClaimTest.php b/tests/Claims/ClaimTest.php new file mode 100644 index 0000000..e1f83dd --- /dev/null +++ b/tests/Claims/ClaimTest.php @@ -0,0 +1,66 @@ +claim = new Expiration($this->testNowTimestamp); + } + + /** @test */ + public function itShouldThrowAnExceptionWhenPassingAnInvalidValue() + { + $this->expectExceptionMessage('Invalid value provided for claim [exp]'); + $this->expectException(\HyperfExt\Jwt\Exceptions\InvalidClaimException::class); + $this->claim->setValue('foo'); + } + + /** @test */ + public function itShouldConvertTheClaimToAnArray() + { + $this->assertSame(['exp' => $this->testNowTimestamp], $this->claim->toArray()); + } + + /** @test */ + public function itShouldGetTheClaimAsAString() + { + $this->assertJsonStringEqualsJsonString((string) $this->claim, $this->claim->toJson()); + } + + /** @test */ + public function itShouldGetTheObjectAsJson() + { + $this->assertJsonStringEqualsJsonString(json_encode($this->claim), $this->claim->toJson()); + } + + /** @test */ + public function itShouldImplementArrayable() + { + $this->assertInstanceOf(Arrayable::class, $this->claim); + } +} diff --git a/tests/Claims/CollectionTest.php b/tests/Claims/CollectionTest.php new file mode 100644 index 0000000..8db73e9 --- /dev/null +++ b/tests/Claims/CollectionTest.php @@ -0,0 +1,71 @@ +getCollection(); + + $this->assertSame(array_keys($collection->toArray()), ['sub', 'iss', 'exp', 'nbf', 'iat', 'jti']); + } + + /** @test */ + public function itShouldDetermineIfACollectionContainsAllTheGivenClaims() + { + $collection = $this->getCollection(); + + $this->assertFalse($collection->hasAllClaims(['sub', 'iss', 'exp', 'nbf', 'iat', 'jti', 'abc'])); + $this->assertFalse($collection->hasAllClaims(['foo', 'bar'])); + $this->assertFalse($collection->hasAllClaims([])); + + $this->assertTrue($collection->hasAllClaims(['sub', 'iss'])); + $this->assertTrue($collection->hasAllClaims(['sub', 'iss', 'exp', 'nbf', 'iat', 'jti'])); + } + + /** @test */ + public function itShouldGetAClaimInstanceByName() + { + $collection = $this->getCollection(); + + $this->assertInstanceOf(Expiration::class, $collection->getByClaimName('exp')); + $this->assertInstanceOf(Subject::class, $collection->getByClaimName('sub')); + } + + private function getCollection() + { + $claims = [ + new Subject(1), + new Issuer('http://example.com'), + new Expiration($this->testNowTimestamp + 3600), + new NotBefore($this->testNowTimestamp), + new IssuedAt($this->testNowTimestamp), + new JwtId('foo'), + ]; + + return new Collection($claims); + } +} diff --git a/tests/Claims/DatetimeClaimTest.php b/tests/Claims/DatetimeClaimTest.php new file mode 100644 index 0000000..c1a844a --- /dev/null +++ b/tests/Claims/DatetimeClaimTest.php @@ -0,0 +1,146 @@ +claimsTimestamp = [ + 'sub' => new Subject(1), + 'iss' => new Issuer('http://example.com'), + 'exp' => new Expiration($this->testNowTimestamp + 3600), + 'nbf' => new NotBefore($this->testNowTimestamp), + 'iat' => new IssuedAt($this->testNowTimestamp), + 'jti' => new JwtId('foo'), + ]; + } + + /** @test */ + public function itShouldHandleCarbonClaims() + { + $testCarbon = Carbon::createFromTimestampUTC($this->testNowTimestamp); + $testCarbonCopy = clone $testCarbon; + + $this->assertInstanceOf(Carbon::class, $testCarbon); + $this->assertInstanceOf(Datetime::class, $testCarbon); + $this->assertInstanceOf(DatetimeInterface::class, $testCarbon); + + $claimsDatetime = [ + 'sub' => new Subject(1), + 'iss' => new Issuer('http://example.com'), + 'exp' => new Expiration($testCarbonCopy->addHour()), + 'nbf' => new NotBefore($testCarbon), + 'iat' => new IssuedAt($testCarbon), + 'jti' => new JwtId('foo'), + ]; + + $payloadTimestamp = new Payload(Collection::make($this->claimsTimestamp)); + $payloadDatetime = new Payload(Collection::make($claimsDatetime)); + + $this->assertEquals($payloadTimestamp, $payloadDatetime); + } + + /** @test */ + public function itShouldHandleDatetimeClaims() + { + $testDateTime = DateTime::createFromFormat('U', (string) $this->testNowTimestamp); + $testDateTimeCopy = clone $testDateTime; + + $this->assertInstanceOf(DateTime::class, $testDateTime); + $this->assertInstanceOf(DatetimeInterface::class, $testDateTime); + + $claimsDatetime = [ + 'sub' => new Subject(1), + 'iss' => new Issuer('http://example.com'), + 'exp' => new Expiration($testDateTimeCopy->modify('+3600 seconds')), + 'nbf' => new NotBefore($testDateTime), + 'iat' => new IssuedAt($testDateTime), + 'jti' => new JwtId('foo'), + ]; + + $payloadTimestamp = new Payload(Collection::make($this->claimsTimestamp)); + $payloadDatetime = new Payload(Collection::make($claimsDatetime)); + + $this->assertEquals($payloadTimestamp, $payloadDatetime); + } + + /** @test */ + public function itShouldHandleDatetimeImmutableClaims() + { + $testDateTimeImmutable = DateTimeImmutable::createFromFormat('U', (string) $this->testNowTimestamp); + + $this->assertInstanceOf(DateTimeImmutable::class, $testDateTimeImmutable); + $this->assertInstanceOf(DatetimeInterface::class, $testDateTimeImmutable); + + $claimsDatetime = [ + 'sub' => new Subject(1), + 'iss' => new Issuer('http://example.com'), + 'exp' => new Expiration($testDateTimeImmutable->modify('+3600 seconds')), + 'nbf' => new NotBefore($testDateTimeImmutable), + 'iat' => new IssuedAt($testDateTimeImmutable), + 'jti' => new JwtId('foo'), + ]; + + $payloadTimestamp = new Payload(Collection::make($this->claimsTimestamp)); + $payloadDatetime = new Payload(Collection::make($claimsDatetime)); + + $this->assertEquals($payloadTimestamp, $payloadDatetime); + } + + /** @test */ + public function itShouldHandleDatetintervalClaims() + { + $testDateInterval = new DateInterval('PT1H'); + + $this->assertInstanceOf(DateInterval::class, $testDateInterval); + + $claimsDateInterval = [ + 'sub' => new Subject(1), + 'iss' => new Issuer('http://example.com'), + 'exp' => new Expiration($testDateInterval), + 'nbf' => new NotBefore($this->testNowTimestamp), + 'iat' => new IssuedAt($this->testNowTimestamp), + 'jti' => new JwtId('foo'), + ]; + + $payloadTimestamp = new Payload(Collection::make($this->claimsTimestamp)); + $payloadDateInterval = new Payload(Collection::make($claimsDateInterval)); + + $this->assertEquals($payloadTimestamp, $payloadDateInterval); + } +} diff --git a/tests/Claims/FactoryTest.php b/tests/Claims/FactoryTest.php new file mode 100644 index 0000000..2c6ec97 --- /dev/null +++ b/tests/Claims/FactoryTest.php @@ -0,0 +1,105 @@ +factory = new Factory(3600, 3600 * 24 * 14); + $this->container->set(ServerRequestInterface::class, new Request('GET', 'http://localhost/foo')); + } + + /** @test */ + public function itShouldSetTheTtl() + { + $this->assertInstanceOf(Factory::class, $this->factory->setTtl(30)); + } + + /** @test */ + public function itShouldGetTheTtl() + { + $this->factory->setTtl($ttl = 30); + $this->assertSame($ttl, $this->factory->getTtl()); + } + + /** @test */ + public function itShouldGetADefinedClaimInstanceWhenPassingANameAndValue() + { + $this->assertInstanceOf(Subject::class, $this->factory->get('sub', 1)); + $this->assertInstanceOf(Issuer::class, $this->factory->get('iss', 'http://example.com')); + $this->assertInstanceOf(Expiration::class, $this->factory->get('exp', $this->testNowTimestamp + 3600)); + $this->assertInstanceOf(NotBefore::class, $this->factory->get('nbf', $this->testNowTimestamp)); + $this->assertInstanceOf(IssuedAt::class, $this->factory->get('iat', $this->testNowTimestamp)); + $this->assertInstanceOf(JwtId::class, $this->factory->get('jti', 'foo')); + } + + /** @test */ + public function itShouldGetACustomClaimInstanceWhenPassingANonDefinedNameAndValue() + { + $this->assertInstanceOf(Custom::class, $this->factory->get('foo', ['bar'])); + } + + /** @test */ + public function itShouldMakeAClaimInstanceWithAValue() + { + $iat = $this->factory->make('iat'); + $this->assertSame($iat->getValue(), $this->testNowTimestamp); + $this->assertInstanceOf(IssuedAt::class, $iat); + + $nbf = $this->factory->make('nbf'); + $this->assertSame($nbf->getValue(), $this->testNowTimestamp); + $this->assertInstanceOf(NotBefore::class, $nbf); + + $iss = $this->factory->make('iss'); + $this->assertSame($iss->getValue(), 'http://localhost/foo'); + $this->assertInstanceOf(Issuer::class, $iss); + + $exp = $this->factory->make('exp'); + $this->assertSame($exp->getValue(), $this->testNowTimestamp + 3600); + $this->assertInstanceOf(Expiration::class, $exp); + + $jti = $this->factory->make('jti'); + $this->assertInstanceOf(JwtId::class, $jti); + } + + /** @test */ + public function itShouldExtendClaimFactoryToAddACustomClaim() + { + $this->factory->extend('foo', Foo::class); + + $this->assertInstanceOf(Foo::class, $this->factory->get('foo', 'bar')); + } +} diff --git a/tests/Claims/IssuedAtTest.php b/tests/Claims/IssuedAtTest.php new file mode 100644 index 0000000..040f595 --- /dev/null +++ b/tests/Claims/IssuedAtTest.php @@ -0,0 +1,30 @@ +expectExceptionMessage('Invalid value provided for claim [iat]'); + $this->expectException(InvalidClaimException::class); + new IssuedAt($this->testNowTimestamp + 3600); + } +} diff --git a/tests/Claims/NotBeforeTest.php b/tests/Claims/NotBeforeTest.php new file mode 100644 index 0000000..a62ab18 --- /dev/null +++ b/tests/Claims/NotBeforeTest.php @@ -0,0 +1,32 @@ +expectExceptionMessage('Invalid value provided for claim [nbf]'); + $this->expectException(InvalidClaimException::class); + new NotBefore('foo'); + } +} diff --git a/tests/CodecTest.php b/tests/CodecTest.php new file mode 100644 index 0000000..c417640 --- /dev/null +++ b/tests/CodecTest.php @@ -0,0 +1,216 @@ +builder = Mockery::mock(Builder::class); + $this->parser = Mockery::mock(Parser::class); + } + + /** @test */ + public function itShouldSetTheAlgo() + { + $codec = $this->getCodec('secret', 'HS256', []); + $codec->setAlgo('HS512'); + + $this->assertSame('HS512', $codec->getAlgo()); + } + + /** @test */ + public function itShouldSetTheSecret() + { + $codec = $this->getCodec('secret', 'HS256', []); + $codec->setSecret('foo'); + + $this->assertSame('foo', $codec->getSecret()); + } + + /** @test */ + public function itShouldReturnTheTokenWhenPassingAValidPayloadToEncode() + { + $payload = ['sub' => 1, 'exp' => $this->testNowTimestamp + 3600, 'iat' => $this->testNowTimestamp, 'iss' => '/foo']; + + $this->builder->shouldReceive('withClaim')->times(count($payload)); + $this->builder->shouldReceive('getToken')->once()->andReturn('foo.bar.baz'); + $this->builder->shouldReceive('sign')->never(); + + $token = $this->getCodec('secret', 'HS256')->encode($payload); + + $this->assertSame('foo.bar.baz', $token); + } + + /** @test */ + public function itShouldThrowAnInvalidExceptionWhenThePayloadCouldNotBeEncoded() + { + $this->expectExceptionMessage('Could not create token:'); + $this->expectException(JwtException::class); + $payload = ['sub' => 1, 'exp' => $this->testNowTimestamp, 'iat' => $this->testNowTimestamp, 'iss' => '/foo']; + + $this->builder->shouldReceive('withClaim')->times(count($payload)); + $this->builder->shouldReceive('sign')->never(); + + $this->getCodec('secret', 'HS256')->encode($payload); + } + + /** @test */ + public function itShouldReturnThePayloadWhenPassingAValidTokenToDecode() + { + $payload = ['sub' => 1, 'exp' => $this->testNowTimestamp + 3600, 'iat' => $this->testNowTimestamp, 'iss' => '/foo']; + + $jwt = Mockery::mock(Token::class); + $jwt->shouldReceive('verify')->once()->with(Mockery::any(), Mockery::any())->andReturn(true); + $jwt->shouldReceive('getClaims')->once()->andReturn($payload); + $this->parser->shouldReceive('parse')->once()->with('foo.bar.baz')->andReturn($jwt); + + $this->assertSame($payload, $this->getCodec('secret', 'HS256')->decode('foo.bar.baz')); + } + + /** @test */ + public function itShouldThrowATokenInvalidExceptionWhenTheTokenCouldNotBeDecodedDueToABadSignature() + { + $this->expectException(TokenInvalidException::class); + $this->expectExceptionMessage('Token Signature could not be verified.'); + + $jwt = Mockery::mock(Token::class); + $jwt->shouldReceive('verify')->once()->with(Mockery::any(), Mockery::any())->andReturn(false); + $jwt->shouldReceive('getClaims')->never(); + $this->parser->shouldReceive('parse')->once()->with('foo.bar.baz')->andReturn(Mockery::self()); + + $this->getCodec('secret', 'HS256')->decode('foo.bar.baz'); + } + + /** @test */ + public function itShouldThrowATokenInvalidExceptionWhenTheTokenCouldNotBeDecoded() + { + $this->expectExceptionMessage('Could not decode token:'); + $this->expectException(TokenInvalidException::class); + $this->parser->shouldReceive('parse')->once()->with('foo.bar.baz')->andThrow(new InvalidArgumentException()); + $this->parser->shouldReceive('verify')->never(); + $this->parser->shouldReceive('getClaims')->never(); + + $this->getCodec('secret', 'HS256')->decode('foo.bar.baz'); + } + + /** @test */ + public function itShouldGenerateATokenWhenUsingAnRsaAlgorithm() + { + $codec = $this->getCodec( + 'does_not_matter', + 'RS256', + ['private' => $this->getDummyPrivateKey(), 'public' => $this->getDummyPublicKey()] + ); + + $payload = ['sub' => 1, 'exp' => $this->testNowTimestamp + 3600, 'iat' => $this->testNowTimestamp, 'iss' => '/foo']; + + $this->builder->shouldReceive('withClaim')->times(count($payload)); + $this->builder->shouldReceive('getToken')->once()->andReturn('foo.bar.baz'); + + $token = $codec->encode($payload); + + $this->assertSame('foo.bar.baz', $token); + } + + /** @test */ + public function itShouldThrowAExceptionWhenTheAlgorithmPassedIsInvalid() + { + $this->expectException(JwtException::class); + $this->expectExceptionMessage('The given algorithm could not be found'); + + $jwt = Mockery::mock(Token::class); + $this->parser->shouldReceive('parse')->andReturn($jwt); + $this->parser->shouldReceive('verify')->never(); + + $this->getCodec('secret', 'AlgorithmWrong')->decode('foo.bar.baz'); + } + + /** + * @test + */ + public function itShouldReturnThePublicKey() + { + $codec = $this->getCodec( + 'does_not_matter', + 'RS256', + $keys = ['private' => $this->getDummyPrivateKey(), 'public' => $this->getDummyPublicKey()] + ); + + $this->assertSame($keys['public'], $codec->getPublicKey()); + } + + /** + * @test + */ + public function itShouldReturnTheKeys() + { + $codec = $this->getCodec( + 'does_not_matter', + 'RS256', + $keys = ['private' => $this->getDummyPrivateKey(), 'public' => $this->getDummyPublicKey()] + ); + + $this->assertSame($keys, $codec->getKeys()); + } + + /** + * @param $secret + * @param $algo + * + * @return \HyperfExt\Jwt\Codec|\PHPUnit\Framework\MockObject\MockObject + */ + public function getCodec($secret, $algo, array $keys = []) + { + $codec = $this->getMockBuilder(Codec::class) + ->setMethods(['getBuilder', 'getParser']) + ->setConstructorArgs([$secret, $algo, $keys]) + ->getMock(); + $codec->method('getBuilder')->willReturn($this->builder); + $codec->method('getParser')->willReturn($this->parser); + return $codec; + } + + public function getDummyPrivateKey() + { + return file_get_contents(__DIR__ . '/Keys/id_rsa'); + } + + public function getDummyPublicKey() + { + return file_get_contents(__DIR__ . '/Keys/id_rsa.pub'); + } +} diff --git a/tests/Fixtures/Foo.php b/tests/Fixtures/Foo.php new file mode 100644 index 0000000..9630b6c --- /dev/null +++ b/tests/Fixtures/Foo.php @@ -0,0 +1,26 @@ +payload = $this->getTestPayload(); + } + + /** @test */ + public function itShouldThrowAnExceptionWhenTryingToAddToThePayload() + { + $this->expectException(PayloadException::class); + $this->expectExceptionMessage('The payload is immutable'); + $this->payload['foo'] = 'bar'; + } + + /** @test */ + public function itShouldThrowAnExceptionWhenTryingToRemoveAKeyFromThePayload() + { + $this->expectExceptionMessage('The payload is immutable'); + $this->expectException(PayloadException::class); + unset($this->payload['foo']); + } + + /** @test */ + public function itShouldCastThePayloadToAStringAsJson() + { + $this->assertSame((string) $this->payload, json_encode($this->payload->get(), JSON_UNESCAPED_SLASHES)); + $this->assertJsonStringEqualsJsonString((string) $this->payload, json_encode($this->payload->get())); + } + + /** @test */ + public function itShouldAllowArrayAccessOnThePayload() + { + $this->assertTrue(isset($this->payload['iat'])); + $this->assertSame($this->payload['sub'], 1); + $this->assertArrayHasKey('exp', $this->payload); + } + + /** @test */ + public function itShouldGetPropertiesOfPayloadViaGetMethod() + { + $this->assertInternalType('array', $this->payload->get()); + $this->assertSame($this->payload->get('sub'), 1); + + $this->assertSame( + $this->payload->get(function () { + return 'jti'; + }), + 'foo' + ); + } + + /** @test */ + public function itShouldGetMultiplePropertiesWhenPassingAnArrayToTheGetMethod() + { + $values = $this->payload->get(['sub', 'jti']); + + $sub = $values[0]; + $jti = $values[1]; + + $this->assertIsArray($values); + $this->assertSame($sub, 1); + $this->assertSame($jti, 'foo'); + } + + /** @test */ + public function itShouldDetermineWhetherThePayloadHasAClaim() + { + $this->assertTrue($this->payload->has(new Subject(1))); + $this->assertFalse($this->payload->has(new Audience(1))); + } + + /** @test */ + public function itShouldMagicallyGetAProperty() + { + $sub = $this->payload->getSubject(); + $jti = $this->payload->getJwtId(); + $iss = $this->payload->getIssuer(); + + $this->assertSame($sub, 1); + $this->assertSame($jti, 'foo'); + $this->assertSame($iss, 'http://example.com'); + } + + /** @test */ + public function itShouldInvokeTheInstanceAsACallable() + { + $payload = $this->payload; + + $sub = $payload('sub'); + $jti = $payload('jti'); + $iss = $payload('iss'); + + $this->assertSame($sub, 1); + $this->assertSame($jti, 'foo'); + $this->assertSame($iss, 'http://example.com'); + + $this->assertSame($payload(), $this->payload->toArray()); + } + + /** @test */ + public function itShouldThrowAnExceptionWhenMagicallyGettingAPropertyThatDoesNotExist() + { + $this->expectExceptionMessage('The claim [getFoo] does not exist on the payload.'); + $this->expectException(\BadMethodCallException::class); + $this->payload->getFoo(); + } + + /** @test */ + public function itShouldGetTheClaims() + { + $claims = $this->payload->getClaims(); + + $this->assertInstanceOf(Expiration::class, $claims['exp']); + $this->assertInstanceOf(JwtId::class, $claims['jti']); + $this->assertInstanceOf(Subject::class, $claims['sub']); + + $this->assertContainsOnlyInstancesOf(ClaimInterface::class, $claims); + } + + /** @test */ + public function itShouldGetTheObjectAsJson() + { + $this->assertJsonStringEqualsJsonString(json_encode($this->payload), $this->payload->toJson()); + } + + /** @test */ + public function itShouldCountTheClaims() + { + $this->assertSame(6, $this->payload->count()); + $this->assertCount(6, $this->payload); + } + + /** @test */ + public function itShouldMatchValues() + { + $values = $this->payload->toArray(); + $values['sub'] = (string) $values['sub']; + + $this->assertTrue($this->payload->matches($values)); + } + + /** @test */ + public function itShouldMatchStrictValues() + { + $values = $this->payload->toArray(); + + $this->assertTrue($this->payload->matchesStrict($values)); + $this->assertTrue($this->payload->matches($values, true)); + } + + /** @test */ + public function itShouldNotMatchEmptyValues() + { + $this->assertFalse($this->payload->matches([])); + } + + /** @test */ + public function itShouldNotMatchValues() + { + $values = $this->payload->toArray(); + $values['sub'] = 'dummy_subject'; + + $this->assertFalse($this->payload->matches($values)); + } + + /** @test */ + public function itShouldNotMatchStrictValues() + { + $values = $this->payload->toArray(); + $values['sub'] = (string) $values['sub']; + + $this->assertFalse($this->payload->matchesStrict($values)); + $this->assertFalse($this->payload->matches($values, true)); + } + + /** @test */ + public function itShouldNotMatchANonExistingClaim() + { + $values = ['foo' => 'bar']; + + $this->assertFalse($this->payload->matches($values)); + } + + /** + * @return \HyperfExt\Jwt\Payload + */ + private function getTestPayload(array $extraClaims = []) + { + $claims = [ + new Subject(1), + new Issuer('http://example.com'), + new Expiration($this->testNowTimestamp + 3600), + new NotBefore($this->testNowTimestamp), + new IssuedAt($this->testNowTimestamp), + new JwtId('foo'), + ]; + + if ($extraClaims) { + $claims = array_merge($claims, $extraClaims); + } + + $collection = Collection::make($claims); + + return new Payload($collection); + } +} diff --git a/tests/RequestParserTest.php b/tests/RequestParserTest.php new file mode 100644 index 0000000..5edf607 --- /dev/null +++ b/tests/RequestParserTest.php @@ -0,0 +1,357 @@ + 'Bearer foobar', + ])); + $request = new HttpServerRequest(); + + $parser = new RequestParser(); + + $parser->setHandlers([ + new QueryString(), + new InputSource(), + new AuthHeaders(), + new RouteParams(), + ]); + + $this->assertSame($parser->parseToken($request), 'foobar'); + $this->assertTrue($parser->hasToken($request)); + } + + /** @test */ + public function itShouldReturnTheTokenFromThePrefixedAuthenticationHeader() + { + Context::set(ServerRequestInterface::class, new Request('POST', 'foo', [ + 'Authorization' => 'Custom foobar', + ])); + $request = new HttpServerRequest(); + + $parser = new RequestParser(); + + $parser->setHandlers([ + new QueryString(), + new InputSource(), + (new AuthHeaders())->setHeaderPrefix('Custom'), + new RouteParams(), + ]); + + $this->assertSame($parser->parseToken($request), 'foobar'); + $this->assertTrue($parser->hasToken($request)); + } + + /** @test */ + public function itShouldReturnTheTokenFromTheCustomAuthenticationHeader() + { + Context::set(ServerRequestInterface::class, new Request('POST', 'foo', [ + 'custom_authorization' => 'Bearer foobar', + ])); + $request = new HttpServerRequest(); + + $parser = new RequestParser(); + + $parser->setHandlers([ + new QueryString(), + new InputSource(), + (new AuthHeaders())->setHeaderName('custom_authorization'), + new RouteParams(), + ]); + + $this->assertSame($parser->parseToken($request), 'foobar'); + $this->assertTrue($parser->hasToken($request)); + } + + /** @test */ + public function itShouldReturnTheTokenFromQueryString() + { + Context::set( + ServerRequestInterface::class, + (new Request('GET', '/')) + ->withAttribute(Dispatched::class, new Dispatched([ + Dispatcher::FOUND, null, ['token' => 'foobar'], + ])) + ); + $request = new HttpServerRequest(); + + $parser = new RequestParser(); + $parser->setHandlers([ + new AuthHeaders(), + new QueryString(), + new InputSource(), + new RouteParams(), + ]); + + $this->assertSame($parser->parseToken($request), 'foobar'); + $this->assertTrue($parser->hasToken($request)); + } + + /** @test */ + public function itShouldReturnTheTokenFromTheCustomQueryString() + { + Context::set( + ServerRequestInterface::class, + (new Request('GET', '/foo')) + ->withQueryParams(['custom_token_key' => 'foobar']) + ->withAttribute(Dispatched::class, new Dispatched([ + Dispatcher::FOUND, null, ['custom_token_key' => 'foobar'], + ])) + ); + $request = new HttpServerRequest(); + + $parser = new RequestParser(); + $parser->setHandlers([ + new AuthHeaders(), + (new QueryString())->setKey('custom_token_key'), + new InputSource(), + new RouteParams(), + ]); + + $this->assertSame($parser->parseToken($request), 'foobar'); + $this->assertTrue($parser->hasToken($request)); + } + + /** @test */ + public function itShouldReturnTheTokenFromTheQueryStringNotTheInputSource() + { + Context::set( + ServerRequestInterface::class, + (new Request('POST', 'foo')) + ->withQueryParams(['token' => 'foobar']) + ->withParsedBody(['token' => 'foobarbaz']) + ); + $request = new HttpServerRequest(); + + $parser = new RequestParser(); + $parser->setHandlers([ + new AuthHeaders(), + new QueryString(), + new InputSource(), + new RouteParams(), + ]); + + $this->assertSame($parser->parseToken($request), 'foobar'); + $this->assertTrue($parser->hasToken($request)); + } + + /** @test */ + public function itShouldReturnTheTokenFromTheCustomQueryStringNotTheCustomInputSource() + { + Context::set( + ServerRequestInterface::class, + (new Request('POST', 'foo')) + ->withQueryParams(['custom_token_key' => 'foobar']) + ->withParsedBody(['custom_token_key' => 'foobarbaz']) + ); + $request = new HttpServerRequest(); + + $parser = new RequestParser(); + $parser->setHandlers([ + new AuthHeaders(), + (new QueryString())->setKey('custom_token_key'), + (new InputSource())->setKey('custom_token_key'), + new RouteParams(), + ]); + + $this->assertSame($parser->parseToken($request), 'foobar'); + $this->assertTrue($parser->hasToken($request)); + } + + /** @test */ + public function itShouldReturnTheTokenFromInputSource() + { + Context::set( + ServerRequestInterface::class, + (new Request('POST', 'foo')) + ->withParsedBody(['token' => 'foobar']) + ); + $request = new HttpServerRequest(); + + $parser = new RequestParser(); + $parser->setHandlers([ + new AuthHeaders(), + new QueryString(), + new InputSource(), + new RouteParams(), + ]); + + $this->assertSame($parser->parseToken($request), 'foobar'); + $this->assertTrue($parser->hasToken($request)); + } + + /** @test */ + public function itShouldReturnTheTokenFromTheCustomInputSource() + { + Context::set( + ServerRequestInterface::class, + (new Request('POST', 'foo')) + ->withParsedBody(['custom_token_key' => 'foobar']) + ); + $request = new HttpServerRequest(); + + $parser = new RequestParser(); + $parser->setHandlers([ + new AuthHeaders(), + new QueryString(), + (new InputSource())->setKey('custom_token_key'), + new RouteParams(), + ]); + + $this->assertSame($parser->parseToken($request), 'foobar'); + $this->assertTrue($parser->hasToken($request)); + } + + /** @test */ + public function itShouldReturnTheTokenFromAnUnencryptedCookie() + { + Context::set(ServerRequestInterface::class, (new Request('POST', 'foo'))->withCookieParams([ + 'token' => 'foobar', + ])); + $request = new HttpServerRequest(); + + $parser = new RequestParser(); + $parser->setHandlers([ + new AuthHeaders(), + new QueryString(), + new InputSource(), + new RouteParams(), + new Cookies(), + ]); + + $this->assertSame($parser->parseToken($request), 'foobar'); + $this->assertTrue($parser->hasToken($request)); + } + + /** @test */ + public function itShouldReturnTheTokenFromRoute() + { + Context::set(ServerRequestInterface::class, (new Request('GET', 'foo'))->withAttribute(Dispatched::class, new Dispatched([ + Dispatcher::FOUND, null, ['token' => 'foobar'], + ]))); + $request = new HttpServerRequest(); + + $parser = new RequestParser(); + $parser->setHandlers([ + new AuthHeaders(), + new QueryString(), + new InputSource(), + new RouteParams(), + ]); + + $this->assertSame($parser->parseToken($request), 'foobar'); + $this->assertTrue($parser->hasToken($request)); + } + + /** @test */ + public function itShouldReturnTheTokenFromRouteWithACustomParam() + { + Context::set(ServerRequestInterface::class, (new Request('GET', 'foo'))->withAttribute(Dispatched::class, new Dispatched([ + Dispatcher::FOUND, null, ['custom_route_param' => 'foobar'], + ]))); + $request = new HttpServerRequest(); + + $parser = new RequestParser(); + $parser->setHandlers([ + new AuthHeaders(), + new QueryString(), + new InputSource(), + (new RouteParams())->setKey('custom_route_param'), + ]); + + $this->assertSame($parser->parseToken($request), 'foobar'); + $this->assertTrue($parser->hasToken($request)); + } + + /** @test */ + public function itShouldIgnoreRoutelessRequests() + { + Context::set(ServerRequestInterface::class, (new Request('GET', 'foo'))->withAttribute(Dispatched::class, new Dispatched([ + Dispatcher::FOUND, null, [], + ]))); + $request = new HttpServerRequest(); + + $parser = new RequestParser(); + $parser->setHandlers([ + new AuthHeaders(), + new QueryString(), + new InputSource(), + new RouteParams(), + ]); + + $this->assertNull($parser->parseToken($request)); + $this->assertFalse($parser->hasToken($request)); + } + + /** @test */ + public function itShouldReturnNullIfNoTokenInRequest() + { + Context::set(ServerRequestInterface::class, (new Request('GET', 'foo'))->withAttribute(Dispatched::class, new Dispatched([ + Dispatcher::FOUND, null, [], + ]))); + $request = new HttpServerRequest(); + + $parser = new RequestParser(); + $parser->setHandlers([ + new AuthHeaders(), + new QueryString(), + new InputSource(), + new RouteParams(), + ]); + + $this->assertNull($parser->parseToken($request)); + $this->assertFalse($parser->hasToken($request)); + } + + /** @test */ + public function itShouldRetrieveTheHandlers() + { + $handlers = [ + new AuthHeaders(), + new QueryString(), + new InputSource(), + new RouteParams(), + ]; + + $parser = new RequestParser(); + $parser->setHandlers($handlers); + + $this->assertSame($parser->getHandlers(), $handlers); + } + + /** @test */ + public function itShouldSetTheCookieKey() + { + $cookies = (new Cookies())->setKey('test'); + $this->assertInstanceOf(Cookies::class, $cookies); + } +} diff --git a/tests/Storage/HyperfCacheTest.php b/tests/Storage/HyperfCacheTest.php new file mode 100644 index 0000000..3455430 --- /dev/null +++ b/tests/Storage/HyperfCacheTest.php @@ -0,0 +1,121 @@ +cache = Mockery::mock(Cache::class); + $this->tag = 'jwt.default'; + $this->storage = new HyperfCache($this->cache, $this->tag); + } + + /** @test */ + public function itShouldAddTheItemToStorage() + { + $this->cache->shouldReceive('set')->with($this->resolveKey('foo'), 'bar', 10)->once(); + + $this->storage->add('foo', 'bar', 10); + $this->assertTrue(true); + } + + /** @test */ + public function itShouldAddTheItemToStorageForever() + { + $this->cache->shouldReceive('set')->with($this->resolveKey('foo'), 'bar')->once(); + + $this->storage->forever('foo', 'bar'); + $this->assertTrue(true); + } + + /** @test */ + public function itShouldGetAnItemFromStorage() + { + $this->cache->shouldReceive('get')->with($this->resolveKey('foo'))->once()->andReturn(['foo' => 'bar']); + + $this->assertSame(['foo' => 'bar'], $this->storage->get('foo')); + } + + /** @test */ + public function itShouldRemoveTheItemFromStorage() + { + $this->cache->shouldReceive('delete')->with($this->resolveKey('foo'))->once()->andReturn(true); + + $this->assertTrue($this->storage->destroy('foo')); + } + + /** @test */ + public function itShouldRemoveAllItemsFromStorage() + { + $this->cache->shouldReceive('clear')->withNoArgs()->once(); + + $this->storage->flush(); + $this->assertTrue(true); + } + + /** @test */ + public function itShouldAddTheItemToTaggedStorage() + { + $this->cache->shouldReceive('set')->with($this->resolveKey('foo'), 'bar', 10)->once(); + + $this->storage->add('foo', 'bar', 10); + $this->assertTrue(true); + } + + /** @test */ + public function itShouldAddTheItemToTaggedStorageForever() + { + $this->cache->shouldReceive('set')->with($this->resolveKey('foo'), 'bar')->once(); + + $this->storage->forever('foo', 'bar'); + $this->assertTrue(true); + } + + /** @test */ + public function itShouldGetAnItemFromTaggedStorage() + { + $this->cache->shouldReceive('get')->with($this->resolveKey('foo'))->once()->andReturn(['foo' => 'bar']); + + $this->assertSame(['foo' => 'bar'], $this->storage->get('foo')); + } + + protected function resolveKey(string $key) + { + return $this->tag . '.' . $key; + } +} diff --git a/tests/TokenTest.php b/tests/TokenTest.php new file mode 100644 index 0000000..12bfebe --- /dev/null +++ b/tests/TokenTest.php @@ -0,0 +1,44 @@ +token = new Token('foo.bar.baz'); + } + + /** @test */ + public function itShouldReturnTheTokenWhenCastingToAString() + { + $this->assertEquals((string) $this->token, $this->token); + } + + /** @test */ + public function itShouldReturnTheTokenWhenCallingGetMethod() + { + $this->assertIsString($this->token->get()); + } +} diff --git a/tests/Validators/PayloadValidatorTest.php b/tests/Validators/PayloadValidatorTest.php new file mode 100644 index 0000000..4689248 --- /dev/null +++ b/tests/Validators/PayloadValidatorTest.php @@ -0,0 +1,235 @@ +validator = $this->container->get(PayloadValidatorInterface::class); + $this->validator->setRequiredClaims([ + 'iss', + 'iat', + 'exp', + 'nbf', + 'sub', + 'jti', + ]); + } + + /** @test */ + public function itShouldReturnTrueWhenProvidingAValidPayload() + { + $claims = [ + new Subject(1), + new Issuer('http://example.com'), + new Expiration($this->testNowTimestamp + 3600), + new NotBefore($this->testNowTimestamp), + new IssuedAt($this->testNowTimestamp), + new JwtId('foo'), + ]; + + $collection = Collection::make($claims); + + $this->assertTrue($this->validator->isValid($collection)); + } + + /** @test */ + public function itShouldThrowAnExceptionWhenProvidingAnExpiredPayload() + { + $this->expectExceptionMessage('Token has expired'); + $this->expectException(TokenExpiredException::class); + $claims = [ + new Subject(1), + new Issuer('http://example.com'), + new Expiration($this->testNowTimestamp - 1440), + new NotBefore($this->testNowTimestamp - 3660), + new IssuedAt($this->testNowTimestamp - 3660), + new JwtId('foo'), + ]; + + $collection = Collection::make($claims); + + $this->validator->check($collection); + } + + /** @test */ + public function itShouldThrowAnExceptionWhenProvidingAnInvalidNbfClaim() + { + $this->expectExceptionMessage('Not Before (nbf) timestamp cannot be in the future'); + $this->expectException(TokenInvalidException::class); + $claims = [ + new Subject(1), + new Issuer('http://example.com'), + new Expiration($this->testNowTimestamp + 1440), + new NotBefore($this->testNowTimestamp + 3660), + new IssuedAt($this->testNowTimestamp - 3660), + new JwtId('foo'), + ]; + + $collection = Collection::make($claims); + + $this->validator->check($collection); + } + + /** @test */ + public function itShouldThrowAnExceptionWhenProvidingAnInvalidIatClaim() + { + $this->expectExceptionMessage('Invalid value provided for claim [iat]'); + $this->expectException(InvalidClaimException::class); + $claims = [ + new Subject(1), + new Issuer('http://example.com'), + new Expiration($this->testNowTimestamp + 1440), + new NotBefore($this->testNowTimestamp - 3660), + new IssuedAt($this->testNowTimestamp + 3660), + new JwtId('foo'), + ]; + + $collection = Collection::make($claims); + + $this->validator->check($collection); + } + + /** @test */ + public function itShouldThrowAnExceptionWhenProvidingAnInvalidPayload() + { + $this->expectExceptionMessage('JWT payload does not contain the required claims'); + $this->expectException(TokenInvalidException::class); + $claims = [ + new Subject(1), + new Issuer('http://example.com'), + ]; + + $collection = Collection::make($claims); + + $this->validator->check($collection); + } + + /** @test */ + public function itShouldThrowAnExceptionWhenProvidingAnInvalidExpiry() + { + $this->expectExceptionMessage('Invalid value provided for claim [exp]'); + $this->expectException(InvalidClaimException::class); + $claims = [ + new Subject(1), + new Issuer('http://example.com'), + new Expiration('foo'), + new NotBefore($this->testNowTimestamp - 3660), + new IssuedAt($this->testNowTimestamp + 3660), + new JwtId('foo'), + ]; + + $collection = Collection::make($claims); + + $this->validator->check($collection); + } + + /** @test */ + public function itShouldSetTheRequiredClaims() + { + $claims = [ + new Subject(1), + new Issuer('http://example.com'), + ]; + + $collection = Collection::make($claims); + + $this->assertTrue($this->validator->setRequiredClaims(['iss', 'sub'])->isValid($collection)); + } + + /** @test */ + public function itShouldCheckTheTokenInTheRefreshContext() + { + $this->claimFactory->setRefreshTtl(3600); + + $claims = [ + new Subject(1), + new Issuer('http://example.com'), + new Expiration($this->testNowTimestamp - 1000), + new NotBefore($this->testNowTimestamp), + new IssuedAt($this->testNowTimestamp - 2600), // this is LESS than the refresh ttl at 1 hour + new JwtId('foo'), + ]; + + $collection = Collection::make($claims); + + $this->assertTrue( + $this->validator->isValid($collection, true) + ); + } + + /** @test */ + public function itShouldReturnTrueIfTheRefreshTtlIsNull() + { + $this->claimFactory->setRefreshTtl(null); + + $claims = [ + new Subject(1), + new Issuer('http://example.com'), + new Expiration($this->testNowTimestamp - 1000), + new NotBefore($this->testNowTimestamp), + new IssuedAt($this->testNowTimestamp - 2600), // this is LESS than the refresh ttl at 1 hour + new JwtId('foo'), + ]; + + $collection = Collection::make($claims); + + $this->assertTrue( + $this->validator->isValid($collection, true) + ); + } + + /** @test */ + public function itShouldThrowAnExceptionIfTheTokenCannotBeRefreshed() + { + $this->expectExceptionMessage('Token has expired and can no longer be refreshed'); + $this->expectException(TokenExpiredException::class); + $this->claimFactory->setRefreshTtl(3600); + + $claims = [ + new Subject(1), + new Issuer('http://example.com'), + new Expiration($this->testNowTimestamp), + new NotBefore($this->testNowTimestamp), + new IssuedAt($this->testNowTimestamp - 5000), // this is MORE than the refresh ttl at 1 hour, so is invalid + new JwtId('foo'), + ]; + + $collection = Collection::make($claims); + + $this->validator->check($collection, true); + } +} diff --git a/tests/Validators/TokenValidatorTest.php b/tests/Validators/TokenValidatorTest.php new file mode 100644 index 0000000..44c1099 --- /dev/null +++ b/tests/Validators/TokenValidatorTest.php @@ -0,0 +1,110 @@ +validator = $this->container->get(TokenValidatorInterface::class); + } + + /** @test */ + public function itShouldReturnTrueWhenProvidingAWellFormedToken() + { + $this->assertTrue($this->validator->isValid('one.two.three')); + } + + public function dataProviderMalformedTokens() + { + return [ + ['one.two.'], + ['.two.'], + ['.two.three'], + ['one..three'], + ['..'], + [' . . '], + [' one . two . three '], + ]; + } + + /** + * @test + * @dataProvider \HyperfTest\Validators\TokenValidatorTest::dataProviderMalformedTokens + * + * @param string $token + */ + public function itShouldReturnFalseWhenProvidingAMalformedToken($token) + { + $this->assertFalse($this->validator->isValid($token)); + } + + /** + * @test + * @dataProvider \HyperfTest\Validators\TokenValidatorTest::dataProviderMalformedTokens + * + * @param string $token + */ + public function itShouldThrowAnExceptionWhenProvidingAMalformedToken($token) + { + $this->expectExceptionMessage('Malformed token'); + $this->expectException(TokenInvalidException::class); + $this->validator->check($token); + } + + public function dataProviderTokensWithWrongSegmentsNumber() + { + return [ + ['one.two'], + ['one.two.three.four'], + ['one.two.three.four.five'], + ]; + } + + /** + * @test + * @dataProvider \HyperfTest\Validators\TokenValidatorTest::dataProviderTokensWithWrongSegmentsNumber + * + * @param string $token + */ + public function itShouldReturnFalseWhenProvidingATokenWithWrongSegmentsNumber($token) + { + $this->assertFalse($this->validator->isValid($token)); + } + + /** + * @test + * @dataProvider \HyperfTest\Validators\TokenValidatorTest::dataProviderTokensWithWrongSegmentsNumber + * + * @param string $token + */ + public function itShouldThrowAnExceptionWhenProvidingAMalformedTokenWithWrongSegmentsNumber($token) + { + $this->expectExceptionMessage('Wrong number of segments'); + $this->expectException(TokenInvalidException::class); + $this->validator->check($token); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..3b87b8c --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,48 @@ +set(ConfigInterface::class, $config = new Config([])); + +$container->set(PayloadValidatorInterface::class, new PayloadValidator($config)); +$container->set(TokenValidatorInterface::class, new TokenValidator()); +$container->set(ServerRequestInterface::class, new HttpServerRequest()); +Context::set(ServerRequestInterface::class, new Request('GET', '/')); + +ApplicationContext::setContainer($container); + +$container->get(Hyperf\Contract\ApplicationInterface::class);