初学者的用户登录系统构建指南

许多初学 PHP 的开发者在尝试创建带有用户登录功能的网站时,往往未意识到潜在的陷阱。以下是关于用户认证系统和授权系统的逐步指南。认证系统旨在验证用户身份,而授权系统则决定用户是否有权执行某些操作(例如访问特定页面或执行某个查询)。


注意事项

建议读者将以下概念融入自己的脚本中,而不是简单地复制粘贴代码示例。登录系统的安全性取决于开发人员的可靠性。由于任何人都可以编辑这些页面(包括可能怀有恶意的人),您不应完全信任示例代码。


认证(Authentication)

认证过程分为两个部分:

  1. 登录表单
    用户填写凭据(例如用户名和密码),系统检查这些凭据是否与已知用户匹配。如果匹配,用户即通过认证。系统通常还会通过设置 cookie 等方式记住用户的认证状态,以避免在每次请求时重复认证过程。

  2. 每次请求的验证检查
    这类似于登录表单的第二部分,但从用户更方便的来源(如 cookie)获取凭据,用于确认用户的身份。

以下代码可能需要根据您的脚本架构进行调整,无论是面向对象还是过程化编程,也无论代码入口点是单一还是多个。尽管登录系统的实现可能不同,但其基本原理是一致的。此处提到的“数据库”并不特指 MySQL 或其他关系数据库管理系统,用户信息也可以存储在平面文件、LDAP 服务器或其他方式中。


登录表单

登录表单是系统中最简单的部分,也是开始实现的最佳切入点。基本逻辑如下:向用户展示 HTML 表单,用户输入凭据,表单内容提交到登录系统的下一个处理步骤。

用户凭据通常是用户名和密码,但也可能包括其他内容(例如硬件令牌生成的随机值)。许多网站现在使用用户的电子邮件地址代替用户名,这样做的优点是电子邮件地址是唯一的,并且允许用户在不同平台上保持一致的用户名。

HTML 登录表单示例

<form action="/login" method="post">
  <p>
    <label for="email">Email address:</label>
    <input id="email" type="text" name="email" />
  </p>
  <p>
    <label for="password">Password:</label>
    <input id="password" type="password" name="password" />
  </p>
  <p>
    <input type="submit" name="login" value="Login" />
  </p>
</form>

表单数据会提交给一个处理认证和登录的脚本,该脚本是登录逻辑的核心。


登录表单中的数据可以提交到处理认证和登录过程的脚本中,真正的登录操作就在这个脚本中完成。

与创建或更改账户时需要验证输入不同,登录时我们只需要将用户输入的凭据与数据库中的数据进行匹配。任何非法输入(如没有“@”符号的电子邮件地址)都会导致匹配失败,登录将失败。所有用户提交的数据在传递到数据库时会进行安全转义,因此此时无需担心 SQL 注入攻击,并且对用户名或密码的长度或格式进行限制并不会带来安全上的好处。

登录处理脚本本身需要执行多个步骤。首先,它会启动一个会话(这通常也是“每次请求认证”的一部分,如下所述)。其次,它会查询数据库以查找匹配的用户;如果找不到匹配的用户,则登录尝试失败,用户将被重定向回登录表单。最后(假设用户确实存在),用户的标识符(用户名或电子邮件地址)将被保存为会话变量。

登录脚本的基本操作如下所示:

session_start();
$db = new Database(); // 数据库抽象类
$email_address = $db->esc($_POST['email']);
$password = $db->esc($_POST['password']);
$matching_users = $db->get_num_rows("SELECT 1 FROM `users` WHERE email_address='$email_address' AND password=crypt('$password', password) LIMIT 1");
if ($matching_users) {
    // 用户存在;登录用户。
    $_SESSION['email_address'] = $email_address;
    echo "您已成功登录。";
} else {
    // 登录失败;重新显示登录表单。
}

注意:

  • Database 数据库抽象类用于隐藏数据库实现细节,在本示例中使用,不能被视为任何实际存在的库类。
  • Database 类的 get_num_rows($sql) 方法返回来自 $sql 查询的行数。上面的 LIMIT 1 表示这将只返回0或1行。

此时,脚本可以显示欢迎信息,且认证信息将保留在用户会话中,用户无需每次加载页面时都登录。接下来,我们来看一下如何做到这一点。

每次请求检查

每次 HTTP 请求时(例如访问页面、图片等)都需要验证用户的身份。这实际上是通过查看相关会话变量是否已设置来完成的:

每次用户请求时需要执行的基本身份验证检查:

session_start();
if (!isset($_SESSION['email_address'])) {
    // 用户未登录,重定向到登录页面。
    header("Location:/login");
    die();
}
// 用户已登录;可以放置私人代码。

这种检查方法在很多情况下是足够的,但它容易受到多种攻击。它完全依赖于会话与正确的用户绑定。这个做法不好,因为会话可能被劫持(第三方可以窃取会话密钥)或被固定(第三方可以强制用户使用他们知道的会话密钥)。有关如何避免这些问题,请阅读本书的会话页面。

防止会话劫持或固定的身份验证检查

$timeout = 60 * 30; // 30分钟,单位为秒
$fingerprint = hash_hmac('sha256', $_SERVER['HTTP_USER_AGENT'], hash('sha256', $_SERVER['REMOTE_ADDR'], true));
session_start();
if (    (isset($_SESSION['last_active']) && $_SESSION['last_active'] < (time() - $timeout))
     || (isset($_SESSION['fingerprint']) && $_SESSION['fingerprint'] != $fingerprint)
     || isset($_GET['logout'])
    ) {
    setcookie(session_name(), '', time() - 3600, '/');
    session_destroy();
}
session_regenerate_id(); 
$_SESSION['last_active'] = time();
$_SESSION['fingerprint'] = $fingerprint;
// 此时用户已认证(即可以信任 $_SESSION['email_address'])。

$_SESSION['last_active']$_SESSION['fingerprint'] 变量也需要在初次登录时设置(即处理登录表单的地方),只需在设置 email_address 变量之前插入以下代码:

// 用于检查用户是否为登录的同一个用户
$fingerprint = hash_hmac('sha256', $_SERVER['HTTP_USER_AGENT'], hash('sha256', $_SERVER['REMOTE_ADDR'], true));
$_SESSION['last_active'] = time();
$_SESSION['fingerprint'] = $fingerprint;

然而,使用浏览器指纹时要记住的一点是,尽管它为应用程序增加了一定的安全性,但它并不是万无一失的。许多互联网服务提供商(ISP)提供动态 IP 地址,这种 IP 地址在某些时间间隔内会发生变化。如果用户在浏览页面时发生这种情况,他会被踢出账户。此外,用于检查浏览器是否相同的代码片段,可以通过修改请求页面的头部的 Firefox 扩展来进行修改。

这就是如何在 PHP5 中实现一个安全的用户认证系统!上述信息中有一些要点被简略处理了,也有一些被完全忽略了(例如如何仅在必要时启动会话)。如果您正在实现自己的用户登录系统,并且正在尝试遵循这里给出的建议,您肯定会在过程中遇到一些问题并希望这里能有更多的解释,所以请大胆编辑此页面,添加您认为缺失的内容。

最后修改: 2025年01月10日 星期五 02:42