diff --git a/config/autoload/routes.global.php b/config/autoload/routes.global.php index b2976df5..7ffdbc74 100644 --- a/config/autoload/routes.global.php +++ b/config/autoload/routes.global.php @@ -13,6 +13,12 @@ return [ ], // Rest + [ + 'name' => 'rest-authenticate', + 'path' => '/rest/authenticate', + 'middleware' => Rest\AuthenticateMiddleware::class, + 'allowed_methods' => ['POST'], + ], [ 'name' => 'rest-create-shortcode', 'path' => '/rest/short-codes', diff --git a/config/autoload/services.global.php b/config/autoload/services.global.php index 08fedf55..d5de3d4a 100644 --- a/config/autoload/services.global.php +++ b/config/autoload/services.global.php @@ -38,6 +38,7 @@ return [ Service\UrlShortener::class => AnnotatedFactory::class, Service\VisitsTracker::class => AnnotatedFactory::class, Service\ShortUrlService::class => AnnotatedFactory::class, + Service\RestTokenService::class => AnnotatedFactory::class, Cache::class => CacheFactory::class, // Cli commands @@ -45,6 +46,7 @@ return [ // Middleware Middleware\Routable\RedirectMiddleware::class => AnnotatedFactory::class, + Middleware\Rest\AuthenticateMiddleware::class => AnnotatedFactory::class, Middleware\Rest\CreateShortcodeMiddleware::class => AnnotatedFactory::class, Middleware\Rest\ResolveUrlMiddleware::class => AnnotatedFactory::class, Middleware\Rest\GetVisitsMiddleware::class => AnnotatedFactory::class, diff --git a/src/Entity/RestToken.php b/src/Entity/RestToken.php index d23dc10f..90a70f0e 100644 --- a/src/Entity/RestToken.php +++ b/src/Entity/RestToken.php @@ -1,6 +1,7 @@ updateExpiration(); + $this->setRandomTokenKey(); } /** @@ -86,4 +90,13 @@ class RestToken extends AbstractEntity { return $this->setExpirationDate((new \DateTime())->add(new \DateInterval(self::DEFAULT_INTERVAL))); } + + /** + * Sets a random unique token key for this RestToken + * @return RestToken + */ + public function setRandomTokenKey() + { + return $this->setToken($this->generateV4Uuid()); + } } diff --git a/src/Exception/AuthenticationException.php b/src/Exception/AuthenticationException.php new file mode 100644 index 00000000..0876be75 --- /dev/null +++ b/src/Exception/AuthenticationException.php @@ -0,0 +1,10 @@ + "%s". Password -> "%s"', $username, $password)); + } +} diff --git a/src/Middleware/Rest/AuthenticateMiddleware.php b/src/Middleware/Rest/AuthenticateMiddleware.php new file mode 100644 index 00000000..0189b249 --- /dev/null +++ b/src/Middleware/Rest/AuthenticateMiddleware.php @@ -0,0 +1,77 @@ +restTokenService = $restTokenService; + } + + /** + * Process an incoming request and/or response. + * + * Accepts a server-side request and a response instance, and does + * something with them. + * + * If the response is not complete and/or further processing would not + * interfere with the work done in the middleware, or if the middleware + * wants to delegate to another process, it can use the `$out` callable + * if present. + * + * If the middleware does not return a value, execution of the current + * request is considered complete, and the response instance provided will + * be considered the response to return. + * + * Alternately, the middleware may return a response instance. + * + * Often, middleware will `return $out();`, with the assumption that a + * later middleware will return a response. + * + * @param Request $request + * @param Response $response + * @param null|callable $out + * @return null|Response + */ + public function __invoke(Request $request, Response $response, callable $out = null) + { + $authData = $request->getParsedBody(); + if (! isset($authData['username'], $authData['password'])) { + return new JsonResponse([ + 'error' => RestUtils::INVALID_ARGUMENT_ERROR, + 'message' => 'You have to provide both "username" and "password"' + ], 400); + } + + try { + $token = $this->restTokenService->createToken($authData['username'], $authData['password']); + return new JsonResponse(['token' => $token->getToken()]); + } catch (AuthenticationException $e) { + return new JsonResponse([ + 'error' => RestUtils::getRestErrorCodeFromException($e), + 'message' => 'Invalid username and/or password', + ], 401); + } + } +} diff --git a/src/Service/RestTokenService.php b/src/Service/RestTokenService.php new file mode 100644 index 00000000..aa9ea0b8 --- /dev/null +++ b/src/Service/RestTokenService.php @@ -0,0 +1,87 @@ +em = $em; + $this->restConfig = $restConfig; + } + + /** + * @param string $token + * @return RestToken + * @throws InvalidArgumentException + */ + public function getByToken($token) + { + $restToken = $this->em->getRepository(RestToken::class)->findOneBy([ + 'token' => $token, + ]); + if (! isset($restToken)) { + throw new InvalidArgumentException(sprintf('RestToken not found for token "%s"', $token)); + } + + return $restToken; + } + + /** + * Creates and returns a new RestToken if username and password are correct + * @param $username + * @param $password + * @return RestToken + * @throws AuthenticationException + */ + public function createToken($username, $password) + { + $this->processCredentials($username, $password); + + $restToken = new RestToken(); + $this->em->persist($restToken); + $this->em->flush(); + + return $restToken; + } + + /** + * @param string $username + * @param string $password + */ + protected function processCredentials($username, $password) + { + $configUsername = strtolower(trim($this->restConfig['username'])); + $providedUsername = strtolower(trim($username)); + $configPassword = trim($this->restConfig['password']); + $providedPassword = trim($password); + + if ($configUsername === $providedUsername && $configPassword === $providedPassword) { + return; + } + + // If credentials are not correct, throw exception + throw AuthenticationException::fromCredentials($providedUsername, $providedPassword); + } +} diff --git a/src/Service/RestTokenServiceInterface.php b/src/Service/RestTokenServiceInterface.php new file mode 100644 index 00000000..fb45483d --- /dev/null +++ b/src/Service/RestTokenServiceInterface.php @@ -0,0 +1,25 @@ +