Tuesday 28 June 2011

CakePHP: Turn an input form into a query form (Version 2)

CakePHP makes constructing input forms easy. The Form helper object's input() methods provides us with the necessary intelligence, to display and get input data from our users, then store it into the controller's $this->data property using the format required by the framework's Model->save() method, so they can eventually be written to the database, with no need for any elaborate code. Furthermore, given the fact that cake's cake script will bake all that standard code in a flash. it becomes a matter of minutes to create a full featured CRUD application, starting from just a good database design.

One thing I find missing from the baked code, is the ability to create input forms whose data will be used as search criteria -- in a QBE fashion -- in an index or display page. My solution requires us to convert the submitted data into a form that is compatible with the Model->find()'s $conditions parameter and then feed this to the Model->find() or the Controller->paginate() methods in order to limit the number of returned items.

As Richard pointed out during the first version of this post, cake offers the postConditions() method, which does this out of the box and the truth of the matter is that I had completely missed that when I started coding. After looking at the CakePHP code and comparing it with my approach, I can only say that with the code below, you don't have to worry about operators and providing complex parameters. The latest version will allow users to type their desired operator before the value in a QBE like fashion. The standard Cake approach gives more accurate control over the entire process, but I am not sure how to create a UI that will allow users to change operators dynamically .

So, to get started with my alternative version, the typical index() method, for a model named Product, using this approach will have to look somehow like this:

    function index()
    {        
        $this->Product->recursive = 0;
        $conditions = $this->getSearchConditions($this->Product->name);
        $products = $this->paginate('Product', $conditions);

        $this->set( 'products', $products);
        $this->prepareCombos();
    }

The important part here s obviously the $this->getSearchConditions() function that will get the controllers $data property and transform it into the $conditions array. The perfect place to put the code for that would be our application's controller.

class AppController extends Controller {
    const CONDITIONS_SESSION_KEY = 'SRCH_COND';
    const FORM_DATA_SESSION_KEY = 'SRCH_DATA';

    private $SQL_OPERATORS = array(
        'IN', '<>', '>=', '<=',
        '>', '<'        
    );

    /**
     * @name: getSearchConditions()
     * @access: protected
     * @author: Thanassis Bakalidis
     * @param : string $modelName name of the model to search controller data
     * Return an array to be used as search conditions in a Model's find 
     * method based on the controller's current data
     * @version: 1.4
     */
    protected function getSearchConditions($modelName = null)
    {
        if ($modelName == null)
            return null;

        // create speciffic keys for the model andcontroller
        $sessionConditionsKey = sprintf("%s-%s-%s",
                                self::CONDITIONS_SESSION_KEY,
                                $this->name,
                                $modelName
                            );
        $sessionDataKey = sprintf("%s-%s-%s",
                                self::FORM_DATA_SESSION_KEY,
                                $this->name,
                                $modelName
                            );

        if (empty($this->data)) {
            // attempt to read conditions from sesion
            $conditions = $this->Session->check($sessionConditionsKey)
                ? $this->Session->read($sessionConditionsKey)
                : array();
            $this->data = $this->Session->check($sessionDataKey)
                ? $this->Session->read($sessionDataKey)
                : array();
        } else {
            // we have posted data. Atempt to rebuild conditons
            // array
            $conditions = array();
            foreach( $this->data[$modelName] as $key => $value) {
                if (empty($value))
                    continue;

                $operator = $this->extractOperator($value);
                
                if (is_array($value)) {
                    // this can only be a date field

                    $month = $value['month'];
                    $day = $value['day'];
                    $year = $value['year'];

                    // We want all three variables to be numeric so we 'll check their
                    // concatenation. After all PHP numbers as just strings with digits
                    if (is_numeric($month.$day.$year) && checkdate( $month, $day, $year)) {
                        $conditionsKey ="$modelName.$key";
                        $conditionsValue = "$year-$month-$day";
                    } else
                        continue;                        
                } else {
                    // we have normal input check the operator given                    
                    if ($operator == '' && !is_numeric($value)) {
                        // turn '=' to 'LIKE' for non numeric data
                        // numeric data will be treated as if they
                        // have an wquals operator
                        $operator = 'LIKE';
                        $value = str_replace('*', '%',  $value);
                        $value = str_replace('?', '.',  $value);                        
                    } else if ($operator === 'IN') {
                        // we need to convert the input string to an aray
                        // of the designated values
                        $operator = '';
                        $value = array_filter(explode( ' ', $value));
                    } 
                    
                    $conditionsValue = $value;
                    $conditionsKey = "$modelName.$key $operator";                    
                }

                // add the new condition entry
                $conditions[trim($conditionsKey)] = $conditionsValue;
            }

            // if we have some criteria, add them in the sesion
            $this->Session->write($sessionConditionsKey, $conditions);
            $this->Session->write($sessionDataKey, $this->data);
        }

        return $conditions;
    }

