这是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]
|