《Yii Rapid Application Development Hotshot》

Chapter 3. Access All Areas – Users and Logins

In this mission, we will replace the default Yii user management and access control with a database-driven implementation, and then we will apply the access control to a new site function, and verify our work with some tests.

Mission Briefing

We will add a user table to the application database, and then generate the Yii scaffolding and customize it. We will extend the user management interface to utilize our user table fields. We will add a new feature to the site – a wish list viewer for friends and family looking for gift ideas, and then create friends and family users and give them access to the wish list. When we are done, we will be able to assign different capabilities to different users, and their menus will reflect the actions they are permitted to take. For example, guest users will only be able to read comic book entries, not add, edit, or delete, as the menu in the following screenshot demonstrates:

Why Is It Awesome?

The generated Yii project files include a basic access control system to help you start building your project. However, if your project requirements include providing access to a large number of users, you will soon find it helpful to include user management in your site. There are some great Yii extensions available that provide user management. These may be more or less what you want. If your project needs are unique or you would just like to take a tour through a user management implementation, this chapter will be of interest.

Your Hotshot Objectives

  • Adding a User Object with CRUD
  • Making a User Management Interface
  • Storing Passwords
  • Activating Database User Login
  • Enforcing Secure Passwords
  • Adding User Functions – Wishlist
  • Configuring User Access
  • User Specific Menus

Mission Checklist

This project assumes that you have a web development environment prepared. If you do not have one, the tasks in Project 1, Develop a Comic Book Database, will guide you through setting one up. In order to work this project, you will need to set up the project files that have been provided with the book. Refer to the Preface of the book for instructions on downloading these files. The files for this project include a Yii project directory with a database schema. To prepare for the project, carry out the following steps replacing the username lomeara with your own username:

  1. Copy the project files into your working directory.
cpr ~/Downloads/project_files/Chapter\ 3/project_files ~/projects/ch3
  1. Make the directories that Yii uses web writeable.
cd ~/projects/ch3/
sudo chown -R lomeara:www-data protected/runtime assets protected/models \ 
    protected/controllers protected/views
  1. If you have a link for a previous project, remove it from the webroot directory.
rm /opt/lampp/htdocs/cddb
  1. Create a link in the webroot directory to the copied directory.