    private function extractOperator(&$input)
    {     
        $operator = strtoupper((strtok($input, ' '));
        
        if (in_array($operator, $this->SQL_OPERATORS)) {
            $opLength = strlen($operator);
            $inputLength = strlen($input);
            $input = trim(substr( $input, $opLength, $inputLength - $opLength));                        
        } else {            
            $operator = '';
        }
        
        return $operator;
    }

This was the hard part. The next ones have to do with only copy and paste. So get the form that cake created from your add or edit views and copy it before the table in the index view page. You may possibly wish to remove some unwanted input fields and change the value of the $this->Form->end() function's parameter from "Submit" to "Search".

So we are almost there. One last stroke and we are ready. Remember the $this->prepareCombos(); last comand on the index action? Well, that is a simple private method I created by copying all the find)'list', ...) commands that the cake bake script created after the add() and edit() methods, so that all foreign key fields have correct value ranges, when the input form gets displayed. For my product's controller for example the function looks like this:

    private function prepareCombos()
    {
        $productTypes = $this->Product->ProductType->find('list');
        $productCategories = $this->Product->ProductCategory->find('list');
        $suppliers = $this->Product->Supplier->find(
                                                'list',
                                                array(
                                                    'order' => array(
                                                        'id' => 'asc'
                                                    )
                                                )
                                            );
        $qualities = $this->Product->Quality->find('list');        
        $this->set(compact('productTypes', 'productCategories', 'suppliers', 'qualities'));
    }

Notes

  • In case your model contains validation rules like range then the default input element that the form helper creates , will contain an appropriate maxlength attribute, that will not allow your users to type more than the maximum allowed characters for the field. Say, for example that you have a rule for a field named width, that looks like this
           'width' => array (
                'rule' => array('range', 1, 999),
                'message' => 'Width must be between 1 and 999 mm',
                'last' => true
           )
          
    ... then the actual HTML input element will have a maxlength attribute of 3. This is not going to make your query UI very usable, so make sure that the search form's echo $this->Form->input( ... statement provides an appropriate hardcoded value for maxlength.
  • The next thing to consider, is what will happen when our users provide wrong inputs, say they type =< 13 instead of >= 13. In that case the code -- as it is now -- will create an SQL statement of the form select ... where Model.Field LIKE '=< 13' , that will provide an empty result set, but is still valid SQL. Any workaround for this type of situation would make our code even more complicated than it is now, so I am not going to spend any more thought on this.
  • After going this far, I believe that the next reasonable thing would be to move the functionality into a new component, that will take away all the complexity from the AppController class, but this is probably going to be the subject of a forthcoming post.

Tuesday 7 June 2011

CakePHP: Authenticating against Microsoft Active Directory

My work environment uses Windows. Everyone logs on to an Active Directory based domain and this is how security is managed. When you have web applications running on Linux and you need some sort of authentication service, then you can either implement your own user data store or you can use an exiting. The good news is that MS AD is actually an LDAPv3 server, so getting data to and from LDAP is not that difficult in the world of both PHP and Cake.

The following "howto" will list the steps I followed in order to perform authentication from the company's Windows 2003 servers. I will also explain one approach for deciding how to grant users admin privileges on a CakePHP application based on membership of a Windows user group. Many might argue that I am oversimplifying, but I believe that on most occasions, this will be more than adequate.

