CodeIgniter + DataMapper
Improvements on what I posted last time
This posting, expands on my posting last week (DataMapper & CodeIgniter for writing access controls) to provide some enhancements and good practices in security. I left it out of that posting, as it was becoming overly lengthy.
The Login Process
The Site controller's login method, as really implemented, is as follows. It adds one additional line: purging the session token before attempting the login check.
public function login() {
// clear any existing credentials
$this->session->unset_userdata('userid');
// if a user/pass were submitted, attempt login using the model
$user = User::attemptLogin(@$_POST['username'],@$_POST['password']);
if ($user) {
$this->session->set_userdata('userid', $user->id);
return redirect(site_url('management/'));
}
// got here: either they didn't submit, or it didn't work out. either way, bail to a form
return $this->load->view('site/login.phtml');
}
I consider this a Good Practice during a login phase. By clearing the session token, we have effectively logged them out (recall that the Management controller checks for the session token, then uses it to fetch their User object) and prevented any possible, hypothetical shenanigans that could result from the rest of the login process screwing up.
Consider, if the programmer had left out that line. A failed login would spit out the login.phtml but their old login session would still be intact. The result would be a Log In page, possibly decorated with elements indicating that they are still logged in. It's a small bug, but would look goofy -- and it may result in some information leakage if you're not careful, since you probably weren't expecting that page to be accessed with an existing session. It's possible, too, that one could contrive less-trivial bug scenarios based on your old session information being present during an attempted login.
Point is, I consider it a good thing to empty out the login session before attempting to repopulate it. Whether this is done before the attemptLogin() call, or in an else statement immediately afterward, doesn't matter as much as ensuring that they are either "logged in as intended" or else "logged out" with no wiggle room for an unknown state.
Password Storage
In the previous article, the attemptLogin() method checked for a literal username and password match:
$u = new User();
$u->where('username',$username)->where('password',$password)->get();
return $u->id ? true : false;
In the real world, of course, you would never store raw passwords in the database, right? (hint: the correct answer is "Never! And I can't believe that you hinted at doing so last week!") The proper way to store passwords is with a non-reversible hash, using a salt.
Okay, lemme back up a little and explain.
- The basic problem of storing passwords, is that someone can do a SELECT and get every password in the system. From there, they don't need additional security holes, hacks, and injected files to wreak havoc: they can login as authorized personnel. Or, your own messup during programming may display the whole list of passwords long enough for someone to snap a photo.
- What we need is a zero-knowledge proof: The server doesn't have your password, but it does have a way of knowing that whatever you supplied as the password is correct.
- A non-reversible hash, also called a message digest, is a technique for scrambling text. The two most common "hashing algorithms" are MD5 and SHA-1. A scramble (called a hash) would be unique based on the input: the SHA-1 hash of the word "apple" will always be d0be2dc421be4fcd0172e5afceea3970e2f3d940, but it will take a billion years to discover a second string with the same hash.
- At its very simplest, the users table's password field could store the MD5 hash, and then compare the MD5 of whatever you typed to the stored MD5. Voila: no stored passwords, but a watertight way to know that you supplied the correct password.
To "salt" a password means that we have an agreed-upon string that we mix into your password, to make the hash even more scrambled. The word "apple" has been cataloged, but the word "H87JKua8apple" has not, and it's gonna be a long time before one of those hash catalogs gets around to that one. Typically, the salt is of a known length and is stored along with the password:
Random 8 character salt: H87JKua8
SHA-1 hash of the word "apple": d0be2dc421be4fcd0172e5afceea3970e2f3d940
Password field: H87JKua8d0be2dc421be4fcd0172e5afceea3970e2f3d940
When encrypting a password, we generate 8 random characters and prepend them to the password, then take the hash of that resulting non-word.
When checking a password, we take the first 8 characters from the password field and prepend them to whatever password the user is trying. That is, they gave us "apple" and we know "H87JKua8" so we hash "H87JKua8apple" and it should match what's in the database.
The result is an extra million years of security on your password hashes. And if you think 8 characters aren't enough of a salt, feel free to crank it up: a 40-character salt with a 40-character hash may seem a bit extreme, but also gives a hacking time longer than the universe is expected to last no matter what mythology you prefer.
Implemented in code, it's not really as complicated as it sounds:
public static function attemptLogin($username,$password) {
if (! $username) return false;
if (! $password) return false;
// fetch the user
$u = new User();
$u->where('username',$username)->get();
if (! $u->password) return false; // user not found (or a blank password), automatic failure
// check the password field
// split off the 16-byte salt, prepend it to whatever password is being tried
// and it should MD5 to the same hash as we have in the database
// MD5 outputs are 32 characters in length, so 'password' is a varchar(48) field
$salt = substr($u->password,0,16);
$crypt = $salt . md5($salt . $password);
if ($crypt == $u->password) return true;
return false;
}
public static function encryptPassword($password) {
// salt is 16 random characters; there are any number of ways to make random strings
// the password field is the MD5 hash of the salt + password
// MD5 outputs are 32 characters in length, so 'password' is a varchar(48) field
$salt = substr(md5(mt_rand()),0,16);
$crypt = $salt . md5($salt . $password);
return $crypt;
}
Some closing notes here:
- There are various ways to generate 16 random characters. At this level it doesn't really matter which technique you choose: the randomness of distribution of the choice of salts, is less important than the fact that "abcccbaaccbabcabapple" still isn't in any hashing database.
- The MD5 hash has some known circumstances in which a collision (that is, a second word with the same hash) can occur. But it's incredibly farfetched with a password field, more for multi-kilobyte files. At this time, PHP doesn't have a sha1() function but does have a md5() function. When PHP does bring up a simple sha1() function,I'd recommend using it over MD5.
Session Cookie Encryption
This isn't program code at all, but is another example of why CodeIgniter rocks.
A known weakness of PHP's sessions, is that they are carried in cookies. If someone intercepts a cookie (say, sniffing your wifi) they can hack their browser's cookie jar to continue using YOUR login session indefinitely. If your program has a logout function, it would invalidate the meaning of the session, but frankly you can't rely on your users to log out reliably.
CodeIgniter encrypts session cookies. This uses a simple single-key encryption, coded into config.php when you first set up CodeIgniter. This means that nobody, including the authorized user of the session, can crack open the cookie and tinker with its contents.
This single fact in itself, is a huge gain to security, and a great reason to use CodeIgniter's session helper instead of PHP's naked $_SESSION system.