LP Stage 3

In stage 3 we are going to add some very basic authentication and registration.  Basically, the site will support only one user, an administrator.  Only that user will be able to add or delete books.

Note that you can download a zip file of a Raspberry Pi disk image backup of Stage 3 here ( NOTE: you will need to run sudo raspi-config to expand file space):

Or, you can also just download the Zip file with the Stage 3 PHP files here (you still need to create the database, and setup lp_dbconf.php):

SQL script for stage 3

Datamodel

We’re going to need some new tables in our datamodel: lp_user and lp_book_del.  lp_user will contain a simple ID, login/email, and password for user authentication, and lp_book_del will serve as a ‘recycle bin’ for deleted books so that we can delete them when it’s convenient.

NOTE: The password is stored un-encrypted, which is a bad practice.  I am leaving it this way for simplicity of demonstration.  The password should use some salt with an MD5 hash so that if anyone steals or compromises the database their passwords aren’t exposed.  I am also not enforcing any sort of minimal requirements for weak password.

create table if not exists lp_user(
  id integer  NOT NULL AUTO_INCREMENT,
  email varchar(254),
  password varchar( 32 ),
  connection_key varchar( 32 ),
  PRIMARY KEY PK_lp_used (id)
);
create table if not exists lp_book_del (
  id integer,
  ukey varchar(32),
  PRIMARY KEY PK_lp_book_del (id)
);

 

Authentication

We are going to add a new lp_user table to hold our login information.  Since our LP->HTMLPageTop function is called at the top of every site page, we are going to add all the steps for authentication, which include Registration, Login, and Logout.  HTMLPageTop will call 2 new functions to support this interaction: UserProcess() and UserHTML().

  • When there are no users in the lp_user table, the HTMLPageTop method will call UserHTML() to output a Registration form allowing one user to enter a username and password.
  • If there is one user in lp_user, the HTMLPageTop method will call UserHTML() to output a login form.
  • If there is a cookie value that indicates a user has logged in, HTMLPageTop will call UserHTML() to output a Logout button.

So, the UserHTML() method outputs the proper type of form based on the authentication status.  Examples:

auth1

auth3

auth2

The HTMLPageTop method also calls the UserProcess() method.  This new method will check the input parameters from the forms, and either register the admin user, or attempt a login.  If a login is successful, then a cookie is set that can be checked upon the next page view (to remember the user already logged in).

With the login process, the LP class will now have a UserID variable.  Pages and code can check this variable to determine if the admin is logged in (>zero) or not (=zero).  Look at the example of how the new HTMLPageTop makes use of this to only show the Upload menu link to the admin user:

if( $this->UserID == 0 )
  $Upload = "";
else
  $Upload = "&nbsp;&nbsp;<a href=upload.php>Upload</a>";
…
echo "<!DOCTYPE HTML PUBLIC ...>\n".
  "<html lang=\"en\">\n" .
  "<head>\n" .
…
  "<div id=lpmenu><a href=index.php>Home</a>$Upload<span style=\"float:right;\">" 
  . $this->UserHTML() 
  . "</span></div>";

We can see that the Upload menu link is now only output if a user is logged in.  Just to be safe that nobody is trying a malicious hack of the URL, the AddBook method also checks for user access:

