aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/Wallabag/ApiBundle
diff options
context:
space:
mode:
authorJeremy <jeremy.benoist@gmail.com>2015-03-29 10:53:10 +0200
committerJeremy <jeremy.benoist@gmail.com>2015-04-01 21:59:12 +0200
commit769e19dc4ab1a068e8165a7b237f42a78a6d312f (patch)
tree8fcb164704dd75a6108db0792c02f4ef6a7e1722 /src/Wallabag/ApiBundle
parente3c34bfc06f3ea266a418d6246560f15d3f73e2a (diff)
downloadwallabag-769e19dc4ab1a068e8165a7b237f42a78a6d312f.tar.gz
wallabag-769e19dc4ab1a068e8165a7b237f42a78a6d312f.tar.zst
wallabag-769e19dc4ab1a068e8165a7b237f42a78a6d312f.zip
Move API stuff in ApiBundle
Diffstat (limited to 'src/Wallabag/ApiBundle')
-rw-r--r--src/Wallabag/ApiBundle/Controller/WallabagRestController.php370
-rw-r--r--src/Wallabag/ApiBundle/DependencyInjection/Configuration.php29
-rw-r--r--src/Wallabag/ApiBundle/DependencyInjection/Security/Factory/WsseFactory.php40
-rw-r--r--src/Wallabag/ApiBundle/DependencyInjection/WallabagApiExtension.php25
-rw-r--r--src/Wallabag/ApiBundle/Resources/config/routing.yml0
-rw-r--r--src/Wallabag/ApiBundle/Resources/config/routing_rest.yml4
-rw-r--r--src/Wallabag/ApiBundle/Resources/config/services.yml12
-rw-r--r--src/Wallabag/ApiBundle/Security/Authentication/Provider/WsseProvider.php78
-rw-r--r--src/Wallabag/ApiBundle/Security/Authentication/Token/WsseUserToken.php23
-rw-r--r--src/Wallabag/ApiBundle/Security/Firewall/WsseListener.php62
-rw-r--r--src/Wallabag/ApiBundle/Tests/Controller/WallabagRestControllerTest.php410
-rw-r--r--src/Wallabag/ApiBundle/WallabagApiBundle.php18
12 files changed, 1071 insertions, 0 deletions
diff --git a/src/Wallabag/ApiBundle/Controller/WallabagRestController.php b/src/Wallabag/ApiBundle/Controller/WallabagRestController.php
new file mode 100644
index 00000000..21e4552d
--- /dev/null
+++ b/src/Wallabag/ApiBundle/Controller/WallabagRestController.php
@@ -0,0 +1,370 @@
1<?php
2
3namespace Wallabag\ApiBundle\Controller;
4
5use Nelmio\ApiDocBundle\Annotation\ApiDoc;
6use Symfony\Bundle\FrameworkBundle\Controller\Controller;
7use Symfony\Component\HttpFoundation\Request;
8use Symfony\Component\HttpFoundation\Response;
9use Wallabag\CoreBundle\Entity\Entry;
10use Wallabag\CoreBundle\Entity\Tag;
11use Wallabag\CoreBundle\Service\Extractor;
12use Hateoas\Configuration\Route;
13use Hateoas\Representation\Factory\PagerfantaFactory;
14
15class WallabagRestController extends Controller
16{
17 /**
18 * @param Entry $entry
19 * @param string $tags
20 */
21 private function assignTagsToEntry(Entry $entry, $tags)
22 {
23 foreach (explode(',', $tags) as $label) {
24 $label = trim($label);
25 $tagEntity = $this
26 ->getDoctrine()
27 ->getRepository('WallabagCoreBundle:Tag')
28 ->findOneByLabel($label);
29
30 if (is_null($tagEntity)) {
31 $tagEntity = new Tag($this->getUser());
32 $tagEntity->setLabel($label);
33 }
34
35 // only add the tag on the entry if the relation doesn't exist
36 if (!$entry->getTags()->contains($tagEntity)) {
37 $entry->addTag($tagEntity);
38 }
39 }
40 }
41
42 /**
43 * Retrieve salt for a giver user.
44 *
45 * @ApiDoc(
46 * parameters={
47 * {"name"="username", "dataType"="string", "required"=true, "description"="username"}
48 * }
49 * )
50 * @return array
51 */
52 public function getSaltAction($username)
53 {
54 $user = $this
55 ->getDoctrine()
56 ->getRepository('WallabagCoreBundle:User')
57 ->findOneByUsername($username);
58
59 if (is_null($user)) {
60 throw $this->createNotFoundException();
61 }
62
63 return array($user->getSalt() ?: null);
64 }
65
66 /**
67 * Retrieve all entries. It could be filtered by many options.
68 *
69 * @ApiDoc(
70 * parameters={
71 * {"name"="archive", "dataType"="boolean", "required"=false, "format"="true or false, all entries by default", "description"="filter by archived status."},
72 * {"name"="star", "dataType"="boolean", "required"=false, "format"="true or false, all entries by default", "description"="filter by starred status."},
73 * {"name"="sort", "dataType"="string", "required"=false, "format"="'created' or 'updated', default 'created'", "description"="sort entries by date."},
74 * {"name"="order", "dataType"="string", "required"=false, "format"="'asc' or 'desc', default 'desc'", "description"="order of sort."},
75 * {"name"="page", "dataType"="integer", "required"=false, "format"="default '1'", "description"="what page you want."},
76 * {"name"="perPage", "dataType"="integer", "required"=false, "format"="default'30'", "description"="results per page."},
77 * {"name"="tags", "dataType"="string", "required"=false, "format"="api%2Crest", "description"="a list of tags url encoded. Will returns entries that matches ALL tags."},
78 * }
79 * )
80 * @return Entry
81 */
82 public function getEntriesAction(Request $request)
83 {
84 $isArchived = $request->query->get('archive');
85 $isStarred = $request->query->get('star');
86 $sort = $request->query->get('sort', 'created');
87 $order = $request->query->get('order', 'desc');
88 $page = (int) $request->query->get('page', 1);
89 $perPage = (int) $request->query->get('perPage', 30);
90 $tags = $request->query->get('tags', []);
91
92 $pager = $this
93 ->getDoctrine()
94 ->getRepository('WallabagCoreBundle:Entry')
95 ->findEntries($this->getUser()->getId(), $isArchived, $isStarred, $sort, $order);
96
97 $pager->setCurrentPage($page);
98 $pager->setMaxPerPage($perPage);
99
100 $pagerfantaFactory = new PagerfantaFactory('page', 'perPage');
101 $paginatedCollection = $pagerfantaFactory->createRepresentation(
102 $pager,
103 new Route('api_get_entries', [], $absolute = true)
104 );
105
106 $json = $this->get('serializer')->serialize($paginatedCollection, 'json');
107
108 return $this->renderJsonResponse($json);
109 }
110
111 /**
112 * Retrieve a single entry
113 *
114 * @ApiDoc(
115 * requirements={
116 * {"name"="entry", "dataType"="integer", "requirement"="\w+", "description"="The entry ID"}
117 * }
118 * )
119 * @return Entry
120 */
121 public function getEntryAction(Entry $entry)
122 {
123 $this->validateUserAccess($entry->getUser()->getId(), $this->getUser()->getId());
124
125 $json = $this->get('serializer')->serialize($entry, 'json');
126
127 return $this->renderJsonResponse($json);
128 }
129
130 /**
131 * Create an entry
132 *
133 * @ApiDoc(
134 * parameters={
135 * {"name"="url", "dataType"="string", "required"=true, "format"="http://www.test.com/article.html", "description"="Url for the entry."},
136 * {"name"="title", "dataType"="string", "required"=false, "description"="Optional, we'll get the title from the page."},
137 * {"name"="tags", "dataType"="string", "required"=false, "format"="tag1,tag2,tag3", "description"="a comma-separated list of tags."},
138 * }
139 * )
140 * @return Entry
141 */
142 public function postEntriesAction(Request $request)
143 {
144 $url = $request->request->get('url');
145
146 $content = Extractor::extract($url);
147 $entry = new Entry($this->getUser());
148 $entry->setUrl($url);
149 $entry->setTitle($request->request->get('title') ?: $content->getTitle());
150 $entry->setContent($content->getBody());
151
152 $tags = $request->request->get('tags', '');
153 if (!empty($tags)) {
154 $this->assignTagsToEntry($entry, $tags);
155 }
156
157 $em = $this->getDoctrine()->getManager();
158 $em->persist($entry);
159 $em->flush();
160
161 $json = $this->get('serializer')->serialize($entry, 'json');
162
163 return $this->renderJsonResponse($json);
164 }
165
166 /**
167 * Change several properties of an entry
168 *
169 * @ApiDoc(
170 * requirements={
171 * {"name"="entry", "dataType"="integer", "requirement"="\w+", "description"="The entry ID"}
172 * },
173 * parameters={
174 * {"name"="title", "dataType"="string", "required"=false},
175 * {"name"="tags", "dataType"="string", "required"=false, "format"="tag1,tag2,tag3", "description"="a comma-separated list of tags."},
176 * {"name"="archive", "dataType"="boolean", "required"=false, "format"="true or false", "description"="archived the entry."},
177 * {"name"="star", "dataType"="boolean", "required"=false, "format"="true or false", "description"="starred the entry."},
178 * }
179 * )
180 * @return Entry
181 */
182 public function patchEntriesAction(Entry $entry, Request $request)
183 {
184 $this->validateUserAccess($entry->getUser()->getId(), $this->getUser()->getId());
185
186 $title = $request->request->get("title");
187 $isArchived = $request->request->get("archive");
188 $isStarred = $request->request->get("star");
189
190 if (!is_null($title)) {
191 $entry->setTitle($title);
192 }
193
194 if (!is_null($isArchived)) {
195 $entry->setArchived($isArchived);
196 }
197
198 if (!is_null($isStarred)) {
199 $entry->setStarred($isStarred);
200 }
201
202 $tags = $request->request->get('tags', '');
203 if (!empty($tags)) {
204 $this->assignTagsToEntry($entry, $tags);
205 }
206
207 $em = $this->getDoctrine()->getManager();
208 $em->flush();
209
210 $json = $this->get('serializer')->serialize($entry, 'json');
211
212 return $this->renderJsonResponse($json);
213 }
214
215 /**
216 * Delete **permanently** an entry
217 *
218 * @ApiDoc(
219 * requirements={
220 * {"name"="entry", "dataType"="integer", "requirement"="\w+", "description"="The entry ID"}
221 * }
222 * )
223 * @return Entry
224 */
225 public function deleteEntriesAction(Entry $entry)
226 {
227 $this->validateUserAccess($entry->getUser()->getId(), $this->getUser()->getId());
228
229 $em = $this->getDoctrine()->getManager();
230 $em->remove($entry);
231 $em->flush();
232
233 $json = $this->get('serializer')->serialize($entry, 'json');
234
235 return $this->renderJsonResponse($json);
236 }
237
238 /**
239 * Retrieve all tags for an entry
240 *
241 * @ApiDoc(
242 * requirements={
243 * {"name"="entry", "dataType"="integer", "requirement"="\w+", "description"="The entry ID"}
244 * }
245 * )
246 */
247 public function getEntriesTagsAction(Entry $entry)
248 {
249 $this->validateUserAccess($entry->getUser()->getId(), $this->getUser()->getId());
250
251 $json = $this->get('serializer')->serialize($entry->getTags(), 'json');
252
253 return $this->renderJsonResponse($json);
254 }
255
256 /**
257 * Add one or more tags to an entry
258 *
259 * @ApiDoc(
260 * requirements={
261 * {"name"="entry", "dataType"="integer", "requirement"="\w+", "description"="The entry ID"}
262 * },
263 * parameters={
264 * {"name"="tags", "dataType"="string", "required"=false, "format"="tag1,tag2,tag3", "description"="a comma-separated list of tags."},
265 * }
266 * )
267 */
268 public function postEntriesTagsAction(Request $request, Entry $entry)
269 {
270 $this->validateUserAccess($entry->getUser()->getId(), $this->getUser()->getId());
271
272 $tags = $request->request->get('tags', '');
273 if (!empty($tags)) {
274 $this->assignTagsToEntry($entry, $tags);
275 }
276
277 $em = $this->getDoctrine()->getManager();
278 $em->persist($entry);
279 $em->flush();
280
281 $json = $this->get('serializer')->serialize($entry, 'json');
282
283 return $this->renderJsonResponse($json);
284 }
285
286 /**
287 * Permanently remove one tag for an entry
288 *
289 * @ApiDoc(
290 * requirements={
291 * {"name"="tag", "dataType"="integer", "requirement"="\w+", "description"="The tag ID"},
292 * {"name"="entry", "dataType"="integer", "requirement"="\w+", "description"="The entry ID"}
293 * }
294 * )
295 */
296 public function deleteEntriesTagsAction(Entry $entry, Tag $tag)
297 {
298 $this->validateUserAccess($entry->getUser()->getId(), $this->getUser()->getId());
299
300 $entry->removeTag($tag);
301 $em = $this->getDoctrine()->getManager();
302 $em->persist($entry);
303 $em->flush();
304
305 $json = $this->get('serializer')->serialize($entry, 'json');
306
307 return $this->renderJsonResponse($json);
308 }
309
310 /**
311 * Retrieve all tags
312 *
313 * @ApiDoc()
314 */
315 public function getTagsAction()
316 {
317 $json = $this->get('serializer')->serialize($this->getUser()->getTags(), 'json');
318
319 return $this->renderJsonResponse($json);
320 }
321
322 /**
323 * Permanently remove one tag from **every** entry
324 *
325 * @ApiDoc(
326 * requirements={
327 * {"name"="tag", "dataType"="integer", "requirement"="\w+", "description"="The tag"}
328 * }
329 * )
330 */
331 public function deleteTagAction(Tag $tag)
332 {
333 $this->validateUserAccess($tag->getUser()->getId(), $this->getUser()->getId());
334
335 $em = $this->getDoctrine()->getManager();
336 $em->remove($tag);
337 $em->flush();
338
339 $json = $this->get('serializer')->serialize($tag, 'json');
340
341 return $this->renderJsonResponse($json);
342 }
343
344 /**
345 * Validate that the first id is equal to the second one.
346 * If not, throw exception. It means a user try to access information from an other user
347 *
348 * @param integer $requestUserId User id from the requested source
349 * @param integer $currentUserId User id from the retrieved source
350 */
351 private function validateUserAccess($requestUserId, $currentUserId)
352 {
353 if ($requestUserId != $currentUserId) {
354 throw $this->createAccessDeniedException('Access forbidden. Entry user id: '.$requestUserId.', logged user id: '.$currentUserId);
355 }
356 }
357
358 /**
359 * Send a JSON Response.
360 * We don't use the Symfony JsonRespone, because it takes an array as parameter instead of a JSON string
361 *
362 * @param string $json
363 *
364 * @return Response
365 */
366 private function renderJsonResponse($json)
367 {
368 return new Response($json, 200, array('application/json'));
369 }
370}
diff --git a/src/Wallabag/ApiBundle/DependencyInjection/Configuration.php b/src/Wallabag/ApiBundle/DependencyInjection/Configuration.php
new file mode 100644
index 00000000..80a07ca2
--- /dev/null
+++ b/src/Wallabag/ApiBundle/DependencyInjection/Configuration.php
@@ -0,0 +1,29 @@
1<?php
2
3namespace Wallabag\ApiBundle\DependencyInjection;
4
5use Symfony\Component\Config\Definition\Builder\TreeBuilder;
6use Symfony\Component\Config\Definition\ConfigurationInterface;
7
8/**
9 * This is the class that validates and merges configuration from your app/config files
10 *
11 * To learn more see {@link http://symfony.com/doc/current/cookbook/bundles/extension.html#cookbook-bundles-extension-config-class}
12 */
13class Configuration implements ConfigurationInterface
14{
15 /**
16 * {@inheritdoc}
17 */
18 public function getConfigTreeBuilder()
19 {
20 $treeBuilder = new TreeBuilder();
21 $rootNode = $treeBuilder->root('wallabag_api');
22
23 // Here you should define the parameters that are allowed to
24 // configure your bundle. See the documentation linked above for
25 // more information on that topic.
26
27 return $treeBuilder;
28 }
29}
diff --git a/src/Wallabag/ApiBundle/DependencyInjection/Security/Factory/WsseFactory.php b/src/Wallabag/ApiBundle/DependencyInjection/Security/Factory/WsseFactory.php
new file mode 100644
index 00000000..402eb869
--- /dev/null
+++ b/src/Wallabag/ApiBundle/DependencyInjection/Security/Factory/WsseFactory.php
@@ -0,0 +1,40 @@
1<?php
2
3namespace Wallabag\ApiBundle\DependencyInjection\Security\Factory;
4
5use Symfony\Component\DependencyInjection\ContainerBuilder;
6use Symfony\Component\DependencyInjection\Reference;
7use Symfony\Component\DependencyInjection\DefinitionDecorator;
8use Symfony\Component\Config\Definition\Builder\NodeDefinition;
9use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\SecurityFactoryInterface;
10
11class WsseFactory implements SecurityFactoryInterface
12{
13 public function create(ContainerBuilder $container, $id, $config, $userProvider, $defaultEntryPoint)
14 {
15 $providerId = 'security.authentication.provider.wsse.'.$id;
16 $container
17 ->setDefinition($providerId, new DefinitionDecorator('wsse.security.authentication.provider'))
18 ->replaceArgument(0, new Reference($userProvider))
19 ;
20
21 $listenerId = 'security.authentication.listener.wsse.'.$id;
22 $listener = $container->setDefinition($listenerId, new DefinitionDecorator('wsse.security.authentication.listener'));
23
24 return array($providerId, $listenerId, $defaultEntryPoint);
25 }
26
27 public function getPosition()
28 {
29 return 'pre_auth';
30 }
31
32 public function getKey()
33 {
34 return 'wsse';
35 }
36
37 public function addConfiguration(NodeDefinition $node)
38 {
39 }
40}
diff --git a/src/Wallabag/ApiBundle/DependencyInjection/WallabagApiExtension.php b/src/Wallabag/ApiBundle/DependencyInjection/WallabagApiExtension.php
new file mode 100644
index 00000000..c5cc204e
--- /dev/null
+++ b/src/Wallabag/ApiBundle/DependencyInjection/WallabagApiExtension.php
@@ -0,0 +1,25 @@
1<?php
2
3namespace Wallabag\ApiBundle\DependencyInjection;
4
5use Symfony\Component\DependencyInjection\ContainerBuilder;
6use Symfony\Component\Config\FileLocator;
7use Symfony\Component\HttpKernel\DependencyInjection\Extension;
8use Symfony\Component\DependencyInjection\Loader;
9
10class WallabagApiExtension extends Extension
11{
12 public function load(array $configs, ContainerBuilder $container)
13 {
14 $configuration = new Configuration();
15 $config = $this->processConfiguration($configuration, $configs);
16
17 $loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'));
18 $loader->load('services.yml');
19 }
20
21 public function getAlias()
22 {
23 return 'wallabag_api';
24 }
25}
diff --git a/src/Wallabag/ApiBundle/Resources/config/routing.yml b/src/Wallabag/ApiBundle/Resources/config/routing.yml
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/src/Wallabag/ApiBundle/Resources/config/routing.yml
diff --git a/src/Wallabag/ApiBundle/Resources/config/routing_rest.yml b/src/Wallabag/ApiBundle/Resources/config/routing_rest.yml
new file mode 100644
index 00000000..5f43f971
--- /dev/null
+++ b/src/Wallabag/ApiBundle/Resources/config/routing_rest.yml
@@ -0,0 +1,4 @@
1entries:
2 type: rest
3 resource: "WallabagApiBundle:WallabagRest"
4 name_prefix: api_
diff --git a/src/Wallabag/ApiBundle/Resources/config/services.yml b/src/Wallabag/ApiBundle/Resources/config/services.yml
new file mode 100644
index 00000000..6854a444
--- /dev/null
+++ b/src/Wallabag/ApiBundle/Resources/config/services.yml
@@ -0,0 +1,12 @@
1services:
2 wsse.security.authentication.provider:
3 class: Wallabag\ApiBundle\Security\Authentication\Provider\WsseProvider
4 public: false
5 arguments: ['', '%kernel.cache_dir%/security/nonces']
6
7 wsse.security.authentication.listener:
8 class: Wallabag\ApiBundle\Security\Firewall\WsseListener
9 public: false
10 tags:
11 - { name: monolog.logger, channel: wsse }
12 arguments: ['@security.context', '@security.authentication.manager', '@logger']
diff --git a/src/Wallabag/ApiBundle/Security/Authentication/Provider/WsseProvider.php b/src/Wallabag/ApiBundle/Security/Authentication/Provider/WsseProvider.php
new file mode 100644
index 00000000..8e49167a
--- /dev/null
+++ b/src/Wallabag/ApiBundle/Security/Authentication/Provider/WsseProvider.php
@@ -0,0 +1,78 @@
1<?php
2namespace Wallabag\ApiBundle\Security\Authentication\Provider;
3
4use Symfony\Component\Security\Core\Authentication\Provider\AuthenticationProviderInterface;
5use Symfony\Component\Security\Core\User\UserProviderInterface;
6use Symfony\Component\Security\Core\Exception\AuthenticationException;
7use Symfony\Component\Security\Core\Exception\NonceExpiredException;
8use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
9use Wallabag\ApiBundle\Security\Authentication\Token\WsseUserToken;
10
11class WsseProvider implements AuthenticationProviderInterface
12{
13 private $userProvider;
14 private $cacheDir;
15
16 public function __construct(UserProviderInterface $userProvider, $cacheDir)
17 {
18 $this->userProvider = $userProvider;
19 $this->cacheDir = $cacheDir;
20
21 // If cache directory does not exist we create it
22 if (!is_dir($this->cacheDir)) {
23 mkdir($this->cacheDir, 0777, true);
24 }
25 }
26
27 public function authenticate(TokenInterface $token)
28 {
29 $user = $this->userProvider->loadUserByUsername($token->getUsername());
30
31 if (!$user) {
32 throw new AuthenticationException("Bad credentials. Did you forgot your username?");
33 }
34
35 if ($user && $this->validateDigest($token->digest, $token->nonce, $token->created, $user->getPassword())) {
36 $authenticatedToken = new WsseUserToken($user->getRoles());
37 $authenticatedToken->setUser($user);
38
39 return $authenticatedToken;
40 }
41
42 throw new AuthenticationException('The WSSE authentication failed.');
43 }
44
45 protected function validateDigest($digest, $nonce, $created, $secret)
46 {
47 // Check created time is not in the future
48 if (strtotime($created) > time()) {
49 throw new AuthenticationException("Back to the future...");
50 }
51
52 // Expire timestamp after 5 minutes
53 if (time() - strtotime($created) > 300) {
54 throw new AuthenticationException("Too late for this timestamp... Watch your watch.");
55 }
56
57 // Validate nonce is unique within 5 minutes
58 if (file_exists($this->cacheDir.'/'.$nonce) && file_get_contents($this->cacheDir.'/'.$nonce) + 300 > time()) {
59 throw new NonceExpiredException('Previously used nonce detected');
60 }
61
62 file_put_contents($this->cacheDir.'/'.$nonce, time());
63
64 // Validate Secret
65 $expected = base64_encode(sha1(base64_decode($nonce).$created.$secret, true));
66
67 if ($digest !== $expected) {
68 throw new AuthenticationException("Bad credentials ! Digest is not as expected.");
69 }
70
71 return $digest === $expected;
72 }
73
74 public function supports(TokenInterface $token)
75 {
76 return $token instanceof WsseUserToken;
77 }
78}
diff --git a/src/Wallabag/ApiBundle/Security/Authentication/Token/WsseUserToken.php b/src/Wallabag/ApiBundle/Security/Authentication/Token/WsseUserToken.php
new file mode 100644
index 00000000..aa68dbdc
--- /dev/null
+++ b/src/Wallabag/ApiBundle/Security/Authentication/Token/WsseUserToken.php
@@ -0,0 +1,23 @@
1<?php
2namespace Wallabag\ApiBundle\Security\Authentication\Token;
3
4use Symfony\Component\Security\Core\Authentication\Token\AbstractToken;
5
6class WsseUserToken extends AbstractToken
7{
8 public $created;
9 public $digest;
10 public $nonce;
11
12 public function __construct(array $roles = array())
13 {
14 parent::__construct($roles);
15
16 $this->setAuthenticated(count($roles) > 0);
17 }
18
19 public function getCredentials()
20 {
21 return '';
22 }
23}
diff --git a/src/Wallabag/ApiBundle/Security/Firewall/WsseListener.php b/src/Wallabag/ApiBundle/Security/Firewall/WsseListener.php
new file mode 100644
index 00000000..50587837
--- /dev/null
+++ b/src/Wallabag/ApiBundle/Security/Firewall/WsseListener.php
@@ -0,0 +1,62 @@
1<?php
2
3namespace Wallabag\ApiBundle\Security\Firewall;
4
5use Symfony\Component\HttpFoundation\Response;
6use Symfony\Component\HttpKernel\Event\GetResponseEvent;
7use Symfony\Component\Security\Http\Firewall\ListenerInterface;
8use Symfony\Component\Security\Core\Exception\AuthenticationException;
9use Symfony\Component\Security\Core\SecurityContextInterface;
10use Symfony\Component\Security\Core\Authentication\AuthenticationManagerInterface;
11use Wallabag\ApiBundle\Security\Authentication\Token\WsseUserToken;
12use Psr\Log\LoggerInterface;
13
14class WsseListener implements ListenerInterface
15{
16 protected $securityContext;
17 protected $authenticationManager;
18 protected $logger;
19
20 public function __construct(SecurityContextInterface $securityContext, AuthenticationManagerInterface $authenticationManager, LoggerInterface $logger)
21 {
22 $this->securityContext = $securityContext;
23 $this->authenticationManager = $authenticationManager;
24 $this->logger = $logger;
25 }
26
27 public function handle(GetResponseEvent $event)
28 {
29 $request = $event->getRequest();
30
31 $wsseRegex = '/UsernameToken Username="([^"]+)", PasswordDigest="([^"]+)", Nonce="([^"]+)", Created="([^"]+)"/';
32 if (!$request->headers->has('x-wsse') || 1 !== preg_match($wsseRegex, $request->headers->get('x-wsse'), $matches)) {
33 return;
34 }
35
36 $token = new WsseUserToken();
37 $token->setUser($matches[1]);
38
39 $token->digest = $matches[2];
40 $token->nonce = $matches[3];
41 $token->created = $matches[4];
42
43 try {
44 $authToken = $this->authenticationManager->authenticate($token);
45
46 $this->securityContext->setToken($authToken);
47
48 return;
49 } catch (AuthenticationException $failed) {
50 $failedMessage = 'WSSE Login failed for '.$token->getUsername().'. Why ? '.$failed->getMessage();
51 $this->logger->err($failedMessage);
52
53 // Deny authentication with a '403 Forbidden' HTTP response
54 $response = new Response();
55 $response->setStatusCode(403);
56 $response->setContent($failedMessage);
57 $event->setResponse($response);
58
59 return;
60 }
61 }
62}
diff --git a/src/Wallabag/ApiBundle/Tests/Controller/WallabagRestControllerTest.php b/src/Wallabag/ApiBundle/Tests/Controller/WallabagRestControllerTest.php
new file mode 100644
index 00000000..ea8ee072
--- /dev/null
+++ b/src/Wallabag/ApiBundle/Tests/Controller/WallabagRestControllerTest.php
@@ -0,0 +1,410 @@
1<?php
2
3namespace Wallabag\CoreBundle\Tests\Controller;
4
5use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
6
7class WallabagRestControllerTest extends WebTestCase
8{
9 protected static $salt;
10
11 /**
12 * Grab the salt once and store it to be available for all tests
13 */
14 public static function setUpBeforeClass()
15 {
16 $client = self::createClient();
17
18 $user = $client->getContainer()
19 ->get('doctrine.orm.entity_manager')
20 ->getRepository('WallabagCoreBundle:User')
21 ->findOneByUsername('admin');
22
23 self::$salt = $user->getSalt();
24 }
25
26 /**
27 * Generate HTTP headers for authenticate user on API
28 *
29 * @param string $username
30 * @param string $password
31 *
32 * @return array
33 */
34 private function generateHeaders($username, $password)
35 {
36 $encryptedPassword = sha1($password.$username.self::$salt);
37 $nonce = substr(md5(uniqid('nonce_', true)), 0, 16);
38
39 $now = new \DateTime('now', new \DateTimeZone('UTC'));
40 $created = (string) $now->format('Y-m-d\TH:i:s\Z');
41 $digest = base64_encode(sha1(base64_decode($nonce).$created.$encryptedPassword, true));
42
43 return array(
44 'HTTP_AUTHORIZATION' => 'Authorization profile="UsernameToken"',
45 'HTTP_x-wsse' => 'X-WSSE: UsernameToken Username="'.$username.'", PasswordDigest="'.$digest.'", Nonce="'.$nonce.'", Created="'.$created.'"',
46 );
47 }
48
49 public function testGetSalt()
50 {
51 $client = $this->createClient();
52 $client->request('GET', '/api/salts/admin.json');
53
54 $user = $client->getContainer()
55 ->get('doctrine.orm.entity_manager')
56 ->getRepository('WallabagCoreBundle:User')
57 ->findOneByUsername('admin');
58
59 $this->assertEquals(200, $client->getResponse()->getStatusCode());
60
61 $content = json_decode($client->getResponse()->getContent(), true);
62
63 $this->assertArrayHasKey(0, $content);
64 $this->assertEquals($user->getSalt(), $content[0]);
65
66 $client->request('GET', '/api/salts/notfound.json');
67 $this->assertEquals(404, $client->getResponse()->getStatusCode());
68 }
69
70 public function testWithBadHeaders()
71 {
72 $client = $this->createClient();
73
74 $entry = $client->getContainer()
75 ->get('doctrine.orm.entity_manager')
76 ->getRepository('WallabagCoreBundle:Entry')
77 ->findOneByIsArchived(false);
78
79 if (!$entry) {
80 $this->markTestSkipped('No content found in db.');
81 }
82
83 $badHeaders = array(
84 'HTTP_AUTHORIZATION' => 'Authorization profile="UsernameToken"',
85 'HTTP_x-wsse' => 'X-WSSE: UsernameToken Username="admin", PasswordDigest="Wr0ngDig3st", Nonce="n0Nc3", Created="2015-01-01T13:37:00Z"',
86 );
87
88 $client->request('GET', '/api/entries/'.$entry->getId().'.json', array(), array(), $badHeaders);
89 $this->assertEquals(403, $client->getResponse()->getStatusCode());
90 }
91
92 public function testGetOneEntry()
93 {
94 $client = $this->createClient();
95 $headers = $this->generateHeaders('admin', 'mypassword');
96
97 $entry = $client->getContainer()
98 ->get('doctrine.orm.entity_manager')
99 ->getRepository('WallabagCoreBundle:Entry')
100 ->findOneBy(array('user' => 1, 'isArchived' => false));
101
102 if (!$entry) {
103 $this->markTestSkipped('No content found in db.');
104 }
105
106 $client->request('GET', '/api/entries/'.$entry->getId().'.json', array(), array(), $headers);
107
108 $this->assertEquals(200, $client->getResponse()->getStatusCode());
109
110 $content = json_decode($client->getResponse()->getContent(), true);
111
112 $this->assertEquals($entry->getTitle(), $content['title']);
113 $this->assertEquals($entry->getUrl(), $content['url']);
114 $this->assertCount(count($entry->getTags()), $content['tags']);
115
116 $this->assertTrue(
117 $client->getResponse()->headers->contains(
118 'Content-Type',
119 'application/json'
120 )
121 );
122 }
123
124 public function testGetOneEntryWrongUser()
125 {
126 $client = $this->createClient();
127 $headers = $this->generateHeaders('admin', 'mypassword');
128
129 $entry = $client->getContainer()
130 ->get('doctrine.orm.entity_manager')
131 ->getRepository('WallabagCoreBundle:Entry')
132 ->findOneBy(array('user' => 2, 'isArchived' => false));
133
134 if (!$entry) {
135 $this->markTestSkipped('No content found in db.');
136 }
137
138 $client->request('GET', '/api/entries/'.$entry->getId().'.json', array(), array(), $headers);
139
140 $this->assertEquals(403, $client->getResponse()->getStatusCode());
141 }
142
143 public function testGetEntries()
144 {
145 $client = $this->createClient();
146 $headers = $this->generateHeaders('admin', 'mypassword');
147
148 $client->request('GET', '/api/entries', array(), array(), $headers);
149
150 $this->assertEquals(200, $client->getResponse()->getStatusCode());
151
152 $content = json_decode($client->getResponse()->getContent(), true);
153
154 $this->assertGreaterThanOrEqual(1, count($content));
155 $this->assertNotEmpty($content['_embedded']['items']);
156 $this->assertGreaterThanOrEqual(1, $content['total']);
157 $this->assertEquals(1, $content['page']);
158 $this->assertGreaterThanOrEqual(1, $content['pages']);
159
160 $this->assertTrue(
161 $client->getResponse()->headers->contains(
162 'Content-Type',
163 'application/json'
164 )
165 );
166 }
167
168 public function testGetStarredEntries()
169 {
170 $client = $this->createClient();
171 $headers = $this->generateHeaders('admin', 'mypassword');
172
173 $client->request('GET', '/api/entries', array('archive' => 1), array(), $headers);
174
175 $this->assertEquals(200, $client->getResponse()->getStatusCode());
176
177 $content = json_decode($client->getResponse()->getContent(), true);
178
179 $this->assertGreaterThanOrEqual(1, count($content));
180 $this->assertEmpty($content['_embedded']['items']);
181 $this->assertEquals(0, $content['total']);
182 $this->assertEquals(1, $content['page']);
183 $this->assertEquals(1, $content['pages']);
184
185 $this->assertTrue(
186 $client->getResponse()->headers->contains(
187 'Content-Type',
188 'application/json'
189 )
190 );
191 }
192
193 public function testDeleteEntry()
194 {
195 $client = $this->createClient();
196 $headers = $this->generateHeaders('admin', 'mypassword');
197
198 $entry = $client->getContainer()
199 ->get('doctrine.orm.entity_manager')
200 ->getRepository('WallabagCoreBundle:Entry')
201 ->findOneByUser(1);
202
203 if (!$entry) {
204 $this->markTestSkipped('No content found in db.');
205 }
206
207 $client->request('DELETE', '/api/entries/'.$entry->getId().'.json', array(), array(), $headers);
208
209 $this->assertEquals(200, $client->getResponse()->getStatusCode());
210
211 $content = json_decode($client->getResponse()->getContent(), true);
212
213 $this->assertEquals($entry->getTitle(), $content['title']);
214 $this->assertEquals($entry->getUrl(), $content['url']);
215
216 // We'll try to delete this entry again
217 $headers = $this->generateHeaders('admin', 'mypassword');
218
219 $client->request('DELETE', '/api/entries/'.$entry->getId().'.json', array(), array(), $headers);
220
221 $this->assertEquals(404, $client->getResponse()->getStatusCode());
222 }
223
224 public function testPostEntry()
225 {
226 $client = $this->createClient();
227 $headers = $this->generateHeaders('admin', 'mypassword');
228
229 $client->request('POST', '/api/entries.json', array(
230 'url' => 'http://www.lemonde.fr/pixels/article/2015/03/28/plongee-dans-l-univers-d-ingress-le-jeu-de-google-aux-frontieres-du-reel_4601155_4408996.html',
231 'tags' => 'google',
232 ), array(), $headers);
233
234 $this->assertEquals(200, $client->getResponse()->getStatusCode());
235
236 $content = json_decode($client->getResponse()->getContent(), true);
237
238 $this->assertGreaterThan(0, $content['id']);
239 $this->assertEquals('http://www.lemonde.fr/pixels/article/2015/03/28/plongee-dans-l-univers-d-ingress-le-jeu-de-google-aux-frontieres-du-reel_4601155_4408996.html', $content['url']);
240 $this->assertEquals(false, $content['is_archived']);
241 $this->assertEquals(false, $content['is_starred']);
242 $this->assertCount(1, $content['tags']);
243 }
244
245 public function testPatchEntry()
246 {
247 $client = $this->createClient();
248 $headers = $this->generateHeaders('admin', 'mypassword');
249
250 $entry = $client->getContainer()
251 ->get('doctrine.orm.entity_manager')
252 ->getRepository('WallabagCoreBundle:Entry')
253 ->findOneByUser(1);
254
255 if (!$entry) {
256 $this->markTestSkipped('No content found in db.');
257 }
258
259 // hydrate the tags relations
260 $nbTags = count($entry->getTags());
261
262 $client->request('PATCH', '/api/entries/'.$entry->getId().'.json', array(
263 'title' => 'New awesome title',
264 'tags' => 'new tag '.uniqid(),
265 'star' => true,
266 'archive' => false,
267 ), array(), $headers);
268
269 $this->assertEquals(200, $client->getResponse()->getStatusCode());
270
271 $content = json_decode($client->getResponse()->getContent(), true);
272
273 $this->assertEquals($entry->getId(), $content['id']);
274 $this->assertEquals($entry->getUrl(), $content['url']);
275 $this->assertEquals('New awesome title', $content['title']);
276 $this->assertGreaterThan($nbTags, count($content['tags']));
277 }
278
279 public function testGetTagsEntry()
280 {
281 $client = $this->createClient();
282 $headers = $this->generateHeaders('admin', 'mypassword');
283
284 $entry = $client->getContainer()
285 ->get('doctrine.orm.entity_manager')
286 ->getRepository('WallabagCoreBundle:Entry')
287 ->findOneWithTags(1);
288
289 $entry = $entry[0];
290
291 if (!$entry) {
292 $this->markTestSkipped('No content found in db.');
293 }
294
295 $tags = array();
296 foreach ($entry->getTags() as $tag) {
297 $tags[] = array('id' => $tag->getId(), 'label' => $tag->getLabel());
298 }
299
300 $client->request('GET', '/api/entries/'.$entry->getId().'/tags', array(), array(), $headers);
301
302 $this->assertEquals(json_encode($tags, JSON_HEX_QUOT), $client->getResponse()->getContent());
303 }
304
305 public function testPostTagsOnEntry()
306 {
307 $client = $this->createClient();
308 $headers = $this->generateHeaders('admin', 'mypassword');
309
310 $entry = $client->getContainer()
311 ->get('doctrine.orm.entity_manager')
312 ->getRepository('WallabagCoreBundle:Entry')
313 ->findOneByUser(1);
314
315 if (!$entry) {
316 $this->markTestSkipped('No content found in db.');
317 }
318
319 $nbTags = count($entry->getTags());
320
321 $newTags = 'tag1,tag2,tag3';
322
323 $client->request('POST', '/api/entries/'.$entry->getId().'/tags', array('tags' => $newTags), array(), $headers);
324
325 $this->assertEquals(200, $client->getResponse()->getStatusCode());
326
327 $content = json_decode($client->getResponse()->getContent(), true);
328
329 $this->assertArrayHasKey('tags', $content);
330 $this->assertEquals($nbTags+3, count($content['tags']));
331
332 $entryDB = $client->getContainer()
333 ->get('doctrine.orm.entity_manager')
334 ->getRepository('WallabagCoreBundle:Entry')
335 ->find($entry->getId());
336
337 $tagsInDB = array();
338 foreach ($entryDB->getTags()->toArray() as $tag) {
339 $tagsInDB[$tag->getId()] = $tag->getLabel();
340 }
341
342 foreach (explode(',', $newTags) as $tag) {
343 $this->assertContains($tag, $tagsInDB);
344 }
345 }
346
347 public function testDeleteOneTagEntrie()
348 {
349 $client = $this->createClient();
350 $headers = $this->generateHeaders('admin', 'mypassword');
351
352 $entry = $client->getContainer()
353 ->get('doctrine.orm.entity_manager')
354 ->getRepository('WallabagCoreBundle:Entry')
355 ->findOneByUser(1);
356
357 if (!$entry) {
358 $this->markTestSkipped('No content found in db.');
359 }
360
361 // hydrate the tags relations
362 $nbTags = count($entry->getTags());
363 $tag = $entry->getTags()[0];
364
365 $client->request('DELETE', '/api/entries/'.$entry->getId().'/tags/'.$tag->getId().'.json', array(), array(), $headers);
366
367 $this->assertEquals(200, $client->getResponse()->getStatusCode());
368
369 $content = json_decode($client->getResponse()->getContent(), true);
370
371 $this->assertArrayHasKey('tags', $content);
372 $this->assertEquals($nbTags-1, count($content['tags']));
373 }
374
375 public function testGetUserTags()
376 {
377 $client = $this->createClient();
378 $headers = $this->generateHeaders('admin', 'mypassword');
379
380 $client->request('GET', '/api/tags.json', array(), array(), $headers);
381
382 $this->assertEquals(200, $client->getResponse()->getStatusCode());
383
384 $content = json_decode($client->getResponse()->getContent(), true);
385
386 $this->assertGreaterThan(0, $content);
387 $this->assertArrayHasKey('id', $content[0]);
388 $this->assertArrayHasKey('label', $content[0]);
389
390 return end($content);
391 }
392
393 /**
394 * @depends testGetUserTags
395 */
396 public function testDeleteUserTag($tag)
397 {
398 $client = $this->createClient();
399 $headers = $this->generateHeaders('admin', 'mypassword');
400
401 $client->request('DELETE', '/api/tags/'.$tag['id'].'.json', array(), array(), $headers);
402
403 $this->assertEquals(200, $client->getResponse()->getStatusCode());
404
405 $content = json_decode($client->getResponse()->getContent(), true);
406
407 $this->assertArrayHasKey('label', $content);
408 $this->assertEquals($tag['label'], $content['label']);
409 }
410}
diff --git a/src/Wallabag/ApiBundle/WallabagApiBundle.php b/src/Wallabag/ApiBundle/WallabagApiBundle.php
new file mode 100644
index 00000000..2484f277
--- /dev/null
+++ b/src/Wallabag/ApiBundle/WallabagApiBundle.php
@@ -0,0 +1,18 @@
1<?php
2
3namespace Wallabag\ApiBundle;
4
5use Symfony\Component\HttpKernel\Bundle\Bundle;
6use Wallabag\ApiBundle\DependencyInjection\Security\Factory\WsseFactory;
7use Symfony\Component\DependencyInjection\ContainerBuilder;
8
9class WallabagApiBundle extends Bundle
10{
11 public function build(ContainerBuilder $container)
12 {
13 parent::build($container);
14
15 $extension = $container->getExtension('security');
16 $extension->addSecurityListenerFactory(new WsseFactory());
17 }
18}