From dfc5bfd0f2b9576aa88aefdedb943d2296ad96de Mon Sep 17 00:00:00 2001 From: Alejandro Celaya <alejandro@alejandrocelaya.com> Date: Mon, 4 Jul 2016 14:45:18 +0200 Subject: [PATCH] Created rest route to perform authentication --- config/autoload/routes.global.php | 6 ++ config/autoload/services.global.php | 2 + src/Entity/RestToken.php | 13 +++ src/Exception/AuthenticationException.php | 10 +++ .../Rest/AuthenticateMiddleware.php | 77 ++++++++++++++++ src/Service/RestTokenService.php | 87 +++++++++++++++++++ src/Service/RestTokenServiceInterface.php | 25 ++++++ src/Util/RestUtils.php | 3 + src/Util/StringUtilsTrait.php | 40 +++++++++ 9 files changed, 263 insertions(+) create mode 100644 src/Exception/AuthenticationException.php create mode 100644 src/Middleware/Rest/AuthenticateMiddleware.php create mode 100644 src/Service/RestTokenService.php create mode 100644 src/Service/RestTokenServiceInterface.php create mode 100644 src/Util/StringUtilsTrait.php 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 @@ <?php namespace Acelaya\UrlShortener\Entity; +use Acelaya\UrlShortener\Util\StringUtilsTrait; use Doctrine\ORM\Mapping as ORM; /** @@ -13,6 +14,8 @@ use Doctrine\ORM\Mapping as ORM; */ class RestToken extends AbstractEntity { + use StringUtilsTrait; + /** * The default interval is 20 minutes */ @@ -32,6 +35,7 @@ class RestToken extends AbstractEntity public function __construct() { $this->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 @@ +<?php +namespace Acelaya\UrlShortener\Exception; + +class AuthenticationException extends \RuntimeException implements ExceptionInterface +{ + public static function fromCredentials($username, $password) + { + return new self(sprintf('Invalid credentials. Username -> "%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 @@ +<?php +namespace Acelaya\UrlShortener\Middleware\Rest; + +use Acelaya\UrlShortener\Exception\AuthenticationException; +use Acelaya\UrlShortener\Service\RestTokenService; +use Acelaya\UrlShortener\Service\RestTokenServiceInterface; +use Acelaya\UrlShortener\Util\RestUtils; +use Acelaya\ZsmAnnotatedServices\Annotation\Inject; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use Zend\Diactoros\Response\JsonResponse; +use Zend\Stratigility\MiddlewareInterface; + +class AuthenticateMiddleware implements MiddlewareInterface +{ + /** + * @var RestTokenServiceInterface + */ + private $restTokenService; + + /** + * AuthenticateMiddleware constructor. + * @param RestTokenServiceInterface|RestTokenService $restTokenService + * + * @Inject({RestTokenService::class}) + */ + public function __construct(RestTokenServiceInterface $restTokenService) + { + $this->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 @@ +<?php +namespace Acelaya\UrlShortener\Service; + +use Acelaya\UrlShortener\Entity\RestToken; +use Acelaya\UrlShortener\Exception\AuthenticationException; +use Acelaya\UrlShortener\Exception\InvalidArgumentException; +use Acelaya\ZsmAnnotatedServices\Annotation\Inject; +use Doctrine\ORM\EntityManagerInterface; + +class RestTokenService implements RestTokenServiceInterface +{ + /** + * @var EntityManagerInterface + */ + private $em; + /** + * @var array + */ + private $restConfig; + + /** + * ShortUrlService constructor. + * @param EntityManagerInterface $em + * + * @param array $restConfig + * @Inject({"em", "config.rest"}) + */ + public function __construct(EntityManagerInterface $em, array $restConfig) + { + $this->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 @@ +<?php +namespace Acelaya\UrlShortener\Service; + +use Acelaya\UrlShortener\Entity\RestToken; +use Acelaya\UrlShortener\Exception\AuthenticationException; +use Acelaya\UrlShortener\Exception\InvalidArgumentException; + +interface RestTokenServiceInterface +{ + /** + * @param string $token + * @return RestToken + * @throws InvalidArgumentException + */ + public function getByToken($token); + + /** + * 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); +} diff --git a/src/Util/RestUtils.php b/src/Util/RestUtils.php index 301c2e57..94ab47ec 100644 --- a/src/Util/RestUtils.php +++ b/src/Util/RestUtils.php @@ -8,6 +8,7 @@ class RestUtils const INVALID_SHORTCODE_ERROR = 'INVALID_SHORTCODE'; const INVALID_URL_ERROR = 'INVALID_URL'; const INVALID_ARGUMENT_ERROR = 'INVALID_ARGUMENT'; + const INVALID_CREDENTIALS = 'INVALID_CREDENTIALS'; const UNKNOWN_ERROR = 'UNKNOWN_ERROR'; public static function getRestErrorCodeFromException(Exception\ExceptionInterface $e) @@ -19,6 +20,8 @@ class RestUtils return self::INVALID_URL_ERROR; case $e instanceof Exception\InvalidArgumentException: return self::INVALID_ARGUMENT_ERROR; + case $e instanceof Exception\AuthenticationException: + return self::INVALID_CREDENTIALS; default: return self::UNKNOWN_ERROR; } diff --git a/src/Util/StringUtilsTrait.php b/src/Util/StringUtilsTrait.php new file mode 100644 index 00000000..2b4bf625 --- /dev/null +++ b/src/Util/StringUtilsTrait.php @@ -0,0 +1,40 @@ +<?php +namespace Acelaya\UrlShortener\Util; + +trait StringUtilsTrait +{ + protected function generateRandomString($length = 10) + { + $characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + $charactersLength = strlen($characters); + $randomString = ''; + for ($i = 0; $i < $length; $i++) { + $randomString .= $characters[rand(0, $charactersLength - 1)]; + } + + return $randomString; + } + + protected function generateV4Uuid() + { + return sprintf( + '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', + // 32 bits for "time_low" + mt_rand(0, 0xffff), + mt_rand(0, 0xffff), + // 16 bits for "time_mid" + mt_rand(0, 0xffff), + // 16 bits for "time_hi_and_version", + // four most significant bits holds version number 4 + mt_rand(0, 0x0fff) | 0x4000, + // 16 bits, 8 bits for "clk_seq_hi_res", + // 8 bits for "clk_seq_low", + // two most significant bits holds zero and one for variant DCE1.1 + mt_rand(0, 0x3fff) | 0x8000, + // 48 bits for "node" + mt_rand(0, 0xffff), + mt_rand(0, 0xffff), + mt_rand(0, 0xffff) + ); + } +}