From 0d41b46ee9ae3c0de4c82eda47181230723bf4a7 Mon Sep 17 00:00:00 2001 From: Kendrick Chan Date: Tue, 23 Oct 2018 02:11:21 +0800 Subject: [PATCH] Refactor api bundle to use security to handle authentication #164 --- catalyst/api-bundle/CatalystAPIBundle.php | 9 + catalyst/api-bundle/Entity/User.php | 138 +++++++++++++++ .../EventSubscriber/TokenSubscriber.php | 75 ++++++-- catalyst/api-bundle/Library/Response.php | 9 - catalyst/api-bundle/Response/Response.php | 9 + .../Security/APIKeyAuthenticator.php | 162 ++++++++++++++++++ .../Security/APIKeyUserProvider.php | 62 +++++++ .../Service/AccessDeniedHandler.php | 18 ++ composer.json | 1 - config/bundles.php | 2 + config/packages/security.yaml | 10 +- config/services.yaml | 9 +- src/Controller/APITestController.php | 5 + 13 files changed, 485 insertions(+), 24 deletions(-) create mode 100644 catalyst/api-bundle/CatalystAPIBundle.php create mode 100644 catalyst/api-bundle/Entity/User.php delete mode 100644 catalyst/api-bundle/Library/Response.php create mode 100644 catalyst/api-bundle/Response/Response.php create mode 100644 catalyst/api-bundle/Security/APIKeyAuthenticator.php create mode 100644 catalyst/api-bundle/Security/APIKeyUserProvider.php create mode 100644 catalyst/api-bundle/Service/AccessDeniedHandler.php diff --git a/catalyst/api-bundle/CatalystAPIBundle.php b/catalyst/api-bundle/CatalystAPIBundle.php new file mode 100644 index 00000000..226e1157 --- /dev/null +++ b/catalyst/api-bundle/CatalystAPIBundle.php @@ -0,0 +1,9 @@ +setAPIKey($this->generateAPIKey()) + ->setSecretKey($this->generateSecretKey()); + + // set date created + $this->date_create = new DateTime(); + } + + public function getID() + { + return $this->id; + } + + public function setAPIKey($api_key) + { + $this->api_key = $api_key; + return $this; + } + + public function getAPIKey() + { + return $this->api_key; + } + + public function setSecretKey($key) + { + $this->secret_key = $key; + return $this; + } + + public function getSecretKey() + { + return $this->secret_key; + } + + public function setName($name) + { + $this->name = $name; + return $this; + } + + public function getName() + { + return $this->name; + } + + public function getRoles() + { + return ['ROLE_API']; + } + + public function getDateCreate() + { + return $this->date_create; + } + + public function getPassword() + { + // we don't need this for API + return 'notneeded'; + } + + public function getSalt() + { + return null; + } + + public function getUsername() + { + // since it's an api, the api key IS the username + return $this->api_key; + } + + public function eraseCredentials() + { + return; + } + + public function generateAPIKey() + { + return $this->generateKey('api'); + } + + public function generateSecretKey() + { + return $this->generateKey('secret'); + } + + protected function generateKey($prefix = '') + { + return md5(uniqid($prefix, true)); + } +} + diff --git a/catalyst/api-bundle/EventSubscriber/TokenSubscriber.php b/catalyst/api-bundle/EventSubscriber/TokenSubscriber.php index aace0487..8a44f99e 100644 --- a/catalyst/api-bundle/EventSubscriber/TokenSubscriber.php +++ b/catalyst/api-bundle/EventSubscriber/TokenSubscriber.php @@ -3,19 +3,29 @@ namespace Catalyst\APIBundle\EventSubscriber; use Catalyst\APIBundle\Controller\APIController; + use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Event\FilterControllerEvent; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpKernel\KernelEvents; + use Doctrine\ORM\EntityManagerInterface; +use DateTime; + class TokenSubscriber implements EventSubscriberInterface { - const HEADER_API_KEY = 'X-Catalyst-API-Key'; - const HEADER_SIGNATURE = 'X-Catalyst-Signature'; + const HEADER_API_KEY = 'X-Cata-API-Key'; + const HEADER_SIGNATURE = 'X-Cata-Signature'; + const HEADER_DATE = 'X-Cata-Date'; - const MODE_HEADER = 'header'; - const MODE_QUERY_STRING = 'query'; + const DATE_FORMAT = 'D, d M Y H:i:s T'; + + const QUERY_API_KEY = 'api_key'; + const QUERY_SIGNATURE = 'sig'; + + // 30 minute time limit + const TIME_LIMIT = 1800; protected $em; @@ -24,6 +34,33 @@ class TokenSubscriber implements EventSubscriberInterface $this-> em = $em; } + protected function getSecretKey($api_key) + { + return 'sldkfjlksdjflksdjflksdjflsjf'; + } + + protected function validateSignature($req, $hdate_string, $secret_key, $sig) + { + // get needed params for generation + $method = $req->getRealMethod(); + $uri = $req->getRequestUri(); + + $elements = [$method, $uri, $hdate_string, $secret_key]; + $sig_source = implode('|', $elements); + + error_log($sig_source); + + // generate signature + $raw_sig = hash_hmac('sha1', $sig_source, $secret_key, true); + $enc_sig = base64_encode($raw_sig); + + error_log($enc_sig); + + if ($enc_sig != trim($sig)) + throw new AccessDeniedHttpException('Invalid signature.'); + + } + public function onKernelController(FilterControllerEvent $event) { $controller = $event->getController(); @@ -37,25 +74,41 @@ class TokenSubscriber implements EventSubscriberInterface if (!($controller[0] instanceof APIController)) return; - // TODO: check if we have a mode setup - - // TODO: if no mode specified default to header - $req = $event->getRequest(); + // check date from headers + $headers = $req->headers->all(); + $hdate_string = $req->headers->get(self::HEADER_DATE); + if ($hdate_string == null) + throw new AccessDeniedHttpException('No date specified.'); + + $hdate = DateTime::createFromFormat(self::DATE_FORMAT, $hdate_string); + if ($hdate == null) + throw new AccessDeniedHttpException('Invalid date specified.'); + + // get number of seconds difference + $date_now = new DateTime(); + $date_diff = abs($date_now->getTimestamp() - $hdate->getTimestamp()); + + // time difference is too much + if ($date_diff > self::TIME_LIMIT) + throw new AccessDeniedHttpException('Clock synchronization error.'); + // api key header $api_key = $req->headers->get(self::HEADER_API_KEY); if ($api_key == null) throw new AccessDeniedHttpException('No api key sent.'); - // TODO: check valid api key + // check valid api key + $secret_key = $this->getSecretKey($api_key); // signature header - $sig = $req->header->get(self::HEADER_SIGNATURE); + $sig = $req->headers->get(self::HEADER_SIGNATURE); if ($sig == null) throw new AccessDeniedHttpException('No signature sent.'); - // TODO: check valid signature + // check valid signature + $this->validateSignature($req, $hdate_string, $secret_key, $sig); return; } diff --git a/catalyst/api-bundle/Library/Response.php b/catalyst/api-bundle/Library/Response.php deleted file mode 100644 index 75eefdb2..00000000 --- a/catalyst/api-bundle/Library/Response.php +++ /dev/null @@ -1,9 +0,0 @@ -em = $em; + } + + protected function getSecretKey($api_key) + { + return 'sldkfjlksdjflksdjflksdjflsjf'; + } + + protected function validateSignature($req, $hdate_string, $secret_key, $sig) + { + // get needed params for generation + $method = $req->getRealMethod(); + $uri = $req->getRequestUri(); + + $elements = [$method, $uri, $hdate_string, $secret_key]; + $sig_source = implode('|', $elements); + + error_log($sig_source); + + // generate signature + $raw_sig = hash_hmac('sha1', $sig_source, $secret_key, true); + $enc_sig = base64_encode($raw_sig); + + error_log($enc_sig); + + if ($enc_sig != trim($sig)) + throw new BadCredentialsException('Invalid signature.'); + + } + + public function createToken(Request $req, $provider_key) + { + // api key header + $api_key = $req->headers->get(self::HEADER_API_KEY); + if ($api_key == null) + throw new BadCredentialsException('No API key sent.'); + + // check date from headers + $hdate_string = $req->headers->get(self::HEADER_DATE); + if ($hdate_string == null) + throw new BadCredentialsException('No date specified.'); + + $hdate = DateTime::createFromFormat(self::DATE_FORMAT, $hdate_string); + if ($hdate == null) + throw new BadCredentialsException('Invalid date specified.'); + + // get number of seconds difference + $date_now = new DateTime(); + $date_diff = abs($date_now->getTimestamp() - $hdate->getTimestamp()); + + // time difference is too much + if ($date_diff > self::TIME_LIMIT) + throw new BadCredentialsException('Clock synchronization error.'); + + // check valid api key + $secret_key = $this->getSecretKey($api_key); + + // signature header + $sig = $req->headers->get(self::HEADER_SIGNATURE); + if ($sig == null) + throw new BadCredentialsException('No signature sent.'); + + // check valid signature + $this->validateSignature($req, $hdate_string, $secret_key, $sig); + + return new PreAuthenticatedToken( + 'anonymous', + $api_key, + $provider_key + ); + } + + public function supportsToken(TokenInterface $token, $provider_key) + { + return $token instanceof PreAuthenticatedToken && $token->getProviderKey() === $provider_key; + } + + public function authenticateToken(TokenInterface $token, UserProviderInterface $user_provider, $provider_key) + { + if (!$user_provider instanceof APIKeyUserProvider) + { + throw new \InvalidArgumentException( + sprintf( + 'The user provider must be an instance of APIKeyUserProvider (%s was given).', + get_class($user_provider) + ) + ); + } + + $api_key = $token->getCredentials(); + $user = $user_provider->getUserByAPIKey($api_key); + + /* + $username = $user_provider->getUsernameForAPIKey($api_key); + + if (!$username) + { + // CAUTION: this message will be returned to the client + // (so don't put any un-trusted messages / error strings here) + throw new CustomUserMessageAuthenticationException( + sprintf('API Key "%s" does not exist.', $api_key) + ); + } + */ + if (!$user) + throw new CustomUserMessageAuthenticationException('Invalid API Key'); + + // $user = $user_provider->loadUserByUsername($username); + + return new PreAuthenticatedToken( + $user, + $api_key, + $provider_key, + $user->getRoles() + ); + } + + public function onAuthenticationFailure(Request $req, AuthenticationException $exception) + { + $data = [ + 'success' => false, + 'error' => [ + 'message' => $exception->getMessage(), + ], + ]; + return new JsonResponse($data, 401); + } +} diff --git a/catalyst/api-bundle/Security/APIKeyUserProvider.php b/catalyst/api-bundle/Security/APIKeyUserProvider.php new file mode 100644 index 00000000..1521a390 --- /dev/null +++ b/catalyst/api-bundle/Security/APIKeyUserProvider.php @@ -0,0 +1,62 @@ +em = $em; + } + + public function getUserByAPIKey($api_key) + { + $user = $this->em->getRepository(User::class)->find($api_key); + + return $user; + } + + public function getUsernameForAPIKey($apiKey) + { + // Look up the username based on the token in the database, via + // an API call, or do something entirely different + $username = 'test'; + + return $username; + } + + public function loadUserByUsername($username) + { + return new User( + $username, + null, + // the roles for the user - you may choose to determine + // these dynamically somehow based on the user + array('ROLE_API') + ); + } + + public function refreshUser(UserInterface $user) + { + // this is used for storing authentication in the session + // but in this example, the token is sent in each request, + // so authentication can be stateless. Throwing this exception + // is proper to make things stateless + throw new UnsupportedUserException(); + } + + public function supportsClass($class) + { + return User::class === $class; + } +} diff --git a/catalyst/api-bundle/Service/AccessDeniedHandler.php b/catalyst/api-bundle/Service/AccessDeniedHandler.php new file mode 100644 index 00000000..7cf5be90 --- /dev/null +++ b/catalyst/api-bundle/Service/AccessDeniedHandler.php @@ -0,0 +1,18 @@ +getMessage(); + + return new Response($content, 403); + } +} diff --git a/composer.json b/composer.json index edc50977..d02f6ed6 100644 --- a/composer.json +++ b/composer.json @@ -38,7 +38,6 @@ "autoload": { "psr-4": { "App\\": "src/", - "Catalyst\\": "catalyst-libs/", "Catalyst\\APIBundle\\": "catalyst/api-bundle/" } }, diff --git a/config/bundles.php b/config/bundles.php index 9e9caf97..db1fdb73 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -11,5 +11,7 @@ return [ Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], + + Catalyst\APIBundle\CatalystAPIBundle::class => ['all' => true], // DataDog\AuditBundle\DataDogAuditBundle::class => ['all' => true], ]; diff --git a/config/packages/security.yaml b/config/packages/security.yaml index b554d0cd..1911adb5 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -5,10 +5,12 @@ security: algorithm: bcrypt cost: 12 providers: - user: + user_provider: entity: class: App\Entity\User property: username + api_key_user_provider: + id: Catalyst\APIBundle\Security\APIKeyUserProvider firewalls: dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ @@ -29,9 +31,13 @@ security: warranty_api: pattern: ^\/capi\/ - security: false + stateless: true + simple_preauth: + authenticator: Catalyst\APIBundle\Security\APIKeyAuthenticator + provider: api_key_user_provider main: + provider: user_provider form_login: login_path: login check_path: login diff --git a/config/services.yaml b/config/services.yaml index 946fbf57..93f1f909 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -80,4 +80,11 @@ services: arguments: $em: "@doctrine.orm.entity_manager" tags: ['kernel.event_subscriber'] - + + Catalyst\APIBundle\Security\APIKeyUserProvider: + arguments: + $em: "@doctrine.orm.entity_manager" + + Catalyst\APIBundle\Security\APIKeyAuthenticator: + arguments: + $em: "@doctrine.orm.entity_manager" diff --git a/src/Controller/APITestController.php b/src/Controller/APITestController.php index f22ed696..2f368ca3 100644 --- a/src/Controller/APITestController.php +++ b/src/Controller/APITestController.php @@ -8,6 +8,7 @@ use App\Entity\VehicleManufacturer; use Doctrine\ORM\Query; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\Validator\Validator\ValidatorInterface; use App\Menu\Generator as MenuGenerator; @@ -19,5 +20,9 @@ class APITestController extends BaseController implements APIController { public function test() { + $data = [ + 'success' => true, + ]; + return new JsonResponse($data); } }