In order to get started, remember that building an authentication system on a CakePHP application starts with creating a User model. Only difference will be here that the model in question will not get its data from an ordinary database table, but rather from an LDAP data source.

Get the LDAP datasource component

So this brings as to the first issue, which is: Get a datasource to read LDAP data. Fortunately the bakery has just what we need. The actual article that contains the code I am still using is this.

Analogrithems, however, the author of the datasource code, is providing new updates on github accessible via this link. The truth is that I have yet to test the new version, so if you are to follow the tutorial better stick with the one in the bakery.

No matter the version you choose you will end up with a file named ldap_source.php, that you will need to place in your APP/models/datasources/ directory.

Set up a connection using the new datasource

The next thing that needs to be done is to create a new connection that will use the new datasource. Open APP/config/database.php and add a new entry looking more or less like this

    var $ldap = array (
        'datasource' => 'ldap',
         // list of active directory hosts
        'host' => array(
                    'ldap1.example.com',
                    'ldap2.example.com',
                    'ldap3.example.com'
                ),
        'port' => 389,
         // location (root) in LDAP tree to start searching 
        'basedn' => 'DC=example,DC=com',
         // user to authenticate on AD server with
        'login' => 'cn=LDAPBindUser,cn=Users,dc=example,dc=com',
        'password' => 'abABa@332',
        'database' => '',
        'tls'      => false,
        'version'  => 3
    );

Verify with your network administrator that LDAPBindUser in created on the correct OU and that the base DN is correct, according to where you placed the user account. (Needless to say that the user must have next to no privileges at all)

Set up the User model class

The next step is our user model. This should be like any model, you have except that it will be based on the $ldap database config, its primary key will be the LDAP dn attribute and that it will need no table.

class User extends AppModel {
    var $name = 'User';
    var $useDbConfig = 'ldap';

    var $primaryKey = 'dn';
    var $useTable = '';

    /**
     * return true if the userName is a member of the groupName
     * Active Directory group
     */
    function isMemberOf($userName, $groupName)
    {
        // trivial check for valid names
        if (empty($userName) || empty($groupName))
            return false;

        // locate the user record
        $userData = $this->find('first',
                                array(
                                    'conditions' => array(
                                        'samaccountname' => $userName
                                    )
                                )
                            );
        // no user by that name exists
        if (empty($userData))
            return false;

        // check if the userin question belongs to any groups
        if (!isset($userData['User']['memberof']))
            return false;

        // search all groups that our user if a meber
        $groups = $userData['User']['memberof'];
        foreach( $groups as $index => $group)
            if (strpos( $group, $groupName) != false)
                return true;

        return false;
    }
}

We have also added a member function which allows us to determine if a user-name belongs to an Active directory group that will be very handy when checking for application privileges later on.

Set up the LdapAuth Component

And finally the "auth" part. The standard CakePHP Auth component will not work for us so we need a specialized version that thanks to analogthemes again is accessible from his web site through here. Just place ldap_auth.php to your APP/controllers/components/ directory and change your AppController so that it uses LdapAuth.

