diff --git a/service-admin/composer.json b/service-admin/composer.json index 09692b9340..f5fba5b488 100644 --- a/service-admin/composer.json +++ b/service-admin/composer.json @@ -24,7 +24,13 @@ "ministryofjustice/opg-lpa-datamodels": "^13.3", "php-http/guzzle6-adapter": "^2.0.2", "slim/flash": "^0.4.0", - "tuupola/slim-jwt-auth": "^3.5.2", + + "firebase/php-jwt": "^6.0", + "psr/http-message": "^1.0", + "tuupola/http-factory": "^0.4.0|^1.0.2", + "tuupola/callable-handler": "^0.3.0|^0.4.0|^1.0", + "psr/http-server-middleware": "^1.0", + "laminas/laminas-authentication": "^2.8", "laminas/laminas-cache": "^3.1.2", "laminas/laminas-cache-storage-adapter-memory": "2.0", diff --git a/service-admin/composer.lock b/service-admin/composer.lock index 22e3db6483..cb1b3bc786 100644 --- a/service-admin/composer.lock +++ b/service-admin/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "54a043c3dd7bd86521bf6fd29d732f32", + "content-hash": "44db6794dd6d50e2c5cf948eabcf4f80", "packages": [ { "name": "aws/aws-crt-php", @@ -58,16 +58,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.222.7", + "version": "3.222.15", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "03d35eef5c509798d2c08587cfd9a7c33afe2260" + "reference": "2c73f3f3716516f733d449f504e954446a994994" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/03d35eef5c509798d2c08587cfd9a7c33afe2260", - "reference": "03d35eef5c509798d2c08587cfd9a7c33afe2260", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/2c73f3f3716516f733d449f504e954446a994994", + "reference": "2c73f3f3716516f733d449f504e954446a994994", "shasum": "" }, "require": { @@ -143,9 +143,9 @@ "support": { "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.222.7" + "source": "https://github.com/aws/aws-sdk-php/tree/3.222.15" }, - "time": "2022-05-06T18:16:59+00:00" + "time": "2022-05-18T18:15:15+00:00" }, { "name": "brick/varexporter", @@ -376,23 +376,28 @@ }, { "name": "firebase/php-jwt", - "version": "v5.5.1", + "version": "v6.2.0", "source": { "type": "git", "url": "https://github.com/firebase/php-jwt.git", - "reference": "83b609028194aa042ea33b5af2d41a7427de80e6" + "reference": "d28e6df83830252650da4623c78aaaf98fb385f3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/firebase/php-jwt/zipball/83b609028194aa042ea33b5af2d41a7427de80e6", - "reference": "83b609028194aa042ea33b5af2d41a7427de80e6", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/d28e6df83830252650da4623c78aaaf98fb385f3", + "reference": "d28e6df83830252650da4623c78aaaf98fb385f3", "shasum": "" }, "require": { - "php": ">=5.3.0" + "php": "^7.1||^8.0" }, "require-dev": { - "phpunit/phpunit": ">=4.8 <=9" + "guzzlehttp/guzzle": "^6.5||^7.4", + "phpspec/prophecy-phpunit": "^1.1", + "phpunit/phpunit": "^7.5||^9.5", + "psr/cache": "^1.0||^2.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" }, "suggest": { "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" @@ -427,9 +432,9 @@ ], "support": { "issues": "https://github.com/firebase/php-jwt/issues", - "source": "https://github.com/firebase/php-jwt/tree/v5.5.1" + "source": "https://github.com/firebase/php-jwt/tree/v6.2.0" }, - "time": "2021-11-08T20:18:51+00:00" + "time": "2022-05-13T20:54:50+00:00" }, { "name": "guzzlehttp/guzzle", @@ -1076,16 +1081,16 @@ }, { "name": "laminas/laminas-diactoros", - "version": "2.10.0", + "version": "2.11.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-diactoros.git", - "reference": "a3f03b3c158a3a7014c6ba553b0cb83bf7da19c4" + "reference": "d1bc565b23c2040fafde398a8a5db083c47928c0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-diactoros/zipball/a3f03b3c158a3a7014c6ba553b0cb83bf7da19c4", - "reference": "a3f03b3c158a3a7014c6ba553b0cb83bf7da19c4", + "url": "https://api.github.com/repos/laminas/laminas-diactoros/zipball/d1bc565b23c2040fafde398a8a5db083c47928c0", + "reference": "d1bc565b23c2040fafde398a8a5db083c47928c0", "shasum": "" }, "require": { @@ -1171,7 +1176,7 @@ "type": "community_bridge" } ], - "time": "2022-05-04T15:16:15+00:00" + "time": "2022-05-17T10:57:52+00:00" }, { "name": "laminas/laminas-escaper", @@ -4550,82 +4555,6 @@ ], "time": "2021-09-14T12:46:25+00:00" }, - { - "name": "tuupola/slim-jwt-auth", - "version": "3.6.0", - "source": { - "type": "git", - "url": "https://github.com/tuupola/slim-jwt-auth.git", - "reference": "d9ed8bca77a0ef2a95ab48e65ddc26073b99c5ff" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/tuupola/slim-jwt-auth/zipball/d9ed8bca77a0ef2a95ab48e65ddc26073b99c5ff", - "reference": "d9ed8bca77a0ef2a95ab48e65ddc26073b99c5ff", - "shasum": "" - }, - "require": { - "firebase/php-jwt": "^3.0|^4.0|^5.0", - "php": "^7.1|^8.0", - "psr/http-message": "^1.0", - "psr/http-server-middleware": "^1.0", - "psr/log": "^1.0|^2.0|^3.0", - "tuupola/callable-handler": "^0.3.0|^0.4.0|^1.0", - "tuupola/http-factory": "^0.4.0|^1.0.2" - }, - "require-dev": { - "equip/dispatch": "^2.0", - "laminas/laminas-diactoros": "^2.0", - "overtrue/phplint": "^1.0", - "phpstan/phpstan": "^0.12.43", - "phpunit/phpunit": "^7.0|^8.0|^9.0", - "squizlabs/php_codesniffer": "^3.4" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-3.x": "3.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Tuupola\\Middleware\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Mika Tuupola", - "email": "tuupola@appelsiini.net", - "homepage": "https://appelsiini.net/", - "role": "Developer" - } - ], - "description": "PSR-7 and PSR-15 JWT Authentication Middleware", - "homepage": "https://github.com/tuupola/slim-jwt-auth", - "keywords": [ - "auth", - "json", - "jwt", - "middleware", - "psr-15", - "psr-7" - ], - "support": { - "issues": "https://github.com/tuupola/slim-jwt-auth/issues", - "source": "https://github.com/tuupola/slim-jwt-auth/tree/3.6.0" - }, - "funding": [ - { - "url": "https://github.com/tuupola", - "type": "github" - } - ], - "time": "2022-01-12T11:15:02+00:00" - }, { "name": "webimpress/safe-writer", "version": "2.2.0", @@ -6087,12 +6016,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "c99945852fb9e9de3bc89c90f24b4c8ef47668fd" + "reference": "c491d086242983f784b8af91cbb9de43d3374971" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/c99945852fb9e9de3bc89c90f24b4c8ef47668fd", - "reference": "c99945852fb9e9de3bc89c90f24b4c8ef47668fd", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/c491d086242983f784b8af91cbb9de43d3374971", + "reference": "c491d086242983f784b8af91cbb9de43d3374971", "shasum": "" }, "conflict": { @@ -6125,11 +6054,12 @@ "bolt/core": "<=4.2", "bottelet/flarepoint": "<2.2.1", "brightlocal/phpwhois": "<=4.2.5", + "brotkrueml/codehighlight": "<2.7", "buddypress/buddypress": "<7.2.1", "bugsnag/bugsnag-laravel": ">=2,<2.0.2", "bytefury/crater": "<6.0.2", "cachethq/cachet": "<2.5.1", - "cakephp/cakephp": "<4.0.6", + "cakephp/cakephp": "<3.10.3|>=4,<4.0.6", "cardgate/magento2": "<2.0.33", "cart2quote/module-quotation": ">=4.1.6,<=4.4.5|>=5,<5.4.4", "cartalyst/sentry": "<=2.1.6", @@ -6568,7 +6498,7 @@ "type": "tidelift" } ], - "time": "2022-05-06T13:19:01+00:00" + "time": "2022-05-16T08:10:11+00:00" }, { "name": "sebastian/cli-parser", diff --git a/service-admin/config/autoload/mezzio.global.php b/service-admin/config/autoload/mezzio.global.php index 66e50c6c3e..f12d5eb5d3 100644 --- a/service-admin/config/autoload/mezzio.global.php +++ b/service-admin/config/autoload/mezzio.global.php @@ -25,14 +25,17 @@ 'api_base_uri' => getenv('OPG_LPA_ENDPOINTS_API') ?: null, - 'admin_accounts' => (getenv('OPG_LPA_COMMON_ADMIN_ACCOUNTS') ? explode(',', getenv('OPG_LPA_COMMON_ADMIN_ACCOUNTS')) : []), + 'admin_accounts' => ( + getenv('OPG_LPA_COMMON_ADMIN_ACCOUNTS') ? + explode(',', getenv('OPG_LPA_COMMON_ADMIN_ACCOUNTS')) : [] + ), 'jwt' => [ 'secret' => getenv('OPG_LPA_ADMIN_JWT_SECRET') ?: null, 'path' => '/', 'header' => 'lpa-admin', 'cookie' => 'lpa-admin', - 'ttl' => 60 * 15, // 15 minutes + 'ttl' => 60 * 15, // 15 minutes 'algo' => 'HS256', ], diff --git a/service-admin/config/pipeline.php b/service-admin/config/pipeline.php index 8987fee5fe..c94e223a96 100644 --- a/service-admin/config/pipeline.php +++ b/service-admin/config/pipeline.php @@ -1,5 +1,9 @@ pipe(ErrorHandler::class); @@ -60,7 +61,8 @@ // Set up the custom middleware to handle sessions and authorization $app->pipe(Middleware\Session\SessionMiddleware::class); - $app->pipe(JwtAuthentication::class); + //$app->pipe(JwtAuthentication::class); + $app->pipe(Middleware\Session\JwtMiddleware::class); $app->pipe(Middleware\Authorization\AuthorizationMiddleware::class); $app->pipe(Middleware\Session\CsrfMiddleware::class); $app->pipe(Middleware\Flash\SlimFlashMiddleware::class); diff --git a/service-admin/src/App/src/ConfigProvider.php b/service-admin/src/App/src/ConfigProvider.php index 4cf5890142..b90c1c9f21 100644 --- a/service-admin/src/App/src/ConfigProvider.php +++ b/service-admin/src/App/src/ConfigProvider.php @@ -5,7 +5,7 @@ namespace App; use App\Logging\LoggingErrorListenerDelegatorFactory; -use Tuupola\Middleware\JwtAuthentication; +//use Tuupola\Middleware\JwtAuthentication; use Laminas\Stratigility\Middleware\ErrorHandler; /** @@ -64,8 +64,10 @@ public function getDependencies(): array Handler\UserFindHandler::class => Handler\UserFindHandlerFactory::class, // Middleware - JwtAuthentication::class => - Middleware\Session\JwtAuthenticationFactory::class, + //JwtAuthentication::class => + // Middleware\Session\JwtAuthenticationFactory::class, + Middleware\Session\JwtMiddleware::class => + Middleware\Session\JwtMiddlewareFactory::class, Middleware\Authorization\AuthorizationMiddleware::class => Middleware\Authorization\AuthorizationMiddlewareFactory::class, Middleware\Session\SessionMiddleware::class => diff --git a/service-admin/src/App/src/Middleware/Authorization/AuthorizationMiddleware.php b/service-admin/src/App/src/Middleware/Authorization/AuthorizationMiddleware.php index 0fd2a21ab2..8cf8b2af90 100644 --- a/service-admin/src/App/src/Middleware/Authorization/AuthorizationMiddleware.php +++ b/service-admin/src/App/src/Middleware/Authorization/AuthorizationMiddleware.php @@ -90,23 +90,23 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface $roles = ['guest']; if (is_string($token)) { - // Attempt to get a user with the token value + // Attempt to get a user with the token value $result = $this->authenticationService->verify($token); $identity = $result->getIdentity(); if ($identity instanceof Identity) { - // Try to get the user details + // Try to get the user details $user = $this->userService->fetch($identity->getUserId() ?? ''); - // There is something wrong with the user here so throw an exception + // There is something wrong with the user here so throw an exception if (!$user instanceof User) { throw new Exception('Can not find a user for ID ' . $identity->getUserId()); } $roles[] = 'authenticated-user'; } else { - // Clear the bad token + // Clear the bad token $this->clearTokenData(); } } @@ -123,7 +123,7 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface // Check each role to see if the user has access to the route foreach ($roles as $role) { if ($this->rbac->hasRole($role) && $this->rbac->isGranted($role, $matchedRoute->getName())) { - // Catch any unauthorized exceptions and trigger a sign out if required + // Catch any unauthorized exceptions and trigger a sign out if required try { return $handler->handle($request->withAttribute('user', $user)); } catch (ApiException $ae) { @@ -136,7 +136,7 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface } } - // If there is no user (not logged in) then redirect to the sign in screen + // If there is no user (not logged in) then redirect to the sign in screen if (is_null($user)) { return new RedirectResponse($this->urlHelper->generate('sign.in')); } diff --git a/service-admin/src/App/src/Middleware/Session/JwtMiddleware.php b/service-admin/src/App/src/Middleware/Session/JwtMiddleware.php new file mode 100644 index 0000000000..c7dafdc9a5 --- /dev/null +++ b/service-admin/src/App/src/Middleware/Session/JwtMiddleware.php @@ -0,0 +1,334 @@ + true, + "algorithm" => "HS256", + "header" => "Authorization", + "regexp" => "/Bearer\s+(.*)$/i", + "cookie" => "token", + "attribute" => "token", + "error" => null, + "before" => null, + "after" => null, + ]; + + /** + * @param mixed[] $options + */ + public function __construct(array $options = []) + { + foreach ($options 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; + } + } + } + + /** + * Process a request in PSR-15 style and return a response. + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $scheme = $request->getUri()->getScheme(); + + /* HTTP allowed only if secure is false */ + if ("https" !== $scheme && true === $this->options["secure"]) { + $message = sprintf( + "Insecure use of middleware over %s denied by configuration.", + $scheme + ); + throw new RuntimeException($message); + } + + /* If token cannot be found or decoded return with 401 Unauthorized. */ + try { + $token = $this->fetchToken($request); + + $this->getLogger()->debug('++++++++++++++++++++++++++++ GOT TOKEN FROM REQUEST'); + $this->getLogger()->debug($token); + + $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"])) { + $response = (new ResponseFactory())->createResponse(200); + $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; + } + + /** + * 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->getLogger()->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->getLogger()->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->getLogger()->warn("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, + new Key($this->options["secret"], $this->options["algorithm"]), + ); + return (array) $decoded; + } catch (Exception $exception) { + $this->getLogger()->warn($exception->getMessage(), [$token]); + throw $exception; + } + } + + /** + * 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 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 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; + } + } +} diff --git a/service-admin/src/App/src/Middleware/Session/JwtMiddlewareFactory.php b/service-admin/src/App/src/Middleware/Session/JwtMiddlewareFactory.php new file mode 100644 index 0000000000..2ea5673236 --- /dev/null +++ b/service-admin/src/App/src/Middleware/Session/JwtMiddlewareFactory.php @@ -0,0 +1,55 @@ +get('config')['jwt']; + + $options = [ + "before" => function (ServerRequestInterface $request, $params) { + // Move the existing JWT data to the session so we can get it after processing + $_SESSION['jwt-payload'] = $request->getAttribute('token'); + }, + "after" => function (ResponseInterface $response, $params) use ($jwtConfig) { + // Re-set the JWT cookie using the updated data and a new timestamp + $ttl = new DateTimeImmutable(sprintf('+%s seconds', $jwtConfig['ttl'])); + + $jwtCookie = JWT::encode( + $_SESSION['jwt-payload'], + $jwtConfig['secret'], + $jwtConfig['algo'], + ); + + setcookie($jwtConfig['cookie'], $jwtCookie, [ + 'expires' => $ttl->getTimeStamp(), + 'secure' => true, + 'httponly' => true, + 'samesite' => 'Strict', + ]); + }, + ]; + + $options = array_merge($options, $jwtConfig); + + return new JwtMiddleware($options); + } +}