From 0f8268c93e6210d368f9dcd1900274871a9eacdf Mon Sep 17 00:00:00 2001 From: Thomas Citharel Date: Sun, 30 Apr 2017 09:16:55 +0200 Subject: Add client_credentials as grant_type Therefore, username and password are no longer needed Signed-off-by: Thomas Citharel Allow to have global clients, auth through direct token or auth code and bring scopes Signed-off-by: Thomas Citharel fix review Signed-off-by: Thomas Citharel remove redirect uri requirement on specific clients add back password and depreciate it enforce state Signed-off-by: Thomas Citharel Allow apps to register themselves A handful of changes Signed-off-by: Thomas Citharel change timeout values Signed-off-by: Thomas Citharel set access_token lifetime to 1 year and double for refresh_token Signed-off-by: Thomas Citharel --- .../Controller/AnnotationRestController.php | 9 +- .../ApiBundle/Controller/AppsController.php | 189 +++++++++++++++++++++ .../ApiBundle/Controller/DeveloperController.php | 105 ------------ .../ApiBundle/Controller/EntryRestController.php | 23 +-- .../ApiBundle/Controller/TagRestController.php | 11 +- src/Wallabag/ApiBundle/Entity/AccessToken.php | 2 +- src/Wallabag/ApiBundle/Entity/Client.php | 87 +++++++++- src/Wallabag/ApiBundle/Form/Type/ClientType.php | 20 +-- .../ApiBundle/Repository/AccessTokenRepository.php | 18 ++ .../ApiBundle/Resources/config/services.yml | 6 + 10 files changed, 324 insertions(+), 146 deletions(-) create mode 100644 src/Wallabag/ApiBundle/Controller/AppsController.php delete mode 100644 src/Wallabag/ApiBundle/Controller/DeveloperController.php create mode 100644 src/Wallabag/ApiBundle/Repository/AccessTokenRepository.php create mode 100644 src/Wallabag/ApiBundle/Resources/config/services.yml (limited to 'src/Wallabag/ApiBundle') diff --git a/src/Wallabag/ApiBundle/Controller/AnnotationRestController.php b/src/Wallabag/ApiBundle/Controller/AnnotationRestController.php index 2dd26c07..c524a24c 100644 --- a/src/Wallabag/ApiBundle/Controller/AnnotationRestController.php +++ b/src/Wallabag/ApiBundle/Controller/AnnotationRestController.php @@ -4,6 +4,7 @@ namespace Wallabag\ApiBundle\Controller; use Nelmio\ApiDocBundle\Annotation\ApiDoc; use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter; +use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\JsonResponse; use Wallabag\CoreBundle\Entity\Entry; @@ -21,7 +22,7 @@ class AnnotationRestController extends WallabagRestController * ) * * @param Entry $entry - * + * @Security("has_role('ROLE_READ')") * @return JsonResponse */ public function getAnnotationsAction(Entry $entry) @@ -46,7 +47,7 @@ class AnnotationRestController extends WallabagRestController * * @param Request $request * @param Entry $entry - * + * @Security("has_role('ROLE_WRITE')") * @return JsonResponse */ public function postAnnotationAction(Request $request, Entry $entry) @@ -72,7 +73,7 @@ class AnnotationRestController extends WallabagRestController * * @param Annotation $annotation * @param Request $request - * + * @Security("has_role('ROLE_WRITE')") * @return JsonResponse */ public function putAnnotationAction(Annotation $annotation, Request $request) @@ -97,7 +98,7 @@ class AnnotationRestController extends WallabagRestController * @ParamConverter("annotation", class="WallabagAnnotationBundle:Annotation") * * @param Annotation $annotation - * + * @Security("has_role('ROLE_WRITE')") * @return JsonResponse */ public function deleteAnnotationAction(Annotation $annotation) diff --git a/src/Wallabag/ApiBundle/Controller/AppsController.php b/src/Wallabag/ApiBundle/Controller/AppsController.php new file mode 100644 index 00000000..6ef77667 --- /dev/null +++ b/src/Wallabag/ApiBundle/Controller/AppsController.php @@ -0,0 +1,189 @@ +getDoctrine()->getRepository('WallabagApiBundle:Client')->findByUser($this->getUser()->getId()); + + $apps = $this->getDoctrine()->getRepository('WallabagApiBundle:AccessToken')->findAppsByUser($this->getUser()->getId()); + + return $this->render('@WallabagCore/themes/common/Developer/index.html.twig', [ + 'clients' => $clients, + 'apps' => $apps, + ]); + } + + /** + * Create a an app + * + * @param Request $request + * + * @Route("/api/apps", name="apps_create") + * @Method("POST") + * + * @return \Symfony\Component\HttpFoundation\Response + */ + public function createAppAction(Request $request) + { + $em = $this->getDoctrine()->getManager(); + + $clientName = $request->request->get('client_name'); + $redirectURIs = $request->request->get('redirect_uris'); + $logoURI = $request->request->get('logo_uri'); + $description = $request->request->get('description'); + $appURI = $request->request->get('app_uri'); + $nextRedirect = $request->request->get('uri_redirect_after_creation'); + + if (!$clientName) { + return new JsonResponse([ + 'error' => 'invalid_client_name', + 'error_description' => 'The client name cannot be empty', + ], 400); + } + + if (!$redirectURIs) { + return new JsonResponse([ + 'error' => 'invalid_redirect_uri', + 'error_description' => 'One or more redirect_uri values are invalid', + ], 400); + } + + $redirectURIs = (array) $redirectURIs; + + $client = new Client(); + + $client->setName($clientName); + + $client->setDescription($description); + + $client->setRedirectUris($redirectURIs); + + $client->setImage($logoURI); + $client->setAppUrl($appURI); + + $client->setAllowedGrantTypes(['token', 'refresh_token', 'authorization_code']); + $em->persist($client); + $em->flush(); + + return new JsonResponse([ + 'client_id' => $client->getPublicId(), + 'client_secret' => $client->getSecret(), + 'client_name' => $client->getName(), + 'redirect_uri' => $client->getRedirectUris(), + 'description' => $client->getDescription(), + 'logo_uri' => $client->getImage(), + 'app_uri' => $client->getAppUrl(), + ], 201); + } + + /** + * Create a client (an app). + * + * @param Request $request + * + * @Route("/apps/client/create", name="apps_create_client") + * + * @return \Symfony\Component\HttpFoundation\Response + */ + public function createClientAction(Request $request) + { + $em = $this->getDoctrine()->getManager(); + $client = new Client($this->getUser()); + $clientForm = $this->createForm(ClientType::class, $client); + $clientForm->handleRequest($request); + + if ($clientForm->isSubmitted() && $clientForm->isValid()) { + $client->setAllowedGrantTypes(['password', 'token', 'refresh_token', 'client_credentials']); // Password is depreciated + $em->persist($client); + $em->flush(); + + $this->get('session')->getFlashBag()->add( + 'notice', + $this->get('translator')->trans('flashes.developer.notice.client_created', ['%name%' => $client->getName()]) + ); + + return $this->render('@WallabagCore/themes/common/Developer/client_parameters.html.twig', [ + 'client_id' => $client->getPublicId(), + 'client_secret' => $client->getSecret(), + 'client_name' => $client->getName(), + ]); + } + + return $this->render('@WallabagCore/themes/common/Developer/client.html.twig', [ + 'form' => $clientForm->createView(), + ]); + } + + /** + * Revoke an access token + * @param $token + * @Route("/api/revoke/{token}", name="apps_revoke_access_token") + * @return JsonResponse + */ + public function removeAccessTokenAction($token) + { + if (false === $this->get('security.authorization_checker')->isGranted('IS_AUTHENTICATED_FULLY')) { + throw new AccessDeniedException(); + } + + $em = $this->getDoctrine()->getManager(); + $accessToken = $em->getRepository('WallabagApiBundle:AccessToken')->findOneBy([ + 'user' => $this->getUser()->getId(), + 'token' => $token + ]); + if ($accessToken) { + $em->remove($accessToken); + $em->flush(); + + return new JsonResponse([], 204); + } + return new JsonResponse([], 404); + } + + /** + * Remove a client. + * + * @param Client $client + * + * @Route("/apps/client/delete/{id}", requirements={"id" = "\d+"}, name="apps_delete_client") + * + * @return \Symfony\Component\HttpFoundation\RedirectResponse + */ + public function deleteClientAction(Client $client) + { + if (null === $this->getUser() || $client->getUser()->getId() != $this->getUser()->getId()) { + throw $this->createAccessDeniedException('You can not access this client.'); + } + + $em = $this->getDoctrine()->getManager(); + $em->remove($client); + $em->flush(); + + $this->get('session')->getFlashBag()->add( + 'notice', + $this->get('translator')->trans('flashes.developer.notice.client_deleted', ['%name%' => $client->getName()]) + ); + + return $this->redirect($this->generateUrl('apps')); + } +} diff --git a/src/Wallabag/ApiBundle/Controller/DeveloperController.php b/src/Wallabag/ApiBundle/Controller/DeveloperController.php deleted file mode 100644 index 9cb1b626..00000000 --- a/src/Wallabag/ApiBundle/Controller/DeveloperController.php +++ /dev/null @@ -1,105 +0,0 @@ -getDoctrine()->getRepository('WallabagApiBundle:Client')->findByUser($this->getUser()->getId()); - - return $this->render('@WallabagCore/themes/common/Developer/index.html.twig', [ - 'clients' => $clients, - ]); - } - - /** - * Create a client (an app). - * - * @param Request $request - * - * @Route("/developer/client/create", name="developer_create_client") - * - * @return \Symfony\Component\HttpFoundation\Response - */ - public function createClientAction(Request $request) - { - $em = $this->getDoctrine()->getManager(); - $client = new Client($this->getUser()); - $clientForm = $this->createForm(ClientType::class, $client); - $clientForm->handleRequest($request); - - if ($clientForm->isSubmitted() && $clientForm->isValid()) { - $client->setAllowedGrantTypes(['token', 'authorization_code', 'password', 'refresh_token']); - $em->persist($client); - $em->flush(); - - $this->get('session')->getFlashBag()->add( - 'notice', - $this->get('translator')->trans('flashes.developer.notice.client_created', ['%name%' => $client->getName()]) - ); - - return $this->render('@WallabagCore/themes/common/Developer/client_parameters.html.twig', [ - 'client_id' => $client->getPublicId(), - 'client_secret' => $client->getSecret(), - 'client_name' => $client->getName(), - ]); - } - - return $this->render('@WallabagCore/themes/common/Developer/client.html.twig', [ - 'form' => $clientForm->createView(), - ]); - } - - /** - * Remove a client. - * - * @param Client $client - * - * @Route("/developer/client/delete/{id}", requirements={"id" = "\d+"}, name="developer_delete_client") - * - * @return \Symfony\Component\HttpFoundation\RedirectResponse - */ - public function deleteClientAction(Client $client) - { - if (null === $this->getUser() || $client->getUser()->getId() != $this->getUser()->getId()) { - throw $this->createAccessDeniedException('You can not access this client.'); - } - - $em = $this->getDoctrine()->getManager(); - $em->remove($client); - $em->flush(); - - $this->get('session')->getFlashBag()->add( - 'notice', - $this->get('translator')->trans('flashes.developer.notice.client_deleted', ['%name%' => $client->getName()]) - ); - - return $this->redirect($this->generateUrl('developer')); - } - - /** - * Display developer how to use an existing app. - * - * @Route("/developer/howto/first-app", name="developer_howto_firstapp") - * - * @return \Symfony\Component\HttpFoundation\Response - */ - public function howtoFirstAppAction() - { - return $this->render('@WallabagCore/themes/common/Developer/howto_app.html.twig'); - } -} diff --git a/src/Wallabag/ApiBundle/Controller/EntryRestController.php b/src/Wallabag/ApiBundle/Controller/EntryRestController.php index 768c4fdc..93f1f461 100644 --- a/src/Wallabag/ApiBundle/Controller/EntryRestController.php +++ b/src/Wallabag/ApiBundle/Controller/EntryRestController.php @@ -5,6 +5,7 @@ namespace Wallabag\ApiBundle\Controller; use Hateoas\Configuration\Route; use Hateoas\Representation\Factory\PagerfantaFactory; use Nelmio\ApiDocBundle\Annotation\ApiDoc; +use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\JsonResponse; @@ -25,7 +26,7 @@ class EntryRestController extends WallabagRestController * {"name"="urls", "dataType"="string", "required"=false, "format"="An array of urls (?urls[]=http...&urls[]=http...)", "description"="Urls (as an array) to check if it exists"} * } * ) - * + * @Security("has_role('ROLE_READ')") * @return JsonResponse */ public function getEntriesExistsAction(Request $request) @@ -80,7 +81,7 @@ class EntryRestController extends WallabagRestController * {"name"="public", "dataType"="integer", "required"=false, "format"="1 or 0, all entries by default", "description"="filter by entries with a public link"}, * } * ) - * + * @Security("has_role('ROLE_READ')") * @return JsonResponse */ public function getEntriesAction(Request $request) @@ -143,7 +144,7 @@ class EntryRestController extends WallabagRestController * {"name"="entry", "dataType"="integer", "requirement"="\w+", "description"="The entry ID"} * } * ) - * + * @Security("has_role('ROLE_READ')") * @return JsonResponse */ public function getEntryAction(Entry $entry) @@ -162,7 +163,7 @@ class EntryRestController extends WallabagRestController * {"name"="entry", "dataType"="integer", "requirement"="\w+", "description"="The entry ID"} * } * ) - * + * @Security("has_role('ROLE_READ')") * @return Response */ public function getEntryExportAction(Entry $entry, Request $request) @@ -302,7 +303,7 @@ class EntryRestController extends WallabagRestController * {"name"="public", "dataType"="integer", "required"=false, "format"="1 or 0", "description"="will generate a public link for the entry"}, * } * ) - * + * @Security("has_role('ROLE_WRITE')") * @return JsonResponse */ public function postEntriesAction(Request $request) @@ -346,7 +347,7 @@ class EntryRestController extends WallabagRestController * {"name"="public", "dataType"="integer", "required"=false, "format"="1 or 0", "description"="will generate a public link for the entry"}, * } * ) - * + * @Security("has_role('ROLE_WRITE')") * @return JsonResponse */ public function patchEntriesAction(Entry $entry, Request $request) @@ -368,7 +369,7 @@ class EntryRestController extends WallabagRestController * {"name"="entry", "dataType"="integer", "requirement"="\w+", "description"="The entry ID"} * } * ) - * + * @Security("has_role('ROLE_WRITE')") * @return JsonResponse */ public function patchEntriesReloadAction(Entry $entry) @@ -410,7 +411,7 @@ class EntryRestController extends WallabagRestController * {"name"="entry", "dataType"="integer", "requirement"="\w+", "description"="The entry ID"} * } * ) - * + * @Security("has_role('ROLE_WRITE')") * @return JsonResponse */ public function deleteEntriesAction(Entry $entry) @@ -436,7 +437,7 @@ class EntryRestController extends WallabagRestController * {"name"="entry", "dataType"="integer", "requirement"="\w+", "description"="The entry ID"} * } * ) - * + * @Security("has_role('ROLE_READ')") * @return JsonResponse */ public function getEntriesTagsAction(Entry $entry) @@ -458,7 +459,7 @@ class EntryRestController extends WallabagRestController * {"name"="tags", "dataType"="string", "required"=false, "format"="tag1,tag2,tag3", "description"="a comma-separated list of tags."}, * } * ) - * + * @Security("has_role('ROLE_WRITE')") * @return JsonResponse */ public function postEntriesTagsAction(Request $request, Entry $entry) @@ -487,7 +488,7 @@ class EntryRestController extends WallabagRestController * {"name"="entry", "dataType"="integer", "requirement"="\w+", "description"="The entry ID"} * } * ) - * + * @Security("has_role('ROLE_WRITE')") * @return JsonResponse */ public function deleteEntriesTagsAction(Entry $entry, Tag $tag) diff --git a/src/Wallabag/ApiBundle/Controller/TagRestController.php b/src/Wallabag/ApiBundle/Controller/TagRestController.php index 354187a0..6f460a2d 100644 --- a/src/Wallabag/ApiBundle/Controller/TagRestController.php +++ b/src/Wallabag/ApiBundle/Controller/TagRestController.php @@ -3,6 +3,7 @@ namespace Wallabag\ApiBundle\Controller; use Nelmio\ApiDocBundle\Annotation\ApiDoc; +use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\JsonResponse; use Wallabag\CoreBundle\Entity\Entry; @@ -14,7 +15,7 @@ class TagRestController extends WallabagRestController * Retrieve all tags. * * @ApiDoc() - * + * @Security("has_role('ROLE_READ')") * @return JsonResponse */ public function getTagsAction() @@ -38,7 +39,7 @@ class TagRestController extends WallabagRestController * {"name"="tag", "dataType"="string", "required"=true, "requirement"="\w+", "description"="Tag as a string"} * } * ) - * + * @Security("has_role('ROLE_WRITE')") * @return JsonResponse */ public function deleteTagLabelAction(Request $request) @@ -71,7 +72,7 @@ class TagRestController extends WallabagRestController * {"name"="tags", "dataType"="string", "required"=true, "format"="tag1,tag2", "description"="Tags as strings (comma splitted)"} * } * ) - * + * @Security("has_role('ROLE_WRITE')") * @return JsonResponse */ public function deleteTagsLabelAction(Request $request) @@ -113,7 +114,7 @@ class TagRestController extends WallabagRestController * {"name"="tag", "dataType"="integer", "requirement"="\w+", "description"="The tag"} * } * ) - * + * @Security("has_role('ROLE_WRITE')") * @return JsonResponse */ public function deleteTagAction(Tag $tag) @@ -133,7 +134,7 @@ class TagRestController extends WallabagRestController /** * Remove orphan tag in case no entries are associated to it. - * + * @Security("has_role('ROLE_WRITE')") * @param Tag|array $tags */ private function cleanOrphanTag($tags) diff --git a/src/Wallabag/ApiBundle/Entity/AccessToken.php b/src/Wallabag/ApiBundle/Entity/AccessToken.php index c09a0c80..a8b46742 100644 --- a/src/Wallabag/ApiBundle/Entity/AccessToken.php +++ b/src/Wallabag/ApiBundle/Entity/AccessToken.php @@ -7,7 +7,7 @@ use FOS\OAuthServerBundle\Entity\AccessToken as BaseAccessToken; /** * @ORM\Table("oauth2_access_tokens") - * @ORM\Entity + * @ORM\Entity(repositoryClass="Wallabag\ApiBundle\Repository\AccessTokenRepository") */ class AccessToken extends BaseAccessToken { diff --git a/src/Wallabag/ApiBundle/Entity/Client.php b/src/Wallabag/ApiBundle/Entity/Client.php index c15fd3fa..24444c9f 100644 --- a/src/Wallabag/ApiBundle/Entity/Client.php +++ b/src/Wallabag/ApiBundle/Entity/Client.php @@ -8,6 +8,7 @@ use Wallabag\UserBundle\Entity\User; use JMS\Serializer\Annotation\Groups; use JMS\Serializer\Annotation\SerializedName; use JMS\Serializer\Annotation\VirtualProperty; +use Symfony\Component\Validator\Constraints as Assert; /** * @ORM\Table("oauth2_clients") @@ -51,13 +52,39 @@ class Client extends BaseClient /** * @ORM\ManyToOne(targetEntity="Wallabag\UserBundle\Entity\User", inversedBy="clients") + * @ORM\JoinColumn(name="user_id", referencedColumnName="id", nullable=true) */ private $user; - public function __construct(User $user) + /** + * @ORM\Column(type="string", nullable=true) + */ + private $image; + + /** + * @ORM\Column(type="string", nullable=true) + */ + private $description; + + /** + * @ORM\Column(type="string", nullable=true) + */ + private $appUrl; + + /** + * @ORM\Column(type="datetime", nullable=true) + */ + private $createdAt; + + /** + * Client constructor. + * @param User|null $user + */ + public function __construct(User $user = null) { parent::__construct(); $this->user = $user; + $this->createdAt = new \DateTime(); } /** @@ -99,6 +126,62 @@ class Client extends BaseClient */ public function getClientId() { - return $this->getId().'_'.$this->getRandomId(); + return $this->getId() . '_' . $this->getRandomId(); + } + + /** + * @return string + */ + public function getImage() + { + return $this->image; + } + + /** + * @param string $image + */ + public function setImage($image) + { + $this->image = $image; + } + + /** + * @return string + */ + public function getDescription() + { + return $this->description; + } + + /** + * @param string $description + */ + public function setDescription($description) + { + $this->description = $description; + } + + /** + * @return string + */ + public function getAppUrl() + { + return $this->appUrl; + } + + /** + * @param string $appUrl + */ + public function setAppUrl($appUrl) + { + $this->appUrl = $appUrl; + } + + /** + * @return \DateTime + */ + public function getCreatedAt() + { + return $this->createdAt; } } diff --git a/src/Wallabag/ApiBundle/Form/Type/ClientType.php b/src/Wallabag/ApiBundle/Form/Type/ClientType.php index eaea4feb..58602d22 100644 --- a/src/Wallabag/ApiBundle/Form/Type/ClientType.php +++ b/src/Wallabag/ApiBundle/Form/Type/ClientType.php @@ -15,24 +15,8 @@ class ClientType extends AbstractType public function buildForm(FormBuilderInterface $builder, array $options) { $builder - ->add('name', TextType::class, ['label' => 'developer.client.form.name_label']) - ->add('redirect_uris', UrlType::class, [ - 'required' => false, - 'label' => 'developer.client.form.redirect_uris_label', - 'property_path' => 'redirectUris', - ]) - ->add('save', SubmitType::class, ['label' => 'developer.client.form.save_label']) - ; - - $builder->get('redirect_uris') - ->addModelTransformer(new CallbackTransformer( - function ($originalUri) { - return $originalUri; - }, - function ($submittedUri) { - return [$submittedUri]; - } - )) + ->add('name', TextType::class, ['label' => 'apps.old_client.form.name_label']) + ->add('save', SubmitType::class, ['label' => 'apps.old_client.form.save_label']) ; } diff --git a/src/Wallabag/ApiBundle/Repository/AccessTokenRepository.php b/src/Wallabag/ApiBundle/Repository/AccessTokenRepository.php new file mode 100644 index 00000000..2b8d24df --- /dev/null +++ b/src/Wallabag/ApiBundle/Repository/AccessTokenRepository.php @@ -0,0 +1,18 @@ +createQueryBuilder('a') + ->innerJoin('a.client', 'c') + ->addSelect('c') + ->where('a.user =:userId')->setParameter('userId', $userId); + return $qb->getQuery()->getResult(); + } +} diff --git a/src/Wallabag/ApiBundle/Resources/config/services.yml b/src/Wallabag/ApiBundle/Resources/config/services.yml new file mode 100644 index 00000000..1275107d --- /dev/null +++ b/src/Wallabag/ApiBundle/Resources/config/services.yml @@ -0,0 +1,6 @@ +services: + wallabag_api.accesstoken_repository: + class: Wallabag\ApiBundle\Repository\AccessTokenRepository + factory: [ "@doctrine.orm.default_entity_manager", getRepository ] + arguments: + - WallabagApiBundle:AccessToken -- cgit v1.2.3