In part 5 of this series we will finish the registration process (actually saving the user data to database), implement sending email with activation link, implement the actual activation of users. Then we will implement login and logout actions in the site, utilizing Zend_Auth. In the next part we will implement very simple access controll system - for now without Zend_Acl, because we still don’t have idea what resources and roles we will have in our system later. On this stage of the development we will have only ‘admin’ module, which can be accessed by users set as admin by flag in their profile. But more on this - later.
From part 4 we have registration form ready, but after submiting it - nothing happens, so new it is time to fill in some code to handle form submition. We will need new table in our DB:
CREATE TABLE `users` ( `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, `username` VARCHAR(32) NOT NULL, `password` VARCHAR(64) NOT NULL, `email` VARCHAR(200) NOT NULL, `active` TINYINT(4) NOT NULL, `code` VARCHAR(40) NOT NULL, `last_login` DATETIME NOT NULL, `registered_on` DATETIME NOT NULL, `gender` ENUM('male','female') NOT NULL, `birthday` DATE NOT NULL, `real_name` VARCHAR(64) NOT NULL, `isAdmin` TINYINT(4) NOT NULL DEFAULT '0', PRIMARY KEY (`id`), UNIQUE KEY `username` (`username`), UNIQUE KEY `email` (`email`) ) ENGINE=INNODB;
Now we have to create our class Users, which will handle saving and retrieving user data to database.
Here is the code, comments are below:
application/models/Users.php:
< ?php class Users extends Zend_Db_Table_Abstract { protected $_name = 'users'; public static function computePasswordHash($password) { return hash('sha256', '21' . $password . 'eoka3b'); } /** * Adds user to database, sends confirmation email * * @param string $username * @param string $password * @param string $email * @throws Zend_Db_Statement_Exception * @return int The id of the new user */ public function add($username, $password, $email) { $activationCode = sha1(uniqid('xyz', true)); $newUser = array( 'username' => $username, 'password' => $this->computePasswordHash($password), 'email' => $email, 'active' => 0, 'code' => $activationCode, 'registered_on' => new Zend_Db_Expr('NOW()') ); $userId = $this->insert($newUser); // send activation email $mailer = new Mailer(); $languageCode = (string) Zend_Registry::get('Zend_Translate')->getAdapter()->getLocale(); $activationLink = Zend_Registry::get('configuration')->general->url . '/user/activate/' . $userId . '/' . $activationCode; $mailer->sendRegistrationMail($email, $username, $activationLink, $languageCode); return $userId; } public function editProfile($userId, $profileData) { $userRowset = $this->find($userId); $user = $userRowset->current(); if (!$user) { throw new Zend_Db_Table_Exception('User with id '.$userId.' is not present in the database'); } foreach ($profileData as $k => $v) { if (in_array($k, $this->_cols)) { if ($k == $this->_primary) { throw new Zend_Db_Table_Exception('Id of user cannot be changed'); } // special case - hash have to be computed for password if ($k == 'password') { $user->password = $this->computePasswordHash($v); } else { $user->{$k} = $v; } } } $user->save(); return $this; } }
our class extends Zend_Db_Table_Abstract, so it inherits from there the functionality to save and retrieve data to and from our database. Because we need some higher level of abstraction we introduce some new public functions here - to add new user and to edit user profile. Also we have function to compute password hash - we will save passwords in the database in hashed format - with sha256 algorithm, prepending and appending the password with some random-looking but fixed strings. The idea behind that is if someone somehow gets access to the database and gets the hashes of the passwords - to prevent him from easy cracking and getting the actual passwords.
Lets take closer look at our public function add() - it takes 3 parameters - $username, $password and $email - strings. The code is quite simple - we generate random string activation code, which we will send to the user via email, then we construct an array for inserting in the users DB table and actually inserting the new row. Finally we send the email. We use not-yet-existing class - Mailer and its method - sendRegistrationMail. We will write these shortly. Also we have added new configuration lines in our config.ini file, one of which is general.url - which takes value something like “http://example.com” - the base url of our site - we use this to generate absolute URLs. We do not use $_SERVER['HTTP_HOST'] here, because later we may have different servers for different types of resources (for example our images may be hosted on different web server with different domain or subdomain). Full listing of our new config.ini will follow after a while, when we discus the Mailer class.
One more function we have in Users.php to comment - editProfile. It takes $userId as parameter along with an array $profileData, which contains the new, updated fields of the profile to save. User Id cannot be changed with this function, so we have explicit check for this - if trying to change the id we throw exception. For each element of the array $profileData we check if it is valid column from our users table - we do this by testing
if (in_array($k, $this->_cols))
in $this->_cols property we have an array, inherited by our base class, containing all columns for the database table. This way we can change our underlaying database schema without need to change some hardcoded values in this class.
It is important to note also that these two functions - both editProfile and add - will throw Zend_Db_Exception if unique constraints are broken - so if trying to insert new row with used already username or email address, or updating a row, changing username or email address to existing in other record.
Now lets see our Mailer class and after this - the new config.ini file:
application/models/Mailer.php
< ?php require_once 'Zend/Mail.php'; require_once 'Zend/Mail/Transport/Smtp.php'; require_once 'Zend/Registry.php'; class Mailer { /** * Directory path, where mail templates are located * * @var string */ protected $templatesDir = ''; /** * Constructor for the class, provide directory path where mail templates are saved * * @param string $templatesDir Directory path, where mail templates are located */ public function __construct($templatesDir = 'languages/mailtemplates') { $this->templatesDir = $templatesDir; $mailConfig = Zend_Registry::get('configuration')->mail; if ($mailConfig->smtp) { $transport = new Zend_Mail_Transport_Smtp($mailConfig->host, $mailConfig->smtpconfig->toArray()); } else { $transport = new Zend_Mail_Transport_Sendmail(); } Zend_Mail::setDefaultTransport($transport); } public function sendRegistrationMail($emailAddress, $name, $activationLink, $languageCode) { $translate = Zend_Registry::get('Zend_Translate'); $sitename = Zend_Registry::get('configuration')->general->sitename; $templatePath = Zend_Registry::get('siteRootDir') . '/' . $this->templatesDir . '/registration/' . $languageCode . '.txt'; if (!is_file($templatePath)) { throw new My_Exception('Missing template for registration mail - language code: '.$languageCode); } $templateTxt = file_get_contents($templatePath); //replace tags: [name], [sitename], [activation link] $templateTxt = str_replace('[name]', $name, $templateTxt); $templateTxt = str_replace('[sitename]', $sitename, $templateTxt); $templateTxt = str_replace('[activation_link]', $activationLink, $templateTxt); $mailer = new Zend_Mail('utf-8'); $mailer->addTo($emailAddress, $name); $mailer->setSubject(sprintf($translate->_('Confirm your registeration in %s'), $sitename)); $mailer->setBodyHtml($templateTxt, 'utf8'); $mailer->setFrom(Zend_Registry::get('configuration')->mail->from); $mailer->send(); } }
We introduce new directory here - languages/mailtemplates - there we will save the templates for our email messages, which our application sends. For every mail type (registration email, lost password email and so on) we have different file for each language of the site. The construction of Mailer constructs Zend_Mail_Transport class for sendmail or smtp according to our settings and sets it as a default transport for Zend_Mail. sendMailRegistration loads the according template and replaces some tags into it, then creates instance of Zend_Mail and sends the new email. Nothing complex. Here is the config.ini file:
configuration/config.ini
[main] ;General settings general.sitename=ZFSite general.url=http://example.com ;Database connection settings db.adapter=Mysqli db.params.host=localhost db.params.username=zfsite db.params.password=123456 db.params.dbname=zfsite ;Mail transport settings ;mail.smpt - when false - sendmail is used mail.smtp=true mail.host=127.0.0.1 mail.smtpconfig.name=localhost mail.smtpconfig.port=25 ;mail.smtpconfig.auth = plain | login | crammd5 mail.smtpconfig.auth= mail.smtpconfig.username= mail.smtpconfig.password= mail.from=no-reply@example.com
And the simple code for My_Exception class:
library/My/Exception.php
< ?php require_once 'Zend/Exception.php'; class My_Exception extends Exception {}
Our mailing system supports both smtp and sendmail methods, and when using smtp we have to set also host, port, authentication if any.
Before we see the registerAction method we will see the updated version of getRegistrationForm method of UserController class:
private function getRegistrationForm(Users $users) { $form = new Zend_Form(); $form->setAction('/user/register')->setMethod('post')->setAttrib('id', 'register'); $filterTrim = new Zend_Filter_StringTrim(); $validatorNotEmpty = new Zend_Validate_NotEmpty(); $validatorNotEmpty->setMessage(__('This field is required, you cannot leave it empty')); $username = new Zend_Form_Element_Text('username'); $validatorAlnum = new Zend_Validate_Alnum(); $validatorAlnum->setMessage(__('You can use only latin letters and numbers')); $validatorStringLength = new Zend_Validate_StringLength(3, 32); $validatorStringLength->setMessages(array( Zend_Validate_StringLength::TOO_SHORT => __('Your username have to be between 3 and 32 symbols long'), Zend_Validate_StringLength::TOO_LONG => __('Your username have to be between 3 and 32 symbols long'), ) ); $validatorUniqueUsername = new My_Validate_DbUnique($users, 'username'); $validatorUniqueUsername->setMessage(__('This username is already registered, please choose another one.')); $username->addValidator($validatorNotEmpty, true)->setRequired(true)->setLabel('Username') ->addFilter($filterTrim) ->addValidator($validatorAlnum) ->addValidator($validatorStringLength) ->addValidator($validatorUniqueUsername); $form->addElement($username); /** * @todo Change this wired error messages to something more user friendly, or even use simple email regex matching validator */ $email = new Zend_Form_Element_Text('email'); $validatorHostname = new Zend_Validate_Hostname(); $validatorHostname->setMessages( array( Zend_Validate_Hostname::IP_ADDRESS_NOT_ALLOWED => __("'%value%' appears to be an IP address, but IP addresses are not allowed"), Zend_Validate_Hostname::UNKNOWN_TLD => __("'%value%' appears to be a DNS hostname but cannot match TLD against known list"), Zend_Validate_Hostname::INVALID_DASH => __("'%value%' appears to be a DNS hostname but contains a dash (-) in an invalid position"), Zend_Validate_Hostname::INVALID_HOSTNAME_SCHEMA => __("'%value%' appears to be a DNS hostname but cannot match against hostname schema for TLD '%tld%'"), Zend_Validate_Hostname::UNDECIPHERABLE_TLD => __("'%value%' appears to be a DNS hostname but cannot extract TLD part"), Zend_Validate_Hostname::INVALID_HOSTNAME => __("'%value%' does not match the expected structure for a DNS hostname"), Zend_Validate_Hostname::INVALID_LOCAL_NAME => __("'%value%' does not appear to be a valid local network name"), Zend_Validate_Hostname::LOCAL_NAME_NOT_ALLOWED => __("'%value%' appears to be a local network name but local network names are not allowed") ) ); $validatorEmail = new Zend_Validate_EmailAddress(Zend_Validate_Hostname::ALLOW_DNS, false, $validatorHostname); $validatorEmail->setMessages( array( Zend_Validate_EmailAddress::INVALID => __("'%value%' is not a valid email address"), Zend_Validate_EmailAddress::INVALID_HOSTNAME => __("'%hostname%' is not a valid hostname for email address '%value%'"), Zend_Validate_EmailAddress::INVALID_MX_RECORD => __("'%hostname%' does not appear to have a valid MX record for the email address '%value%'"), Zend_Validate_EmailAddress::DOT_ATOM => __("'%localPart%' not matched against dot-atom format"), Zend_Validate_EmailAddress::QUOTED_STRING => __("'%localPart%' not matched against quoted-string format"), Zend_Validate_EmailAddress::INVALID_LOCAL_PART => __("'%localPart%' is not a valid local part for email address '%value%'") ) ); $validatorUniqueEmail = new My_Validate_DbUnique($users, 'email'); $validatorUniqueEmail->setMessage(__('This email address is already registered, please choose another one.')); $email->addValidator($validatorNotEmpty, true)->setRequired(true)->setLabel('Email Address') ->addFilter($filterTrim) ->addValidator($validatorEmail) ->addValidator($validatorUniqueEmail); $form->addElement($email); $password = new Zend_Form_Element_Password('password'); $password->addValidator($validatorNotEmpty, true)->setRequired(true)->setLabel('Password') ->addValidator(new Zend_Validate_StringLength(3)); $form->addElement($password); $password2 = new Zend_Form_Element_Password('password2'); $validatorPassword = new My_Validate_PasswordConfirmation('password'); $validatorPassword->setMessage(__('Passwords do not match')); $password2->setLabel('Confirm Password')->addValidator($validatorPassword); $form->addElement($password2); $gender = new Zend_Form_Element_Select('gender'); $gender->setLabel('Gender') ->addMultiOption('',' ')->addMultiOption('male',__('male'))->addMultiOption('female',__('female')); $form->addElement($gender); $date = new My_Form_Element_DateSelects('birthday'); $validatorDate = new Zend_Validate_Date(); $validatorDate->setMessages( array( Zend_Validate_Date::NOT_YYYY_MM_DD => __("'%value%' is not of the format YYYY-MM-DD"), Zend_Validate_Date::INVALID => __("'%value%' does not appear to be a valid date"), Zend_Validate_Date::FALSEFORMAT => __("'%value%' does not fit given date format") ) ); $date->setLabel('Birthdate')->addValidator($validatorDate); $date->setShowEmptyValues(true)->setStartEndYear(1900, date("Y")-7)->setReverseYears(true); $form->addElement($date); $realName = new Zend_Form_Element_Text('realname'); $realName->setLabel('Real Name')->addFilter($filterTrim); $form->addElement($realName); $validatorNotEmptyAgreement = new Zend_Validate_NotEmpty(); $validatorNotEmptyAgreement->setMessage(__('You have to accept our terms and conditions before you register')); $agreement = new Zend_Form_Element_Checkbox('agreement'); $agreement->setLabel('I agree to terms and conditions') ->addValidator($validatorNotEmptyAgreement, true)->setRequired(true); $form->addElement($agreement); if (!isset($this->session->passedRegisterCaptcha) || !$this->session->passedRegisterCaptcha) { //if we have set captcha in the session for this request - use it, else generate new one if (isset($this->session->registerCaptcha)) { $captchaCode = $this->session->registerCaptcha; } else { $md5Hash = md5($_SERVER['REQUEST_TIME']); $captchaCode = substr($md5Hash, rand(0, 25), 5); $this->session->registerCaptcha = $captchaCode ; } $captcha = new Zend_Form_Element_Text('captcha'); $validatorIdentical = new Zend_Validate_Identical($captchaCode); $validatorIdentical->setMessage('The text entered is not the same as the shown one, please try again.'); $captcha->setLabel('Enter the text') ->addValidator($validatorIdentical, true) ->addValidator($validatorNotEmpty, true)->setRequired(true); $captchaDecorator = new My_Form_Decorator_Captcha(); $captchaDecorator->setOption('namespace', 'User')->setOption('captchaId', 'registerCaptcha'); $captchaDecorator->setTag('div'); $captcha->addDecorator($captchaDecorator); $form->addElement($captcha); } $submit = new Zend_Form_Element_Submit('register'); $submit->setLabel('Submit'); $form->addElement($submit); return $form; }
now the function takes one argument - $users - instance of class Users. This is needed because of a new validator which we will attach to username and email address fields. This validator will check if the value, which is entered by the user, already exists in the users DB table and if it exists - the validation will fail. The purpose of this is to prevent users to register with dublicate username and email addresses. The validator is My_Validate_DbUnique and here is its listing:
library/My/Validate/DbUnique.php
< ?php require_once 'Zend/Validate/Abstract.php'; class My_Validate_DbUnique extends Zend_Validate_Abstract { const NOT_UNIQUE = 'dbUniqueNotUnique'; protected $_messageTemplates = array( self::NOT_UNIQUE => "'%column%' '%value' already exists" ); /** * @var array */ protected $_messageVariables = array( 'column' => '_column', ); /** * The table where to check for unique value in column * * @var Zend_Db_Table */ protected $_dbTable = NULL; /** * The column name where to check for unique value * * @var string */ protected $_column = ''; /** * The values of the primary key for this row if updating - to exclude the current row from the test * * @var array */ protected $_rowPrimaryKey = NULL; public function __construct(Zend_Db_Table_Abstract $table, $column, $rowPrimaryKey = NULL) { $this->_dbTable = $table; $this->_column = $column; $this->_rowPrimaryKey = $rowPrimaryKey; } public function isValid($value) { $this->_setValue($value); $select = $this->_dbTable->select(); $select->where($this->_dbTable->getAdapter()->quoteInto($this->_column . ' = ?', $value)); if (isset($this->_rowPrimaryKey)) { $rowPrimaryKey = (array) $this->_rowPrimaryKey; $info = $this->_dbTable->info(); foreach ($info['primary'] as $key => $column) { $select->where($this->_dbTable->getAdapter()->quoteInto($column . ' != ?', $rowPrimaryKey[$key - 1])); } } $row = $this->_dbTable->fetchAll($select); if ($row->count()) { $this->_error(); return false; } return true; } }
Its constructor takes 3 arguments - instance of Zend_Db_Table_Abstract - in our case this is instance of Users, column name for which to check the uniqueness of the value and the third argument is used only when updating old row - then the primary key of the current row is given (in array format if the key is from more than one field).
It is important to understand, that having these validators here does not prevent actual insertion in the DB of rows with not unique username for example. Race condition can occur - imagine that 2 users are registering at the same time with same username. They both click on the submit button on same time (I know this is not at all likely situation, but the probability exist and we should prevent ugly error message or not expected application dying) - then if both validators are run for the 2 users before their DB rows are inserted then for the second user process, which tries to insert the DB row - exception will be thrown, so we will just try {} and catch the exception. Now it’s time to see the actual registration form handling code - here is the registerAction in our UserController class:
public function registerAction() { $usersTable = new Users(); $this->view->registerForm = $this->getRegistrationForm($usersTable); if ($this->getRequest()->isPost()) { if ($this->view->registerForm->isValid($_POST)) { $values = $this->view->registerForm->getValues(); $badRegistration = false; try { $newUserId = $usersTable->add($values['username'], $values['password'], $values['email']); } catch (Zend_Db_Statement_Exception $e) { // race condition can occure between validator and actual DB operation // so check if username or email is already registered // NOTE: this should be very rare case, because we check this in validators just before inserting the row $message = $e->getMessage(); if (strpos($message, $values['email']) !== false) { //email already registered $badRegistration = true; $this->view->globalPageError = 'email'; } else if (strpos($message, $values['username']) !== false) { //username already registered $badRegistration = true; $this->view->globalPageError = 'username'; } else { // other error, rethrow the exception throw $e; } } if (!$badRegistration) { $profileFields = array( 'gender' => $values['gender'], 'birthday' => $values['birthday'], 'real_name' => $values['realname'] ); $usersTable->editProfile($newUserId, $profileFields); // delete captcha code from session: $this->session->registerCaptcha = NULL; /* * after successfull submit of the form, we want to prevent double submit with the * same data, because this will lead to error - trying to register again the same * username, so we set in our session 'success flag' and redirect to activate action */ $this->session->userJustRegistered = $values['username']; $this->_redirect($this->_helper->url('activate', 'user'), array('exit'=>true)); } } //we want to know if we have passed the captcha, so won't show it again: if (!isset($this->session->passedRegisterCaptcha) || !$this->session->passedRegisterCaptcha) { $captchaField = $this->view->registerForm->captcha; if ($captchaField->isValid($captchaField->getValue())) { $this->session->passedRegisterCaptcha = true; $this->view->registerForm->removeElement('captcha'); } } } }
After user is registered - we save his username in the session and redirect him to the activate action of the controller. There he should recieve message that the he is registered and to check his email for the registration link. Other things that the activate action should do is to actually activate the users when they click on the link. Here is the code:
public function activateAction() { $parameters = $this->_getAllParams(); if (isset($parameters['userId']) && isset($parameters['activationCode'])) { // activate the user if the activation code is valid. $usersTable = new Users(); $rows = $usersTable->find($parameters['userId']); $user = $rows->current(); if ($user) { if (!$user->active && $user->code == $parameters['activationCode']) { // activate the user $user->active = 1; $user->save(); $this->view->activatedOK = true; } else if ($user->active) { $this->view->userAlreadyActive = true; } } } if (isset($this->session->userJustRegistered)) { $this->view->username = $this->session->userJustRegistered; // use 'user/justRegistered.phtml' template. $this->renderScript('user/justRegistered.phtml'); } }
If the user is not activating, but have just registered - we render different view template than the default one - user/justRegistered.phtml. Here it is:
< ?php echo sprintf($this->translate->_('Welcome to our site. You are now registered with username %s. In order to activate your account, please check the email you provided in the registration process. We have sent you email message with your account information. Please follow the activation link, which you will find in the email message.'), '<strong>'.$this->escape($this->username).'</strong>') ?>
And the template for the actual activation: application/modules/default/views/scripts/user/activate.phtml
< ?php if ($this->activatedOK) { ?> < ?php echo $this->translate->_('Thank you for your registration. Your account is now active.'); ?> < ? } else if ($this->userAlreadyActive) { ?> < ?php echo $this->translate->_('This account is already active.'); ?> < ? } else { ?> < ?php echo $this->translate->_('Bad activation code. If you have followed an email sent by us may be your registration is expired because you have not activated it for several days. You can register again if you wish.'); ?> < ? } ?>
Now the registration and activation should be working. We register some users, activate them and… we need a login form.
private function getLoginForm() { $form = new Zend_Form(); $form->setAction('/user/login')->setMethod('post')->setAttrib('id', 'login'); $username = new Zend_Form_Element_Text('username'); $username->setLabel('Username'); $form->addElement($username); $password = new Zend_Form_Element_Password('password'); $password->setLabel('Password'); $form->addElement($password); $submit = new Zend_Form_Element_Submit('login'); $submit->setLabel('Submit'); $form->addElement($submit); return $form; }
getLoginForm is method of UserController. Here is the loginAction itself:
public function loginAction() { if