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?
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();
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With