API Project - Part 6 - Authenticating Users
Authentication and Securing Passwords
Before starting on this step, read these articles:
Also, hopefully you have read chapter 13 at this point.
Now that you have built a REST API that allows CRUD operations, we need to start exploring how we can keep our database safe from hackers.
Storing user passwords should be done so in a way that they are NOT clearly visible to anyone who has access to the database. We will 'scramble' user passwords by 'salting' and 'hashing' them. This will add quite a bit of complexity to the code that handles creating, updating, and authenticating users. We'll also need to make changes to some of the code that we've already written.
We could create a dataccess class for handling passwords and authenticating users, but it also makes sense to put that functionality into the UserDataAccess class, which is what we'll do.
Add these methods to the UserDataAccess class:
/**
* Generates a random 'salt' string
* @return {string} A random string
*/
function getRandomSalt(){
$bytes = random_bytes(5);
return bin2hex($bytes);
}
/**
* Applies salt to a password (before hasing)
* @param {string} $salt A random salt string
* @param {string} $password The password to be salted
* @return {string} The salted password
*/
function saltPassword($salt, $password){
return $salt . $password . $salt;
}
/**
* Salts and hashes a password
* @param {string} $salt The salt to use
* @param {string} $password The password to salt and hash
* @return {string} The salted, hashed, password
*/
function saltAndHashPassword($salt, $password){
$salted_password = $this->saltPassword($salt, $password);
$encrypted_password = password_hash($salted_password, PASSWORD_DEFAULT);
return $encrypted_password;
}
Of course we should test these methods so that we understand how they work (and if they work)
Add these functions to UserDataAccessTests.php:
function testGetRandomSalt(){
global $testResults, $link;
$testResults[] = "<b>TESTING getRandomSalt()...</b>";
$da = new UserDataAccess($link);
$testResults[] = $da->getRandomSalt();
$testResults[] = $da->getRandomSalt();
$testResults[] = $da->getRandomSalt();
// not sure how we can test this
// unless we just make sure that all the returned values are different
}
function testSaltPassword(){
global $testResults, $link;
$testResults[] = "<b>TESTING saltPassword()...</b>";
$da = new UserDataAccess($link);
$salt = "24f0c77058";
$password = "opensesame";
$actualResult = $da->saltPassword($salt, $password);
$expectedResult = "24f0c77058opensesame24f0c77058";
if($actualResult === $expectedResult){
$testResults[] = "PASS - saltPassword() returned the expected result";
}else{
$testResults[] = "FAIL - saltPassword() DID NOT return the expected result";
}
}
function testSaltAndHashPassword(){
global $testResults, $link;
$testResults[] = "<b>TESTING saltAndHashPassword()...</b>";
$da = new UserDataAccess($link);
$salt = "03603e88f8";
$password = "test";
$saltedHashedPassword = $da->saltAndHashPassword($salt, $password);
//$testResults[] = "PASSWORD: $password";
//$testResults[] = "SALT: $salt";
//$testResults[] = "SALTED AND HASHED: $saltedHashedPassword";
// if you use password_hash() as we are in saltAndHashPasswor()
// then you should be able to use password_verify to see if the salted password will match
// what you get from passing it into password_hash()
if(password_verify($da->saltPassword($salt, $password), $saltedHashedPassword)){
$testResults[] = "PASS - saltAndHashPassword() returned the expected result";
}else{
$testResults[] = "FAIL - saltAndHashPassword() DID NOT return the expected result";
}
}
Don't forget to call these methods near the top of UserDataAccessTests.php (after all the other 'test' functions are being called):
testGetRandomSalt();
testSaltPassword();
testSaltAndHashPassword();
The passwords in both of our databases are not salted and hashed, let's update them (note that the update statement below will set passwords for all users to test). Run this query in the api_dev_db database:
UPDATE users SET user_password = '$2y$10$rUzsEqfrVyyIotlYxnRrlelrKMDakjPSTCRV51a0dxnPc0q8uv4h.', user_salt='03603e88f8';
You'l also need to add it to your create-test-database.php file. Add this just before the call to mysql_multi_query():
// to salt and hash the passwords
$sql .= 'UPDATE users SET user_password = \'$2y$10$rUzsEqfrVyyIotlYxnRrlelrKMDakjPSTCRV51a0dxnPc0q8uv4h.\', user_salt=\'03603e88f8\';';
Note that the changes we just made to the test database (and the changes we will make in the next few steps) will cause some of our tests to fail! When we are done with this step, we should really take the time to fix those tests.
Now add this login() method to the UserDataAccess class (be careful to put it inside the class, not after the class!):
/**
* Authenticates a user
* @param {string} $email
* @param {string} $password
* @return {User} Returns a User model object if authentication is successful
* Returns false otherwise
*/
function login($email, $password){
//REMINDER: the user should be 'active' in order to login
// Prevent SQL injection
$email = mysqli_real_escape_string($this->link, $email);
$password = mysqli_real_escape_string($this->link, $password);
// Select all columns from the user table where user_email = $email AND user_active = "yes"
// Note that we aren't checking the password here, we'll do that next.
$qStr = "SELECT
user_id,
user_first_name,
user_last_name,
user_email,
user_role,
user_role_name,
user_salt,
user_password,
user_active
FROM users U
INNER JOIN user_roles UR on U.user_role = UR.user_role_id
WHERE user_email = '{$email}' AND user_active=true";
//die($qStr);
$result = mysqli_query($this->link, $qStr) or $this->handleError(mysqli_error($this->link));
if($result && $result->num_rows == 1){
$row = mysqli_fetch_assoc($result);
$salted_password = $this->saltPassword($row['user_salt'], $password);
// verify that the salted password matches the user's password in the database:
if(password_verify($salted_password, $row['user_password'])){
$user = $this->convertRowToModel($row);
// WE PROBABLY SHOULD REMOVE THE SALT PROPERTY FROM THE USER MODEL!!! NO NEED TO SHARE IT OUTSIDE OF THE DB!!!
return $user;
}
}
return false;
}
Add this test function to UserDataAccessTests.php:
function testLogin(){
global $testResults, $link;
$testResults[] = "<b>TESTING login()...</b>";
$da = new UserDataAccess($link);
// Valid login test
$email = "jane@doe.com";
$password = "test";
$user = $da->login($email, $password);
//var_dump($user);
if($user !== false){
$testResults[] = "PASS - login() authenticated $email";
}else{
$testResults[] = "FAIL - login() DID NOT authenticate $email";
}
// INvalid login test
$email = "jane@doe.comxxxxxxxxxxxxxxxxxxxxxxxx";
$password = "test";
$user = $da->login($email, $password);
//var_dump($user);
if($user === false){
$testResults[] = "PASS - login() DID NOT authenticated $email";
}else{
$testResults[] = "FAIL - login() authenticate $email";
}
}
Make sure to call the testLogin() function at the top of UserDataAccessTests.php
We'll make one more change to make the API more secure. We should never share passwords with the outside world, even if they are salted and hashed. So we'll make a quick change to convertRowToModel() in the UserDataAccess class. Update the lines that set the set the password and salt properties of the user object:
$u->password = "";// never send passwords
$u->salt = ""; // never send salt
As noted earlier, some of the changes we have made will cause some tests to fail. We should fix them. To do this, look for all the expectedResults that include 'password' and 'salt' and set them to empty strings.
Currently the insert() method in the UserDataAccess class is not hashing the password when a new user is created.
To fix this, add this code just after the call to convertModelToRow():
// salt and hash the password
$row['user_salt'] = $this->getRandomSalt();
$row['user_password'] = $this->saltAndHashPassword($row['user_salt'], $row['user_password']);
Now reload the UserDataAccessTest page and then have a look in phpMyAdmin (the api_test_db database) to verify that the new user's password is hashed.
The update() method is a little more complicated. Remember that we are now sending an empty string for the password when we send users in response to a GET request. So for updates, if the password is an empty string, we know that it did not change. In this case we must make sure not to change the salt and password for the user in the database.
On the other hand, if the password sent in the PUT request is not an empty string, then we can assume that it has been changed. In this case we need to salt and hash it before updateing the row in the database.
Replace the update() method in the UserDataAccess class to look like this:
function update($user){
$row = $this->convertModelToRow($user);
$qStr;
if(!empty($user->password)){
// If the password is NOT empty, then we'll salt and hash it
$salt = $this->getRandomSalt();
$hashedPassword = $this->saltAndHashPassword($salt, $row['user_password']);
$qStr = "UPDATE users SET
user_first_name = '{$row['user_first_name']}',
user_last_name = '{$row['user_last_name']}',
user_email = '{$row['user_email']}',
user_role = '{$row['user_role']}',
user_password = '$hashedPassword',
user_salt = '$salt',
user_active = '{$row['user_active']}'
WHERE user_id = " . $row['user_id'];
}else{
// If the password is emtpy we just won't include the password
// and the salt in the update query, which will leave them as they are in the database
$qStr = "UPDATE users SET
user_first_name = '{$row['user_first_name']}',
user_last_name = '{$row['user_last_name']}',
user_email = '{$row['user_email']}',
user_role = '{$row['user_role']}',
user_active = '{$row['user_active']}'
WHERE user_id = " . $row['user_id'];
}
//die($qStr);
$result = mysqli_query($this->link, $qStr) or $this->handleError(mysqli_error($this->link));
// Remember we discovered a bug, that when you run an update
// statement for a user that doesn't exist, the $result will be true
if($result){
return true;
}else{
$this->handleError("unable to update user");
}
return false;
}
This one is a little tricky to test, but we should fiddle with the test to see if we can confirm it's working In the testUpdate() method, change the password in the $options array to an empty string and then reload the page.
Then uncomment the die($qStr); line in the update() method and confirm that the SQL query looks correct.
Now, in the testUpdate() method, set the password to something other than an empty string and reload the test page to look at the update statement in the $qStr variable.
Make sure comment to out the die($qStr) line once you've confirmed that it's working.