This however is not enough. There is one little thing that needs to be changed in the LdapAuth code, so that authenticating in AD works: Locate the login() function in line 153 of ldao_auth.php and change line 156 so that it looks like this:

     ...

     function login($uid, $password) {
        $this->__setDefaults();
        $this->_loggedIn = false;
        // $dn = $this->getDn('uid', $uid);
        $dn = $this->getDn('samaccountname', $uid);
        $loginResult = $this->ldapauth($dn, $password); 
        ...

My simple approach to security says that an application has two types of users. Readers (i.e. everyone) and writers (admins only). We set the administrative users to be the members of a specific group in the active directory server. That way our application controller looks like this :

class AppController extends Controller {
    const ADMIN_KEY  = 'myApp-Admin';
    const AUTH_GROUP = 'MyApp-Admins';

    var $components = array('RequestHandler', 'LdapAuth', 'Session');
    var $helpers = array(
                        'Form', 'Html', 'Locale', 'Session',
                        'Js' => array('Jquery')
                    );
    var $isAdmin;

    function beforeFilter()
    {
        // pr($this->referer());
        $this->isAdmin = false;
        $this->LdapAuth->authorize = 'controller';
        $this->LdapAuth->allowedActions = array(
                                            'index',
                                            'view',
                                            'details'
                                        );

        $this->LdapAuth->loginError = "Invalid credentials";
        $this->LdapAuth->authError  = "Not authorized!";

        $userInfo = $this->LdapAuth->user();

        if (!empty($userInfo)) {
            $user = $userInfo['User'];
            // setup display username and user's full name
            $this->set('loggedInUser', $user['samaccountname']);
            $this->set('loggedInFullName', $user['cn']);

            // if we already have the admin role stored in the session
            if ($this->Session->check(self::ADMIN_KEY))
                $this->isAdmin = $this->Session->read(self::ADMIN_KEY);
            else {
                // determine the admin role from wheither the current 
                // user is a member of the AUTH group
                $this->LoadModel('User');
                $this->isAdmin = $this->User->isMemberOf(
                                                $user['samaccountname'],
                                                self::AUTH_GROUP);
                $this->Session->write(self::ADMIN_KEY, $this->isAdmin);
            }
        } else {
            $this->Session->delete(self::ADMIN_KEY);
        }

        $this->set('admin', $this->isAdmin);
    }

    function isAuthorized()
    {
        // Only administrators have access to the CUD actions
        if ($this->action == 'delete' ||
            $this->action == 'edit' ||
            $this->action == 'add')
          return $this->isAdmin;

        return true;                
    }
}

That way, everyone can access our view, details and index actions of all the application controllers, but will need to enter the user name and password of an ADMIN_GROUP member to gain access to the rest of our controller methods. This tutorial however is has gone long enough. To finish it, please remember to create your user controller and you login user view ...

Thursday 2 June 2011

C and UTF-8 data in Linux

During the last few days, I found myself struggling to create a PHP extension what would allow PHP to convert common characters between Greek and Latin, so that typed codes would for instance, always use the same version of 'A' no matter the language that the user keyboard is switched to. You see whether you type a Latin 'A' (U+0041) or a Greek 'Α' (U+0391) the two letters appear to be the same despite the difference in their internal representation. The same applies to other common letters like 'E', 'P' and 'Y'

I reckoned that this conversion and translation of characters based on their position on some given string would be an excellent chance to remember string pointers from my student C days, so I set out to create a library with functions called latinToGreek() and greekToLatin() that would do the conversion and return the new string.

Like most people in similar positions, I tried googling for C and Unicode howtos and run into some very good articles but nothing was in the form of a recipe that I could follow. I did read many of them and managed to get the work done. The basic idea here is that since UTF-8 uses a variable number of bytes per character, we need to convert UTF-8 strings into wchat_t strings, then process them like they were ordinary constant length null terminated character arrays and finally convert them back to UTF-8 single byte character strings in order for them to display correctly.

Following is the check list of things to do before actually playing with UTF-8 encoded Unicode.

  1. Use setlocale to set the current program locale to something that supports UTF-8. For example 'en_US.UTF-8' is just fine.
    Bear in mind, that the default locale for C programs in the "C" locale and unless you set the locale correctly nothing will work as expected.
  2. Read your input using normal char[] arrays. Just make sure that hey are big enough. Remember that UTF-8, uses one, two or even more bytes per individual character, so a logical guess would be to allocate an array at least twice the size of your maximum anticipated input.
    Do not forget that good old strlen() will still give you the size of your input in bytes, but not in characters.
  3. Convert multibyte input to wchar_t[] input in order to perform any processing. Remember that wchar_t constants need to be prefixed by an L, so '\0' is now L'\0'.
    Functions mbstowcs and wcstombs can be used to perform the conversion to and from.
  4. Perform your processing as you would ordinarily do using wchat_t data and wchar_t speciffic functions. For example toupper() for wchar_t is now towupper().
  5. Convert your data back to multibyte format using wcstombs and return them to the user.

... and that's about it.