25 May 2010When I was a young web developer, I had to code an admin panel, with username and login. I also added the well-known "Remember me" checkbox.
I wasn't really educated to the security concerns of developing such a feature. In fact, I was even storing the passwords as plain text in the database.
And to allow the user to auto-login, I stored the user and password, in plain text, in a cookie and requested a login with those credentials whenever its session expired.
That's insane !
Yes it was, but I remember thinking "Well, nobody can even have access to the database so there's no risk" and "I can store the password in the cookie, it is only accessible by the user, and he surely already know its password. There's no harm in that"
Well, XSS and CSRF weren't so common back in those days.
Since then I've learned a lot. There's just no way I'll ever store a plain text or uncrypted password in a database again. Hashing and salting is the way to go.
But I kept implementing the persistent login feature following the same pattern : saving the username and hashed password in the cookie.
I was thinking "The password is not stored as plain text but as a hash, there's no way an attacker would even be able to guess the password from that".
That's better, but still...
I haven't thought that the cookie was like an open sesame to my app. One would just have to steal the file and place it in its browser cookie list and he would have exactly the same access to the website, without having to know my password.
In the meantime I was also implementing OpenId authentication and as it is passwordless (at least from the website point of view), I couldn't save the username and password in the cookie.
Clearly, something was broken in my persistent login implementation.
Here come the tokens
After some research, I finally get it the right way.
Whenever a user authenticate, be it from a classical user/pass login form or using OpenId, I keep it loggued using session vars. When the session var expires, the user is loggued out. If he had checked the "Remember me" checkbox, I would try to re-authenticate him automatically.
In order to do that, I had to change my implementation. The first thing to do was creating a UserToken
table with 3 fields : user_id
, token
and duration
.
Then, when the user authenticate I generate a random token and save it along the user_id
in a cookie.
I'll also populate the UserToken
table with the user_id
, a hashed version of the token, and matching the duration
to the expires date of the cookie.
That way, the next time that the user comes to my website without a session I check its cookie. If he has a user_id
and a token, I'll try to authenticate him.
I'll find a matching user_id
and hashed token. If there are no results, then the cookie is not valid. Either it has been already used or it is a cookie forgery attempt. In that case, I'll do nothing.
If there's a match, I still check that the token is not expired. If it is, I'll delete it in the database, as well as clearing the cookie. On the other hand, if the cookie is still valid, I log the user in.
Once the user is loggued in I generate a new token, update the cookie and update the table with a hashed version of it.
That way each token can only be used once and are generated only when the user correctly authenticate and are only used once the session expires.
But one can still steal cookies
Yes, a malicious user can still steal my cookie, copy it to its computer and use the token to login. That's true and as we can't do anything against that, we could still try to mitigate it.
First precaution would be to disable any sensitive information edition while loggued in using the cookie. Like bank account, personnal information, password changing, etc.
The second thing would be to check for any hacking attempt. Whenever I guess for a forgery attempt (trying to login with a bad set of id / hashed token), I delete all UserToken associated for this user. This will block any bruteforce attack because even there won't be any more match in the database.
It is also wise to display a warning message to the user telling him that his account may have been compromised and that he should be wary and maybe change its password.
21 May 2010After all the praises I've read about OpenId, I decided to implement it in this CMS. My goal was to set an easy way for readers to leave comments without the need for registering to anything.
I thought it would also be great to add as a secondary, and easiest, mechanism for logging in.
So I downloaded and installed the openID component by cakebaker. As I'm running my dev environment under Windows, I had to set some settings.
Fixing the pluginPath
I've save the OpenId component in a plugin, and it has a clever mechanism to import the PHP OpenId library based on the folder it is saved.
Unfortunatly, the regexp used to know the name of the current plugin was throwing errors on Windows, due to the backslashes used in the file path.
I updated the getPluginName()
method to this new one and it did the trick :
private function getPluginName() {
$result = array();
$ds = (Folder::isWindowsPath(__FILE__)) ? '\\\\' : DS;
if (preg_match('#'.$ds.'plugins'.$ds.'(.*)'.$ds.'controllers#', __FILE__, $result)) {
return $result[1];
}
return false;
}
Basically it makes sure that the backslashes are correctly escaped under Windows.
Edit : I've sent this patch to the OpenId component author and it is now fixed in the latest versions.
Generating randomness
The second fix was to change the Auth_OpenID_RAND_SOURCE
constant to null
. This constant enable the library to generate randomness (AFAIK), by using the /dev/urandom
.
This does not exists on Windows, so I added the following lines in my bootstrap.php
if (Folder::isWindowsPath(__FILE__)) {
define('Auth_OpenID_RAND_SOURCE', null);
}
Connecting to SSL servers
The PHP bundle on Windows comes with cURL
already builtin, but without the bundle of the X.509 certificates of public CA. It means that the OpenId PHP library will refuse to connect to any CA using an SSL connection because it won't be able to check the certificate.
This does not happens on Linux, the list is correctly built in.
Fortunatly, we can pass a CURLOPT_CAINFO
option to cURL
to manually set a pre-defined bundle, and there already is one shipped with the PHP OpenId library.
All you have to do is add the following line on line 93 of the vendors/Auth/Yadis/ParanoidHTTPFetcher.php
file :
curl_setopt($c, CURLOPT_CAINFO, str_replace('\\', '/', dirname(__FILE__)).'/../OpenID/ca-bundle.crt');
19 May 2010I wanted for this blog a search feature, but I had some prerequisites for it :
- The search url could be bookmarked
- It should be paginated
- It should play well with my custom url starting with /blog
Defining custom urls
Here are the two routes I defined in my routes.php
Router::connect('/blog/search/:keyword',
array('controller' => 'posts', 'action' => 'search'),
array(
'pass' => array('keyword'),
'keyword' => '[^/]+'
)
);
Router::connect('/blog/search/*', array('controller' => 'posts', 'action' => 'search'));
Going to/blog/search/*keyword*
will start a search on the keyword, while going to /blog/search/
would display a search form.
Writing the method
I started by creating a search
action in my PostController
, then creating a form submitting to this action, with a keyword
input field.
In the search
method, the first thing I do is checking if some POST data is submitted (coming from the search form). If so, I redirect to the same page, but passing the keyword
as first parameter.
If no keyword
is passed nor data submitted, I'll display a simple search form.
And finally if a keyword
is specified, I'll do a paginated search on every posts whose name
or text
contains the keyword
.
function search() {
// We redirect to get it in GET mode
if (!empty($this->data)) {
return $this->redirect(array('keyword' => urlencode($this->data['Post']['keyword'])));
}
// Search index
if (empty($keyword)) {
return $this->render('search_index');
}
// Adding conditions to name and text
$keyword = urldecode($keyword);
$this->paginate = Set::merge(
$this->paginate,
array(
'conditions' => array(
'AND' => array(
'OR' => array(
'Post.name LIKE' => '%'.$keyword.'%',
'Post.text LIKE' => '%'.$keyword.'%'
)
)
)
)
);
// Getting paginated result
$itemList = $this->paginate();
$this->set(array(
'keyword' => $keyword,
'itemList' => $itemList
));
}
15 May 2010I just realized that one of my domains was sending mails (in PHP), but I never actually received them.
I first tested the mail adress, sending mails from a personal adress. I then tested the mail()
php function, sending mail to a personal adress. But all that was working fine.
But sending mails in PHP on www.server.com to name@server.com did nothing. No mail, no error.
Damn you Gmail
After some digging it appears that local mails (to addresses on the same server) were using some sort of special local delivery.
I recently moved the whole mail handling stuff from this server to Google Apps, but I forgot to remove/edit the postfix configuration file so the local delivery routine was still up and local mails where internally routed.
So, I just had to edit the /etc/postfix/main.cf
file, find the mydestination
line and remove any reference to my server here.
Then, just restart postfix by doing a
It still doesn't work
In my case, it still didn't change anything... That's when I understood that postfix was using a vmail
mysql database to check for existing domains.
I renamed the domain
field value in the domains
table, and I can now correctly receive mails.
But, I have lost a lot of mails !
No you don't, as they are routed by the local delivery system, they should be somewhere on your hard drive. In my case it was in the /home/mailusers/
directory
14 May 2010Trying to push some new code to a Hg repository on my Dreamhost account, I had the following error message :
remote: *** failed to import extension hgext.imerge: No module named imerge
I used to have the same kind of issues with an other extensions, hgext/hbisect
a while ago and I fixed it by forcing Hg to not use this extension.
Here's how :
Edit your .hgrc
file and under the [extensions]
category, add hgext.imerge=!
, like this
[extensions]
hgext/hbisect = !
hgext.imerge=!
This should stop the warning.