Securing Our User Endpoint - Part 3


In this video we are continuing on with securing our User resources, this time implementing the Symfony Voter that will participate in authorisation checks on requested resources.

In the previous video we covered how there is the concept of a Restricted\UserRepository which will pass any requested object off to an authorisation check before returning the requested resource.

Let's take a look at the code:

<?php

// src/AppBundle/Repository/Restricted/UserRepository.php

namespace AppBundle\Repository\Restricted;

use AppBundle\Repository\UserRepositoryInterface;
use AppBundle\Repository\Doctrine\UserRepository as DoctrineUserRepository;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;

class UserRepository implements UserRepositoryInterface
{
    /**
     * @var DoctrineUserRepository
     */
    private $userRepository;
    /**
     * @var AuthorizationCheckerInterface
     */
    private $authorizationChecker;

    /**
     * UserRepository constructor.
     * @param DoctrineUserRepository        $userRepository
     * @param AuthorizationCheckerInterface $authorizationChecker
     */
    public function __construct(
        DoctrineUserRepository $userRepository,
        AuthorizationCheckerInterface $authorizationChecker
    )
    {
        $this->userRepository = $userRepository;
        $this->authorizationChecker = $authorizationChecker;
    }

    /**
     * @param $id
     * @return mixed
     */
    public function find($id)
    {
        $user = $this->userRepository->find($id);

        $this->denyAccessUnlessGranted('view', $user);

        return $user;
    }

    /**
     * @param        $attribute
     * @param null   $object
     * @param string $message
     */
    protected function denyAccessUnlessGranted($attribute, $object = null, $message = 'Access Denied')
    {
        if ( ! $this->authorizationChecker->isGranted($attribute, $object)) {
            throw new AccessDeniedHttpException($message);
        }
    }
}

In the previous video we covered how we would really delegate the responsibility of finding an object over to the injected DoctrineUserRepository instead of re-implementing the logic in this class also.

We need to do this as before we can check if the currently logged in User has access to the requested object - a User resource in this instance - it is necessary to actually retrieve the requested User data from the database.

Once we have that User object (thanks to a Doctrine entity repository, in our case) we can verify - somehow - that the currently logged in User should have access to view the requested User data.

How can we do this?

Using a Symfony Voter is one particular method we could use here. There are likely other methods, as Users are a somewhat different case to most any other resource.

For the sake of standardisation I am going to show the way I use with all other resources in this project - which is to retrieve the requested object from the database and then call a vote against it.

The voting code is:

    /**
     * @param        $attribute
     * @param null   $object
     * @param string $message
     */
    protected function denyAccessUnlessGranted($attribute, $object = null, $message = 'Access Denied')
    {
        if ( ! $this->authorizationChecker->isGranted($attribute, $object)) {
            throw new AccessDeniedHttpException($message);
        }
    }

We covered the service definition for this in the previous video write up, but here it is again for reference:

# app/config/services.yml

services:
    crv.repository.restricted.user_repository:
        class: AppBundle\Repository\Restricted\UserRepository
        arguments:
            - "@crv.repository.doctrine_user_repository"
            - "@security.authorization_checker"

However, we haven't yet updated the UserController to take advantage of anything we have done in the previous two videos. Let's go ahead and update the UserController now:

<?php

// src/AppBundle/Controller/UsersController.php

namespace AppBundle\Controller;

use AppBundle\Handler\UserHandler;
use FOS\RestBundle\View\View;
use FOS\RestBundle\Controller\Annotations;
use FOS\RestBundle\View\RouteRedirectView;
use FOS\RestBundle\Controller\FOSRestController;
use FOS\RestBundle\Routing\ClassResourceInterface;
use Nelmio\ApiDocBundle\Annotation\ApiDoc;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Form\FormTypeInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

/**
 * Class UsersController
 * @package AppBundle\Controller
 * @Annotations\RouteResource("users")
 */
class UsersController extends FOSRestController implements ClassResourceInterface
{
    /**
     * Get a single User.
     *
     * @ApiDoc(
     *   output = "AppBundle\Entity\User",
     *   statusCodes = {
     *     200 = "Returned when successful",
     *     404 = "Returned when not found"
     *   }
     * )
     *
     * @param int   $userId     the user id
     *
     * @throws NotFoundHttpException when does not exist
     *
     * @return View
     */
    public function getAction($userId)
    {
        $user = $this->getUserHandler()->get($userId);

        $view = $this->view($user);

        return $view;
    }

    /**
     * @return UserHandler
     */
    private function getUserHandler()
    {
        return $this->container->get('crv.handler.user_handler');
    }
}

Finally, the the first step in the chain has been created.

Let's take a look back at the Behat scenarios that this should cover:

  Background:
    Given there are Users with the following details:
    | uid | username | email          | password |
    | u1  | peter    | peter@test.com | testpass |
    | u2  | john     | john@test.org  | johnpass |
   #And there are Accounts with the following details:
   # | uid | name     | users |
   # | a1  | account1 | u1    |
   # And I am successfully logged in with username: "peter", and password: "testpass"
    And when consuming the endpoint I use the "headers/content-type" of "application/json"

  Scenario: User can GET their personal data by their unique ID
    When I send a "GET" request to "/users/u1"
    Then the response code should 200
     And the response header "Content-Type" should be equal to "application/json; charset=utf-8"
     And the response should contain json:
      """
      {
        "id": "u1",
        "email": "peter@test.com",
        "username": "peter",
        "accounts": [
          {
            "id": "a1",
            "name": "account1"
          }
        ]
      }
      """

  Scenario: User cannot GET a different User's personal data
    When I send a "GET" request to "/users/u2"
    Then the response code should 403

  Scenario: User cannot determine if another User ID is active
    When I send a "GET" request to "/users/u100"
    Then the response code should 403

We're definitely going to get those 403 status codes now, as the voter is going to block off any unauthorised access.

But we've broken our first test.

Why?

Well, as part of the vote, the voter will be passed the token from which it can get access to the currently logged in user.

Only... we aren't logged in. We've commented that bit out :)

To make that work, we need to implement login. And for that we are going to use LexikJWTBundle. We'll configure that in the next video, and get login using JWT's working for our API.

Code For This Course

Get the code for this course.

Episodes