Jan032012

Route middleware with Lithium

I spend most of my day switching between languages. Sometimes I start the morning with an early dose of LUA, then I get a lot of Python and C++, followed by the yummy dessert that is Node.js. But there’s always room for PHP. So let’s talk about PHP today.

People often think that when you are coding PHP you have to do things “the PHP way”. Well, let me clear it up for you: there’s no such thing as “the PHP way”. If there’s something that defines PHP is its flexibility to be as ugly or as beautiful as you want it to be. With that in mind, what prevents you from taking the lessons learned in one language to another?

That’s the premise I always have when I’m coding. Let me give you a for instance. On Node.js, I normally use express.js as a framework. Check it out, it’s pretty awesome. One of the things I love in express.js, is its route middleware capabilities. When I code in any PHP framework, that’s one of the things I miss the most.

Not many PHP frameworks are flexible enough to support such concepts. One of them is, though. Lithium has many of the things I love about PHP (5.3+ obviously, as I consider anything < 5.3 a waste of my time lately), and some of the things I love about other languages. One of them is Lithium’s addiction to closures. I love it. They allowed me to take the concept of express.js’ route middleware and apply it to my PHP code.

Let’s start with a dummy application skeleton. A users table:

CREATE TABLE `users`(
    `id` INT NOT NULL AUTO_INCREMENT,
    `email` VARCHAR(255) NOT NULL,
    `password` VARCHAR(255) NOT NULL,
    PRIMARY KEY(`id`)
);

INSERT INTO `users`(`email`, `password`) VALUES(
    'test@email.com', '$2a$04$U7qYPVYq2YBxqfHL8F2pteERxQYwLTVtAjMIh48Lef9sLSiMVtGHy',
    'john@email.com', '$2a$04$U7qYPVYq2YBxqfHL8F2pteERxQYwLTVtAjMIh48Lef9sLSiMVtGHy'
);

In your app/config/bootstrap/connections.php, make sure you uncomment the default database and hook it up to the database owning the table we just created. We’ll also be using sessions, so uncomment the session.php reference in app/config/bootstrap.php. You may have noticed that our initial users have a password set to a specific value, which means a specific salt was used (the password hashed there in plain text is ‘password’, minus the quotes.). So go ahead and add the following to your app/config/bootstrap/session.php file:

use lithium\security\Auth;
use lithium\security\Password;

$salt = '$2a$04$U7qYPVYq2YBxqfHL8F2pte';
Auth::config(array(¬
    'adapter' => 'Form',
    'model' => 'Users',
    'filters' => array('password' => function($text) use($salt){
        return Password::hash($text, $salt);
    }),
    'validators' => array(
        'password' => function($form, $data) {
            return (strcmp($form, $data) === 0);
        }
    ),
    'fields' => array('email', 'password')
));

The salt was generated with a call to \lithium\security\Password::salt('bf', 4). I always use blowfish for password hashing (and 2^16 iterations in production). If you don’t use blowfish, here’s why you should. Anyway so you may want to store the hash on a better, configurable approach. I opted for a simple variable for this example. Once the salt is defined, I went ahead and configured Auth to use lithium’s Password::hash() method for hashing using the generated salt, and telling it how to compare hashed passwords against the database value. Pretty simple.

Let’s now build the Users model. It won’t have anything in there, really. So just create your app/models/User.php file with the following contents:

<?php
namespace app\models;

class Users extends \lithium\data\Model {
}
?>

Now the controller. Create a file named app/controllers/UsersController.php with the following contents:

<?php
namespace app\controllers;

use lithium\security\Auth;

class UsersController extends \lithium\action\Controller {
    public function login() {
        if (!empty($this->request->data)) {
            $user = Auth::check('default', $this->request);
            if ($user) {
                $this->redirect(array('action' => 'view', 'id' => $user['id']), array('exit' => true));
            }
        }
    }

    public function logout() {
        Auth::clear('default');
    }
}
?>

Nothing really complicated there. Don’t forget the view in app/views/users/login.html.php:

<?php
echo $this->form->create();
echo $this->form->field('email');
echo $this->form->field('password', array('type' => 'password'));
echo $this->form->submit('Login');
echo $this->form->end();
?>

That should give you a working login / logout. Add some dummy actions to the UsersController.php file:

public function view() {
    echo 'view';
    $this->_stop();
}

public function edit() {
    echo 'edit';
    $this->_stop();
}

