Custom Authentication using the Yii Framework

January 7, 2010
The Yii Book If you like my writing on the Yii framework, you'll love "The Yii Book"!

In a [intlink id=”826″ type=”post”]previous post[/intlink], I walk through the Yii framework’s built-in authentication system for adding login functionality to a Web site. There are a number of files and components involved, but simple authentication works fine out of the box, or with just a little tweaking. That’s the focus of that previous post. In this post, I explain how you can customize the authentication process further.The default authentication behavior allows users to login with hardcoded username/password combinations. In this post, I want to change that behavior so that:

  • Authentication is performed against a database table
  • The user’s email address is used instead of their username
  • The user’s ID is stored for later reference
  • The user’s “role” is stored for later reference

To start, let’s assume there is a database table called User that’s already been modeled and populated. The table has several columns, including at least: id, email, password, and role. The password column stores a SHA1()-encrypted version of the user’s password. The role dictates what the user can do in the site. Possible values are reader, editor, and writer (these are hypothetical values; they could be anything).

Now, by default, Yii will use cookies for authentication. In most situations that’s fine, but if anything of a sensitive nature is being stored, you should use sessions instead. This would apply to both the user’s ID value and their role. If either is available through a cookie, it wouldn’t be hard for the user to edit that cookie’s value in order to become someone else. So, to start, let’s disable the potential for using cookies. To do that, open up the protected/config/main.php configuration file and find this section from under components:

'user'=>array(
    // enable cookie-based authentication
    'allowAutoLogin'=>true,
),

To disable cookie-based authentication, either remove that code entirely, or change allowAutoLogin to false.

Next, let’s turn to the login form, which will require a couple of alterations. Open up protected/views/site/login.php, which is the form. The default form looks like this:

Yii Login Form

First, we need to remove the hint paragraph, as demo/demo and admin/admin will no longer work. Then we remove the code that displays the “remember me” checkbox. Remember me functionality is only good for cookies, so it’s useless here. Finally, we want to take an email address, not a username, so those two lines must be changed. The complete form file is now:

<?php $this->pageTitle=Yii::app()->name . ' - Login'; ?>

<h1>Login</h1>

<div>
<?php echo CHtml::beginForm(); ?>

<?php echo CHtml::errorSummary($form); ?>

<div>
<?php echo CHtml::activeLabel($form,'email'); ?>
<?php echo CHtml::activeTextField($form,'email') ?>
</div>

<div>
<?php echo CHtml::activeLabel($form,'password'); ?>
<?php echo CHtml::activePasswordField($form,'password') ?>
</div>

<div>
<?php echo CHtml::submitButton('Login'); ?>
</div>

<?php echo CHtml::endForm(); ?>

</div><!-- yiiForm -->

Next, we turn to the LoginForm Model, defined in protected/models/LoginForm.php. This Model is associated with the login form, handling and validating that submitted data. At the top of the Model, the class variables are defined. We need to change $username to $email and remove $rememberMe:

