From 56c4e5fb4e0a9b30190419452667fabba362022b Mon Sep 17 00:00:00 2001 From: Bagus <25834188+contactjavas@users.noreply.github.com> Date: Wed, 28 Sep 2022 11:29:44 +0700 Subject: [PATCH] Thanks, Mika Tuupola & contributors > This was a fork of [tuupola/slim-jwt-auth](https://github.com/tuupola/slim-jwt-auth) by [Mika Tuupola](https://github.com/tuupola). The fork was taken from `3.x` branch (at [this state](https://github.com/tuupola/slim-jwt-auth/tree/a4d6b3857daccb393f885473a08b2ea25874ae6b)). > > Thanks to Mika Tuupola & the package's contributors for their hard work. > > We forked it because we wanted to use [firebase/php-jwt](https://github.com/firebase/php-jwt) version 6 which had not supported at that time. Related [issue](https://github.com/tuupola/slim-jwt-auth/issues/217). --- .editorconfig | 13 + .gitattributes | 11 + .gitignore | 9 + CHANGELOG.md | 0 CONTRIBUTING.md | 54 ++ Makefile | 37 + README.md | 338 +++++++++ UPGRADING.md | 0 codecov.yml | 1 + composer.json | 49 ++ phpcs.xml | 17 + phpstan.neon | 2 + phpunit.xml.dist | 13 + src/JwtAuth.php | 485 ++++++++++++ src/JwtAuth/RequestMethodRule.php | 71 ++ src/JwtAuth/RequestPathRule.php | 91 +++ src/JwtAuth/RuleInterface.php | 49 ++ tests/ArrayAccessImpl.php | 65 ++ tests/JwtAuthTest.php | 1182 +++++++++++++++++++++++++++++ tests/RequestMethodRuleTest.php | 90 +++ tests/RequestPathRuleTest.php | 212 ++++++ tests/TestAfterHandler.php | 53 ++ tests/TestBeforeHandler.php | 51 ++ tests/TestErrorHandler.php | 58 ++ 24 files changed, 2951 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 Makefile create mode 100644 README.md create mode 100644 UPGRADING.md create mode 100644 codecov.yml create mode 100644 composer.json create mode 100644 phpcs.xml create mode 100644 phpstan.neon create mode 100644 phpunit.xml.dist create mode 100644 src/JwtAuth.php create mode 100644 src/JwtAuth/RequestMethodRule.php create mode 100644 src/JwtAuth/RequestPathRule.php create mode 100644 src/JwtAuth/RuleInterface.php create mode 100644 tests/ArrayAccessImpl.php create mode 100644 tests/JwtAuthTest.php create mode 100644 tests/RequestMethodRuleTest.php create mode 100644 tests/RequestPathRuleTest.php create mode 100644 tests/TestAfterHandler.php create mode 100644 tests/TestBeforeHandler.php create mode 100644 tests/TestErrorHandler.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..7a9d802 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 + +[*.{css,scss,js,ts,tsx}] +indent_size = 2 + +[package.json] +indent_size = 2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..0808b3c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +/.editorconfig export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/CONTRIBUTING.md export-ignore +/Makefile export-ignore +/README.md export-ignore +/codecov.yml export-ignore +/phpunit.xml.dist export-ignore +/tests export-ignore +/package.json export-ignore +/phpunit.xml export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0559241 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +/node_modules/ +/vendor/ +/report/ +composer.lock +.parcel-cache +.env +.phplint-cache +coverage.xml +package-lock.json diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e69de29 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..dde0f22 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,54 @@ +# Contributing + +## Thanks for contributing! + +Following these guidelines improves the possibility of your pull request to get accepted. They also help to save everyones time. + +## Only one feature or change per pull request + +Pull request should contain only one feature or change. For example you have fixed a bug and optimized some code. Optimization is not related to the bug. These should be submitted as two separate pull requests. + +## Discuss new features first + +Before sending a totally new feature it is a good idea to discuss it first. If you have an idea open an issue about it. Maybe there already is a way to achieve what you are after. + +## Write meaningful commit messages + +Proper commit message is a full sentence. It starts with capital letter but does not end with period. The GitHub default `Update filename.js` is not enough. When needed include also longer explanation what the commit does. + +``` +Capitalized, short (50 chars or less) summary + +More detailed explanatory text, if necessary. Wrap it to about 72 +characters or so. In some contexts, the first line is treated as the +subject of an email and the rest of the text as the body. The blank +line separating the summary from the body is critical (unless you omit +the body entirely); tools like rebase can get confused if you run the +two together. +``` + +When in doubt see Tim Pope's blogpost [A Note About Git Commit Messages](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html) + +## Follow the existing coding standards + +Code should look like it is written by one person. Follow the original coding standard. It might be different than yours but it is not a holy war. This project uses **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)**. + +## Include tests + +New features and bugfixes should have an accompanying tests. This single thing greatly improves the possibility of pull request being approved. + +## Test before committing + +You can run tests either manually or automatically on every code change. Automatic tests require [entr](http://entrproject.org/) to work. + +``` bash +$ make test +``` +``` bash +$ brew install entr +$ make watch +``` + +## Send coherent history + +Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please squash them before submitting. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9c95b0b --- /dev/null +++ b/Makefile @@ -0,0 +1,37 @@ +.DEFAULT_GOAL := help + +help: + @echo "" + @echo "Available tasks:" + @echo " test Run all tests and generate coverage" + @echo " watch Run all tests and coverage when a source file is upaded" + @echo " lint Run only linter and code style checker" + @echo " unit Run unit tests and generate coverage" + @echo " static Run static analysis" + @echo " vendor Install dependencies" + @echo " clean Remove vendor and composer.lock" + @echo "" + +vendor: $(wildcard composer.lock) + composer install --prefer-dist + +lint: vendor + vendor/bin/phplint . --exclude=vendor/ + vendor/bin/phpcs -p --standard=PSR2 --extensions=php --encoding=utf-8 --ignore=*/vendor/*,*/benchmarks/* . + +unit: vendor + phpdbg -qrr vendor/bin/phpunit --testdox --coverage-text --coverage-clover=coverage.xml --coverage-html=./report/ + +static: vendor + vendor/bin/phpstan analyse src --level max + +watch: vendor + find . -name "*.php" -not -path "./vendor/*" -o -name "*.json" -not -path "./vendor/*" | entr -c make test + +test: lint unit static + +clean: + rm -rf vendor + rm composer.lock + +.PHONY: help lint unit watch test clean diff --git a/README.md b/README.md new file mode 100644 index 0000000..51c223c --- /dev/null +++ b/README.md @@ -0,0 +1,338 @@ +# PSR-7 and PSR-15 JWT Authentication Middleware + +> This was a fork of [tuupola/slim-jwt-auth](https://github.com/tuupola/slim-jwt-auth) by [Mika Tuupola](https://github.com/tuupola). The fork was taken from `3.x` branch (at [this state](https://github.com/tuupola/slim-jwt-auth/tree/a4d6b3857daccb393f885473a08b2ea25874ae6b)). +> Thanks to Mika Tuupola & the package's contributors for their hard work. +> We forked it because we wanted to use [firebase/php-jwt](https://github.com/firebase/php-jwt) version 6 which had not supported at that time. Related [issue](https://github.com/tuupola/slim-jwt-auth/issues/217). + +This middleware implements JSON Web Token Authentication. It was originally developed for Slim but can be used with any framework using PSR-7 and PSR-15 style middlewares. It has been tested with [Slim Framework](http://www.slimframework.com/) and [Zend Expressive](https://zendframework.github.io/zend-expressive/). + +[![Latest Version](https://img.shields.io/packagist/v/usefulteam/slim-jwt-auth.svg?style=flat-square)](https://packagist.org/packages/usefulteam/slim-jwt-auth) +[![Packagist](https://img.shields.io/packagist/dm/usefulteam/slim-jwt-auth.svg)](https://packagist.org/packages/usefulteam/slim-jwt-auth) +[![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE) + +Middleware does **not** implement OAuth 2.0 authorization server nor does it provide ways to generate, issue or store authentication tokens. It only parses and authenticates a token when passed via header or cookie. This is useful for example when you want to use [JSON Web Tokens as API keys](https://auth0.com/blog/2014/12/02/using-json-web-tokens-as-api-keys/). + +For example implementation see [Slim API Skeleton](https://github.com/usefulteam/slim-api-skeleton). + +## Install + +Install latest version using [composer](https://getcomposer.org/). + +``` bash +$ composer require usefulteam/slim-jwt-auth +``` + +If using Apache add the following to the `.htaccess` file. Otherwise PHP wont have access to `Authorization: Bearer` header. + +``` bash +RewriteRule .* - [env=HTTP_AUTHORIZATION:%{HTTP:Authorization}] +``` + +## Usage + +Configuration options are passed as an array. The only mandatory parameter is `secret` which is used for verifying then token signature. Note again that `secret` is not the token. It is the secret you use to sign the token. + +For simplicity's sake examples show `secret` hardcoded in code. In real life you should store it somewhere else. Good option is environment variable. You can use [dotenv](https://github.com/vlucas/phpdotenv) or something similar for development. Examples assume you are using Slim Framework. + +``` php +$app = new Slim\App; + +$app->add(new Usefulteam\Middleware\JwtAuth([ + "secret" => "supersecretkeyyoushouldnotcommittogithub" +])); +``` + +An example where your secret is stored as an environment variable: + +``` php +$app = new Slim\App; + +$app->add(new Usefulteam\Middleware\JwtAuth([ + "secret" => getenv("JWT_SECRET") +])); +``` + +When a request is made, the middleware tries to validate and decode the token. If a token is not found or there is an error when validating and decoding it, the server will respond with `401 Unauthorized`. + +Validation errors are triggered when the token has been tampered with or the token has expired. For all possible validation errors, see [JWT library](https://github.com/firebase/php-jwt/blob/master/src/JWT.php#L60-L138) source. + + +## Optional parameters +### Path + +The optional `path` parameter allows you to specify the protected part of your website. It can be either a string or an array. You do not need to specify each URL. Instead think of `path` setting as a folder. In the example below everything starting with `/api` will be authenticated. If you do not define `path` all routes will be protected. + +``` php +$app = new Slim\App; + +$app->add(new Usefulteam\Middleware\JwtAuth([ + "path" => "/api", /* or ["/api", "/admin"] */ + "secret" => "supersecretkeyyoushouldnotcommittogithub" +])); +``` + +### Ignore + +With optional `ignore` parameter you can make exceptions to `path` parameter. In the example below everything starting with `/api` and `/admin` will be authenticated with the exception of `/api/token` and `/admin/ping` which will not be authenticated. + +``` php +$app = new Slim\App; + +$app->add(new Usefulteam\Middleware\JwtAuth([ + "path" => ["/api", "/admin"], + "ignore" => ["/api/token", "/admin/ping"], + "secret" => "supersecretkeyyoushouldnotcommittogithub" +])); +``` + +### Header + +By default middleware tries to find the token from `Authorization` header. You can change header name using the `header` parameter. + +``` php +$app = new Slim\App; + +$app->add(new Usefulteam\Middleware\JwtAuth([ + "header" => "X-Token", + "secret" => "supersecretkeyyoushouldnotcommittogithub" +])); +``` + +### Regexp + +By default the middleware assumes the value of the header is in `Bearer ` format. You can change this behaviour with `regexp` parameter. For example if you have custom header such as `X-Token: ` you should pass both header and regexp parameters. + +``` php +$app = new Slim\App; + +$app->add(new Usefulteam\Middleware\JwtAuth([ + "header" => "X-Token", + "regexp" => "/(.*)/", + "secret" => "supersecretkeyyoushouldnotcommittogithub" +])); +``` + +### Cookie + +If token is not found from neither environment or header, the middleware tries to find it from cookie named `token`. You can change cookie name using `cookie` parameter. + +``` php +$app = new Slim\App; + +$app->add(new Usefulteam\Middleware\JwtAuth([ + "cookie" => "nekot", + "secret" => "supersecretkeyyoushouldnotcommittogithub" +])); +``` + +### Algorithm + +You can set supported algorithms via `algorithm` parameter. This can be either string or array of strings. Default value is `["HS256", "HS512", "HS384"]`. Supported algorithms are `HS256`, `HS384`, `HS512` and `RS256`. Note that enabling both `HS256` and `RS256` is a [security risk](https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/). + +``` php +$app = new Slim\App; + +$app->add(new Usefulteam\Middleware\JwtAuth([ + "secret" => "supersecretkeyyoushouldnotcommittogithub", + "algorithm" => ["HS256", "HS384"] +])); +``` + +### Attribute + +When the token is decoded successfully and authentication succeeds the contents of the decoded token is saved as `token` attribute to the `$request` object. You can change this with. `attribute` parameter. Set to `null` or `false` to disable this behavour + +``` php +$app = new Slim\App; + +$app->add(new Usefulteam\Middleware\JwtAuth([ + "attribute" => "jwt", + "secret" => "supersecretkeyyoushouldnotcommittogithub" +])); + +/* ... */ + +$decoded = $request->getAttribute("jwt"); +``` + +### Logger + +The optional `logger` parameter allows you to pass in a PSR-3 compatible logger to help with debugging or other application logging needs. + +``` php +use Monolog\Logger; +use Monolog\Handler\RotatingFileHandler; + +$app = new Slim\App; + +$logger = new Logger("slim"); +$rotating = new RotatingFileHandler(__DIR__ . "/logs/slim.log", 0, Logger::DEBUG); +$logger->pushHandler($rotating); + +$app->add(new Usefulteam\Middleware\JwtAuth([ + "path" => "/api", + "logger" => $logger, + "secret" => "supersecretkeyyoushouldnotcommittogithub" +])); +``` + +### Before + +Before function is called only when authentication succeeds but before the next incoming middleware is called. You can use this to alter the request before passing it to the next incoming middleware in the stack. If it returns anything else than `Psr\Http\Message\ServerRequestInterface` the return value will be ignored. + +``` php +$app = new Slim\App; + +$app->add(new Usefulteam\Middleware\JwtAuth([ + "secret" => "supersecretkeyyoushouldnotcommittogithub", + "before" => function ($request, $arguments) { + return $request->withAttribute("test", "test"); + } +])); +``` + +### After + +After function is called only when authentication succeeds and after the incoming middleware stack has been called. You can use this to alter the response before passing it next outgoing middleware in the stack. If it returns anything else than `Psr\Http\Message\ResponseInterface` the return value will be ignored. + + +``` php +$app = new Slim\App; + +$app->add(new Usefulteam\Middleware\JwtAuth([ + "secret" => "supersecretkeyyoushouldnotcommittogithub", + "after" => function ($response, $arguments) { + return $response->withHeader("X-Brawndo", "plants crave"); + } +])); +``` + +> Note that both the after and before callback functions receive the raw token string as well as the decoded claims through the `$arguments` argument. + +### Error + +Error is called when authentication fails. It receives last error message in arguments. You can use this for example to return JSON formatted error responses. + +```php +$app = new Slim\App; + +$app->add(new Usefulteam\Middleware\JwtAuth([ + "secret" => "supersecretkeyyoushouldnotcommittogithub", + "error" => function ($response, $arguments) { + $data["status"] = "error"; + $data["message"] = $arguments["message"]; + + $response->getBody()->write( + json_encode($data, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) + ); + + return $response->withHeader("Content-Type", "application/json") + } +])); +``` + +### Rules + +The optional `rules` parameter allows you to pass in rules which define whether the request should be authenticated or not. A rule is a callable which receives the request as parameter. If any of the rules returns boolean `false` the request will not be authenticated. + +By default middleware configuration looks like this. All paths are authenticated with all request methods except `OPTIONS`. + +``` php +$app = new Slim\App; + +$app->add(new Usefulteam\Middleware\JwtAuth([ + "rules" => [ + new Usefulteam\Middleware\JwtAuth\RequestPathRule([ + "path" => "/", + "ignore" => [] + ]), + new Usefulteam\Middleware\JwtAuth\RequestMethodRule([ + "ignore" => ["OPTIONS"] + ]) + ] +])); +``` + +RequestPathRule contains both a `path` parameter and a `ignore` parameter. Latter contains paths which should not be authenticated. RequestMethodRule contains `ignore` parameter of request methods which also should not be authenticated. Think of `ignore` as a whitelist. + +99% of the cases you do not need to use the `rules` parameter. It is only provided for special cases when defaults do not suffice. + +## Security + +JSON Web Tokens are essentially passwords. You should treat them as such and you should always use HTTPS. If the middleware detects insecure usage over HTTP it will throw a `RuntimeException`. This rule is relaxed for requests on localhost. To allow insecure usage you must enable it manually by setting `secure` to `false`. + +``` php +$app->add(new Usefulteam\Middleware\JwtAuth([ + "secure" => false, + "secret" => "supersecretkeyyoushouldnotcommittogithub" +])); +``` + +Alternatively you can list your development host to have relaxed security. + +``` php +$app->add(new Usefulteam\Middleware\JwtAuth([ + "secure" => true, + "relaxed" => ["localhost", "dev.example.com"], + "secret" => "supersecretkeyyoushouldnotcommittogithub" +])); +``` + +## Authorization + +By default middleware only authenticates. This is not very interesting. Beauty of JWT is you can pass extra data in the token. This data can include for example scope which can be used for authorization. + +**It is up to you to implement how token data is stored or possible authorization implemented.** + +Let assume you have token which includes data for scope. By default middleware saves the contents of the token to `token` attribute of the request. + +``` php +[ + "iat" => "1428819941", + "exp" => "1744352741", + "scope" => ["read", "write", "delete"] +] +``` + +``` php +$app = new Slim\App; + +$app->add(new Usefulteam\Middleware\JwtAuth([ + "secret" => "supersecretkeyyoushouldnotcommittogithub" +])); + +$app->delete("/item/{id}", function ($request, $response, $arguments) { + $token = $request->getAttribute("token"); + if (in_array("delete", $token["scope"])) { + /* Code for deleting item */ + } else { + /* No scope so respond with 401 Unauthorized */ + return $response->withStatus(401); + } +}); +``` + +## Testing + +You can run tests either manually or automatically on every code change. Automatic tests require [entr](http://entrproject.org/) to work. + +``` bash +$ make test +``` + +``` bash +$ brew install entr +$ make watch +``` + +## Contributing + +Please see [CONTRIBUTING](CONTRIBUTING.md) for details. + +## Security + +If you discover any security related issues, please email bagus@usefulteam.com instead of using the issue tracker. + +## License +- [tuupola/slim-jwt-auth](https://github.com/tuupola/slim-jwt-auth/) `3.x` branch license: The [MIT License](https://github.com/tuupola/slim-jwt-auth/blob/3.x/LICENSE) +- [usefulteam/slim-jwt-auth](https://github.com/usefulteam/slim-jwt-auth/) license: The [MIT License](https://oss.ninja/mit?organization=Useful%20Team&project=Slim%20Jwt%20Auth) diff --git a/UPGRADING.md b/UPGRADING.md new file mode 100644 index 0000000..e69de29 diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..959972a --- /dev/null +++ b/codecov.yml @@ -0,0 +1 @@ +comment: false \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..a008fce --- /dev/null +++ b/composer.json @@ -0,0 +1,49 @@ +{ + "name": "usefulteam/slim-jwt-auth", + "description": "PSR-7 and PSR-15 JWT Authentication Middleware", + "keywords": [ + "psr-7", + "psr-15", + "middleware", + "jwt", + "json", + "auth" + ], + "homepage": "https://github.com/usefulteam/slim-jwt-auth", + "license": "MIT", + "authors": [ + { + "name": "Bagus", + "email": "bagus@usefulteam.com", + "homepage": "https://usefulteam.com/", + "role": "Developer" + } + ], + "require": { + "php": "^8.1", + "psr/log": "^3.0", + "firebase/php-jwt": "^6.0", + "psr/http-message": "^1.0", + "tuupola/http-factory": "^1.4", + "tuupola/callable-handler": "^1.1", + "psr/http-server-middleware": "^1.0" + }, + "require-dev": { + "squizlabs/php_codesniffer": "^3.7", + "overtrue/phplint": "^5.3", + "phpstan/phpstan": "^1.8", + "equip/dispatch": "^2.0", + "laminas/laminas-diactoros": "^2.17", + "phpunit/phpunit": "9.5" + }, + "autoload": { + "psr-4": { + "Usefulteam\\Middleware\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "Usefulteam\\Middleware\\": "tests" + } + } +} diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 0000000..3d3ab6a --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,17 @@ + + + The coding standard + + + + + + + + + + + + src + + diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..74ea988 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,2 @@ +parameters: + treatPhpDocTypesAsCertain: false \ No newline at end of file diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..e0a6e13 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,13 @@ + + + + + tests/ + + + + + src + + + diff --git a/src/JwtAuth.php b/src/JwtAuth.php new file mode 100644 index 0000000..14ee080 --- /dev/null +++ b/src/JwtAuth.php @@ -0,0 +1,485 @@ + + */ + private $rules; + + /** + * Stores all the options passed to the middleware. + * @var mixed[] + */ + private $options = [ + "secure" => true, + "relaxed" => ["localhost", "127.0.0.1"], + "algorithm" => ["HS256", "HS512", "HS384"], + "header" => "Authorization", + "regexp" => "/Bearer\s+(.*)$/i", + "cookie" => "token", + "attribute" => "token", + "path" => "/", + "ignore" => null, + "before" => null, + "after" => null, + "error" => null + ]; + + /** + * @param mixed[] $options + */ + public function __construct(array $options = []) + { + /* Setup stack for rules */ + $this->rules = new \SplStack; + + /* Store passed in options overwriting any defaults. */ + $this->hydrate($options); + + /* If nothing was passed in options add default rules. */ + /* This also means $options["rules"] overrides $options["path"] */ + /* and $options["ignore"] */ + if (!isset($options["rules"])) { + $this->rules->push(new RequestMethodRule([ + "ignore" => ["OPTIONS"] + ])); + $this->rules->push(new RequestPathRule([ + "path" => $this->options["path"], + "ignore" => $this->options["ignore"] + ])); + } + } + + /** + * Process a request in PSR-15 style and return a response. + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $scheme = $request->getUri()->getScheme(); + $host = $request->getUri()->getHost(); + + /* If rules say we should not authenticate call next and return. */ + if (false === $this->shouldAuthenticate($request)) { + return $handler->handle($request); + } + + /* HTTP allowed only if secure is false or server is in relaxed array. */ + if ("https" !== $scheme && true === $this->options["secure"]) { + if (!in_array($host, $this->options["relaxed"])) { + $message = sprintf( + "Insecure use of middleware over %s denied by configuration.", + strtoupper($scheme) + ); + throw new RuntimeException($message); + } + } + + /* If token cannot be found or decoded return with 401 Unauthorized. */ + try { + $token = $this->fetchToken($request); + $decoded = $this->decodeToken($token); + } catch (RuntimeException | DomainException $exception) { + $response = (new ResponseFactory)->createResponse(401); + return $this->processError($response, [ + "message" => $exception->getMessage(), + "uri" => (string)$request->getUri() + ]); + } + + $params = [ + "decoded" => $decoded, + "token" => $token, + ]; + + /* Add decoded token to request as attribute when requested. */ + if ($this->options["attribute"]) { + $request = $request->withAttribute($this->options["attribute"], $decoded); + } + + /* Modify $request before calling next middleware. */ + if (is_callable($this->options["before"])) { + $beforeRequest = $this->options["before"]($request, $params); + if ($beforeRequest instanceof ServerRequestInterface) { + $request = $beforeRequest; + } + } + + /* Everything ok, call next middleware. */ + $response = $handler->handle($request); + + /* Modify $response before returning. */ + if (is_callable($this->options["after"])) { + $afterResponse = $this->options["after"]($response, $params); + if ($afterResponse instanceof ResponseInterface) { + return $afterResponse; + } + } + + return $response; + } + + /** + * Set all rules in the stack. + * + * @param RuleInterface[] $rules + */ + public function withRules(array $rules): self + { + $new = clone $this; + /* Clear the stack */ + unset($new->rules); + $new->rules = new \SplStack; + /* Add the rules */ + foreach ($rules as $callable) { + $new = $new->addRule($callable); + } + return $new; + } + + /** + * Add a rule to the stack. + */ + public function addRule(callable $callable): self + { + $new = clone $this; + $new->rules = clone $this->rules; + $new->rules->push($callable); + return $new; + } + + /** + * Check if middleware should authenticate. + */ + private function shouldAuthenticate(ServerRequestInterface $request): bool + { + /* If any of the rules in stack return false will not authenticate */ + foreach ($this->rules as $callable) { + if (false === $callable($request)) { + return false; + } + } + return true; + } + + /** + * Call the error handler if it exists. + * + * @param mixed[] $arguments + */ + private function processError(ResponseInterface $response, array $arguments): ResponseInterface + { + if (is_callable($this->options["error"])) { + $handlerResponse = $this->options["error"]($response, $arguments); + if ($handlerResponse instanceof ResponseInterface) { + return $handlerResponse; + } + } + return $response; + } + + /** + * Fetch the access token. + */ + private function fetchToken(ServerRequestInterface $request): string + { + /* Check for token in header. */ + $header = $request->getHeaderLine($this->options["header"]); + + if (false === empty($header)) { + if (preg_match($this->options["regexp"], $header, $matches)) { + $this->log(LogLevel::DEBUG, "Using token from request header"); + return $matches[1]; + } + } + + /* Token not found in header try a cookie. */ + $cookieParams = $request->getCookieParams(); + + if (isset($cookieParams[$this->options["cookie"]])) { + $this->log(LogLevel::DEBUG, "Using token from cookie"); + if (preg_match($this->options["regexp"], $cookieParams[$this->options["cookie"]], $matches)) { + return $matches[1]; + } + return $cookieParams[$this->options["cookie"]]; + }; + + /* If everything fails log and throw. */ + $this->log(LogLevel::WARNING, "Token not found"); + throw new RuntimeException("Token not found."); + } + + /** + * Decode the token. + * + * @return mixed[] + */ + private function decodeToken(string $token): array + { + try { + $decoded = JWT::decode( + $token, + $this->options["secret"], + (array) $this->options["algorithm"] + ); + return (array) $decoded; + } catch (Exception $exception) { + $this->log(LogLevel::WARNING, $exception->getMessage(), [$token]); + throw $exception; + } + } + + /** + * Hydrate options from given array. + * + * @param mixed[] $data + */ + private function hydrate(array $data = []): void + { + foreach ($data as $key => $value) { + /* https://github.com/facebook/hhvm/issues/6368 */ + $key = str_replace(".", " ", $key); + $method = lcfirst(ucwords($key)); + $method = str_replace(" ", "", $method); + if (method_exists($this, $method)) { + /* Try to use setter */ + /** @phpstan-ignore-next-line */ + call_user_func([$this, $method], $value); + } else { + /* Or fallback to setting option directly */ + $this->options[$key] = $value; + } + } + } + + /** + * Set path where middleware should bind to. + * + * @param string|string[] $path + */ + private function path($path): void + { + $this->options["path"] = (array) $path; + } + + /** + * Set path which middleware ignores. + * + * @param string|string[] $ignore + */ + private function ignore($ignore): void + { + $this->options["ignore"] = (array) $ignore; + } + + /** + * Set the cookie name where to search the token from. + */ + private function cookie(string $cookie): void + { + $this->options["cookie"] = $cookie; + } + + /** + * Set the secure flag. + */ + private function secure(bool $secure): void + { + $this->options["secure"] = $secure; + } + + /** + * Set hosts where secure rule is relaxed. + * + * @param string[] $relaxed + */ + private function relaxed(array $relaxed): void + { + $this->options["relaxed"] = $relaxed; + } + + /** + * Set the secret key. + * + * @param string|string[] $secret + */ + private function secret($secret): void + { + if (false === is_array($secret) && false === is_string($secret) && ! $secret instanceof \ArrayAccess) { + throw new InvalidArgumentException( + 'Secret must be either a string or an array of "kid" => "secret" pairs' + ); + } + $this->options["secret"] = $secret; + } + + /** + * Set the error handler. + */ + private function error(callable $error): void + { + if ($error instanceof Closure) { + $this->options["error"] = $error->bindTo($this); + } else { + $this->options["error"] = $error; + } + } + + /** + * Set the logger. + */ + private function logger(LoggerInterface $logger = null): void + { + $this->logger = $logger; + } + + /** + * Logs with an arbitrary level. + * + * @param mixed[] $context + */ + private function log(string $level, string $message, array $context = []): void + { + if ($this->logger) { + $this->logger->log($level, $message, $context); + } + } + + /** + * Set the attribute name used to attach decoded token to request. + */ + private function attribute(string $attribute): void + { + $this->options["attribute"] = $attribute; + } + + /** + * Set the header where token is searched from. + */ + private function header(string $header): void + { + $this->options["header"] = $header; + } + + /** + * Set the regexp used to extract token from header or environment. + */ + private function regexp(string $regexp): void + { + $this->options["regexp"] = $regexp; + } + + /** + * Set the allowed algorithms + * + * @param string|string[] $algorithm + */ + private function algorithm($algorithm): void + { + $this->options["algorithm"] = (array) $algorithm; + } + + /** + * Set the before handler. + */ + + private function before(callable $before): void + { + if ($before instanceof Closure) { + $this->options["before"] = $before->bindTo($this); + } else { + $this->options["before"] = $before; + } + } + + /** + * Set the after handler. + */ + private function after(callable $after): void + { + if ($after instanceof Closure) { + $this->options["after"] = $after->bindTo($this); + } else { + $this->options["after"] = $after; + } + } + + /** + * Set the rules. + * @param RuleInterface[] $rules + */ + private function rules(array $rules): void + { + foreach ($rules as $callable) { + $this->rules->push($callable); + } + } +} diff --git a/src/JwtAuth/RequestMethodRule.php b/src/JwtAuth/RequestMethodRule.php new file mode 100644 index 0000000..12c6fda --- /dev/null +++ b/src/JwtAuth/RequestMethodRule.php @@ -0,0 +1,71 @@ + ["OPTIONS"] + ]; + + /** + * @param mixed[] $options + */ + public function __construct(array $options = []) + { + $this->options = array_merge($this->options, $options); + } + + public function __invoke(ServerRequestInterface $request): bool + { + return !in_array($request->getMethod(), $this->options["ignore"]); + } +} diff --git a/src/JwtAuth/RequestPathRule.php b/src/JwtAuth/RequestPathRule.php new file mode 100644 index 0000000..51a2e17 --- /dev/null +++ b/src/JwtAuth/RequestPathRule.php @@ -0,0 +1,91 @@ + ["/"], + "ignore" => [] + ]; + + /** + * @param mixed[] $options + */ + public function __construct(array $options = []) + { + $this->options = array_merge($this->options, $options); + } + + public function __invoke(ServerRequestInterface $request): bool + { + $uri = "/" . $request->getUri()->getPath(); + $uri = preg_replace("#/+#", "/", $uri); + + /* If request path is matches ignore should not authenticate. */ + foreach ((array)$this->options["ignore"] as $ignore) { + $ignore = rtrim($ignore, "/"); + if (!!preg_match("@^{$ignore}(/.*)?$@", (string) $uri)) { + return false; + } + } + + /* Otherwise check if path matches and we should authenticate. */ + foreach ((array)$this->options["path"] as $path) { + $path = rtrim($path, "/"); + if (!!preg_match("@^{$path}(/.*)?$@", (string) $uri)) { + return true; + } + } + return false; + } +} diff --git a/src/JwtAuth/RuleInterface.php b/src/JwtAuth/RuleInterface.php new file mode 100644 index 0000000..78993b1 --- /dev/null +++ b/src/JwtAuth/RuleInterface.php @@ -0,0 +1,49 @@ +array[$offset]); + } + + public function offsetGet($offset) + { + return $this->array[$offset]; + } + + public function offsetSet($offset, $value) + { + $this->array[$offset] = $value; + } + + public function offsetUnset($offset) + { + unset($this->array[$offset]); + } +} diff --git a/tests/JwtAuthTest.php b/tests/JwtAuthTest.php new file mode 100644 index 0000000..c7fcfe3 --- /dev/null +++ b/tests/JwtAuthTest.php @@ -0,0 +1,1182 @@ + "Acme Toothpics Ltd", + "iat" => "1428819941", + "exp" => "1744352741", + "aud" => "www.example.com", + "sub" => "someone@example.com", + "scope" => ["read", "write", "delete"] + ]; + + public static $betaTokenArray = [ + "iss" => "Beta Sponsorship Ltd", + "iat" => "1428819941", + "exp" => "1744352741", + "aud" => "www.example.com", + "sub" => "someone@example.com", + "scope" => ["read"] + ]; + + public function testShouldReturn401WithoutToken() + { + $request = (new ServerRequestFactory) + ->createServerRequest("GET", "https://example.com/api"); + + $default = function (RequestInterface $request) { + $response = (new ResponseFactory)->createResponse(); + $response->getBody()->write("Success"); + return $response; + }; + + $collection = new MiddlewareCollection([ + $auth = new JwtAuth([ + "secret" => "supersecretkeyyoushouldnotcommittogithub" + ]) + ]); + + $response = $collection->dispatch($request, $default); + + $this->assertEquals(401, $response->getStatusCode()); + $this->assertEquals("", $response->getBody()); + } + + public function testShouldReturn200WithTokenFromHeader() + { + $request = (new ServerRequestFactory) + ->createServerRequest("GET", "https://example.com/api") + ->withHeader("X-Token", "Bearer " . self::$acmeToken); + + $default = function (ServerRequestInterface $request) { + $response = (new ResponseFactory)->createResponse(); + $response->getBody()->write("Success"); + return $response; + }; + + $collection = new MiddlewareCollection([ + new JwtAuth([ + "secret" => "supersecretkeyyoushouldnotcommittogithub", + "header" => "X-Token" + ]) + ]); + + $response = $collection->dispatch($request, $default); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals("Success", $response->getBody()); + } + + public function testShouldReturn200WithTokenFromHeaderWithCustomRegexp() + { + $request = (new ServerRequestFactory) + ->createServerRequest("GET", "https://example.com/api") + ->withHeader("X-Token", self::$acmeToken); + + $default = function (ServerRequestInterface $request) { + $response = (new ResponseFactory)->createResponse(); + $response->getBody()->write("Success"); + return $response; + }; + + $collection = new MiddlewareCollection([ + new JwtAuth([ + "secret" => "supersecretkeyyoushouldnotcommittogithub", + "header" => "X-Token", + "regexp" => "/(.*)/" + ]) + ]); + + $response = $collection->dispatch($request, $default); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals("Success", $response->getBody()); + } + + public function testShouldReturn200WithTokenFromCookie() + { + $request = (new ServerRequestFactory) + ->createServerRequest("GET", "https://example.com/api") + ->withCookieParams(["nekot" => self::$acmeToken]); + + $default = function (ServerRequestInterface $request) { + $response = (new ResponseFactory)->createResponse(); + $response->getBody()->write("Success"); + return $response; + }; + + $collection = new MiddlewareCollection([ + new JwtAuth([ + "secret" => "supersecretkeyyoushouldnotcommittogithub", + "cookie" => "nekot", + ]) + ]); + + $response = $collection->dispatch($request, $default); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals("Success", $response->getBody()); + } + + public function testShouldReturn200WithTokenFromBearerCookie() + { + $request = (new ServerRequestFactory) + ->createServerRequest("GET", "https://example.com/api") + ->withCookieParams(["nekot" => "Bearer " . self::$acmeToken]); + + $default = function (ServerRequestInterface $request) { + $response = (new ResponseFactory)->createResponse(); + $response->getBody()->write("Success"); + return $response; + }; + + $collection = new MiddlewareCollection([ + new JwtAuth([ + "secret" => "supersecretkeyyoushouldnotcommittogithub", + "cookie" => "nekot", + ]) + ]); + + $response = $collection->dispatch($request, $default); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals("Success", $response->getBody()); + } + + + public function testShouldReturn200WithSecretArray() + { + $request = (new ServerRequestFactory) + ->createServerRequest("GET", "https://example.com/api") + ->withHeader("Authorization", "Bearer " . self::$betaToken); + + $default = function (ServerRequestInterface $request) { + $response = (new ResponseFactory)->createResponse(); + $response->getBody()->write("Success"); + return $response; + }; + + $collection = new MiddlewareCollection([ + new JwtAuth([ + "secret" => [ + "acme" =>"supersecretkeyyoushouldnotcommittogithub", + "beta" =>"anothersecretkeyfornevertocommittogithub" + ] + ]) + ]); + + $response = $collection->dispatch($request, $default); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals("Success", $response->getBody()); + } + + public function testShouldReturn401WithSecretArray() + { + $request = (new ServerRequestFactory) + ->createServerRequest("GET", "https://example.com/api") + ->withHeader("Authorization", "Bearer " . self::$betaToken); + + $default = function (ServerRequestInterface $request) { + $response = (new ResponseFactory)->createResponse(); + $response->getBody()->write("Success"); + return $response; + }; + + $collection = new MiddlewareCollection([ + new JwtAuth([ + "secret" => [ + "xxxx" =>"supersecretkeyyoushouldnotcommittogithub", + "yyyy" =>"anothersecretkeyfornevertocommittogithub" + ] + ]) + ]); + + $response = $collection->dispatch($request, $default); + $this->assertEquals(401, $response->getStatusCode()); + $this->assertEquals("", $response->getBody()); + } + + public function testShouldReturn200WithSecretArrayAccess() + { + $request = (new ServerRequestFactory) + ->createServerRequest("GET", "https://example.com/api") + ->withHeader("Authorization", "Bearer " . self::$betaToken); + + $default = function (ServerRequestInterface $request) { + $response = (new ResponseFactory)->createResponse(); + $response->getBody()->write("Success"); + return $response; + }; + + $secret = new ArrayAccessImpl(); + $secret["acme"] = "supersecretkeyyoushouldnotcommittogithub"; + $secret["beta"] ="anothersecretkeyfornevertocommittogithub"; + + $collection = new MiddlewareCollection([ + new JwtAuth([ + "secret" => $secret + ]) + ]); + + $response = $collection->dispatch($request, $default); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals("Success", $response->getBody()); + } + + public function testShouldReturn401WithSecretArrayAccess() + { + $request = (new ServerRequestFactory) + ->createServerRequest("GET", "https://example.com/api") + ->withHeader("Authorization", "Bearer " . self::$betaToken); + + $default = function (ServerRequestInterface $request) { + $response = (new ResponseFactory)->createResponse(); + $response->getBody()->write("Success"); + return $response; + }; + + $secret = new ArrayAccessImpl(); + $secret["xxxx"] = "supersecretkeyyoushouldnotcommittogithub"; + $secret["yyyy"] = "anothersecretkeyfornevertocommittogithub"; + + $collection = new MiddlewareCollection([ + new JwtAuth([ + "secret" => $secret + ]) + ]); + + $response = $collection->dispatch($request, $default); + $this->assertEquals(401, $response->getStatusCode()); + $this->assertEquals("", $response->getBody()); + } + + public function testShouldAlterResponseWithAnonymousAfter() + { + $request = (new ServerRequestFactory) + ->createServerRequest("GET", "https://example.com/api") + ->withHeader("Authorization", "Bearer " . self::$acmeToken); + + $default = function (ServerRequestInterface $request) { + $response = (new ResponseFactory)->createResponse(); + $response->getBody()->write("Success"); + return $response; + }; + + $collection = new MiddlewareCollection([ + new JwtAuth([ + "secret" => "supersecretkeyyoushouldnotcommittogithub", + "after" => function ($response, $arguments) { + return $response->withHeader("X-Brawndo", "plants crave"); + } + ]) + ]); + + $response = $collection->dispatch($request, $default); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals("plants crave", (string) $response->getHeaderLine("X-Brawndo")); + } + + public function testShouldAlterResponseWithInvokableAfter() + { + $request = (new ServerRequestFactory) + ->createServerRequest("GET", "https://example.com/api") + ->withHeader("Authorization", "Bearer " . self::$acmeToken); + + $default = function (ServerRequestInterface $request) { + $response = (new ResponseFactory)->createResponse(); + $response->getBody()->write("Success"); + return $response; + }; + + $collection = new MiddlewareCollection([ + new JwtAuth([ + "secret" => "supersecretkeyyoushouldnotcommittogithub", + "after" => new TestAfterHandler + ]) + ]); + + $response = $collection->dispatch($request, $default); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals( + "plants crave", + (string) $response->getHeaderLine("X-Brawndo") + ); + } + + public function testShouldAlterResponseWithArrayNotationAfter() + { + $request = (new ServerRequestFactory) + ->createServerRequest("GET", "https://example.com/api") + ->withHeader("Authorization", "Bearer " . self::$acmeToken); + + $default = function (ServerRequestInterface $request) { + $response = (new ResponseFactory)->createResponse(); + $response->getBody()->write("Success"); + return $response; + }; + + $collection = new MiddlewareCollection([ + new JwtAuth([ + "secret" => "supersecretkeyyoushouldnotcommittogithub", + "after" => [TestAfterHandler::class, "after"] + ]) + ]); + + $response = $collection->dispatch($request, $default); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals( + "like from toilet?", + (string) $response->getHeaderLine("X-Water") + ); + } + + public function testShouldReturn401WithInvalidAlgorithm() + { + $request = (new ServerRequestFactory) + ->createServerRequest("GET", "https://example.com/api") + ->withHeader("Authorization", "Bearer " . self::$acmeToken); + + $default = function (ServerRequestInterface $request) { + $response = (new ResponseFactory)->createResponse(); + $response->getBody()->write("Success"); + return $response; + }; + + $collection = new MiddlewareCollection([ + new JwtAuth([ + "secret" => "supersecretkeyyoushouldnotcommittogithub", + "algorithm" => "nosuch", + ]) + ]); + + $response = $collection->dispatch($request, $default); + + $this->assertEquals(401, $response->getStatusCode()); + $this->assertEquals("", $response->getBody()); + } + + public function testShouldReturn200WithOptions() + { + $request = (new ServerRequestFactory) + ->createServerRequest("GET", "https://example.com/api") + ->withMethod("OPTIONS"); + + $default = function (ServerRequestInterface $request) { + $response = (new ResponseFactory)->createResponse(); + $response->getBody()->write("Success"); + return $response; + }; + + $collection = new MiddlewareCollection([ + new JwtAuth([ + "secret" => "supersecretkeyyoushouldnotcommittogithub" + ]) + ]); + + $response = $collection->dispatch($request, $default); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals("Success", $response->getBody()); + } + + public function testShouldReturn400WithInvalidToken() + { + $request = (new ServerRequestFactory) + ->createServerRequest("GET", "https://example.com/api") + ->withHeader("Authorization", "Bearer invalid" . self::$acmeToken); + + $default = function (ServerRequestInterface $request) { + $response = (new ResponseFactory)->createResponse(); + $response->getBody()->write("Success"); + return $response; + }; + + $collection = new MiddlewareCollection([ + new JwtAuth([ + "secret" => "supersecretkeyyoushouldnotcommittogithub" + ]) + ]); + + $response = $collection->dispatch($request, $default); + + $this->assertEquals(401, $response->getStatusCode()); + $this->assertEquals("", $response->getBody()); + } + + public function testShouldReturn400WithExpiredToken() + { + $request = (new ServerRequestFactory) + ->createServerRequest("GET", "https://example.com/api") + ->withHeader("Authorization", "Bearer " . self::$expired); + + $default = function (ServerRequestInterface $request) { + $response = (new ResponseFactory)->createResponse(); + $response->getBody()->write("Success"); + return $response; + }; + + $collection = new MiddlewareCollection([ + new JwtAuth([ + "secret" => "supersecretkeyyoushouldnotcommittogithub" + ]) + ]); + + $response = $collection->dispatch($request, $default); + + $this->assertEquals(401, $response->getStatusCode()); + $this->assertEquals("", $response->getBody()); + } + + public function testShouldReturn200WithoutTokenWithPath() + { + $request = (new ServerRequestFactory) + ->createServerRequest("GET", "https://example.com/public"); + + $default = function (ServerRequestInterface $request) { + $response = (new ResponseFactory)->createResponse(); + $response->getBody()->write("Success"); + return $response; + }; + + $collection = new MiddlewareCollection([ + new JwtAuth([ + "path" => ["/api", "/foo"], + "secret" => "supersecretkeyyoushouldnotcommittogithub" + ]) + ]); + + $response = $collection->dispatch($request, $default); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals("Success", $response->getBody()); + } + + public function testShouldReturn200WithoutTokenWithIgnore() + { + $request = (new ServerRequestFactory) + ->createServerRequest("GET", "https://example.com/api/ping"); + + $default = function (ServerRequestInterface $request) { + $response = (new ResponseFactory)->createResponse(); + $response->getBody()->write("Success"); + return $response; + }; + + $collection = new MiddlewareCollection([ + new JwtAuth([ + "path" => ["/api", "/foo"], + "ignore" => ["/api/ping"], + "secret" => "supersecretkeyyoushouldnotcommittogithub" + ]) + ]); + + $response = $collection->dispatch($request, $default); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals("Success", $response->getBody()); + } + + public function testShouldNotAllowInsecure() + { + $this->expectException("RuntimeException"); + + $request = (new ServerRequestFactory) + ->createServerRequest("GET", "http://example.com/api") + ->withHeader("Authorization", "Bearer " . self::$acmeToken); + + $default = function (ServerRequestInterface $request) { + $response = (new ResponseFactory)->createResponse(); + $response->getBody()->write("Success"); + return $response; + }; + + $collection = new MiddlewareCollection([ + new JwtAuth([ + "secret" => "supersecretkeyyoushouldnotcommittogithub" + ]) + ]); + + $response = $collection->dispatch($request, $default); + } + + public function testShouldAllowInsecure() + { + $request = (new ServerRequestFactory) + ->createServerRequest("GET", "http://example.com/api") + ->withHeader("Authorization", "Bearer " . self::$acmeToken); + + $default = function (ServerRequestInterface $request) { + $response = (new ResponseFactory)->createResponse(); + $response->getBody()->write("Success"); + return $response; + }; + + $collection = new MiddlewareCollection([ + new JwtAuth([ + "secret" => "supersecretkeyyoushouldnotcommittogithub", + "secure" => false + ]) + ]); + + $response = $collection->dispatch($request, $default); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals("Success", $response->getBody()); + } + + public function testShouldRelaxInsecureInLocalhost() + { + $request = (new ServerRequestFactory) + ->createServerRequest("GET", "http://localhost/api") + ->withHeader("Authorization", "Bearer " . self::$acmeToken); + + $default = function (ServerRequestInterface $request) { + $response = (new ResponseFactory)->createResponse(); + $response->getBody()->write("Success"); + return $response; + }; + + $collection = new MiddlewareCollection([ + new JwtAuth([ + "secret" => "supersecretkeyyoushouldnotcommittogithub" + ]) + ]); + + $response = $collection->dispatch($request, $default); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals("Success", $response->getBody()); + } + + public function testShouldRelaxInsecureInExampleCom() + { + $request = (new ServerRequestFactory) + ->createServerRequest("GET", "http://example.com/api") + ->withHeader("Authorization", "Bearer " . self::$acmeToken); + + $default = function (ServerRequestInterface $request) { + $response = (new ResponseFactory)->createResponse(); + $response->getBody()->write("Success"); + return $response; + }; + + $collection = new MiddlewareCollection([ + new JwtAuth([ + "secret" => "supersecretkeyyoushouldnotcommittogithub", + "relaxed" => ["example.com"], + ]) + ]); + + $response = $collection->dispatch($request, $default); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals("Success", $response->getBody()); + } + + public function testShouldAttachToken() + { + $request = (new ServerRequestFactory) + ->createServerRequest("GET", "https://example.com/api") + ->withHeader("Authorization", "Bearer " . self::$acmeToken); + + $default = function (ServerRequestInterface $request) { + $acmeToken = $request->getAttribute("token"); + + $response = (new ResponseFactory)->createResponse(); + $response->getBody()->write($acmeToken["iss"]); + + return $response; + }; + + $collection = new MiddlewareCollection([ + new JwtAuth([ + "secret" => "supersecretkeyyoushouldnotcommittogithub" + ]) + ]); + + $response = $collection->dispatch($request, $default); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals("Acme Toothpics Ltd", $response->getBody()); + } + + public function testShouldAttachCustomToken() + { + $request = (new ServerRequestFactory) + ->createServerRequest("GET", "https://example.com/api") + ->withHeader("Authorization", "Bearer " . self::$acmeToken); + + $default = function (ServerRequestInterface $request) { + $acmeToken = $request->getAttribute("nekot"); + + $response = (new ResponseFactory)->createResponse(); + $response->getBody()->write($acmeToken["iss"]); + + return $response; + }; + + $collection = new MiddlewareCollection([ + new JwtAuth([ + "secret" => "supersecretkeyyoushouldnotcommittogithub", + "attribute" => "nekot" + ]) + ]); + + $response = $collection->dispatch($request, $default); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals("Acme Toothpics Ltd", $response->getBody()); + } + + public function testShouldCallAfterWithProperArguments() + { + $request = (new ServerRequestFactory) + ->createServerRequest("GET", "https://example.com/api") + ->withHeader("Authorization", "Bearer " . self::$acmeToken); + + $decoded = null; + $token = null; + + $default = function (ServerRequestInterface $request) { + $response = (new ResponseFactory)->createResponse(); + $response->getBody()->write("Success"); + return $response; + }; + + $collection = new MiddlewareCollection([ + new JwtAuth([ + "secret" => "supersecretkeyyoushouldnotcommittogithub", + "after" => function ($response, $arguments) use (&$decoded, &$token) { + $decoded = $arguments["decoded"]; + $token = $arguments["token"]; + } + ]) + ]); + + $response = $collection->dispatch($request, $default); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals("Success", $response->getBody()); + $this->assertEquals(self::$acmeTokenArray, (array) $decoded); + $this->assertEquals(self::$acmeToken, $token); + } + + public function testShouldCallBeforeWithProperArguments() + { + $request = (new ServerRequestFactory) + ->createServerRequest("GET", "https://example.com/api") + ->withHeader("Authorization", "Bearer " . self::$acmeToken); + + $decoded = null; + $token = null; + + $default = function (ServerRequestInterface $request) { + $response = (new ResponseFactory)->createResponse(); + $response->getBody()->write("Success"); + return $response; + }; + + $collection = new MiddlewareCollection([ + new JwtAuth([ + "secret" => "supersecretkeyyoushouldnotcommittogithub", + "before" => function ($response, $arguments) use (&$decoded, &$token) { + $decoded = $arguments["decoded"]; + $token = $arguments["token"]; + } + ]) + ]); + + $response = $collection->dispatch($request, $default); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals("Success", $response->getBody()); + $this->assertEquals(self::$acmeTokenArray, (array) $decoded); + $this->assertEquals(self::$acmeToken, $token); + } + + public function testShouldCallAnonymousErrorFunction() + { + $request = (new ServerRequestFactory) + ->createServerRequest("GET", "https://example.com/api"); + + $default = function (ServerRequestInterface $request) { + $response = (new ResponseFactory)->createResponse(); + $response->getBody()->write("Success"); + return $response; + }; + + $collection = new MiddlewareCollection([ + new JwtAuth([ + "secret" => "supersecretkeyyoushouldnotcommit", + "error" => function (ResponseInterface $response, $arguments) use (&$dummy) { + $response->getBody()->write("error"); + return $response + ->withHeader("X-Electrolytes", "Plants"); + } + ]) + ]); + + $response = $collection->dispatch($request, $default); + + $this->assertEquals(401, $response->getStatusCode()); + $this->assertEquals("Plants", $response->getHeaderLine("X-Electrolytes")); + $this->assertEquals("error", $response->getBody()); + } + + public function testShouldCallInvokableErrorClass() + { + $request = (new ServerRequestFactory) + ->createServerRequest("GET", "https://example.com/api"); + + $dummy = null; + + $default = function (ServerRequestInterface $request) { + $response = (new ResponseFactory)->createResponse(); + $response->getBody()->write("Success"); + return $response; + }; + + $collection = new MiddlewareCollection([ + new JwtAuth([ + "secret" => "supersecretkeyyoushouldnotcommit", + "error" => new TestErrorHandler + ]) + ]); + + $response = $collection->dispatch($request, $default); + + $this->assertEquals(402, $response->getStatusCode()); + $this->assertEquals("Bar", $response->getHeaderLine("X-Foo")); + $this->assertEquals(TestErrorHandler::class, $response->getBody()); + } + + public function testShouldCallArrayNotationError() + { + $request = (new ServerRequestFactory) + ->createServerRequest("GET", "https://example.com/api"); + + $dummy = null; + + $default = function (ServerRequestInterface $request) { + $response = (new ResponseFactory)->createResponse(); + $response->getBody()->write("Success"); + return $response; + }; + + $collection = new MiddlewareCollection([ + new JwtAuth([ + "secret" => "supersecretkeyyoushouldnotcommit", + "error" => [TestErrorHandler::class, "error"] + ]) + ]); + + $response = $collection->dispatch($request, $default); + + $this->assertEquals(418, $response->getStatusCode()); + $this->assertEquals("Foo", $response->getHeaderLine("X-Bar")); + $this->assertEquals(TestErrorHandler::class, $response->getBody()); + } + + public function testShouldCallErrorAndModifyBody() + { + $request = (new ServerRequestFactory) + ->createServerRequest("GET", "https://example.com/api"); + + $dummy = null; + + $default = function (ServerRequestInterface $request) { + $response = (new ResponseFactory)->createResponse(); + $response->getBody()->write("Success"); + return $response; + }; + + $collection = new MiddlewareCollection([ + new JwtAuth([ + "secret" => "supersecretkeyyoushouldnotcommittogithub", + "error" => function (ResponseInterface $response, $arguments) use (&$dummy) { + $dummy = true; + $response->getBody()->write("Error"); + return $response; + } + ]) + ]); + + $response = $collection->dispatch($request, $default); + + $this->assertEquals(401, $response->getStatusCode()); + $this->assertEquals("Error", $response->getBody()); + $this->assertTrue($dummy); + } + + public function testShouldAllowUnauthenticatedHttp() + { + + $request = (new ServerRequestFactory) + ->createServerRequest("GET", "https://example.com/public/foo"); + + $default = function (ServerRequestInterface $request) { + $response = (new ResponseFactory)->createResponse(); + $response->getBody()->write("Success"); + return $response; + }; + + $collection = new MiddlewareCollection([ + new JwtAuth([ + "secret" => "supersecretkeyyoushouldnotcommittogithub", + "path" => ["/api", "/bar"], + ]) + ]); + + $response = $collection->dispatch($request, $default); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals("Success", $response->getBody()); + } + + public function testShouldReturn401FromAfter() + { + $request = (new ServerRequestFactory) + ->createServerRequest("GET", "https://example.com/api") + ->withHeader("Authorization", "Bearer " . self::$acmeToken); + + $default = function (ServerRequestInterface $request) { + $response = (new ResponseFactory)->createResponse(); + $response->getBody()->write("Success"); + return $response; + }; + + $collection = new MiddlewareCollection([ + new JwtAuth([ + "secret" => "supersecretkeyyoushouldnotcommittogithub", + "after" => function ($response, $arguments) { + return $response + ->withBody((new StreamFactory)->createStream()) + ->withStatus(401); + } + ]) + ]); + + $response = $collection->dispatch($request, $default); + + $this->assertEquals(401, $response->getStatusCode()); + $this->assertEquals("", $response->getBody()); + } + + public function testShouldModifyRequestUsingAnonymousBefore() + { + $request = (new ServerRequestFactory) + ->createServerRequest("GET", "https://example.com/") + ->withHeader("Authorization", "Bearer " . self::$acmeToken); + + $default = function (ServerRequestInterface $request) { + $response = (new ResponseFactory)->createResponse(); + $test = $request->getAttribute("test"); + $response->getBody()->write($test); + return $response; + }; + + $collection = new MiddlewareCollection([ + new JwtAuth([ + "secret" => "supersecretkeyyoushouldnotcommittogithub", + "before" => function ($request, $arguments) { + return $request->withAttribute("test", "test"); + } + ]) + ]); + + $response = $collection->dispatch($request, $default); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals("test", (string) $response->getBody()); + } + + public function testShouldModifyRequestUsingInvokableBefore() + { + $request = (new ServerRequestFactory) + ->createServerRequest("GET", "https://example.com/") + ->withHeader("Authorization", "Bearer " . self::$acmeToken); + + $default = function (ServerRequestInterface $request) { + $response = (new ResponseFactory)->createResponse(); + $test = $request->getAttribute("test"); + $response->getBody()->write($test); + return $response; + }; + + $collection = new MiddlewareCollection([ + new JwtAuth([ + "secret" => "supersecretkeyyoushouldnotcommittogithub", + "before" => new TestBeforeHandler + ]) + ]); + + $response = $collection->dispatch($request, $default); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals("invoke", (string) $response->getBody()); + } + + public function testShouldModifyRequestUsingArrayNotationBefore() + { + $request = (new ServerRequestFactory) + ->createServerRequest("GET", "https://example.com/") + ->withHeader("Authorization", "Bearer " . self::$acmeToken); + + $default = function (ServerRequestInterface $request) { + $response = (new ResponseFactory)->createResponse(); + $test = $request->getAttribute("test"); + $response->getBody()->write($test); + return $response; + }; + + $collection = new MiddlewareCollection([ + new JwtAuth([ + "secret" => "supersecretkeyyoushouldnotcommittogithub", + "before" => [TestBeforeHandler::class, "before"] + ]) + ]); + + $response = $collection->dispatch($request, $default); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals("function", (string) $response->getBody()); + } + + public function testShouldHandleRulesArrayBug84() + { + $request = (new ServerRequestFactory) + ->createServerRequest("GET", "https://example.com/api"); + + $default = function (ServerRequestInterface $request) { + $response = (new ResponseFactory)->createResponse(); + $response->getBody()->write("Success"); + return $response; + }; + + $collection = new MiddlewareCollection([ + new JwtAuth([ + "secret" => "supersecretkeyyoushouldnotcommittogithub", + "rules" => [ + new RequestPathRule([ + "path" => ["/api"], + "ignore" => ["/api/login"], + ]), + new RequestMethodRule([ + "ignore" => ["OPTIONS"], + ]) + ], + ]) + ]); + + $response = $collection->dispatch($request, $default); + + $this->assertEquals(401, $response->getStatusCode()); + $this->assertEquals("", $response->getBody()); + + $request = (new ServerRequestFactory) + ->createServerRequest("GET", "https://example.com/api/login"); + + $response = $collection->dispatch($request, $default); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals("Success", $response->getBody()); + } + + public function testShouldHandleDefaultPathBug118() + { + $request = (new ServerRequestFactory) + ->createServerRequest("GET", "https://example.com/api"); + + $default = function (ServerRequestInterface $request) { + $response = (new ResponseFactory)->createResponse(); + $response->getBody()->write("Success"); + return $response; + }; + + $collection = new MiddlewareCollection([ + new JwtAuth([ + "secret" => "supersecretkeyyoushouldnotcommittogithub", + "ignore" => "/api/login", + ]) + ]); + + $response = $collection->dispatch($request, $default); + + $this->assertEquals(401, $response->getStatusCode()); + $this->assertEquals("", $response->getBody()); + + $request = (new ServerRequestFactory) + ->createServerRequest("GET", "https://example.com/api/login"); + + $response = $collection->dispatch($request, $default); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals("Success", $response->getBody()); + } + + public function testShouldBindToMiddleware() + { + $request = (new ServerRequestFactory) + ->createServerRequest("GET", "https://example.com/") + ->withHeader("Authorization", "Bearer " . self::$acmeToken); + + $default = function (ServerRequestInterface $request) { + $response = (new ResponseFactory)->createResponse(); + $before = $request->getAttribute("before"); + $response->getBody()->write($before); + return $response; + }; + + $collection = new MiddlewareCollection([ + new JwtAuth([ + "secret" => "supersecretkeyyoushouldnotcommittogithub", + "before" => function ($request, $arguments) { + $before = get_class($this); + return $request->withAttribute("before", $before); + }, + "after" => function ($response, $arguments) { + $after = get_class($this); + $response->getBody()->write($after); + return $response; + } + + ]) + ]); + + $response = $collection->dispatch($request, $default); + $expected = str_repeat("Usefulteam\Middleware\JwtAuth", 2); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals($expected, (string) $response->getBody()); + } + + public function testShouldHandlePsr7() + { + $request = (new ServerRequestFactory) + ->createServerRequest("GET", "https://example.com/api") + ->withHeader("X-Token", "Bearer " . self::$acmeToken); + + $response = (new ResponseFactory)->createResponse(); + + $auth = new JwtAuth([ + "secret" => "supersecretkeyyoushouldnotcommittogithub", + "header" => "X-Token" + ]); + + $next = function (ServerRequestInterface $request, ResponseInterface $response) { + $response->getBody()->write("Success"); + return $response; + }; + + $response = $auth($request, $response, $next); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals("Success", $response->getBody()); + } + + public function testShouldHaveUriInErrorHandlerIssue96() + { + $request = (new ServerRequestFactory) + ->createServerRequest("GET", "https://example.com/api/foo?bar=pop"); + + $dummy = null; + + $default = function (ServerRequestInterface $request) { + $response = (new ResponseFactory)->createResponse(); + $response->getBody()->write("Success"); + return $response; + }; + + $collection = new MiddlewareCollection([ + new JwtAuth([ + "secret" => "supersecretkeyyoushouldnotcommittogithub", + "error" => function (ResponseInterface $response, $arguments) use (&$dummy) { + $dummy = $arguments["uri"]; + } + ]) + ]); + + $response = $collection->dispatch($request, $default); + + $this->assertEquals(401, $response->getStatusCode()); + $this->assertEquals("", $response->getBody()); + $this->assertEquals("https://example.com/api/foo?bar=pop", $dummy); + } + + public function testShouldUseCookieIfHeaderMissingIssue156() + { + $request = (new ServerRequestFactory) + ->createServerRequest("GET", "https://example.com/api") + ->withCookieParams(["token" => self::$acmeToken]); + + $default = function (ServerRequestInterface $request) { + $response = (new ResponseFactory)->createResponse(); + $response->getBody()->write("Success"); + return $response; + }; + + $collection = new MiddlewareCollection([ + new JwtAuth([ + "secret" => "supersecretkeyyoushouldnotcommittogithub", + "header" => "X-Token", + "regexp" => "/(.*)/", + ]) + ]); + + $response = $collection->dispatch($request, $default); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals("Success", $response->getBody()); + } +} diff --git a/tests/RequestMethodRuleTest.php b/tests/RequestMethodRuleTest.php new file mode 100644 index 0000000..e3a1863 --- /dev/null +++ b/tests/RequestMethodRuleTest.php @@ -0,0 +1,90 @@ +createServerRequest( + "OPTIONS", + "https://example.com/api" + ); + + $rule = new RequestMethodRule; + + $this->assertFalse($rule($request)); + } + + public function testShouldAuthenticatePost() + { + $request = (new ServerRequestFactory)->createServerRequest( + "POST", + "https://example.com/api" + ); + + $rule = new RequestMethodRule; + + $this->assertTrue($rule($request)); + } + + public function testShouldAuthenticateGet() + { + $request = (new ServerRequestFactory)->createServerRequest( + "GET", + "https://example.com/api" + ); + + $rule = new RequestMethodRule; + + $this->assertTrue($rule($request)); + } + + public function testShouldConfigureIgnore() + { + $request = (new ServerRequestFactory)->createServerRequest( + "GET", + "https://example.com/api" + ); + + $rule = new RequestMethodRule([ + "ignore" => ["GET"] + ]); + + $this->assertFalse($rule($request)); + } +} diff --git a/tests/RequestPathRuleTest.php b/tests/RequestPathRuleTest.php new file mode 100644 index 0000000..9c4dca3 --- /dev/null +++ b/tests/RequestPathRuleTest.php @@ -0,0 +1,212 @@ +createServerRequest( + "GET", + "https://example.com/api" + ); + + $rule = new RequestPathRule(["path" => "/api"]); + $this->assertTrue($rule($request)); + + $rule = new RequestPathRule(["path" => ["/api", "/foo"]]); + $this->assertTrue($rule($request)); + } + + public function testShouldAuthenticateEverything() + { + $request = (new ServerRequestFactory)->createServerRequest( + "GET", + "https://example.com/" + ); + + $rule = new RequestPathRule(["path" => "/"]); + $this->assertTrue($rule($request)); + + $request = (new ServerRequestFactory)->createServerRequest( + "GET", + "https://example.com/api" + ); + + $this->assertTrue($rule($request)); + } + + public function testShouldAuthenticateOnlyApi() + { + $request = (new ServerRequestFactory)->createServerRequest( + "GET", + "https://example.com/" + ); + + $rule = new RequestPathRule(["path" => "/api"]); + $this->assertFalse($rule($request)); + + $request = (new ServerRequestFactory)->createServerRequest( + "GET", + "https://example.com/api" + ); + + $this->assertTrue($rule($request)); + } + + public function testShouldIgnoreLogin() + { + $request = (new ServerRequestFactory)->createServerRequest( + "GET", + "https://example.com/api" + ); + + $rule = new RequestPathRule([ + "path" => "/api", + "ignore" => ["/api/login"] + ]); + $this->assertTrue($rule($request)); + + $request = (new ServerRequestFactory)->createServerRequest( + "GET", + "https://example.com/login" + ); + + $this->assertFalse($rule($request)); + } + + public function testShouldAuthenticateCreateAndList() + { + $request = (new ServerRequestFactory)->createServerRequest( + "GET", + "https://example.com/api" + ); + + /* Should not authenticate */ + $rule = new RequestPathRule(["path" => ["/api/create", "/api/list"]]); + $this->assertFalse($rule($request)); + + /* Should authenticate */ + $request = (new ServerRequestFactory)->createServerRequest( + "GET", + "https://example.com/api/create" + ); + + $this->assertTrue($rule($request)); + + /* Should authenticate */ + $request = (new ServerRequestFactory)->createServerRequest( + "GET", + "https://example.com/api/list" + ); + + $this->assertTrue($rule($request)); + + /* Should not authenticate */ + $request = (new ServerRequestFactory)->createServerRequest( + "GET", + "https://example.com/api/ping" + ); + + $this->assertFalse($rule($request)); + } + + public function testShouldAuthenticateRegexp() + { + $request = (new ServerRequestFactory)->createServerRequest( + "GET", + "https://example.com/api/products/123/tickets/anything" + ); + + /* Should authenticate */ + $rule = new RequestPathRule(["path" => ["/api/products/(\d*)/tickets"]]); + $this->assertTrue($rule($request)); + + /* Should not authenticate */ + $request = (new ServerRequestFactory)->createServerRequest( + "GET", + "https://example.com/api/products/xxx/tickets" + ); + + $this->assertFalse($rule($request)); + } + + public function testBug50ShouldAuthenticateMultipleSlashes() + { + $request = (new ServerRequestFactory)->createServerRequest( + "GET", + "https://example.com/" + ); + + $rule = new RequestPathRule(["path" => "/v1/api"]); + $this->assertFalse($rule($request)); + + $request = (new ServerRequestFactory)->createServerRequest( + "GET", + "https://example.com/v1/api" + ); + + $this->assertTrue($rule($request)); + + $request = (new ServerRequestFactory)->createServerRequest( + "GET", + "https://example.com/v1//api" + ); + + $this->assertTrue($rule($request)); + + $request = (new ServerRequestFactory)->createServerRequest( + "GET", + "https://example.com/v1//////api" + ); + + $this->assertTrue($rule($request)); + + $request = (new ServerRequestFactory)->createServerRequest( + "GET", + "https://example.com//v1/api" + ); + + $this->assertTrue($rule($request)); + + $request = (new ServerRequestFactory)->createServerRequest( + "GET", + "https://example.com//////v1/api" + ); + + $this->assertTrue($rule($request)); + } +} diff --git a/tests/TestAfterHandler.php b/tests/TestAfterHandler.php new file mode 100644 index 0000000..b3e8435 --- /dev/null +++ b/tests/TestAfterHandler.php @@ -0,0 +1,53 @@ +getBody()->write(self::class); + return $response->withHeader("X-Brawndo", "plants crave"); + } + + public static function after( + ResponseInterface $response, + array $arguments + ) { + $response->getBody()->write(self::class); + return $response->withHeader("X-Water", "like from toilet?"); + } +} diff --git a/tests/TestBeforeHandler.php b/tests/TestBeforeHandler.php new file mode 100644 index 0000000..8502bac --- /dev/null +++ b/tests/TestBeforeHandler.php @@ -0,0 +1,51 @@ +withAttribute("test", "invoke"); + } + + public static function before( + ServerRequestInterface $request, + array $arguments + ) { + return $request->withAttribute("test", "function"); + } +} diff --git a/tests/TestErrorHandler.php b/tests/TestErrorHandler.php new file mode 100644 index 0000000..2e325ca --- /dev/null +++ b/tests/TestErrorHandler.php @@ -0,0 +1,58 @@ +getBody()->write(self::class); + return $response + ->withStatus(402) + ->withHeader("X-Foo", "Bar"); + } + + public static function error( + ResponseInterface $response, + array $arguments + ) { + $response->getBody()->write(self::class); + return $response + ->withStatus(418) + ->withHeader("X-Bar", "Foo"); + } +}