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)
+        );
+    }
+}