cd /opt/lampp/htdocs
sudo ln -s ~/projects/ch3 cbdb
  1. Import the project into NetBeans (remember to set the project URL to http://localhost/cbdb) and configure for Yii development with PHPUnit.
  2. Create a database named cbdb and load the database schema (~/projects/ch3/protected/data/schema.sql) into it.
  3. If you are not using the XAMPP stack or if your access to MySQL is password protected, you should review and update the Yii configuration file (in NetBeans it is ch3/Source Files/protected/config/main.php)

Adding a User Object with CRUD

As a foundation for our user management system, we will add a User table to the database and then use Gii to build a quick functional interface.

Engage Thrusters

  1. Let's set the first building block by adding a User table containing the following information:
    • A username
    • Password hash
    • Reference to a person entry for first name and last name

In NetBeans, open a SQL Command window for the cbdb database and run the following command:

CREATE TABLE 'user' (
  'id' int(10) unsigned NOT NULL AUTO_INCREMENT,
  'username' varchar(20) NOT NULL,
  'pwd_hash' char(34) NOT NULL,
  'person_id' int(10) unsigned NOT NULL,
  PRIMARY KEY ('id'),
  UNIQUE KEY 'username' ('username'),
  CONSTRAINT 'userperson_ibfk_2' FOREIGN KEY ('person_id') REFERENCES 'person' ('id') 
  ON DELETE CASCADE
) ENGINE=InnoDB;
  1. Open a web browser to the Gii URL http://localhost/cbdb/index.php/gii (the password configured in the sample code is yiibook) and use Gii to generate a model from the user table. Refer to the Generating an Application Scaffold section in Project 1, Develop a Comic Book Database, for a more detailed description of how to use Gii.
  2. Then, use Gii to generate CRUD from the user model.
  3. Back in NetBeans, add a link to the user index in your site's logged in menu (ch3 | Source Files | protected | views | layouts | main.php). It should look like this:
} else {
    $this->widget('zii.widgets.CMenu',array('activeCssClass' => 'active',
        'activateParents' => true,
        'items'=>array(
            array('label'=>'Home', 'url'=>array('/site/index')),
            array('label'=>'Comic Books', 
                'url'=>array('/book'),
                'items' => array(
                    array('label'=>'Publishers', 'url'=>array('/publisher')),
                )
            ),
            array('label'=>'Users', 'url'=>array('/user/index')),
            array('label'=>'Logout('.Yii::app()->user->name.')', 
                'url'=>array('/site/logout')),
        )
    );
}
?>
  1. Right-click on the project name, run the site, and log in with the default username and password (admin/admin). You will see a menu that includes a link named Users.
  1. If you click on the Users link in the menu and then click on Create User, you will see a pretty awful-looking user-creation screen. We are going to fix that. First, we will update the user form to include fields for first name, last name, password, and repeat password. Edit ch3 | Source Files | protected | views | user | _form.php and add those fields.
  2. Start by changing all instances of $model to $user. Then, add a call to errorSummary on the person data under the errorSummary call on user.
<?php echo $form->errorSummary($user); ?>
<?php echo $form->errorSummary($person); ?>
  1. Add rows for first name and last name at the beginning of the form.
<div class="row">
    <?php echo $form->labelEx($person,'fname'); ?>
    <?php echo $form->textField($person,'fname',array('size'=>20,'maxlength'=>20)); ?>
    <?php echo $form->error($person,'fname'); ?>
</div>
 
<div class="row">
    <?php echo $form->labelEx($person,'lname'); ?>
    <?php echo $form->textField($person,'lname',array('size'=>20,'maxlength'=>20)); ?>
    <?php echo $form->error($person,'lname'); ?>
</div>
  1. Replace the pwd_hash row with the following two rows:
<div class="row">
    <?php echo $form->labelEx($user,'password'); ?>
    <?php echo $form->passwordField($user,'password',array('size'=>20,'maxlength'=>64)); ?>
    <?php echo $form->error($user,'password'); ?>
</div>
 
<div class="row">
    <?php echo $form->labelEx($user,'password_repeat'); ?>
    <?php echo $form->passwordField($user,'password_repeat',
        array('size'=>20,'maxlength'=>64)); ?>
    <?php echo $form->error($user,'password_repeat'); ?>
</div>
  1. Finally, remove the row for person_id.
  2. These changes are going to completely break the User create/update form for the time being. We want to capture the password data and ultimately make a hash out of it to store securely in the database. To collect the form inputs, we will add password fields to the User model that do not correspond to values in the database. Edit the User model ch3 | Source Files | protected | models | User.php and add two public variables to the class:
class User extends CActiveRecord
{
        public $password;
        public $password_repeat;
  1. In the same User model file, modify the attribute labels function to include labels for the new password fields.
public function attributeLabels()
{
    return array(
        'id' => 'ID',
        'username' => 'Username',
        'password' => 'Password',
        'password_repeat' => 'Password Repeat'
    );
}
  1. In the same User model file, update the rules function with the following rules:
    • Require username
    • Limit length of username and password
    • Compare password with password repeat
    • Accept only safe values for username and password

We will come back to this and improve it, but for now, it should look like the following:

public function rules()
{
    // NOTE: you should only define rules for those attributes //that will receive user inputs.
    return array(
        array('username', 'required'),
        array('username', 'length', 'max'=>20),
        array('password', 'length', 'max'=>32),
        array('password', 'compare'),
        array('password_repeat', 'safe'),
    );
}
  1. In order to store the user's first and last name, we must change the Create action in the User controller ch3 | Source Files | protected | controllers | UserController.php to create a Person object in addition to a User object.

Change the variable name $model to $user, and add an instance of the Person model.

public function actionCreate()
{
    $user=new User;
     $person=new Person;
 
     // Uncomment the following line if AJAX validation is //needed
    // $this->performAjaxValidation($user);
 
    if(isset($_POST['User']))
    {
        $user->attributes=$_POST['User'];
        if($user->save())
            $this->redirect(array('view','id'=>$user->id));
    }
 
    $this->render('create',array(
        'user'=>$user,
        'person'=>$person,
    ));
}
  1. Don't reload the create user page yet. First, update the last line of the User Create view ch3 | Source Files | protected | views | user | create.php to send a User object and a Person object.
<?php echo $this->renderPartial('_form', array('user'=>$user, 'person' =>$person)); ?>
  1. Make a change to the attributeLabels function in the Person model (ch3 | Source Files | protected | models | Person.php) to display clearer labels for first name and last name.
public function attributeLabels()
{
    return array(
        'id' => 'ID',
        'fname' => 'First Name',
        'lname' => 'Last Name',
    );
}

The resulting user form should look like this:

  1. Looks pretty good, but if you try to submit the form, you will receive an error. To fix this, we will change the User Create action in the User controller ch3 | Source Files | protected | controllers | UserController.php to check and save both User and Person data.
if(isset($_POST['User'], $_POST['Person']))
{
    $person->attributes=$_POST['Person'];
    if($person->save()) {
        $user->attributes=$_POST['User'];
        $user->person_id = $person->id;
        if($user->save())
            $this->redirect(array('view','id'=>$user->id));
    }
}
  1. Great! Now you can create users, but if you try to edit a user entry, you see another error. This fix will require a couple of more changes.

First, in the user controller ch3 | Source Files | protected | controllers | UserController.php, change the loadModel function to load the user model with its related person information:

$model=User::model()->with('person')->findByPk((int)$id);
  1. Next, in the same file, change the actionUpdate function. Add a call to save the person data, if the user save succeeds:
if($model->save()) {
    $model->person->attributes=$_POST['Person'];
    $model->person->save();
    $this->redirect(array('view','id'=>$model->id));
}
  1. Then, in the user update view ch3 | Source Files | protected | views | user | update.php, add the person information to the form render.
<?php echo $this->renderPartial('_form', array('user'=>$model, 'person' => $model->person)); ?>
  1. One more piece of user management housekeeping; try deleting a user. Look in the database for the user and the person info. Oops. Didn't clean up after itself, did it? Update the User controller ch3 | Source Files | protected | controllers | UserController.php once again. Change the call to delete in the User delete action:
$this->loadModel($id)->person->delete();

Objective Complete - Mini Debriefing

We have added a new object, User, to our site, and associated it with the Person object to capture the user's first and last name. Gii helped us get the basic structure of our user management function in place, and then we altered the model, view, and controller to bring the pieces together.

Making a User Management Interface

The default Yii object index provides a nice summary listing of the user entries, but for many applications, it is more efficient to have a quick search capability. For this, Yii provides an additional "admin" view. We are going to completely replace the default listing with the admin view and update the scaffold view with a better integration of User with Person information for searching and sorting.

Engage Thrusters

  1. Delete the file ch3 | Source Files | protected | views | user | index.php.
  2. Rename the file ch3 | Source Files | protected | views | user | admin.php to index.php.
  3. In the files create.php, update.php, and view.php in ch3 | Source Files | protected | views | user, remove the following line from the menu array:
array('label'=>'Manage User', 'url'=>array('admin')),
  1. In the User controller ch3 | Source Files | protected | controllers | UserController.php, delete the function named actionIndex.
  2. Also in the User controller, remove the admin accessRule for the admin action. The admin accessRule should look like the following:
<?php
array('allow', 
    // allow admin user to perform 'delete' actions
    'actions'=>array('delete'),
    'users'=>array('admin'),
),

Also, change the redirect in the delete action to send to the index.

<?php
$this->redirect(isset($_POST['returnUrl']) ? $_POST['returnUrl'] : array('index'));
  1. In the same file, rename the function actionAdmin to actionIndex, and change the call to render in the newly renamed actionIndex function to render to index instead of admin. Now, if you click on the Users link in the menu, you will see a user management grid, instead of a list of user entries.

However, the information in the grid could be more useful.

  1. Edit the new user index ch3 | Source Files | protected | views | user | index.php. Remove the unnecessary column values id, pwd_hash, and person_id from the view. Add the columns we do want to see, namely first name and last name. These fields come from a related object, so their entries will look a little different. The file should look as follows:
'columns'=>array(
    'username',
    array(
        'name' => 'person_fname',
        'header' => 'First Name',
        'value' => '$data->person->fname',
    ),
    array(
        'name' => 'person_lname',
        'header' =>'Last Name',
        'value' => '$data->person->lname',
    ),
    array(
        'class'=>'CButtonColumn',
    ),
),

The entries for first name and last name include:

    • A name value, which is the name of the search variable
    • A header value, which is the column label
    • A data value, which is the data field that will populate the column output
  1. Edit the user model ch3 | Source Files | protected | models | User.php, and add public variables to catch the search fields person_fname and person_lname:
class User extends CActiveRecord
{
    public $password;
    public $password_repeat;
    public $person_fname;
    public $person_lname;
  1. In the same file, add a search field entry to the rules function with username, person_fname, and person_lname:
public function rules()
{
    // NOTE: you should only define rules for 
    //those attributes that will receive user inputs.
    return array(
        array('username', 'required'),
        array('username', 'length', 'max'=>20),
        array('password', 'length', 'max'=>32),
        array('password', 'compare'),
        array('password_repeat', 'safe'),
        array('username, person_fname, person_lname', 'safe', 'on'=>'search'),
    );
}
  1. The search function will require the most changes. We will need to remove the unused fields (id, pwd_hash, and person_id), update the username field to indicate that it is from the base model, add the person relationship to the criteria, and add comparisons of the related fields (first and last name).
public function search()
{
    $criteria=new CDbCriteria;
 
    $criteria->compare('t.username',$this->username,true);
    $criteria->compare('person.fname',$this->person_fname,true);
    $criteria->compare('person.lname',$this->person_lname,true);
 
    $criteria->with = array('person');
 
    return new CActiveDataProvider($this, array(
        'criteria'=>$criteria,
    ));
}

Now the grid will display all of the fields perfectly.

However, you can only sort on the username column.

  1. In the User model search function, add a sort object with first name and last name fields and include it in the data provider to activate sort on the first name and last name columns.
$sort = new CSort;
$sort->attributes= array(
    'person_fname' => array(
        'asc' => 'person.fname',
        'desc' => 'person.fname DESC',
    ),
    'person_lname' => array(
        'asc' => 'person.lname',
        'desc' => 'person.lname DESC',
    ),
    '*',
);
 
return new CActiveDataProvider($this, array(
    'criteria'=>$criteria,
    'sort' => $sort,
));
  1. Oh! One more thing. Have you clicked on the Advanced Search link yet?

That doesn't look great.

We can clean up the advanced search form ch3 | Source Files | protected | views | users | _search.php. Remove ID and password hash fields, and add first name and last name fields.

<div class="row">
    <?php echo $form->label($model,'username'); ?>
    <?php echo $form->textField($model,'username',array('size'=>20,'maxlength'=>20)); ?>
</div>
 
<div class="row">
    <?php echo $form->label($model,'First Name'); ?>
    <?php echo $form->textField($model,'person_fname',array('size'=>10,'maxlength'=>10)); ?>
</div>
 
<div class="row">
    <?php echo $form->label($model,'Last Name'); ?>
    <?php echo $form->textField($model,'person_lname',array('size'=>10,'maxlength'=>10)); ?>
</div>

Now it looks good!

  1. The last view we will change is named View. Edit ch3 | Source Files | protected | views | users | view.php. Delete id, pwd_hash, and person_id from the attributes array. Add person_fname and person_lname to the list.
array(
    'name' => 'person_fname',
    'header' => 'First Name',
    'value' => $model->person->fname,
),
array(
    'name' => 'person_lname',
    'header' => 'Last Name',
    'value' => $model->person->lname,
),

Objective Complete - Mini Debriefing

We have removed the original user index and replaced it with the admin page that Yii provides, but modified so that the information is relevant, searchable, and sortable.

Classified Intel

We could make the index even more compact by moving the Create User link into the page, perhaps incorporating it into the grid, and removing the side menu, because the only other link is back to the index, and changing the layout to one column, instead of two.

Storing Passwords

In this task, we will add a hashing function and store the hashed password values in the database.

Engage Thrusters

  1. We have a nice user management interface, but if you open a SQL command window and query the user table, you will see that the password field for each user is empty.
select * from user;
  1. We need to store the password, and in order to do that, we need to make a function to hash passwords. We will implement this function in the User model and do it in a rather simplistic way, using the crypt library that comes with PHP and providing no salt value, so that it is randomly generated by the library. You can replace this function with your own preferred method of hashing.
public function hash($value)
{
    return crypt($value);
}
  1. Next, we need to call the encryption function whenever we store a password – on create and on update – so we will overload the beforeSave function to do the hashing. Add the following function to the User model:
protected function beforeSave()
{
    if (parent::beforeSave()) {
        $this->pwd_hash = $this->hash($this->password);
        return true;
    }
    return false;
}

Now, if you add or update a user and look at the user table, you will see a hash value in your user entry.

  1. In preparation for logging in, let's go ahead and add a function to check a password value against the hashed value.
public function check($value)
{
    $new_hash = crypt($value, $this->pwd_hash);
    if ($new_hash == $this->pwd_hash) {
        return true;
    }
    return false;
}

Objective Complete - Mini Debriefing

We have added a hashing function to our User model to perform one way hashing on password values, then applied the hashing function to password values after they have been validated. We prepared for the next step by adding a hash check function to the User model as well. At this point, the hashing will not be applied to the login, but in the next task, we will activate it.

Activating Database User Login

In this task, we will convert the login action from the default authentication system provided by Yii to the authentication we have prepared in the previous tasks.

Prepare for Lift Off

We are about to cut over to a new authentication system. Before we do, be sure to create a user for yourself with a password that you know! If you haven't already, log in as admin/admin, go to the Users screen, create a user named admin with a password test. You can give this user whatever first and last name you like. We are about to use it to log in.

Engage Thrusters

  1. Edit the UserIdentity file ch3 | Source Files | protected | components | UserIdentity.php and replace the contents of the authenticate function with the following:
$user = User::model()->findByAttributes(array(
    'username' => $this->username
));
 
if($user===null)
    $this->errorCode=self::ERROR_USERNAME_INVALID;
else if ($user->check($this->password)) 
{
    $this->errorCode=self::ERROR_NONE;
}
else
    $this->errorCode=self::ERROR_PASSWORD_INVALID;
return !$this->errorCode;

Now, the authenticate function will look in the database for the provided username. If that user is found, it will check the provided password against the user's password hash.

  1. Give it a try. Log out (if you are logged in), and try logging back in as admin/admin. Now log in with the admin user we created earlier (admin/test).
  2. Before we forget, edit the login view ch3 | Source Files | protected | views | site | login.php and remove the demo and admin user hint.

Objective Complete - Mini Debriefing

Now, instead of having to edit the UserIdentity file, and hardcode in another user/password combination, you can use the web interface to create as many users as you like. If there is a user who no longer needs access to the system, you can delete the user and his/her credentials will no longer work. This approach will be much easier to maintain.

Enforcing Secure Passwords

Looking again at user creation, we can see another problem. You can create a user with no password. That is not so bad, because the login form requires a password. If your user has no password, he will not be able to login, but what about the quality of the passwords? If you try to enter a one-character password, no problem, you can do it. This might be ok if you are the only person creating users and entering passwords. You can be careful to give your users passwords that are difficult to guess. You can devise and enforce your own password strength requirements, but typically, sooner or later, you are going to let your users set their own passwords. When this happens, you will want to enforce some checking to make sure the passwords your users set are difficult to guess. Otherwise, your users and your site are vulnerable to password cracking.

You can use this basic pattern for applying a password strength scheme and implement your own password strength requirements that are appropriate to your site. We will go with a basic requirement of a minimum length of eight characters, including at least one capital character, at least one number, and at least one non-alphanumeric character.

This pattern is also useful for implementing any custom validation rule.

Engage Thrusters

  1. Open the user model file for edit (ch3 | Source Files | protected | models | User.php).
  2. Add a function named passwordStrengthOk as follows:
public function passwordStrengthOk($attribute, $params)
{
    // default to true
    $valid = true;
 
    // at least one number
    $valid = $valid && preg_match('/.*[\d].*/', $this->$attribute); 
 
    // at least one non-word character
    $valid = $valid && preg_match('/.*[\W].*/', $this->$attribute);
 
    // at least one capital letter
    $valid = $valid && preg_match('/.*[A-Z].*/', $this->$attribute);
 
    if (!$valid) 
        $this->addError($attribute, "Does not meet password requirements.");
 
    return $valid;
}
  1. Add two new rules to the validation array:
    • Require a unique username.
    • Add the new rule we created, passwordStrengthOk and change one old rule. Add a check for minimum password length of 8 to the password length requirements.
return array(
    array('username', 'required'),
    array('username', 'unique'),
    array('password, password_repeat', 'required'),
    array('username', 'length', 'min' => 3, 'max'=>20),
    array('password', 'length', 'min' => 8, 'max'=>32),
    array('password', 'compare', 'compareAttribute' => 'password_repeat'),
    array('password', 'passwordStrengthOk'),
    array('username, password, password_repeat', 'safe'),
    array('username, person_fname, person_lname', 'safe', 'on'=>'search'),
);
  1. But what if we want to update something about the user, such as change the username, and not enter a new password? To do this, we will use a scenario. First, update the rules that apply to passwords and add the scenario parameter, so that the rules are only applied when the scenario is in play.
return array(
    array('username', 'required'),
    array('username', 'unique'),
    array('password, password_repeat', 'required', 'on' => 'passwordset'),
    array('username', 'length', 'min' => 3, 'max'=>20),
    array('password', 'length', 'min' => 8, 'max'=>32, 'on' => 'passwordset'),
    array('password', 'compare', 'compareAttribute' => 'password_repeat'),
    array('password', 'passwordStrengthOk', 'on' => 'passwordset'),
    array('username, password, password_repeat', 'safe'),
    array('username, person_fname, person_lname', 'safe', 'on'=>'search'),
);

i. Then, in the User controller, activate the passwordset scenario whenever we want the passwordset rules to apply. In the Create function, we always want the scenario to apply, so pass it to the model constructor at the start.

$user=new User('passwordset');

ii. In the Update function, we only want to apply the scenario when a password field has been entered, so set the scenario on the model conditionally. After the attributes are gathered from the form, if the password or password_repeat value has been set, apply the scenario.

$model->attributes=$_POST['User'];
if ($model->password|| $model->password_repeat)
    $model->scenario = 'passwordset';
  1. Let's make sure we did all of that correctly by making and running a functional test.
  2. First, we will augment our testing setup by downloading the Selenium standalone server from http://seleniumhq.org/.
  3. Then, update the phpunit config to define the browsers that you will test against. In our example, we will test against Firefox (of course, you must have Firefox installed to do this). Add the following section to ch3 | Test Files | phpunit.xml:
<selenium>
    <browser name="Firefox" browser="*firefox" />
</selenium>
  1. Open ch3 | Test Files | WebTestCase.php and change TEST_BASE_URL to the URL of our site.
define('TEST_BASE_URL', 'http://localhost/cbdb/');
  1. Start the Selenium standalone server by opening a terminal window, changing to the directory where you downloaded the standalone server, and running the following command (updated to include the version of the server you downloaded):
java -jar selenium-server-standalone-<version-number>.jar
  1. Navigate to ch3 | Test Files.
  2. Right-click on the folder named functional and select New | PHP File.
  3. Enter UserTest for the filename and click on Finish.
  4. Input the following contents into the new test file.
 
<?php
class UserTest extends WebTestCase {
 
  protected function setUp() {
    parent::setUp();
 
    $this->start();
    $this->open('');
 
    // login
    $this->clickAndWait('link=Login');
    $this->type('name=LoginForm[username]','admin');
    $this->click("//input[@value='Login']");
    $this->waitForTextPresent('Password cannot be blank.');
    $this->type('name=LoginForm[password]','test');
    $this->clickAndWait("//input[@value='Login']");
    // go to users page
    $this->clickAndWait('link=Users');
  }
 
  public function testPasswordMatch() {
    $this->clickAndWait('link=Create User');  
    $this->type('name=Person[fname]','Func');
    $this->type('name=Person[lname]','Test');
    $this->type('name=User[username]','functest');
    $this->type('name=User[password]','functest');
    $this->type('name=User[password_repeat]','nomatchpass');
    $this->clickAndWait("//input[@value='Create']");
    $this->assertTextPresent('Password must be repeated exactly.');
    $this->assertTextPresent('Does not meet password requirements.');
    $this->assertTextNotPresent('Password is too short (minimum is 8 characters).');
  }
 
  public function testPasswordTooShort() {
    $this->clickAndWait('link=Create User');  
    $this->type('name=Person[fname]','Func');
    $this->type('name=Person[lname]','Test');
    $this->type('name=User[username]','functest');
    $this->type('name=User[password]','moo');
    $this->type('name=User[password_repeat]','moo');
    $this->clickAndWait("//input[@value='Create']");
    $this->assertTextPresent('Password is too short (minimum is 8 characters).');
    $this->assertTextPresent('Does not meet password requirements.');
    $this->assertTextNotPresent('Password must be repeated exactly.');
  }
 
  public function testGoodPassword() {
    $this->clickAndWait('link=Create User');  
    $this->type('name=Person[fname]','Func');
    $this->type('name=Person[lname]','Test');
    $this->type('name=User[username]','functest');
    $this->type('name=User[password]','m00!Isay');
    $this->type('name=User[password_repeat]','m00!Isay');
    $this->clickAndWait("//input[@value='Create']");
    $this->assertTextPresent('View User');
  }
 
  public function testDeleteUser() {
    $this->clickAndWait("xpath=(//img[@alt=\"View\"])[2]");
    $this->clickAndWait('link=Delete User');
    $this->assertConfirmation('Are you sure you want to delete this item?');
    $this->assertTextNotPresent('functest');
  }    
}
?>
  1. Be sure to save the new contents. Then, while viewing the new test file in NetBeans, press Shift + F6 to run the functional test. You should see Selenium and Firefox windows flash on your screen as the tests run.

The tests should complete successfully and confirm that the password validation rules are applied correctly.

Objective Complete - Mini Debriefing

In order to improve our site security, we have added a custom validation rule to the user model. The new rule implements a password strength requirement that we defined, but you can replace this with your own custom definition or an existing library, such as CrackLib. To make sure your new rule is being enforced correctly, and to demonstrate functional testing with Selenium, we added a set of Selenium tests.

Adding User Functions – Wishlist

To demonstrate access control, we will create a new function for users of our site to show them our comic book wishlist. When a special occasion is coming up, your friends and family will be able to log in and view your wishlist to get gift ideas.

Engage Thrusters

  1. Start by adding a new table to the database as follows:

CREATE TABLE 'wish' (
  'id' int(10) unsigned NOT NULL AUTO_INCREMENT,
  'title' varchar(256) NOT NULL,
  'issue_number' varchar(10) DEFAULT NULL,
  'type_id' int(10) unsigned DEFAULT NULL,
  'publication_date' date DEFAULT NULL,
  'store_link' varchar(255) DEFAULT NULL,
  'notes' text DEFAULT NULL,
  'got_it' int(10) unsigned DEFAULT NULL,
  PRIMARY KEY ('id'),
  KEY 'type_id' ('type_id'),
  KEY 'got_it' ('got_it'),
  CONSTRAINT 'wish_ibfk_1' FOREIGN KEY ('type_id') REFERENCES 'type' ('id'),
  CONSTRAINT 'wish_ibfk_2' FOREIGN KEY ('got_it') REFERENCES 'user' ('id')
) ENGINE=InnoDB;
  1. Add join tables for author, illustrator, and publisher as follows:
CREATE TABLE 'wishauthor' (
  'wish_id' int(10) unsigned NOT NULL,
  'author_id' int(10) unsigned NOT NULL,
  PRIMARY KEY ('wish_id','author_id'),
  KEY 'author_id' ('author_id'),
  CONSTRAINT 'wishauthor_ibfk_1' FOREIGN KEY ('wish_id') REFERENCES 'wish' ('id') 
  ON DELETE CASCADE,
  CONSTRAINT 'wishauthor_ibfk_2' FOREIGN KEY ('author_id') REFERENCES 'person' ('id')
) ENGINE=InnoDB;
 
CREATE TABLE 'wishillustrator' (
  'wish_id' int(10) unsigned NOT NULL,
  'illustrator_id' int(10) unsigned NOT NULL,
  PRIMARY KEY ('wish_id','illustrator_id'),
  KEY 'illustrator_id' ('illustrator_id'),
  CONSTRAINT 'wishillustrator_ibfk_1' FOREIGN KEY ('wish_id') REFERENCES 'wish' ('id') 
  ON DELETE CASCADE,
  CONSTRAINT 'wishillustrator_ibfk_2' FOREIGN KEY ('illustrator_id') REFERENCES 
  'person' ('id')
) ENGINE=InnoDB;
 
CREATE TABLE 'wishpublisher' (
  'wish_id' int(10) unsigned NOT NULL,
  'publisher_id' int(10) unsigned NOT NULL,
  PRIMARY KEY ('wish_id','publisher_id'),
  KEY 'publisher_id' ('publisher_id'),
  CONSTRAINT 'wishpublisher_ibfk_1' FOREIGN KEY ('wish_id') REFERENCES 'wish' ('id') 
  ON DELETE CASCADE,
  CONSTRAINT 'wishpublisher_ibfk_2' FOREIGN KEY ('publisher_id') REFERENCES 
  'publisher' ('id')
) ENGINE=InnoDB;
  1. The new tables and their relationships look like the following:
  1. Use Gii to generate a model and CRUD from the wish table, and models for all of the join tables (wishauthor, wishillustrator, and wishpublisher).
  2. Add a new item to the Comic Book menu in ch3 | Source Files | protected | views | layouts | main.php as follows:
array(
    'label'=>'Comic Books',
    'url'=>array('/book'),
    'items' => array(
        array('label'=>'Publishers', 'url'=>array('/publisher')),
        array('label'=>'WishList', 'url'=>array('/wish/index')),
    )
),

Now, when you expand the Comic Books menu, you will see an entry for WishList, as follows:

  1. Since wish has a lot of the same fields as book, we copied ch3 | Source Files | protected | views | book | _form.php to the wish view folder, removed the unnecessary fields value, price, signed, grade, and bagged, and added the unique wish field, store_link. The resulting file ch3 | Source Files | protected | views | wish | _form.php can be found in the chapter files ch3 | Source Files | example | wish | _form.php. The form will not work until we make a few changes.
  2. We have already pulled the author functions that you might want to use out of the Book controller and put them in a base controller that the Book controller is using. To access the author functions in the Wish controller, change the base class.
class WishController extends BController

i. Delete the update action from the Wish controller and change the generated create action to the following:

public function actionCreate()
{
    $model=new Wish;
    $this->create($model);
}

ii. Create a function to record the association between a wish and an author.

protected function saveAssociation($model, $author)
{
    // record wish/author association
    $wa = new WishAuthor;
    $wa->wish_id = $model->id;
    $wa->author_id = $author->id;
    $wa->save();
}

iii. Add removeAuthor and createAuthor to the allowed actions for users. (We will adjust the permissions in a later task.)

array('allow', // allow authenticated user to perform 'create' and 'update' actions
    'actions'=>array('create','update', 'removeAuthor','createAuthor'),
    'users'=>array('@'),
),
  1. Add support for authors to the Wish model ch3 | Source Files | protected | models | Wish.php. Start by adding the following functions:
/* 
 * assign this author to this wish
 */
public function addAuthor($author) {
    $wishauthor = new WishAuthor();
 
    $author->save();
    $wishauthor->wish_id = $this->id;
    $wishauthor->author_id = $author->id;
    $wishauthor->save();
}
 
/*
 * remove an author association from wish
 */
public function removeAuthor($author_id) {
    $pk = array('wish_id'=>$this->id, 'author_id' => $author_id);
    WishAuthor::model()->deleteByPk($pk);

Then, add the following two relations:

'authors' => array(self::MANY_MANY, 'Person',
    'wishauthor(author_id, wish_id)', 'index'=>'id'),
    'wishauthors' => array(self::HAS_MANY, 'WishAuthor', 'wish_id', 'index' => 'author_id'),
  1. Add the author variable to the call to renderPartial in the Wish create and update views, ch3 | Source Files | protected | views | wish | create.php and ch3 | Source Files | protected | views | wish | update.php respectively. In both files, the call will look like the following:
<?php echo $this->renderPartial('_form', array('model'=>$model, 'author'=>$author)); ?>

Also include the author add JavaScript at the beginning of both files:

Yii::app()->clientScript->registerScriptFile(
    Yii::app()->request->baseUrl . '/js/book_form_ajax.js'
);

Objective Complete - Mini Debriefing

In this task, we have created another object, a wish. This object benefitted from its similarity to the book object and the work we had already done to associate books to authors. We will use this object to demonstrate configuring different capabilities for different users.

Configuring User Access

There is more than one way to define user access. One is the file-based method we replaced in this chapter. Another method is role-based and it is demonstrated in another project. For this project, we will define user-based access to the wishlist function, and we will provide two levels of access:

  • Admin – our own login, which will be used to create and maintain the wishlist
  • Everyone else – for our friends and family who want to view the wishlist and claim items that they have got for us

Prepare for Lift Off

To perform a cursory check (as opposed to a comprehensive suite of unit tests) of the changes we are about to make, you will need to have the ability to log in as two different users with different levels of access. We already have an admin user, which is our own login for creating and maintaining the wishlist. Create another account with username guest and password Gu3st!!! to test guest access. For our development and testing, this user represents all of our other users who will have the ability to view the wishlist and claim items they have got for us (so we don't get duplicate presents).

Run the following MySQL commands to insert some wish data into your database:

INSERT INTO 'wish' VALUES (1,'Moebius\' Airtight Garage Vol.1','1',1,'0000-00-00',
'http://www.amazon.com/Moebius-Airtight-Garage-Vol-1-No/dp/B00178YGFE/
ref=sr_1_3?s=books&ie=UTF8&qid=1339476850&sr=1-3',
'',NULL),(2,'The Squiddy Avenger','1',1,'2012-06-21','www.amazon.com','',NULL),
(3,'another great title','',1,'2012-06-21','','',NULL);
 
INSERT INTO 'person' VALUES (226,'Jean','Giraud'),(227,'John','Smith');
INSERT INTO 'wishauthor' VALUES (1,226),(2,227);

Engage Thrusters

As administrators, we pretty much have all the capabilities we need already. In fact, we may want to limit one thing. What good is our wishlist if we lose the surprise by seeing what our friends have got for us? Of course, you can cut out the parts that hide the information from admin or leave them in and find other ways to peek at your gifts in the database.

Let's start by limiting our guests' access, and then update the wishlist view to achieve the desired effect.

We are going to customize the users' menu view in a later task, so that for now, when we are logged in as a guest, we can easily click all options and see what we can and cannot do.

  1. Log in as guest/Gu3st!!! .
  2. From the menu, you can see and access the user index. Let's eliminate that option for our guests. For now, only the administrator will be able to view, create, update, or delete users. Open the user controller ch3 | Source Files | protected | controllers | UserController.php and consolidate all of the actions into the allowed array for the admin user. When you are done, accessRules should look as follows:
public function accessRules()
{
    return array(
        array('allow', // allow admin user to perform 'delete' actions
            'actions'=>array('index', 'view', 'create', 'update', 'delete'),
            'users'=>array('admin'),
        ),
        array('deny',  // deny all users
            'users'=>array('*'),
        ),
    );
}
  1. Now, click on Users in the menu and you should see an error message as shown in the following screenshot:
  1. If you want to log out of guest and log back in as admin at this point to make sure you still have user management access, we don't blame you. Go ahead. Check it out. Then, log back in as guest to continue.
  2. Guests really don't need to view publishers either. So make the same change to accessRules in ch3 | Source Files | protected | controllers | PublisherController.php.
  3. We want to share our list of books with our users, but not anonymous strangers. Also we don't want guests changing anything. To accomplish this, make the following changes to the book controller.

Move access to index and view down to authenticated users.

Move access to create, update, removeAuthor, and createAuthor down to admin user. Remove the all users section.

The result should look as follows:

public function accessRules()
{
    return array(
        array('allow', // allow authenticated user to perform 'index' and 'view' actions
            'actions'=>array('index','view'),
            'users'=>array('@'),
        ),
        array('allow', // allow admin user to perform 'create' 'update' 'admin' 
                        // and 'delete' actions
            'actions'=>array('create','update','removeAuthor','createAuthor',
                                'admin','delete'),
            'users'=>array('admin'),
        ),
         array('deny',  // deny all users
            'users'=>array('*'),
        ),
    );
}
  1. Wishlist is similar to the book list in the previous step, but we do want guests to be able to claim items from the list. Let's start by replacing the access rules in the Wish controller with the access rules we just made for Books.

Now we will make one small side-track to make our wishlist look nice for our guests.

  1. We created a field for a store link to make it convenient for our friends to click right on an item we want and purchase it. But right now that URL displays as un-clickable text. Edit ch3 | Source Files | protected | views | wish | _view.php and make this change to the store_link field to make it clickable from the index.
<b><?php echo CHtml::encode($data->getAttributeLabel('store_link')); ?>:</b>
<a href="<?php echo CHtml::encode($data->store_link); ?>" target="_blank">Purchase</a>
<br />
  1. We added target="_blank" to the link so that it will open in another browser window. That way our users can remain in our site.
  2. Our users don't need to see ID values, and for that matter, neither do we. But they may want to click an item and read more about it. To that end, remove the ID field, and update the title field to be the link to the item.
<b><?php echo CHtml::encode($data->getAttributeLabel('title')); ?>:</b>
<?php echo CHtml::link(CHtml::encode($data->title), array('view', 'id'=>$data->id)); ?>
<br />
  1. Now, let's clean up the individual wish view ch3 | Source Files | protected | views | wish | view.php. Replace the page title with the wish title.
<h1>Wish: <?php echo CHtml::encode($model->title);?></h1>
  1. Remove id from the detail view attribute list, and change the store link to pass an attribute array, including a name, type=raw, and the defined link. We also want to display the type value as text instead of a number.

The final attributes of the detail view should look as follows:

'attributes'=>array(
    'title',
    'issue_number',
    array(
        'name' => 'type',
        'value' => $model->type->name,
    ),
    'publication_date',
    array(
        'name' => 'store link',
        'type' => "raw",
        'value' => "<a href=\"" . $model->store_link . "\" target=\"_blank\">Purchase</a>",
    ),
    'notes',
    'got_it',
),

The resulting wish details screen will look like the following:

  1. Now that the index and view are looking pretty nice for our guests, we should limit the wish list items so that a user only sees unclaimed items or items that user has claimed.

First, we must extend the UserIdentity class to store and return the user's ID and name.

i. Open ch3 | Source Files | protected | components | UserIdentity.php and add private fields named _id and _username to the class.

private $_id;
private $_username;

ii. Add functions to return the values.

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

iii. Set the value when we have a successful authentication.

if ($user->check($this->password))
{
    $this->_id = $user->id;
    $this->_username = $user->username;
    $this->errorCode=self::ERROR_NONE;
}
  1. Now that the user identity class can return the current user's ID and username, we can update the Index action in the Wish controller to limit the wish list, if the user is not admin.
public function actionIndex()
{
    if(Yii::app()->user->getName() == 'admin') {
        $dataProvider=new CActiveDataProvider('Wish');
    } else {
        $dataProvider=new CActiveDataProvider('Wish', array(
            'criteria' => array(
                'condition' => 'got_it is null OR ' .
                'got_it=' . Yii::app()->user->getId(),
            )
        ));
    }
    
    $this->render('index',array(
        'dataProvider'=>$dataProvider,
    ));
}

If you are currently logged in as a guest and try to access this page, you will see an error message. You will need to log out and log back in as a guest in order to access the Wish index.

At this point, to test our work, we logged in as admin and created a few wishes. Then we used the database Execute Command feature to set a few wish got_it values to various users, and left a few unclaimed. We logged in as each of those users to verify that we saw unclaimed wishes and the user's claimed wishes. We also logged in as admin to verify that admin sees the complete list.

  1. The next part of the feature that we will need is the ability to claim a wish. To keep it a surprise, we are going to show this feature to users but not to admin.

Edit ch3 | Source Files | protected | views | wish | view.php and change the attribute value for the field got_it as follows:

array(
    'name' => 'Got It',
    'value' => (Yii::app()->user->getName() != 'admin') ? $model->got_it : ''
),
  1. Edit ch3 | Source Files | protected | views | wish | _view.php and add the following to display a claim/unclaim checkbox:
<?php if (Yii::app()->user->getName() != 'admin') { ?>
    <b><?php echo CHtml::encode($data->getAttributeLabel('got_it')); ?>:</b>
    <?php 
        echo CHtml::checkBox("got", ($data->got_it == Yii::app()->user->getId()), 
            array('class' => 'claim',
                'url'=> Yii::app()->controller->createUrl("claim", array("id"=>$data->id))
            )
        );
    ?>
<?php } ?>
<br />

The result should look like the following screenshot:

  1. To support the checkbox toggle function we are going to add Ajax to catch the checkbox click. Create a file in ch3 | Source Files | js named wish_list_ajax.js with the following contents:
$(document).ready(function() {
    $('.claim').click(function() {
        $.ajax({
            type: 'get',
            url: $(this).attr('url'),
            data: {"ajax" : "1"},
            success: function(resp) {
                $("ul.authors").append(resp);
            },
            error: function() {
                alert("Error claiming wish.");
            }
        });
    });
});
  1. To include the script in the wish index, edit ch3 | Source Files | protected | views | wish | index.php and add the script at the beginning of the file.
Yii::app()->clientScript->registerScriptFile(
    Yii::app()->request->baseUrl . '/js/wish_list_ajax.js'
);
  1. Then, of course, we need to add the claim action itself to the Wish controller.
public function actionClaim($id)
{
    // request must be made via ajax
    if(isset($_GET['ajax'])) {
        $model=$this->loadModel($id);
        // if the wish was claimed by the user, toggle it //off
        if ($model->got_it == Yii::app()->user->getId()) {
            $model-> got_it = new CDbExpression('NULL');
        }
        // if the wish was claimed by no one, toggle it on
        if ($model->got_it == null) {
            $model->got_it = Yii::app()->user->getId();
        }
        $model->save();
    }
    else
        throw new CHttpException(400,'Invalid request.');
}

Update the access rules to include the new action, allowing authenticated users to claim wishes.

array('allow', // allow authenticated user to perform 'index' and 'view' actions
    'actions'=>array('index','view','claim'),
    'users'=>array('@'),
),

Objective Complete - Mini Debriefing

We have used the basic access control that is provided by Yii to limit general user access for viewing the comic book list and items and the wish list and items.

User Specific Menus

In the last task, we limited user access, but we did not update the menus. The site menu provides links to objects that users do not have permission to access, and the object menus provide links to actions that users do not have authorization to take. It would be nice if we could associate the menus to the access we have already defined, so that we do not have to manually coordinate menu contents with accessRules. To do this, we created an extension to the CMenu widget.

Engage Thrusters

  1. Create a new file in ch3 | Source Files | protected | components named AuthMenu.php.
  2. Enter the following contents into the file:
<?php
 
Yii::import ( 'zii.widgets.CMenu' );
 
/**
 * Auth Menu extends CMenu to apply access rules to menu items before
 * displaying them.
 * The idea is to define menu items once and only
 * display relevant items.
 *
 * This extension was inspired by YiiSmartMenu
 *
 * @author Lauren O'Meara <lauren@plumflowersoftware.com>
 * @copyright Copyright &copy; 2012 Plum Flower Software
 * @version 0.1
 * @license New BSD Licence
 */
class AuthMenu extends CMenu {
    public function init() {
        $this->items = $this->filterItems ( $this->items );
        return parent::init ();
    }
    
    /**
     * Filter recursively the menu items received setting visibility true or
     * false according to controller/action preFilter
     *
     * @param array $items
     *            The menu items being filtered.
     * @return array The menu items with visibility defined by preFilter().
     */
    protected function filterItems(array $items) {
        $app = Yii::app ();
        foreach ( $items as $pos => $item ) {
            if (! isset ( $item ['visible'] )) {
                // get the url parameter
                if (isset ( $item ['url'] ) && is_array ( $item ['url'] ))
                    $url = $item ['url'] [0];
                    
                    // parse the url into controller and action
                $parts = explode ( "/", $url );
                if (count ( $parts ) == 1) {
                    $controller = $app->controller;
                    $actionId = $parts [0];
                } else {
                    $controllerId = ucfirst ( $parts [1] );
                    $actionId = count ( $parts ) > 2 ? $parts [2] : 'index';
                    $controllerList = $app->createController ( $controllerId );
                    $controller = $controllerList [0];
                }
                // generate a controller instance to access and //compare the
                // rules
                $action = $controller->createAction ( $actionId );
                $filter = new CAccessControlFilter ();
                $filter->setRules ( $controller->accessRules () );
                $user = $app->getUser ();
                $request = $app->getRequest ();
                $ip = $request->getUserHostAddress ();
                $item ['visible'] = false;
                foreach($filter->getRules () as $rule) {
                    // we are making an assumption for now that all
                    // menu items are GET actions
                    if ($rule->isUserAllowed($user, $controller, $action, $ip, 'GET') > 0) {
                        $item ['visible'] = true;
                        break;
                    }
                }
            }
            
            /**
             * If current item is visible and has sub items, loops recursively
             * on them.
             */
            if (isset ( $item ['items'] ) && $item ['visible'])
                $item ['items'] = $this->filterItems ( $item ['items'] );
            
            $items [$pos] = $item;
        }
        return $items;
    }
}
  1. Next, consolidate the main site menu back into one widget call. Set the visible value for the Home item to true, and set the visible value for Login and Logout to conditional based on whether the user is logged into the site or not, using the isGuest command. Replace the call to CMenu in main layout file ch3 | Source Files | protected | views | layouts | main.php.
<div id="mainmenu">
    <?php
        $this->widget('application.components.AuthMenu',array(
            'activeCssClass' => 'active',
            'activateParents' => true,
            'items'=>array(
                array('label'=>'Home', 'url'=>array('/site/index'), 'visible' => true),
                array(
                    'label'=>'Comic Books',
                    'url'=>array('/book'),
                    'items' => array(
                        array('label'=>'Publishers', 'url'=>array('/publisher')),
                        array('label'=>'WishList', 'url'=>array('/wish/index')),
                    )
                ),
                array('label'=>'Users', 'url'=>array('/user/index')), 
                array('label'=>'Login', 'url'=>array('/site/login'), 
                    'visible'=>Yii::app()->user->isGuest),
                array('label'=>'Logout ('.Yii::app()->user->name.')', 
                    'url'=>array('/site/logout'), 'visible'=>!Yii::app()->user->isGuest),
            ),
        ));
    ?>
</div><!-- mainmenu -->

The consolidated menu for admin should look the same as before, like the following screenshot:

The menu for a guest will have fewer items, as shown in the following screenshot:

  1. Also replace the call to CMenu in the two column layout ch3 | Source Files | protected | views | layouts | column2.php.
$this->widget('application.components.AuthMenu', array(
    'items'=>$this->menu,
    'htmlOptions'=>array('class'=>'operations'),
));

The action menu on the right-hand side of the screen will now display only authorized actions. For admin, the list will include all actions, as shown in the following screenshot:

For guests, the list of authorized actions will not include Create, Update, or Delete, as shown in the following screenshot:

Objective Complete - Mini Debriefing

In the extension that we created, we iterate over the list of menu items and use context and the url parameter to determine which controller and action the menu item contains. Then we check the array in the accessRules function for the controller against the user and action to determine the visibility of the menu item.

Mission Accomplished

In this project, we have improved user management for our site by replacing the default file-based user management that Yii framework provides with database-stored users. By making this change, we get the benefit of the web interface to manage our users, instead of having to change the text in a source file.

We removed the default User index and replaced it with the Yii-generated admin page, which provides Ajax record searching and quick links to view/update/delete users. We also customized this view to include support for fields from the related table, Person. As a result we can search and sort on fields from Person, as well as User.

We improved site security by creating a custom validation rule that enforces some password strength requirements, and we apply this rule only when we need to change the password, not when we are making a change to an existing user.

We tested the implementation of this validation rule and tried out functional testing with Selenium.

We added a new function to demonstrate Yii access control settings. And we showed a way to display user-specific menus. This will get even better in the next project when we group users into roles.

Remember to review your security if you put this site online. You may want to review the following considerations:

  • You may not want to use XAMPP for a public server
  • If you do use XAMPP, at the least, enable secure mode
  • Obtain and use an SSL certificate; require encrypted access to the login page, so that passwords are not transmitted unencrypted
  • Definitely disable Gii in your Yii site configuration
  • Also be sure to disable phpMyAdmin

You Ready to go Gung HO? A Hotshot Challenge

Here are some ideas to go gung ho with user functions:

  • You could expand your wishlist system to allow other users to track their wishlists.
  • You could extend the user object with a Boolean value to indicate active/inactive users, instead of deleting user entries.
  • Extend the functional tests.
  • Check out the Selenium Firefox extension and the Selenium IDE: PHP Formatters. Try using them to record more functional tests.
  • Replace all index views with the admin view, like we did for Users.
  • Add a function to transfer a wish into your book list, once you have received it.
评论 X

      友荐云推荐
      Copyright 2011-2014. YiiBook.com