I created this security mechanism for a non-profit website. It is not for the novice. I'm sure there's lots of room for improvement (particularly in the "multiple security level" arena). The main goals were:
1) Ease of implimentation (after it's created, of course)
2) Secure (difficult to crack or hijack)
3) Scaleable: members can see some secure page, admin can see all secure pages.
4) Convenient: separate "login" page not needed, no need to navigate BACK to the secure page after logging in.
For security, I store all passwords one-way encrypted in the MySQL database. It means I can't ever send someone their password if they forget it, but it also means that no one can read it even if they get their hands on the database.
I'm using MySQL 5.x which supports Stored Routines. It is the only way I communicate with the database. This, combined with the sprintf function for parsing query input makes for a good defense against SQL Injection.
To me, the most annoying shortcomming of MySQL stored routines is their lack of error-code support. To get around this, I use the "unknown table" hack: basically, I have MySQL try to drop a table that doesn't exist. The name of the table is the text I want as my error code. I parse for the well-known "unknown table" error in PHP after the routine returns, and capture the error code text.
Also, I use CSS to control the layout of all my pages and forms. This may confuse table-layout folks and I'm sorry for the confusion. I include the CSS for my login form so you can muck with it if ya like.
One last thing before I get to the code (perhaps bigger than a "snippet"): I wanted the member to be automatically logged out after 10 minutes. This is to prevent "session hijacking". I store that PHP session value in the database along with some other values and a lastupdatedttm (last updated date/time) value. This value gets updated every time the user loads a page.
OK - here's the two tables that I use regarding security: login and sessions:
login
+---------------+-------------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+---------------+-------------+------+-----+---------+----------------+
| loginid | int(11) | NO | PRI | NULL | auto_increment |
| loginnm | varchar(64) | NO | | | |
| password | varchar(32) | NO | | | |
| memberid | int(11) | NO | | | |
| createddttm | datetime | NO | | | |
| validateddttm | datetime | YES | | NULL | |
| lastlogindttm | datetime | YES | | NULL | |
| activeflg | bit(1) | NO | | | |
| memberlevel | int(11) | YES | | NULL | |
+---------------+-------------+------+-----+---------+----------------+
sessions
+----------------+-------------+------+-----+---------+-------+
| Field | Type | Null | Key | Default | Extra |
+----------------+-------------+------+-----+---------+-------+
| phpsessid | varchar(32) | NO | PRI | | |
| loginid | int(11) | YES | | NULL | |
| lastupdatedttm | datetime | NO | | | |
+----------------+-------------+------+-----+---------+-------+
This site doesn't need a lot of flexibility in their data access, so I put all the database functions into a single "db.php" file. I store this file off the web root directory because it has the database password in it. It contains a function to connect to the MySQL database, a function to parse/clean variables, and a function to execute the queries (which contains the "unknown table" hack to pass error codes). Here's db.php:
// db.php
// This file is used to connect to the MySQL database
// It also includes some useful/common database functions.
// hard-coded parameters: replace as needed for your setup
$dbhost = "mysql.yourDomain.tld";
$dbuser = "yourDatabaseUser";
$dbpass = "yourPassword";
// dbConnect() function: called prior to making a query
function dbConnect($db="yourDatabaseName") {
global $dbhost, $dbuser, $dbpass;
$dbcnx = @mysql_connect($dbhost, $dbuser, $dbpass, false, 65536)
or die("The site database appears to be down.");
if ($db!="" and !@mysql_select_db($db))
die("The site database is unavailable.");
return $dbcnx;
}
// The dbparse function will format input to help
// prevent SQL injection
function dbparse($value)
{
// Stripslashes
if (get_magic_quotes_gpc()) {
$value=stripslashes($value);
}
// Quote if not integer
if (!is_numeric($value)) {
$value = "'" . mysql_real_escape_string($value) . "'";
}
return $value;
}
// The execsql function is how we call queries.
// NOTE: every query I use is designed to return some values.
// Null or empty recordsets will return an error.
// Also note the hack around MySQL's inability to return user-defined
// error codes from stored routines (the 'unknown table' hack)
function execsql ($qry) {
global $errorCode,
$errorMsg;
$errorCode=0;
$errorMsg="";
dbConnect();
$result = mysql_query($qry);
if(mysql_error()!="")
{
$errorCode = -1;
$errorMsg = mysql_error();
if(substr($errorMsg,0,15) == "Unknown table '")
{
$errorMsg = substr($errorMsg,strpos($errorMsg,"'")+1,(strlen($errorMsg)-strpos($errorMsg,"'"))-2);
}
unset($result);
}
else
{
if(!$result)
{
error("$_SERVER[PHP_SELF]\\nNo Result from query.");
}
if(mysql_num_rows($result)==0)
{
error("$_SERVER[PHP_SELF]\\nInvalid result set from query.");
}
}
return $result;
}
// The "error" function: pops up a window with the error message.
// I normally don't put this here, but I think it will work:
function error($msg) {
?>
exit;
}
?>
The Sessions table is used in our security model. It keeps track of sessions and the last activity of the session. We need to update this table on every page (not just the secure ones). To do that, I created "hereiam.php" which I include on all my pages. It calls a stored routine (prc_hereiam) which makes the SQL adjustments. First hereiam.php:
// hereiam.php
// This script maintains entries in the sessions
// table of the database. The sessions are used
// for security.
include_once 'path/to/db.php';
$myPhpsessid = isset($_COOKIE['PHPSESSID']) ? $_COOKIE['PHPSESSID'] : '';
if($myPhpsessid!='') {
dbConnect();
$qry=sprintf("call prc_hereiam(%s)",dbparse($myPhpsessid));
$result=mysql_query($qry);
if(!$result)
{
error('HereIAm.php\\nNo Result from Database.');
}
$row = mysql_fetch_row($result);
$sessionLoginId = $row[0];
$sessionMemberLevel = $row[1];
}
?>
And the stored routine prc_hereiam (note: it also deletes any entries more than 10 minutes old):
begin
declare outLoginId int;
declare outMemberLevel int;
delete
from sessions
where lastupdatedttm <= date_add(now(),interval -10 minute);
if((select count(*) from sessions where phpsessid=varPHPSessID)>0) then
update sessions
set lastupdatedttm = now()
where phpsessid = varPHPSessID;
else
insert sessions (
phpsessid,
lastupdatedttm
)
values (
varPHPSessID,
now()
);
end if;
select login.loginid,
login.memberlevel
into outLoginId,
outMemberLevel
from login,
sessions
where sessions.loginid = login.loginid
and sessions.phpsessid=varPHPSessID
and login.activeflg = 1;
if(isnull(outLoginId)=1) then
set outLoginId = 0,
outMemberLevel = 0;
end if;
select outLoginId,
outMemberLevel;
commit;
end
*Please Note - this is just the code of the procedure. You'll need to add the 'create procedure' and the delims to actually build the procedure.
So now when hereiam.php is called on any page, two PHP variables are set: $sessionLoginId will be the logonid from the login table (or zero if not logged in) and $sessionMemberLevel is the member level from the login table (or zero if not logged in). Also every entry more than 10 minutes old has been deleted.
Now for the actual security PHP file. There are actually two PHP files; one for each security level. I know there's a better way to do this, but it works fine like this. The only difference between security1.php and security2.php is the value of the $securityLevel variable. If you include security1.php, members and admins can see the page. Including security2.php means only admins can see it. This can be scaled up to many levels of security. Here's security1.php:
// secure1.php
// The basic security: user must be logged in and have a security level
// of 1 or higher. The $securityLevel is a hack - there's definately a
// better way to do this. But for pages that require a higher security
// level, this file could be coppied and $securityLevel changed to
// something higher.
include_once 'path/to/hereiam.php';
include_once 'path/to/processloginform.php';
$securityLevel = 1;
if($sessionLoginId==0 || $sessionMemberLevel < $securityLevel) {
if($errorMsg!="") {
?>
=$errorMsg?>
}
?>
die;
// If the user is allowed to see the page, nothing is displayed.
}
?>
*NOTE that hereiam.php is called inside this file. That means that you don't need to call hereiam.php on secure pages: the act of making them secure causes hereiam.php to be called.
Notice all the div classes? Quickly, here's the CSS that makes that form look OK:
#loginformdiv {
float: left;
margin: 20px;
}
#loginformdiv div {
margin: 0px;
padding: 0px;
border: 0px;
}
#loginform{
width: 400px;
border-top: 2px solid #ddd;
border-left: 2px solid #ddd;
border-bottom: 2px solid #999;
border-right: 2px solid #999;
float: left;
}
#loginformdiv .loginformtitle {
background: #346800;
color: #fff;
border-bottom: 1px solid #ddd;
padding-left: 20px;
}
#loginformdiv .loginformrow {
background: #ddd;
float: left;
padding: 3px 0 3px 0;
}
#loginformdiv .loginformcolleft {
width: 150px;
text-align: right;
float: left;
}
#loginformdiv .loginformcolright {
width: 240px;
text-align: left;
float: right;
}
.error {
color: #f00;
background: #eee;
width: 80%;
padding: 2px;
margin: 2px;
border-top: 2px solid #aaa;
border-left: 2px solid #aaa;
border-right: 2px solid #333;
border-bottom: 2px solid #333;
}
Alright, nearly done. The processloginform.php file is responsible for all the action the form needs to take. It calls a procedure: prc_login, and makes sure the user has supplied valid inputs. First, here's the processloginform.php file:
// processLoginForm.php
// This PHP file is responsible for processing the
// Login form and setting the value in the Sessions table
// It is called from the secure1.php file.
$postloginname = isset($_POST['loginname']) ? $_POST['loginname'] : '';
$postpassword = isset($_POST['pass']) ? $_POST['pass'] : '';
$postsubmit = isset($_POST['submit']) ? $_POST['submit'] : '';
$myPhpsessid = isset($_COOKIE['PHPSESSID']) ? $_COOKIE['PHPSESSID'] : '';
$formpassword = $postpassword;
$formloginname = $postloginname;
if($postsubmit!="" && $postloginname!="" && $postpassword !="")
{
$qry = sprintf("call prc_login(%s, %s, %s)",
dbparse($postloginname),
dbparse($postpassword),
dbparse($myPhpsessid));
$result=execsql($qry);
if($result)
{
$row = mysql_fetch_row($result);
$sessionLoginId = $row[0];
$sessionMemberLevel = $row[1];
}
}
?>
And here's the text of the prc_login MySQL stored routine. It returns the loginid and memberlevel for the user. Notice the "unknown table" hack. This is just the text; you'll need to put the extra stuff around it to make it a stored routine.
begin
declare varLoginId int;
select loginid
into varLoginId
from login
where loginnm = varLoginName
and password(varPassword) = password
and activeflg = 1
and validateddttm is not null;
if (isnull(varLoginId)) then
drop table `Error: Invalid login name or password`;
end if;
update sessions
set loginid = varLoginId
where phpsessid = varPHPSessId;
if (isnull(varLoginId)=0) then
update login
set lastlogindttm = now()
where loginid = varLoginId;
end if;
select loginid,
memberlevel
from login
where loginid = varLoginId;
commit;
end
So that's it. If you include secure1.php on any php page, the user must login to see the page. The user is logged out after 10 minutes of inactivity (the browser won't update after 10 mintues, but if they try to view a secure page, they'll be asked to login again). A secure2.php (or 3.php or 10.php) may also be created and included instead of secure1.php. A user may view pages with a security level equal to or below his own security level.
I know it's a lot. I've seen other people put up their "authentication" methods to be cut to shreds. SQL Injection, etc - everyone has their own concerns. This, I feel, is a tidy way to keep things secure. The session cookie is used, but holds no user-identifiable information. You'd need to read the database to find out what the session value means, and even then, you won't get a useful password.
Let me know what ya think. Peace.