Creating a community in five minutes with CakePHP
Posted on May 1st, 2009 in Programming | 17 Comments »
CakePHP’s automatic hashing makes things a lot harder than they need to be, and simple tasks (e.g. a registration page) become annoyingly difficult.
Here, we build a complete community based website in five minutes using Cake best practices, with the following features: account registration, login and logout, account management page, and password retrieval.
We will take the following steps:
- Bake a new Cake website
- Build an account registration page
- Create a login page, and optionally add a
lastloginfield - Add change password functionality to our account page
- Finish with the password retrieval feature
At any time you can download the source code used in this tutorial. Let’s get started!
Step 1. Bake a new Cake website
I’m going to assume that you have a working cake shell installation, so change to your DocumentRoot and execute:
$ cake bake community
This will create a community folder with all the necessary files for your new website. If you visit http://example.com/community, you should see the “all green” indicating you have a working cake installation.
Next, we’ll create the users table by executing the following SQL:
CREATE TABLE `users` ( `id` int(11) NOT NULL auto_increment, `created` datetime, `modified` datetime, `lastlogin` datetime, `name` varchar(100), `email` varchar(200), `username` varchar(50), `password` varchar(42), PRIMARY KEY (`id`), UNIQUE KEY `email` (`email`) )
That’s it for the setting up … Let’s move on.
Step 2. Build an account registration page
We’ll get straight into it by creating the users controller, model and the register view.
Because of CakePHP’s automatic hashing, we have to do all our validation work on the password_confirm field, as the password field contains only the hash of the users password.
models/user.php:
class User extends AppModel
{
/**
* Standard validation behaviour
*/
var $validate = array(
'name' => array(
'length' => array(
'rule' => array('minLength', 5),
'message' => 'Please enter your full name (more than 5 chars)',
'required' => true,
),
),
'username' => array(
'length' => array(
'rule' => array('minLength', 5),
'message' => 'Must be more than 5 characters',
'required' => true,
),
'alphanum' => array(
'rule' => 'alphanumeric',
'message' => 'May only contain letters and numbers',
),
'unique' => array(
'rule' => 'isUnique',
'message' => 'Already taken',
),
),
'email' => array(
'email' => array(
'rule' => 'email',
'message' => 'Must be a valid email address',
),
'unique' => array(
'rule' => 'isUnique',
'message' => 'Already taken',
),
),
'password' => array(
'empty' => array(
'rule' => 'notEmpty',
'message' => 'Must not be blank',
'required' => true,
),
),
'password_confirm' => array(
'compare' => array(
'rule' => array('password_match', 'password', true),
'message' => 'The password you entered does not match',
'required' => true,
),
'length' => array(
'rule' => array('between', 6, 20),
'message' => 'Use between 6 and 20 characters',
),
'empty' => array(
'rule' => 'notEmpty',
'message' => 'Must not be blank',
),
),
);
/**
* Ensure two password fields match
*
* @param array data provided by the controller
* @param string the original (already hashed) password fieldname
* @param bool whether the password field has been hashed,
* only hashed when a username input is present
*/
function password_match($data, $password_field, $hashed = true)
{
$password = $this->data[$this->alias][$password_field];
$keys = array_keys($data);
$password_confirm = $hashed ?
Security::hash($data[$keys[0]], null, true) :
$data[$keys[0]];
return $password === $password_confirm;
}
}
Next we create our view at views/users/register.ctp. Because we’ve done all our validation on the password_confirm field, the error messages (password too short, etc.) will appear under password_confirm. This isn’t ideal from the user interface perspective; luckily we can just swap the input labels and noone is the wiser.
views/users/register.ctp:
<?php
echo $form->create(array('action' => 'register'));
echo $form->input('name');
echo $form->input('email');
echo $form->input('username');
echo $form->input('password_confirm', array('label' => 'Password', 'type' => 'password'));
echo $form->input('password', array('label' => 'Password Confirm'));
echo $form->end('Register');
?>
Finally we create our users controller at controllers/users_controller.php. We define a beforeFilter method to make our registration page publicly accessible, our register action, and a blank login action.
controllers/users_controller.php:
class UsersController extends AppController
{
var $components = array('Auth');
/**
* Runs automatically before the controller action is called
*/
function beforeFilter()
{
$this->Auth->allow('register');
parent::beforeFilter();
}
/**
* Registration page for new users
*/
function register()
{
if (!empty($this->data)) {
$this->User->create();
if ($this->User->save($this->data)) {
$this->Session->setFlash(__('Your account has been created', true));
$this->redirect('/');
} else {
$this->Session->setFlash(__('Your account could not be created. Please, try again.', true));
}
}
}
/**
* Account login page
*/
function login()
{
}
/**
* Log a user out
*/
function logout()
{
return $this->redirect($this->Auth->logout());
}
}
The next issue with Auth arises when the user has entered a valid password, but has failed to validate the entire form – for example, if they picked a username that is in use and must be re-entered. As the password has already been replaced by the hashed password, the form will be redisplayed with the hashed password in the password input. If the user attempts to resubmit the form without retyping their password, form validation will fail on the password_confirm field. Even worse, if you’re not using a password_confirm field, the user will simply be unable to log in to their newly created account.
Thus, we need some hackery to make sure encrypted passwords are not sent back to the user. We can do this in AppController, by writing a beforeRender method.
app_controller.php:
class AppController extends Controller
{
/**
* Before Render
*/
function beforeRender()
{
unset($this->data['User']['password']);
unset($this->data['User']['password_confirm']);
}
}
And that’s it, we have a complete working registration page. We can view this completed page by visiting /users/register and create our first account.
Step 3. Create a login page
Next up is our login page. We need to modify the default layout which lives at views/layouts/default.ctp. We replace the content div which allows Auth to display messages to the user.
views/layouts/default.ctp:
<div id="content">
<?php $session->flash(); ?>
<?php $session->flash('auth'); ?>
<?php echo $content_for_layout; ?>
</div>
Next we create a very simple login page:
views/users/login.ctp:
<?php
echo $form->create(array('action' => 'login'));
echo $form->input('username');
echo $form->input('password');
echo $form->end('Login');
?>
And that’s it – it doesn’t get much simpler. We can view our new login page at /users/login.
If we wanted to track the time the user logged in last, we can do some extra work:
Adding to the AppController, we set Auth->autoRedirect:
app_controller.php:
class AppController extends Controller
{
...
/**
* Before Filter
*/
function beforeFilter()
{
$this->Auth->autoRedirect = false;
}
}
Then we modify our login controller to do the necessary work:
controllers/users_controller.php:
class UsersController extends AppController
{
...
/**
* Ran directly after the Auth component has executed
*/
function login()
{
// Check for a successful login
if (!empty($this->data) && $id = $this->Auth->user('id')) {
// Set the lastlogin time
$this->User->id = $id;
$this->User->saveField('lastlogin', date('Y-m-d H:i:s'));
// Redirect the user
$url = array('controller' => 'users', 'action' => 'account');
if ($this->Session->check('Auth.redirect')) {
$url = $this->Session->read('Auth.redirect');
}
$this->redirect($url);
}
}
}
Not a lot of additional work, and it will come in handy if you need to prune inactive accounts. Note we can’t this part of the step yet, as it’s expecting an /users/account page to exist (which we create next).
Step 4. Add change password functionality
Cake’s form validation functionality is somewhat limited, in that it only allows you to define validation rules at the model level. Often, you will want to validate data at a per-form level instead. To achieve this, we can dynamically modify the validation rules before form validation takes place. We dive into the user model again:
Note: If we simply added the password_old rule to our validation behaviour, other forms that didn’t include password_old would not validate (because we have set required = true). If we removed required = true, a POST request can easily be forged bypassing this security check.
models/user.php:
class User extends AppModel
{
/**
* Extra form dependent validation rules
*/
var $validateChangePassword = array(
'_import' => array('password', 'password_confirm'),
'password_old' => array(
'correct' => array(
'rule' => 'password_old',
'message' => 'Does not match',
'required' => true,
),
'empty' => array(
'rule' => 'notEmpty',
'message' => 'Must not be blank',
),
),
);
/**
* Dynamically adjust our validation behaviour
*
* Look for an _import key in new ruleset, and import
* those rules from the base validation ruleset
*
* @param string array key of the validation ruleset to use
*/
function useValidationRules($key)
{
$variable = 'validate' . $key;
$rules = $this->$variable;
if (isset($rules['_import'])) {
foreach ($rules['_import'] as $key) {
$rules[$key] = $this->validate[$key];
}
unset($rules['_import']);
}
$this->validate = $rules;
}
/**
* Ensure password matches the user session
*
* @param array data provided by the controller
*/
function password_old($data)
{
$password = $this->field('password',
array('User.id' => $this->id));
return $password ===
Security::hash($data['password_old'], null, true);
}
...
}
Our change password logic now becomes very simple. We validate, then update the password and redirect the user.
Note: In this case Cake decides not to automatically hash the users password field, so we must perform the hashing manually. This happens because Cake will only hash the password if both the username and password key are set.
controllers/users_controller.php:
class UsersController extends AppController
{
/**
* Account details page (change password)
*/
function account()
{
// Set User's ID in model which is needed for validation
$this->User->id = $this->Auth->user('id');
// Load the user (avoid populating $this->data)
$current_user = $this->User->findById($this->User->id);
$this->set('current_user', $current_user);
$this->User->useValidationRules('ChangePassword');
$this->User->validate['password_confirm']['compare']['rule'] =
array('password_match', 'password', false);
$this->User->set($this->data);
if (!empty($this->data) && $this->User->validates()) {
$password = $this->Auth->password($this->data['User']['password']);
$this->User->saveField('password', $password);
$this->Session->setFlash('Your password has been updated');
$this->redirect(array('action' => 'account'));
}
}
...
}
We create a template for the account page as follows:
views/users/account.ctp:
<h2>Account Page</h2>
<h3>Change your password</h3>
<p>You are <?php echo $current_user['User']['name']; ?> who last logged in <?php echo $current_user['User']['lastlogin']; ?>.</p>
<?php
echo $form->create(array('action' => 'account'));
echo $form->input('password_old', array('label' => 'Old password', 'type' => 'password', 'autocomplete' => 'off'));
echo $form->input('password_confirm', array('label' => 'New password', 'type' => 'password', 'autocomplete' => 'off'));
echo $form->input('password', array('label' => 'Re-enter new password', 'type' => 'password', 'autocomplete' => 'off'));
echo $form->end('Update Password');
?>
The user is now able to change their password. We can test this by logging in at /users/login which will take us to our new account page.
Step 5. Create a password retrieval page
In our final step, we allow the user to retrieve a forgotten password. We do this by emailing the user a token. The user clicks back to our website with the token, and gets emailed a replacement password.
We create a models/token.php to handle the token generation, information storage and retrieval.
models/token.php:
<?php
class Token extends AppModel
{
/**
* Create a new ticket by providing the data to be stored in the ticket.
*/
function generate($data = null)
{
$data = array(
'token' => substr(md5(uniqid(rand(), 1)), 0, 10),
'data' => serialize($data),
);
if ($this->save($data)) {
return $data['token'];
}
return false;
}
/**
* Return the value stored or false if the ticket can not be found.
*/
function get($token)
{
$this->garbage();
$token = $this->findByToken($token);
if ($token) {
$this->del($token['Token']['id']);
return unserialize($token['Token']['data']);
}
return false;
}
/**
* Remove old tickets
*/
function garbage()
{
return $this->deleteAll(array('created < INTERVAL -1 DAY + NOW()'));
}
}
We’ll also create the necessary database table.
CREATE TABLE `tokens` ( `id` int(11) unsigned NOT NULL auto_increment, `created` datetime default NULL, `modified` datetime default NULL, `token` varchar(32) default NULL, `data` text, PRIMARY KEY (`id`), UNIQUE KEY `token` (`token`) )
We next update the users controller to make the recover and verify actions public, then we define our recover and verify actions:
controllers/users_controller.php:
<?php
class UsersController extends AppController
{
var $components = array('Auth', 'Email');
...
/**
* Runs automatically before the controller action is called
*/
function beforeFilter()
{
$this->Auth->allow('register', 'recover', 'verify');
parent::beforeFilter();
}
/**
* Allows the user to email themselves a password redemption token
*/
function recover()
{
if ($this->Auth->user()) {
$this->redirect(array('controller' => 'users', 'action' => 'account'));
}
if (!empty($this->data['User']['email'])) {
$Token = ClassRegistry::init('Token');
$user = $this->User->findByEmail($this->data['User']['email']);
if ($user === false) {
$this->Session->setFlash('No matching user found');
return false;
}
$token = $Token->generate(array('User' => $user['User']));
$this->Session->setFlash('An email has been sent to your account, please follow the instructions in this email.');
$this->Email->to = $user['User']['email'];
$this->Email->subject = 'Password Recovery';
$this->Email->from = 'Support <support@example.com>';
$this->Email->template = 'recover';
$this->set('user', $user);
$this->set('token', $token);
$this->Email->send();
}
}
/**
* Accepts a valid token and resets the users password
*/
function verify($token = null)
{
if ($this->Auth->user()) {
$this->redirect(array('controller' => 'users', 'action' => 'account'));
}
$Token = ClassRegistry::init('Token');
if ($data = $Token->get($token)) {
// Update the users password
$password = $this->User->generatePassword();
$this->User->id = $data['User']['id'];
$this->User->saveField('password', $this->Auth->password($password));
$this->set('success', true);
// Send email with new password
$this->Email->to = $data['User']['email'];
$this->Email->subject = 'Password Changed';
$this->Email->from = 'Support <support@example.com>';
$this->Email->template = 'verify';
$this->set('user', $data);
$this->set('password', $password);
$this->Email->send();
}
}
}
Because we are generating new passwords for users, we need a function to do this. Alternative approaches, like PEAR’s Text/Password can be substituted if you like.
models/user.php:
class User extends AppModel
{
....
/**
* Generate a random pronounceable password
*/
function generatePassword($length = 10) {
// Seed
srand((double) microtime()*1000000);
$vowels = array('a', 'e', 'i', 'o', 'u');
$cons = array('b', 'c', 'd', 'g', 'h', 'j', 'k', 'l', 'm', 'n',
'p', 'r', 's', 't', 'u', 'v', 'w', 'tr',
'cr', 'br', 'fr', 'th', 'dr', 'ch', 'ph',
'wr', 'st', 'sp', 'sw', 'pr', 'sl', 'cl');
$num_vowels = count($vowels);
$num_cons = count($cons);
$password = '';
for ($i = 0; $i < $length; $i++){
$password .= $cons[rand(0, $num_cons - 1)] . $vowels[rand(0, $num_vowels - 1)];
}
return substr($password, 0, $length);
}
We define two email templates for recovery and verification respectively:
views/elements/email/text/recover.ctp:
Dear <?php echo $user['User']['name']; ?>,
Someone is attempting to reset your password.
Your username for this account is: <?php echo $user['User']['username']; ?>
If you wish to continue, you may reset your password by
following this link:
<?php echo Router::url(array('controller' => 'users', 'action' => 'verify', $token), true); ?>
If you did not initiate this action, please contact
support. You can log in to change your password
at this address:
<?php echo Router::url(array('controller' => 'users', 'action' => 'login'), true); ?>
Thanks,
Support
views/elements/email/text/verify.ctp:
Dear <?php echo $user['User']['name']; ?>,
Your password has been reset, please use the following
details to log into our site.
Username: <?php echo $user['User']['username']; ?>
Password: <?php echo $password; ?>
Please change your password to something more memorable.
You can log in to change your password at this address:
<?php echo Router::url(array('controller' => 'users', 'action' => 'login'), true); ?>
Thanks,
Support
And finally we define our two templates for the recovery and verification views:
views/users/recover.ctp:
<h2>Recover Password</h2>
<?php
echo $form->create('User', array('action' => 'recover'));
echo $form->input('email');
echo $form->end('Recover');
?>
views/users/verify.ctp:
<h2>Recover Password</h2>
<?php if (isset($success)): ?>
<div class="message">Access verified. Your new password has been emailed to you.</div>
<p>A new password has been generated for your account and mailed to you. After you've logged in, you should change your password to something memorable via the account information page.</p>
<?php else: ?>
<div class="warning">Invalid token. This page has expired, or the link was not copied from your email client correctly.</div>
<p>Make sure you have copied the entire link correctly, pasting it together if the link was split over two lines. If you're copying the link correctly and still can't get access, please contact us.</p>
<?php endif; ?>
And that’s it … we’ve developed a complete community orientated website for CakePHP in less time it takes than to make a cup of tea (if you’re very slow at making tea).
On a side note: you may have noticed that CakePHP’s automatic hashing makes things a lot harder than they should be. In my opinion, automatic hashing directly goes against Cake’s mantra of “If it’s easy to do in Cake, then it doesn’t belong in core”, and I hope the core development team rethink in the idea in Cake 1.3.x.x. Although forcing developers to automatically hash their users password is a nice idea, the current implementation a major stumbling block for new bakers. We shouldn’t need a section in the manual about common problems the user will encounter trying to do simple tasks. Instead, we should fix the cause of the common problems. It is, however, a testament to Cake’s flexibility that we are able to work around most of these issues quite elegantly. It’s also worth noting that there are several ways to override this behavior.
I hope you’ve enjoyed this tutorial, please feel free to leave comments, suggestions or improvements.
17 Responses
Hi Aiden,
Nice work! There is an issue with multiple accounts per email address. I guess you need to either:
Limit it to one account per email.
Add the ability to reset the passwords for all the accounts for that email address.
[Editor's update: That's a good pick up, I've altered the code to add a unique check in. Thanks.]
FYI for the password change, I’m getting:
Notice (8): Undefined index: password_confirm [APP/models/user.php, line 89]
Any ideas?
[Editor's update: This is due to Cake's automatic hashing again. It took me several hours to work out what was going on, and I've updated the tutorial. Thanks.]
Nice work Aidan!
Hi Aidan, thanks for the tutorial!!
But I’m having a problem… the verify function it’s not saving the password hashed in the DB. Therefore, when a user tries to login with the provided password the login form cannot validate correctly.
Could you please help me with this?
[Editor's update: The user supplied a fix, the password was not hashed (foiled by Cake's automatic hashing again). I've updated the tutorial, thanks.]
Hi Aidan, excellent work, thank you.
Best,
Mario
Hey,
Thanks for the info, i’m revisiting cake after a 3 year absence developing in rails, glad to see it’s matured and the user community (ie: you) is much stronger than it once was.
[...] Creating a community in five minutes with CakePHP [...]
Very helpful. I was having much difficulty defining a validation rule to check the current password when changing the password. This helped me alot.
Hi,
Excellent work, thanks.
New to cakephp, and I have just one problem (FYI, I installed from the downloadable source) and the “Old password” in the account page always shows “Does not match”.
I suspect this is something to do with the hashing, but could you please let me know what I can do to correct it? Any debugging pointers will be helpful too.
Excellent I am a newbie in Cakephp, and it helps me alot to learn the basics.
I’m afraid I’m completely lost on this one! This is an excellent tutorial, but I haven’t been able to make it work, even by downloading the source.
When I run http://localhost/users/recover, it goes to the “account” function and displays the view to change the password. I cannot get it to present a recovery page.
I downloaded the source for the tutorial, mainly because I was worried I may have made some undetectable typos. The downloaded source causes a failure in the webroot/index.php for an invalid CAKE_CORE_INCLUDE_PATH constant. Fortunately, I had saved the original webroot and config folders and the failure is corrected. I’m using CakePHP 1.2.
I would very much appreciate any assistance to help me understand what I’m doing wrong.
Thank you.
Hi Aiden,
Great tutorial.
Just one little thing: In step 3 – Create a Login Page, you add this line of code to the users_controller to update the lastlogin field when a user logs in:
$this->User->saveField(‘lastlogin’, date(‘Y-m-d H:i:s’));
Problem with this is that it will also update the modified field.
To prevent the modified field from being updated, you need to use $this->User->save() instead, like this:
$this->User->save(array(‘lastlogin’ => date(‘Y-m-d H:i:s’), ‘modified’ => false), false, array(‘lastlogin’));
Setting modified to false in the$data array – first element in the save() call – ensures the modified field won’t be updated.
Hi, first off, thank you for the tutorial – creating the ability to let people register and log into a site has always been a pain for me, but this tutorial makes it easy.
I think you might have an error in your code however as I was unable to get the password recovery function to work. I think I fixed it, but would like some feedback to make sure I’ve done it correctly.
In the /users_controller.php file, within the verify() method, you had this:
if ($data == $Token->get($token))
But this was just producing an error stating that the token value must be incorrect, in addition to a php notice saying that the variable $data didn’t exist.
I fixed this by replacing the line above with this:
$data = $Token->get($token);
if ($data)
And it is now working as intended. Does this sound about right to you?
(PS, I’m also using Cake 1.3, so I also had to replace the $this->del line in token.php with $this->delete to make it compatable)
PS, @Adriaan – thank you for the tip, that works great
Sorry, one last thing – all this tutorial needs to make it perfect is to include the ability for users to verify their email address before they can log in. I might try and build it myself and share the results here
hi can you please tell me where to fix this problem
Notice (8): Undefined index: password_confirm
thanks and regardss
Is there anything else that need to be changed to make this compatible with 1,3 ? I cant seem to get the lastlogin or redirect to the account page to work.