function AddBook( $Title )
{
   if( $this->UserID == 0 ) die( "Access denied" );

UserHTML

The UserHTML method in the LP class will output the different HTML forms for Registration, Login, and Logout, and is called from HTMLPageTop.  Take a look at the if tests that determine the current authentication status, and output the needed HTML in $Ret:

function UserHTML()
{
 // Output either Register, Login, or Logout
 $Ret = "";
 if( $this->UserID > 0 ) // Must have just logged in
  $Ret = "<form method=post><input type=hidden name=act value=logout> <input type=submit value=Logout></form>";
 if( strlen( $Ret ) == 0 ) 
 {
  $result = mysqli_query( db(), "select count(*) Cnt from lp_user");
  if( $result === false )
   return ( "Error: Can't read user data.  Did you create the SQL tables in the correct database?<br>" );
  if ( $row = mysqli_fetch_array( $result ) )
  {
   if( $row["Cnt"] == 0 ) // If there are no users, it's a register
    $Ret = "<form method=post>$this->UserErr Register: EMail: <input type=text name=email maxlength=254>"
    ." Password: <input type=password name=pass1 >  again: <input type=password name=pass2 >"
    ."<input type=hidden name=act value=register>"
    ."<input type=submit value=Register></form>";
  }
  $result->close();
 }
 if( strlen( $Ret ) == 0 )  // else, there are users, but nobody logged in, so login:
  $Ret = "<form method=post>$this->UserErr EMail: <input type=text name=email maxlength=254>"
   ." Password: <input type=password name=pass1 >"
   ."<input type=hidden name=act value=login>"
   ."<input type=submit value=Login></form>";
 return  $Ret;
}

Again, the HTMLPageTop function that is called on every page will in turn call the above UserHTML to output the correct authentication form.

UserProcess

The UserProcess method will process the user’s data from the registration form, login form, or logout button:

  • When a user registers, their name and password are added to the lp_user table.  Only one user can register.
  • When a user logs in, their name and password is checked against the lp_user table.  If found, a cookie is created and stored in lp_user so that the authentication is remembered.
  • When a user logs out, their cookie is cleared and the lp_user table also has the cookie cleared.

 

 function UserProcess()
 {
  $Act = isset( $_POST["act"] ) ? $_POST["act"] : "";
  $ConnKey = isset( $_COOKIE[ 'connkey' ] ) ? $_COOKIE[ 'connkey' ] : '';
  if( $Act != "logout" && strlen( $ConnKey ) > 0 ) // Reconnect using cookie?
  {
   $result = mysqli_query( db(), "select * from lp_user where connection_key = '$ConnKey'");
   if( $result === false )
    die( "Error: Looks like user table is not setup" );
   if ( $row = mysqli_fetch_array( $result ) )
    $this->UserID = $row["id"];
   $result->close();
  }
  if( $this->UserID > 0 )
   return;
  $EMail = str_replace( "'", "", isset( $_POST["email"] ) ? $_POST["email"] : "" );
  $Pass1 = str_replace( "'", "", isset( $_POST["pass1"] ) ? $_POST["pass1"] : "" );
  if( $Act == "register" ) // Was the Registration form submitted?
  {
   $Pass2 = str_replace( "'", "", isset( $_POST["pass2"] ) ? $_POST["pass2"] : "" );
   if( strcmp( $Pass1, $Pass2 ) != 0 || strlen( $Pass1 )==0 || strlen( $EMail) == 0 )
    $this->UserErr = "<span class=err>Verify email & password</span>";
   else{
    mysqli_query( db(), "insert ignore into lp_user set id=1, email='$EMail', password='$Pass1'" ); // Only 1 user!
    $Act = "login";
   }
  } 
  if( $Act == "login" ) // Was the Login form submitted?
  {
   $result = mysqli_query( db(), "select * from  lp_user where email='$EMail' and password='$Pass1'" );
   if ( $row = mysqli_fetch_array( $result ) )
   {
    $this->UserID = $row["id"];
    $Key = $this->UKey();
    setcookie( "connkey", $Key );
    mysqli_query( db(), "update lp_user set connection_key = '$Key' where email='$EMail' and password='$Pass1'" );
   }
   else
    $this->UserErr = "<span class=err>Invalid login</span>";
   $result->close();   
  }
  else if( $Act == "logout" ) // Was the logout form/button submitted?
  {
   $this->UserID = 0;
   setcookie( "connkey", null );
   mysqli_query( db(), "update lp_user set connection_key = '' where key='$ConnKey'" );
  }
 }

Security

Now with authentication, we’re going to make some additional changes:  only the admin user can upload books, and will also be able to delete books.

In order to only show the Uploads option to the admin user, we’ve already seen this code that was added to the HTMLPageTop method:

if( $this->UserID == 0 )
 $Upload = "";
else
 $Upload = "&nbsp;&nbsp;<a href=upload.php>Upload</a>";

If the UserID is zero. then nobody is logged in and we won’t present the link to the upload page.  If the UserID is > zero, then it’s the admin that’s logged in, and we can display the upload link.

For deleting books, look at this change to the BookList method:

while ( $row = mysqli_fetch_array( $result ) )
{
  if( $this->UserID == 0 )
    $Del = "";
  else
    $Del = "&nbsp;<a href=# onclick=\"if( confirm( 'Are you sure you want to delete this?' ) ) window.location='index.php?act=del&bk=" . $row["ukey"] . "';\">[DELETE]<a>&nbsp;&nbsp;";
  $Pages = $row["Pages"];
  $ToDo = $row["ToDo"];
  if( $ToDo == 0 )
    $Det = "$Pages pages";
  else
    $Det = "$ToDo pages to process";
  $Ret .= "$Del<a href=\"reader.php?bk=" . $row["ukey"] . "\">".$row["title"] . " [$Det]</a><br />";
}

In the above code, if the Admin user is logged in, then the $Del variable is initialized with a link that will lead the user to delete the book.  And now look again at the change to HTMLPageTop:

if( $this->UserID == 0 )
  $Upload = "";
else
{
  $Upload = "&nbsp;&nbsp;<a href=upload.php>Upload</a>";
  $Act = isset( $_GET["act"] ) ? $_GET["act"] : "";
  if( $Act == "del" )
  {
    $UKey = isset( $_GET["bk"] ) ? $_GET["bk"] : "";
    $this->DeleteBook( $UKey );
  }
}

So when the UserID > zero (admin logged in) not only does the page show the ‘Upload’ option, but it also checks for the “del” delete operation and calls a new DeleteBook method.

DeleteBook

Remember how we have a scheduled crontab job, that is calling the proc.php page every minute?  Well, if the user wants to delete a book while the proc.php script is in the middle of OCR’ing it, then we can have remnant files and possible errors.  So here’s how we’re going to avoid that:

  • When a book is deleted, it’s not really deleted.  It’s record is moved into another table named lp_book_del.
  • Since the book isn’t in lp_book anymore, the OCR code won’t see it and won’t process anymore pages in it.
  • The proc.php script will now also check for records in lp_book_del to be purged from the system.  Since this is the same method that invokes the OCR process, we can make sure that the delete doesn’t occur in the middle of an OCR.

So here is the DeleteBook method in the LP class:

function DeleteBook( $UKey )
{
  if( $this->UserID == 0 ) die( "Access denied" );
  $UKey = str_replace( "'", "", $UKey );
  $result = mysqli_query( db(), "select * from lp_book where ukey = '$UKey'" );
  if ( $row = mysqli_fetch_array( $result ) )
  {
    $BookID = $row["id"];
    mysqli_query( db(), "insert into lp_book_del select id, ukey from lp_book where id = $BookID" );
    mysqli_query( db(), "delete from lp_book where id = $BookID" );
  }
  $result->close();
}

Of course, there is a ‘real’ method to delete the book and related files, and it’s called PurgeBooks:

function PurgeBooks()
{
  $result = mysqli_query( db(), "select * from lp_book_del" );
  if ( $row = mysqli_fetch_array( $result ) )
  {
    $UKey = $row[ "ukey"];
    $BookID = $row["id"];
    $this->deleteDir( $_SERVER["DOCUMENT_ROOT"]."/uploads/$UKey/" );
    mysqli_query( db(), "delete from lp_page_word where page_id in (select id from lp_page where book_id = $BookID)" );
    mysqli_query( db(), "delete from lp_page where book_id = $BookID" );
    mysqli_query( db(), "delete from lp_book_del where id = $BookID" );
  }
}

And finally, we see how proc.php (which is called every minute) is modified to include the above code:

 function DoProcess() {
  $lp = new LP();
  $lp->OCRFiles();
  $lp->AddOCRRecords();
  $lp->PurgeBooks();
 } // end of function DoProcess()