这是PHP应用程序框架设计系列教程的第二部分。在第一部分,我们已经介绍框架的基础类结构,并展示了项目的大体。这一部分,我们将在程序中添加会话处理功能,并演示管理用户的各种方法。
会话
HTTP是一种无状态的协议,正因为如此,它没有包含任何与服务器连 接的相关信息。这就意味着,HTTP是孤立的,web服务器并不知道用户与你web程序相连接的任何信息,并且服务器会将每个页面请求视为一个新的连接。 Apache/PHP通过提供对会话的支持来避开这一限制。从概念上来说,会话是相当简单的。在一个用户第一次连接到服务器的时候,他被分配一个唯一的 ID。web服务器在一个文件中维护会话信息(译注:即把会话信息存储到文件中),于是可以通过这个ID来定位用户信息。用户同样会在每次连接中维护这个 ID。最典型的作法,就是将ID存储在cookie中,之后,这个ID会作为请求-应答序列的一部分发回给服务器。如果用户不允许使用cookie,会话 ID同样可以在请求每个页面时,通过query字符串(即URL中?以后的部分)发回给服务器。因为web客户端会断开连接,所以web服务器会在一定周 期后,使那些不活动的会话信息过期。
我们不想在这篇文章中过多地谈论Apache/PHP的配置,除了利用会话来维护用户信息。我们假 设会话支持功能已经开启,并在你的服务器上配置好了。我们将直接从本序列教程第一部分谈论系统基础类时,被我们搁在一边的地方谈起。你可能还记得 class_system.php的第一行是session_start(),这一句的作用是,如果不存在会话信息,则开始一个新的用户会话,否则不做其 他的事情。根据你服务器的配置,开始会话的时候,会话ID会被保存在客户端的cookie里或者作为URL的一部分进行传递。当你调用内建的 session_id()函数时,总可以得到会话ID。通过这些工具,我们现在可以建立一个web应用程序,它可以对用户进行验证,并且能够在用户浏览网 站不同页面的时候去维护用户的信息。如果没有会话,那么用户每一次请求页面的时候,我们就不得不提醒用户进行登录。
那么,我们应该在会 话中存储什么信息呢?我们一下子就可以想到如用户名这类信息。如果你看一下class_user.php,你会看到其他要存储的数据。(在程序 中)include这个文件的时候,首先会检查用户是否登录(如果没有用户id,那么会设置一个默认的会话值)。注意,session_start()必 须在我们使用$_SESSION数组之前调用,$_SESSION数组包含所有我们的会话数据。UserID用来标识存储在我们数据库中的用户(如果您已 经完成了本系列教程的第一部分,那么这个数据库中的数据应该可以访问了)。Role(角色)是用来检测用户是否有足够的权限去访问程序中的某一部分功能。 LoggedIn标识用来检测用户是否通过验证,Persistent标识用来检测用户是否想依靠他们的cookie内容自动进行登录。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
[PHP] //session has not been established if (!isset( $_SESSION [ 'UserID' ]) ) { set_session_defaults(); } //reset session values function set_session_defaults() { $_SESSION [ 'UserID' ] = '0' ; //User ID in Database $_SESSION [ 'Login' ] = '' ; //Login Name $_SESSION [ 'UserName' ] = '' ; //User Name $_SESSION [ 'Role' ] = '0' ; //Role $_SESSION [ 'LoggedIn' ] = false; //is user logged in $_SESSION [ 'Persistent' ] = false; //is persistent cookie set }[/PHP] |
用户数据
我们将所有的用户数据存储到数据库的tblUsers表,这个表可以使用下面的SQL语句来创建(仅限MySQL)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
CREATE TABLE `tblUsers` ( `UserID` int(10) unsigned NOT NULL auto_increment, `Login` varchar(50) NOT NULL default '' , `Password` varchar(32) NOT NULL default '' , `Role` int(10) unsigned NOT NULL default '1' , `Email` varchar(100) NOT NULL default '' , `RegisterDate` date default '0000-00-00' , `LastLogon` date default '0000-00-00' , `SessionID` varchar(32) default '' , `SessionIP` varchar(15) default '' , `FirstName` varchar(50) default NULL, `LastName` varchar(50) default NULL, PRIMARY KEY (`UserID`), UNIQUE KEY `Email` (`Email`), UNIQUE KEY `Login` (`Login`) ) TYPE=MyISAM COMMENT= 'Registered Users' ; |
这个语句建立了一个大概的用户表。大多数字段不言自明。我们用UserID这个字段来唯一标识每个用户。Login字段同样也必须是唯一的,存 储用户使用的登录名。Password字段用来存储用户密码的MD5散列值。我们没有存储实际的密码是因为安全和隐私的原因。我们可以拿用户输入的密码的 MD5散列值与数据表中的进行对比来验证用户。用户角色用来将用户分配到一个许可组。最后,我们用LastLogon, SessionID和SessionIP字段来跟踪用户对系统的使用情况,包括用户最后登录时间,用户最后使用的会话ID,用户机器的IP地址。用户每次 成功登录后,会调用user系统类中的_updateRecord()函数来更新这些字段值。这些字段同时也可以用来保证安全性,保证不受XSS(跨站脚 本)攻击。
1
2
3
4
5
6
7
8
9
10
11
12
|
[PHP] //Update session data on the server function _updateRecord () { $session = $this ->db->quote(session_id()); $ip = $this ->db->quote( $_SERVER [ 'REMOTE_ADDR' ]); $sql = "UPDATE tblUsers SET LastLogon = CURRENT_DATE, SessionID = $session , SessionIP = $ip WHERE UserID = $this ->id"; $this ->db->query( $sql ); }[/PHP] |
安全问题
这一部分看起来应该来考虑几个在开发web应用程序会遇到的安全问题。因为安全性是用户管理的一个主要方面,我们得非常细心,不在我们这一部分的代码中留下任何因为粗心导致的bug。
第一个要考虑的问题是,不管在任何web应用程序中都会遇到的——SQL注入攻击(SQL注入会发送web数据来进行数据库查询)。在我们的情况中,我 们使用用户提供的登录名和密码来查询数据库进而验证用户。一个怀有恶意的用户可以提交SQL代码作为输入文本的一部分,从而可能达到下面的几个目的:1 不需要拥有有效的账号即可登录 2 探测我们数据库的内部结构 3 修改我们的数据库。下面是一个非常简单的例子,用来测试用户是否有效。
1
2
|
$sql = "SELECT * FROM tblUsers WHERE Login = '$username' AND Password = md5( '$password' )"; |
设想一下,用户输入 admin'-- ,然后将密码框留空。服务器执行的SQL代码则为:SELECT * FROM tblUsers WHERE Login = 'admin'--' AND Password = md5('')。你是否发现问题了?代码不同时检查登录名和密码了,只是检查登录名,(因为)余下的部分被注释掉了。只要在表里面有一个admin用户, 这个查询就会返回一个肯定的回答。
你该怎么样保护你自己的代码免受这种类型的威胁呢。第一步是检验任何从不可靠的来源(比如:用户)发 送到SQL服务器的数据。PEAR DB中的quote()函数为我们提供了这样的保护,这个函数可用于发送到SQL服务器的任何字符串。我们的login()函数(译注:该函数请见下文) 显示了我们可以采取的其他预防措施。在我们的代码中,我们在SQL服务器和PHP中(根据SQL服务器返回的记录)都检查了密码。这样的话,攻击必须同时 对SQL服务器和PHP都有效,才能使一个未验证的用户登录进去。你想说这杀伤力太大了吧?是的,也许吧。
另一个问题是,我们必须警惕会话窃取和跨站脚本攻击(XSS)的可能性。我不想过多地谈论一个黑客冒 充其他已验证用户会话信息的各种方法,但确定的是那确实有可能。事实上,比起利用代码中的bug,许多基于社会工程学的方法更可以称得上是十分难解决的问 题。为了保护我们的用户不受这样的威胁,我们在用户每次登录的时候存储他的会话IP和会话ID。然后,当页面加载完成,我们就拿用户当前的会话ID和IP 地址和数据库中的值进行比对。如果不匹配,那么就破坏会话信息。这样子,如果一个黑客让一个受害者从一台机器上登录,然后试着从他自己的机器使用受害者的 活动会话,那么在他做出任何破坏之前会话就会被关闭。具体的实现代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
[PHP] //check if the current session is valid (otherwise logout) function _checkSession() { $login = $this ->db->quote( $_SESSION [ 'Login' ]); $role = $this ->db->quote( $_SESSION [ 'Role' ]); $session = $this ->db->quote(session_id()); $ip = $this ->db->quote( $_SERVER [ 'REMOTE_ADDR' ]); $sql = "SELECT * FROM tblUsers WHERE Login = $login AND Role = $role AND SessionID = $session AND SessionIP = $ip "; $result = $this ->db->getRow( $sql ); if ( $result ) { $this ->_setSession( $result ); } else { $this ->logout(); } }[/PHP] |
验证
现在我们已经了解了各种相关的安全问题,下面我们来看一看验证用户的代码。login()函数接收一个登录名和密码,返回一 个Boolean(布尔值)来标明是否正确。正如上面所说的,我们必须假定传入函数中的值是来自于不可靠的来源,用quote()函数来避免问题。完整的 代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
[PHP] //Login a user with name and pw. //Returns Boolean function login( $username , $password ) { $md5pw = md5( $password ); $username = $this ->db->quote( $username ); $password = $this ->db->quote( $password ); $sql = "SELECT * FROM tblUsers WHERE Login = $username AND Password = md5( $password )"; $result = $this ->db->getRow( $sql ); //check if pw is correct again (prevent sql injection) if ( $result and $result [ 'Password' ] == $md5pw ) { $this ->_setSession( $result ); $this ->_updateRecord(); //update session info in db return true; } else { set_session_defaults(); return false; } }[/PHP] |
用户注销的时候,我们要清理在服务器上的会话变量,还有在客户端的会话cookie。我们还要关闭会话。代码如下:
1
2
3
4
5
6
|
[PHP] //Logout the current user (reset session) function logout() { $_SESSION = array (); //clear session unset( $_COOKIE [session_name()]); //clear cookie session_destroy(); //kill the session }[/PHP] |
在每一个页面都要求验证,我们可以简单地检查一下会话,看用户是否已经登录,或者我们可以检查用户角色,看用户是否有足够的权利。角色被定义为一个数字(译者注:即用数字来表明角色),更大的数字意味着更多的权利,下面的代码使用角色来检查用户是否有足够的权利。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
[PHP] //check if user has enough permissions //$role is the minimum level required for entry //Returns Boolean function checkPerm( $role ) { if ( $_SESSION [ 'LoggedIn' ]) { if ( $_SESSION [ 'Role' ]>= $role ) { return true; } else { return false; } } else { return false; } }[/PHP] |
登录/注销的接口
现在我们已经有一个处理会话和用户账号的框架了,我们需要一个接口,这个接口允许用户登录和注销。使用我们的框架,建立这样的一个接口应该是十分简单 的。下面我们就从比较简单的logout.php页面开始,这个页面用来注销用户。这个页面没有任何内容展现给用户,只是在注销用户以后,简单将用户重定 向到index页面。
1
2
3
4
5
6
7
8
9
10
11
12
|
define( 'NO_DB' , 1); define( 'NO_PRINT' , 1); include "include/class_system.php" ; class Page extends SystemBase { function init() { $this ->user->logout(); $this ->redirect( "index.php" ); } } $p = new Page(); |
首先,我们定义NO_DB和NO_PRINT常量来优化加载这个页面的时间(正如我们在本系列教程中第一部分描述的那样)。现在,我们要做的所有事情,就是使用user类来注销用户,并在页面初始化事件中重定向到另外的页面。
这个login.php页面需要一个接口,我们使用系统的表单处理能力简化处理的实现过程。至于这个过程是如何运作的,我们将会在本系列教程的第三和第 四部分详细介绍。现在呢,我们所需要知道的全部事情,就是我们需要一个HTML表单,这个表单与应用程序的逻辑相连接。表单代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
[PHP]<form action= "<?=$_SERVER['PHP_SELF']?>" method= "POST" name= "<?=$formname?>" > <input type= "hidden" name= "__FORMSTATE" value= "<?=$_POST['__FORMSTATE']?>" > <table> <tr> <td>Username: <td><input type= "text" name= "txtUser" value= "<?=$_POST['txtUser']?>" ></td> </tr> <tr> <td>Password: <td><input type= "password" name= "txtPW" value= "<?=$_POST['txtPW']?>" ></td> </tr> <tr> <td colspan= "2" > <input type= "checkbox" name= "chkPersistant" <?= $persistant ?>> Remember me on this computer </td> </tr> <tr style= "text-align: center; color: red; font-weight: bold" > <td colspan= "2" > <?= $error ?> </td> </tr> <tr> <td colspan= "2" > <input type= "submit" name= "Login" value= "Login" > <input type= "reset" name= "Reset" value= "Clear" > </td> </tr> </table> </form>[/PHP] |