Ok now we are ready to play with some route middleware. What we want to achieve is the following:

  • No action named edit, on any controller, should be accessible without a logged in user.
  • When accessing either the Users::edit or Users::view action, there should be an ID specified as a route parameter, and it should match an existing User record.
  • When accessing the Users::edit action, the given user should match the currently logged in user.

These are pretty basic security checks that you would normally put on the controller. Not this time. Edit your app/config/routes.php file and add the following right below the use statements found at the beginning of the file:

use lithium\net\http\RoutingException;
use lithium\action\Response;
use lithium\security\Auth;
use app\models\Users;

These are all classes that we will use in our route middleware. Let’s start with the first checkpoint we want to achieve: “No action named edit, on any controller, should be accessible without a logged in user“. Add the following to the routes.php file, below the content we just added:

Router::connect('/{:controller}/{:action:edit}/?.*', array(), array(
    'continue' => true,
    'handler' => function($request) {
        if (!Auth::check('default')) {
            return new Response(array('location' => 'Users::login'));
        }
    }
));

The first parameter (continue) ensures that this route definition is treated as a continuation route. This is because we don’t want to interrupt any normal route / parameter processing in this definition. We just wanna “grab” all calls to any edit action, and check (using Auth) for a valid user. If none is found, we process the request by returning a Response, which in the end redirects the user to the login page. If there is indeed a logged in user, the router will continue looking for other route definitions to match the request. So now all edit actions require a logged in user. Cool.

Next in our list: “When accessing either the Users::edit or Users::view action, there should be an ID specified as a route parameter, and it should match an existing User record.” Add the following to the routes.php file, below the content we just added:

Router::connect('/{:controller:users}/{:action:edit|view}/{:id:\d*}', array('id' => null), function($request) {
    if (empty($request->params['id'])) {
        throw new RoutingException('Missing ID');
    } elseif (!Users::first(array('conditions' => array('id' => $request->params['id'])))) {
        throw new RoutingException('Invalid ID');
    }
});

We are now getting more serious. In this definition, we are only matching the Users controller, and actions named either edit or view, which may or may not contain an id parameter. The route handler first checks to make sure the id parameter is given (if not, a RoutingException is thrown.) If the parameter is specified, it is used to find a matching User record with the given ID. If none is found, yet another RoutingException is thrown (you may wish to do something different here, like ensuring a 404 status). If the user is found, the route is not handled, which means some other route definition will handle it (the default route, in this case.)

The final checkpoint we have is: “When accessing the Users::edit action, the given user should match the currently logged in user.” So add the following to the routes.php file, below the content we just added:

Router::connect('/{:controller:users}/{:action:edit}/{:id:\d+}', array('id' => null), function($request) {
    $user = Auth::check('default');
    if ($user['id'] != $request->params['id']) {
        throw new RoutingException('You can only edit your own account');
    }
    return $request;
});

This defines a specific match to the Users::edit action with a set id parameter. We use that parameter to make sure it matches the ID of the logged in user. If it doesn’t match, we throw a RoutingException. If it does match, we return the request as we have successfully processed it.

You can now try accessing the edit and view actions using different scenarios: with a logged in user, while being logged out, editing a user which is not the current logged in user, etc. Everything should be nicely protected. And yet our controller code remained untouched. Nice, huh? That’s routing middleware for you. :)

No related posts.



Leave a Comment

3 Comments to "Route middleware with Lithium"

  1. Jan042012 at 7:43 am

    AgBorkowski wrote:

    hi, ur solution is nice but i think more transparent and “beuty” is put A&A process to dispatcher
    bootstrap/action.php

    Dispatcher::applyFilter(‘_callable’, function($self, $params, $chain) {
    ….

    more code is on https://github.com/agborkowski/li3_access

  2. Jan052012 at 11:14 am

    mariano wrote:

    I agree, and I in fact use route filtering for access check, but this was another approach worth discussing

  3. May102012 at 3:17 pm

    matths wrote:

    if it’s routing what you’re interested in, then also check out the Slim micro framework.
    I have a lot of hosting situation, where I can’t use PHP >= 5.3, so Slim easily falls back to 5.2. As much as I like closures in JS, it’s still very early for closures in the PHP world.
    But your article is a really nice read! Keep on doing! :)

 
Powered by Wordpress and MySQL. Clauz's design for by Cricava