Implementation notes for persistent logins.

Introduction.

To finish off (well almost) the simple newsfeed system I've been working on, I needed to implement the 'Remember' checkbox that was present, but did not do anything. I was delighted to find what I think is an excellent 'Best Practice' article by Charles Miller. There was no implementation example there, so I thought I'd just sketch out what I ended up using. This only represents a partial implementation, since my page does not really do anything that requires the higher level of security.

The Extra Database Table

I'll be honest about my naming - mine is called 'turds' (I'm not a big cookie fan.) It's very simple:

CREATE TABLE `turds` (
  `uid` varchar(50) NOT NULL,
  `otp` char(32) NOT NULL,
  `ts` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`otp`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8
As Charles points out, there can be several rows for each user, corresponding to different machines, and/or different browsers. 'uid' is the user ID, 'otp' the 'one-time-password', and the timestamp is there for housekeeping purposes - I haven't used it yet.

Populating the Turds Table

The rows in the table get created by my pre-existing login script with additions basically as follows:

$sticky = 'N';
if (isset($_GET["sticky"]))
   $sticky = 'Y';
...
if ($sticky == 'Y')
{
   $otp = md5(uniqid().mt_rand());
   mysql_query("insert into turds values('$userid', '$otp', NULL)");
   setcookie("feedturd", "1:$bevid:$otp", time()+3600*24*365, "/feed/");
}
You'll notice that I have expanded Charles' cookie data slightly. My page can authenticate using Facebook, or conventionally, and I need to be able to tell from the cookie what kind of login it was. Hence the '1:' prefix. For the random 128 bits I thought that the microsecond time was one good component, and a random number another. An md5 digest is conveniently 128 bits, so I don't have to worry about the length of either randomizing component.

You might wonder why my login PHP is expecting a GET. Well since it is Ajax either way. I figured that GET and POST were essentially equivalent. I stand willing, or even eager, to be corrected.

Deciding to Log In Automatically

The PHP code that writes my page already checks the $_SESSION array to see if there is an existing login. If not it creates a page that includes login components - including the previously inert 'Remember' checkbox.

Now when it finds no login, before offering one, it checks for the turd cookie, and attempts to get the relevant information via its contents. Here 'bevcom' is my existing table of registered users. Dept is not relevant to the current topic - it just indicates which part of the web site the news-feed page relates to.


function logTurd($turd, $dept)
{
   $ip = $_SERVER["REMOTE_ADDR"];
   $a = explode(':', $turd);
   $query = "";
   if ($a[0] == "1")
      $query = "select turds.uid, bevcom.id, bevcom.name, bevcom.dept from turds LEFT JOIN bevcom
                ON turds.uid=bevcom.bevid where turds.otp='".$a[2]."'";
   else
      $query = "select turds.uid, bevcom.id, bevcom.name, bevcom.dept from turds LEFT JOIN bevcom
                ON turds.uid=bevcom.fbid where turds.otp='".$a[2]."'";
   $result = mysql_query($query);
   if (!$result) die(sql_error());;
   if ($row = mysql_fetch_row($result))
   {
      if ($row[0] = $a[1])
      {
         $current = $row[3];
         if (strpos($current, $dept) === false)
            mysql_query("update bevcom set loggedip='$ip', dept=CONCAT_WS(',',`dept`,'$dept') where id=".$row[1]);
         else
            mysql_query("update bevcom set loggedip='$ip' where id=".$row[1]);
         $rv = array();
         $rv[0] = 1; $rv[1] = $row[1]; $rv[2] = $row[2]; $rv[3] = $a[1];
         $_SESSION['bcid'] = $row[1];
         $_SESSION['name'] = $row[2];
         if ($a[0] == "1")
         {
            $_SESSION['bevid'] = $a[1];
            $_SESSION['fbuser'] = "";
         }
         else
         {
            $_SESSION['bevid'] = "";
            $_SESSION['fbuser'] = $a[1];
            $rv[0] = 2;
         }
         session_regenerate_id();
         $otp = md5(uniqid().mt_rand());
         // Update the cookie
         $cv = $a[0].":".$a[1].":$otp";
         // Update the turd table and the cookie
         $query = "update turds set otp='$otp' where otp='".$a[2]."'";
         if (!mysql_query($query)) die (sql_error());
         setcookie("feedturd", $cv, time()+3600*24*365, "/feed/");
         return $rv;
      }
   }
   return "";
}
I call this, as I said, when I have determined there is no existing login.

$turd = "";
if(isset($_COOKIE['feedturd']))
   $turd = $_COOKIE['feedturd'];
   
$autolog = 0;
if (!$logtype && $turd != "")
{
    // Try to log in from the turd
    $rv = logTurd($turd, $dept);
    if ($rv != "")
    {
       $logtype = $rv[0];
       $bcid = $rv[1];
       $name = $rv[2];
       if ($logtype == 1)
          $bevid = $rv[3];
       else
          $fbuser = $rv[3];
       $autolog = 1;
    }
}
The autolog variable can be used to turn on the remember checkbox in future invocations of the page.

If the above fails the sequence will continue as before to offer login.

Logout

Because we have the cookie to give us a specific OTP, this is straightforward, with the following added to my logout script:

$turd = "";
if(isset($_COOKIE['feedturd']))
   $turd = $_COOKIE['feedturd'];
if ($turd != "")
{
   $a = explode(':', $turd);
   mysql_query("delete from turds where otp='".$a[2]."'");
   setcookie("feedturd", "", time()-3600, "/feed/");
}

Passing the Remember Requirement to Login

Just in case you don't already know this, the bit of jQuery/Javascript goes something like this:

var sticky = $('#remember').is(':checked');
url = /feed/feedlogin.php/....;
if (sticky)
   url += "&sticky";
$.get(url, handlerfunction);
Et voilĂ  - persistent login! So in principle I never have to see that popup Facebook login window again. Snag is that they have given up handing out long term access tokens now, so to get a useful one I'd have to log out and log in again. But you can't win them all.