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?