5
votes

I've created a custom voter that denies access to my API if the request doesn't contain a valid auth header. It's based on a combination of two cookbook entries: http://symfony.com/doc/current/cookbook/security/voters.html and http://symfony.com/doc/current/cookbook/security/custom_authentication_provider.html

<?php

namespace Acme\RestBundle\Security\Authorization\Voter;

use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\NonceExpiredException;
use Monolog\Logger;

class ApiClientVoter implements VoterInterface
{
    protected $container;
    protected $nonceCacheDir;

    /**
     * We inject the full service container due to scope issues when
     * injecting the request.
     *
     * @param ContainerInterface $container
     * @param string $nonceCacheDir
     */
    public function __construct(ContainerInterface $container, $nonceCacheDir)
    {
        $this->container     = $container;
        $this->nonceCacheDir = $nonceCacheDir;
    }

    public function supportsAttribute($attribute)
    {
        // we won't check against a user attribute, so we return true
        return true;
    }

    public function supportsClass($class)
    {
        // our voter supports all type of token classes, so we return true
        return true;
    }

    public function vote(TokenInterface $token, $object, array $attributes)
    {
        if ($this->authenticate()) {
            return VoterInterface::ACCESS_ABSTAIN;
        }

        return VoterInterface::ACCESS_DENIED;
    }


    /**
     * Checks for an authentication header in the request and confirms
     * the client is valid.
     *
     * @return bool
     */
    protected function authenticate()
    {
        $request = $this->container->get('request');

        if ($request->headers->has('x-acme-auth')) {

            $authRegex = '/ApiKey="([^"]+)", ApiDigest="([^"]+)", Nonce="([^"]+)", Created="([^"]+)"/';

            if (preg_match($authRegex, $request->headers->get('x-acme-auth'), $matches)) {
                $apiClient = $this->container->get('in_memory_user_provider')->loadUserByUsername($matches[1]);

                if ($apiClient && $this->validateDigest($matches[2], $matches[3], $matches[4], $apiClient->getPassword())) {
                    return true;
                }
            }
        } else {
            $this->container->get('logger')->err('no x-acme-auth header present in request');
        }

        return false;
    }

    /**
     * Performs checks to prevent replay attacks and to validate
     * digest against a known client.
     *
     * @param string $digest
     * @param string $nonce
     * @param string $created
     * @param string $secret
     * @return bool
     * @throws AuthenticationException
     * @throws NonceExpiredException
     */
    protected function validateDigest($digest, $nonce, $created, $secret)
    {
        // Expire timestamp after 5 minutes
        if (time() - strtotime($created) > 300) {
            $this->container->get('logger')->err('Timestamp expired');

            return false;
        }

        if (!is_dir($this->nonceCacheDir)) {
            mkdir($this->nonceCacheDir, 0777, true);
        }

        // Validate nonce is unique within 5 minutes
        if (file_exists($this->nonceCacheDir.'/'.$nonce) && file_get_contents($this->nonceCacheDir.'/'.$nonce) + 300 > time()) {
            $this->container->get('logger')->err('Previously used nonce detected');

            return false;
        }

        file_put_contents($this->nonceCacheDir.'/'.$nonce, time());

        // Validate Secret
        $expected = base64_encode(sha1(base64_decode($nonce).$created.$secret, true));

        return $digest === $expected;
    }
}

The problem I'm having is that when my voter returns ACCESS_DENIED, I get a 500 error when the firewall redirects to the authentication entry point:

[2012-10-05 11:09:16] app.ERROR: no x-acme-auth header present in request [] []
[2012-10-05 11:09:16] event.DEBUG: Notified event "kernel.exception" to listener "Symfony\Component\Security\Http\Firewall\ExceptionListener::onKernelException". [] []
[2012-10-05 11:09:16] security.DEBUG: Access is denied (user is not fully authenticated) by "/home/phil/projects/acme/vendor/symfony/symfony/src/Symfony/Component/Security/Http/Firewall/AccessListener.php" at line 70; redirecting to authentication entry point [] []
[2012-10-05 11:09:16] event.DEBUG: Notified event "kernel.exception" to listener "Symfony\Component\HttpKernel\EventListener\ProfilerListener::onKernelException". [] []
[2012-10-05 11:09:16] event.DEBUG: Notified event "kernel.exception" to listener "Symfony\Component\HttpKernel\EventListener\ExceptionListener::onKernelException". [] []
[2012-10-05 11:09:16] request.CRITICAL: Symfony\Component\Security\Core\Exception\InsufficientAuthenticationException: Full authentication is required to access this resource. (uncaught exception) at /home/phil/projects/acme/vendor/symfony/symfony/src/Symfony/Component/Security/Http/Firewall/ExceptionListener.php line 109

What I actually want to happen is just to return a 403 response. Is it possible to do this?

1

1 Answers

1
votes

Exceptions caught by the kernel always return HTTP 500. (code).

You'll have to return your own response if authentication is invalid.

use Symfony\Component\HttpFoundation\Response;
$response = new Response();

$response->setContent('<html><body><h1>Bad Credentials</h1></body></html>');
$response->setStatusCode(403);
$response->headers->set('Content-Type', 'text/html');

// prints the HTTP headers followed by the content
$response->send();