3

I am using Silex 2.0 (I know - it's development version and not fully released yet) along with CNAM's JWT security provider (see: https://github.com/cnam/security-jwt-service-provider) to write an API for an open source application I am writing.

In short, there are three types of users that I care about:

  1. Sitewide admins (ROLE_ADMIN) that have complete access
  2. Commissioners (ROLE_COMMISH) who create objects they own, and can edit their own objects
  3. Anonymous users who access read-only information.

As such, there are three sections of routes that go along with these "roles":

  1. /admin/* where administrators can perform their uber actions
  2. /commish/* where commissioners or admins can perform their actions on their objects
  3. /* where all users can read information

The issue that I've come across is that while I can setup 3 firewalls, one for each, there are times in the 3rd route category (GET /object/1 for instance) where it needs to be accessibly anonymously, but if the user provides a valid JWT token, I need to access that user in order to perform some additional logic on the data I hand back in the response.

As I have it setup currently (more on my config below), it's all-or-nothing: I either restrict an entire firewall to only authenticated users with a certain role, or I open it up to anonymous users (and therefore cannot view user information).

Is it possible to have a route that anyone can hit, but logged in users can also be seen?

Current security configuration:

$app['users'] = function () use ($app) {
    return new UserProvider($app);
};

$app['security.jwt'] = [
    'secret_key' => AUTH_KEY,
    'life_time'  => 86400,
    'algorithm'  => ['HS256'],
    'options'    => [
        'header_name' => 'X-Access-Token'
    ]
];

$app['security.firewalls'] = array(
  'login' => [
    'pattern' => 'login|register|verify|lostPassword|resetPassword',
    'anonymous' => true,
  ],
  'admin' => array(
    'pattern' => '^/admin',
    'logout' => array('logout_path' => '/logout'),
    'users' => $app['users'],
    'jwt' => array(
        'use_forward' => true,
        'require_previous_session' => false,
        'stateless' => true,
    )
  ),
  'commish' => array(
    'pattern' => '^/commish',
    'logout' => array('logout_path' => '/logout'),
    'users' => $app['users'],
    'jwt' => array(
        'use_forward' => true,
        'require_previous_session' => false,
        'stateless' => true,
    )
  )
);

$app['security.role_hierarchy'] = array(
  'ROLE_ADMIN' => array('ROLE_MANAGER'),
);

$app->register(new Silex\Provider\SecurityServiceProvider());
$app->register(new Silex\Provider\SecurityJWTServiceProvider());

Additionally, I've attempted another approach where I match all routes under a single firewall, but then protect certain ones by using securty.access_rules configuration, but it does not work. An example of what I've tried:

$app['security.firewalls'] = array(
  'api' => array(
    'pattern' => '^/',
    'logout' => array('logout_path' => '/logout'),
    'anonymous' => true,
    'jwt' => array(
      'use_forward' => true,
      'require_previous_session' => false,
      'stateless' => true
    )
  )
);

$app['security.access_rules'] = array(
  array('^/admin', 'ROLE_ADMIN'),
  array('^/commish', 'ROLE_MANAGER'),
  array('^/', 'IS_AUTHENTICATED_ANONYMOUSLY')
);
Mattygabe
  • 1,772
  • 4
  • 23
  • 44

3 Answers3

7

You can use $app['security.jwt.encoder'] to decode jwt and either create a custom trait and extending the route object or using midddlewareeeither on the route level or an easier way would be to use a middleware on the application level. I had similar issue and this is how i solved it, something like below

ex.

   $app->before(function (Request $request, Application $app) {                      

       $request->decodedJWT  = $app['security.jwt.encoder']-> 
        decode($request->headers->get('X-Access-Token'));

    }); 

and then you can access the decoded jwt form any route by doing this

 $app->get('/object/1', function(Request $request) {

     $decodedJWT = $request->decodedJWT;

     // do whatever logic you need here 
  })
  • Nice! This already feels a lot cleaner than the hack I've put in place - and certainly feels like it's the "Symfony" way to solve the issue. – Mattygabe Jul 15 '15 at 13:51
  • The middleware approach is great. I've updated my other answer to use the DI container to not have to instantiate the encoder manually, and also pointed to this answer. – Mattygabe Jul 17 '15 at 17:35
1

So: so far I have not found this to be possible through the "normal" way, which is disappointing. I will not mark what I detail below as the "answer" for a few days, hoping that someone can chime in and offer a better, more "official" way to solve the dilemma.

TL;DR: I manually check the request headers for the access token string, then decode the token using the JWT classes in order to load the user account in routes outside of the firewall. It's incredibly hacky, it feels downright dirty, but it's the only solution to the issue that I see at the moment.

Technical Details: First, you must acquire the token value from the request header. Your controller method will have been handed a Symfony\Component\HttpFoundation\Request object, from which you can access $request->headers->get('X-Access-Token'). In most instances the user will not be authenticated, so this will be empty, and you can return null.

If not empty, you must then use Silex's instance of JWTEncoder to decode the token contents, create a new token instance of JWTToken, set the context to the decoded value from the encoder, and finally you can access the username property from said token - which can then be used to grab the corresponding user record. An example of what I came up with:

$request_token = $request->headers->get('X-Access-Token','');

if(empty($request_token)) {
  return null;
}

try {
  $decoded = $app['security.jwt.encoder']->decode($request_token);

  $token = new \Silex\Component\Security\Http\Token\JWTToken();
  $token->setTokenContext($decoded);

  $userName = $token->getTokenContext()->name;

  //Here, you'd use whatever "load by username" function you have at your disposal
}catch(\Exception $ex) {
  return null;
}

And obviously, any code calling this function would need to know that because the request is outside of the firewall, there is zero guarantee that a user will be returned (hence the hacky try-catch that silences exceptions by just returning null).

Edit: I've updated the code here to use Silex's built-in DI container (provided by Pimple) so there's no need to create a new instance of the JWT encoder by hand. I'm also marking @user5117342 's answer as the correct one, as using some sort of Silex middleware approach is far more robust.

Edit (April 2016): Using the updated cnam/security-jwt-service 2.1.0 along with symfony/security 2.8, there's a slight update that makes the code above a little simpler:

$request_token = $request->headers->get('X-Access-Token','');

if(empty($request_token)) {
  return null;
}

try {
  $decodedToken = $app['security.jwt.encoder']->decode($request_token);

  $userName = $decodedToken->name;

  //Here, you'd use whatever "load by username" function you have at your disposal
}catch(\Exception $ex) {
  return null;
}

The issue with the newer dependencies is that the JWTToken constructor requires 3 parameters which are difficult to obtain in most service layers, not to mention is quite out of place. As I was updating my Composer dependencies, I ended up finding out that I didn't actually need to create a JWTToken in order to get the username I needed.

Of course, it's to be noted I'm only using this method on public (anonymous) API routes to provide some niceties to users who are logged in - my app doesn't deal with sensitive data so I'm not overly concerned with this avenue outside of the firewalls. At worst a black hat user would end up seeing non-sensitive data that they normally wouldn't, but that's it. So YMMV.

Mattygabe
  • 1,772
  • 4
  • 23
  • 44
  • 1
    I've never used 2 firewalls, but seems that you may benefit from [sharing context between multiple firewalls](http://symfony.com/doc/current/reference/configuration/security.html#firewall-context) as, by default, the user is not authenticated across all firewalls at once, give it a try. Also [check out this answer](http://stackoverflow.com/questions/9075041/authenticate-multiple-symfony2-firewalls-with-one-login-form) – mTorres May 25 '15 at 09:34
0

Your are must be use regular expression e.g.

$app['security.firewalls'] = array(
   'login' => [
       'pattern' => 'login|register|oauth',
       'anonymous' => true,
   ],
   'secured' => array(
       'pattern' => '^/api|/admin|/manager',
       'logout' => array('logout_path' => '/logout'),
       'users' => $app['users'],
       'jwt' => array(
           'use_forward' => true,
           'require_previous_session' => false,
           'stateless' => true,
       )
   ),
);