What do we lack in our current scheme? Obviously, before saving a user record in the database, we need to compute the secure hash of the password provided and store the hashed value instead of the plaintext one. More than that, when updating the user record, we don't want to rehash the already hashed value in case we don't change the password at all.
Yii 2 (as well its predecessor, Yii 1.1.x) defines several methods for the instances of the ActiveRecord
class, which we can override to do stuff at several predefined stages of the active record life. While you can read about them all in more detail in the Events of yiidbBaseActiveRecord section of Chapter 10, Events and Behaviors, we will be using one of them right now, the beforeSave()
method. We will use it right before the active record is to be saved to the database.
It has the following default definition:
public function beforeSave($insert) { return true; }
The $insert
argument passed to this method indicates whether the active record in question is a new one that is to be inserted into the database or if it's an already existing one that is to be updated into the database.
This method must return a Boolean value indicating whether this record is allowed to be saved. If not, it will not be saved, without any additional indication to the user or program. It's really important to remember this fact. If you don't return anything from this method, then a null
will be implicitly returned, which is equal to false
and you will effectively prohibit the relevant operation completely. We will be overriding this method in the UserRecord
class for our purposes.
Additionally, Yii 2 has the special yiiaseSecurity
application component that holds, among others, the helper methods for securely computing hashes for passwords. This component is accessible through the Yii::$app->security
invocation. This is of great help to us, as we become relieved from the task of manually fiddling with the specifics of PHP's crypt()
method. We are particularly interested in the Security::generatePasswordHash()
and Security::validatePassword()
methods.
How do we check that a password is stored in the hashed form in the database? After saving a user record, we will simply check that a call to the Security::validatePassword()
method returns true
for a given plaintext and hash.
This feature doesn't need to be tested by the end-to-end acceptance test. We will be using the functional testing suite for it, which is already included by the Codeception framework for us since Chapter 2, Making a Custom Application with Yii 2, but is unused until now.
For functional (or, in different words, integration) testing in Codeception, a special module called Db
exists, documentation for which can be found at http://codeception.com/docs/modules/Db. Its most important feature for us is the fact that before each test run it reverts the database to the state described in the tests/_data/dump.sql
file, which does not exist yet. This SQL file should contain instructions about how to create the database schema. With the default toolset of MySQL 5 it can be generated as follows, given that your database doesn't require credentials and is named crmapp
:
$ mysqldump -d crmapp > tests/_data/dump.sql
The -d
flag means no data, only schema .
We need to configure the functional test suite in the tests/functional.suite.yml
file like this (lines to be inserted are highlighted):
class_name: FunctionalTester modules: enabled: [Db, Filesystem, FunctionalHelper] config: Db: dsn: 'mysql:host=localhost;dbname=crmapp' user: 'root' password: 'mysqlroot' dump: tests/_data/dump.sql
Of course, you need to insert your own hostname, database name, username, and password for a database on the deploy target. You also need to rebuild the Codeception suite using the ./cept build
invocation.
So, generate the new functional test, as we will be testing the database:
$ ./cept generate:test functional PasswordHashing
Here's how we encode this test in the generated tests/functional/PasswordHashingTest.php
file:
/** @test */ public function PasswordIsHashedWhenSavingUser() { $user = $this->imagineUserRecord(); $plaintext_password = $user->password; //1 $user->save(); // Don't care about mutated model now, just fetch new one. $saved_user = UserRecord::findOne($user->id); //2 $security = new yiiaseSecurity(); $this->assertInstanceOf(get_class($user), $saved_user); $this->assertTrue( $security->validatePassword( // 3 $plaintext_password, $saved_user->password ) ); }
The entire idea is expressed in highlighted lines of the preceding code:
password
field still holds the plaintext value, which we save for future reference.password
field of the UserRecord
class fetched from the database.We don't really care here how exactly the password is hashed. All we want to know is whether validatePassword()
will return true
for the given plaintext and hash value.
We are imagining the user record the same way as in our acceptance tests for user management:
private function imagineUserRecord() { $faker = FakerFactory::create(); $user = new UserRecord(); $user->username = $faker->word; $user->password = md5(time()); return $user; }
This is obviously a case of code duplication here, and it should be refactored too.
This test will not run yet, though. If you run it right now, it will say that it cannot find the UserRecord model. This is because you don't have the appropriate autoloaders prepared in the Codeception harness. Fortunately, this harness includes special bootstrap
scripts, which are executed before all of the tests in the given suite. So, we will utilize the tests/functional/_bootstrap.php
file and put the require
calls to our autoloaders in there:
require_once(__DIR__ . '/../../vendor/autoload.php'),
require_once(__DIR__ . '/../../vendor/yiisoft/yii2/Yii.php'), new yiiwebApplication( require(__DIR__ . '/../../config/web.php') );
Unfortunately, we need to create the full application for Yii autoloader to be initialized. We need it to get to our UserRecord
class.
After the bootstrap file is in place, configuration is appropriately changed and the Codeception harness is rebuilt, the password hashing test should finally fail with the message Hash is invalid
. It's time to actually implement password hashing.
To really implement this feature, you need the now-obvious method inside the UserRecord
class:
public function beforeSave($insert)
{
$return = parent::beforeSave($insert);
$this->password = Yii::$app->security->generatePasswordHash($this->password);
return $return;
}
Well, turns out, it's not so obvious really. The highlighted part in the preceding code is the line we need. Lines before and after this line are framework-related code to preserve the original behavior of the beforeSave()
method on the all the ActiveRecord
instances. To be short, parent implementation of beforeSave()
uses the concept of Yii 2 called Event. It triggers the particular event telling to whoever is listening in the ActiveRecord
component that this record is being saved now. The point is that listeners can deny the event. You can read more about events in Chapter 10, Events and Behaviors, but here we don't really have any use of this system at all.
Note that in our functional test, we generate the Security
class instance manually. But in production code, we reuse the application component. In tests, it was done so because it's generally not acceptable that tests are dependent on some global state, which Yii singleton clearly is. In production code, it was done so because it's simply irrational to not use what is already here. However, if for some reason you have some custom-crafted component in your application named security
, you will end up changing tests.
We now have a problem that was already mentioned before: when we save the UserRecord
instance again, the password will be hashed again. Now, we have no way to know how this user will login, because he now needs to enter the hash of his original password in the password
field in login form!
The problem is expressed in the following test:
/** @test */ public function PasswordIsNotRehashedAfterUpdatingWithoutChangingPassword() { $user = $this->imagineUserRecord(); $user->save(); /** @var UserRecord $saved_user */ $saved_user = UserRecord::findOne($user->id); $expected_hash = $saved_user->password; $saved_user->username = md5(time()); $saved_user->save(); /** @var UserRecord $updated_user */ $updated_user = UserRecord::findOne($saved_user->id); $this->assertEquals($expected_hash, $saved_user->password); $this->assertEquals($expected_hash, $updated_user->password); }
The difference between $saved_user->password
and $updated_user->password
in the assertions (at the end of the test) is that $saved_user
at that point in time is the UserRecord
instance modified in the memory, and $updated_user
at that point in time is the UserRecord
instance newly-fetched from the updated record in the database. We need to be sure we don't have mangled data in both cases.
Fortunately for us, Yii 2 has a functionality integrated into its active records, which allows us to check whether any field was changed. You can choose among the following options:
The idea is that only values of "dirty" (changed) attributes are saved into the database when you call the save()
method. Obviously, when you create a new record, all attributes are "dirty," so all of them will be saved to the database.
The method named isAttributeChanged()
is exactly what we need. Just adding one line to the beforeSave()
handler as follows makes our test pass:
public function beforeSave($insert)
{
...
if ($this->isAttributeChanged('password'))
$this->password = Security::generatePasswordHash($this->password);
...
}
So, we don't unnecessarily rehash the password now. The backend of user management is now complete.
3.137.176.166