class LoginForm extends CFormModel
{
    public $email;
    public $password;

Next, alter the rules accordingly. Instead of username and password being required, email and password are required. Also, the email value should be in a valid email address format. The application of the authenticate() method to validate the password remains. Here are the updated rules:

public function rules()
{
    return array(
        array('email, password', 'required'),
        array('email', 'email'),
        array('password', 'authenticate'),
    );
}

Next, if you want, change the attributeLabels() method for the email address and remove the label for rememberMe:

public function attributeLabels()
{
    return array('email'=>'Email Address');
}

The final changes to the LoginForm Model are in the authenticate() method. Several references to username must be changed to email. For example, this:

$identity=new UserIdentity($this->username,$this->password);

becomes:

$identity=new UserIdentity($this->email,$this->password);

Then there’s a call to UserIdentity::authenticate(), which is where the actual authentication against the database takes place (see my previous post and I’ll also return to this shortly). After that, there’s an important switch conditional that responds to three possibilities: authenticated, invalid username, and invalid password. The applicable case is signaled by a UserIdentity constant. Do note that the UserIdentity class is an extension of CUserIdentity, so it uses ERROR_USERNAME_INVALID instead of ERROR_EMAIL_INVALID. In the following code I treat username and email address as synonymous, because it’s the easiest solution. A full alteration would require changing the Yii framework’s definition of CBaseUserIdentity (which CUserIdentity extends), which is not a good idea. So here’s the modified switch:

switch($identity->errorCode)
{
    case UserIdentity::ERROR_NONE:
        Yii::app()->user->login($identity);
        break;
    case UserIdentity::ERROR_USERNAME_INVALID:
        $this->addError('email','Email address is incorrect.');
        break;
    default: // UserIdentity::ERROR_PASSWORD_INVALID
        $this->addError('password','Password is incorrect.');
        break;
}

In the first case, with no error present, the user is logged in. I’ve removed references to rememberMe and duration, both of which are present in the Yii-generated code. The second case applies if the email address was not found in the database. Again, the error code is ERROR_USERNAME_INVALID, but it applies just the same. We do want to change the error so that it applies to the email element and has the proper error message. The final case applies if the email address was found but the supplied password was incorrect. Here’s an image for how the login form looks and behaves after these modifications:

Modified Login Form with Errors

Finally, we turn to protected/components/UserIdentity.php, for the final changes. There are a couple of things we need to do in this file. First, we need to perform the authentication against the User Model (and therefore, the database). Second, we need to store the user’s ID and role in the session for later use in the site. For the authentication, we’ll modify the authenticate() method:

public function authenticate()
{
    $user = User::model()->findByAttributes(array('email'=>$this->username));

Now the $user object represents the User record with an email field equal to the submitted email address. You may be wondering why I refer to $this->username here. That’s because the CUserIdentity class’s constructor takes the provided email address and password (from LoginForm) and stores them in $this->username and $this->password. So I need to equate username with email here, which is better than editing the framework itself. You ought to leave a comment about this so that you won’t be confused later when looking at the code.

Next the authenticate() method checks a series of possiblities and assigns constant values to the errorCode variable:

if ($user===null) { // No user found!
    $this->errorCode=self::ERROR_USERNAME_INVALID;
} else if ($user->password !== SHA1($this->password) ) { // Invalid password!
    $this->errorCode=self::ERROR_PASSWORD_INVALID;

In the first conditional, if $user has no value, then no records were found, so the email address was incorrect. In the second conditional, the stored password is compared against the SHA1() version of the submitted password. This assumes the record’s password was stored in a SHA1()-encrypted format. If neither of these conditionals are true, then everything is okay:

} else { // Okay!
    $this->errorCode=self::ERROR_NONE;
    // Store the role in a session:
    $this->setState('role', $user->role);
}

As you can see, a constant representing no error is assigned to the error code. After that, the user’s role value, from the database table, is stored in the session. This is accomplished by invoking the setState() method. Provide it with a name, role, and a value. After you’ve done this, the user’s role will be available through Yii::app()->user->role. You could do the same thing to store the user’s ID in the session, but the built-in authentication already has a getId() method that returns the user’s identifier. By default, the method returns the username value, so you’ll need to override the default behavior to return the ID instead. Start by creating a private variable in UserIdentity:

class UserIdentity extends CUserIdentity
{
    // Need to store the user's ID:
    private $_id;

Then, in the else clause (for successful authentication), assign the user’s ID to the class ID variable:

$this->_id = $user->id;

Finally, after the authenticate() method, override the getId() method by redefining it as:

public function getId()
{
    return $this->_id;
}

Now the user’s ID will be available through Yii::app()->user->id.

And that should be it. You now have an authentication process based upon an email address and password combination, using a database table, that also stores two pieces of information in the session. As always, thanks for reading and let me know if you have any questions or comments. Thanks, Larry!

Edit: Per a request, here’s the database schema, the LoginForm.php Model file, and the components/UserIdentity.php script that I *think* I used for this post:

CREATE TABLE `User` (
 `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
 `email` varchar(80) NOT NULL,
 `pass` char(40) NOT NULL,
 `role` enum('reader','editor','writer') NOT NULL,
 PRIMARY KEY (`id`)
);

LoginForm.php

UserIdentity.php