顾名思义,中间件位于函数或方法调用序列的中间。相应地,中间件很适合做 "守门人 "的任务。你可以很容易地用中间件类实现访问控制列表(ACL)机制,中间件类读取ACL,并允许或拒绝对序列中下一个函数或方法调用的访问。
如何做...
1.这个过程中最困难的部分可能是确定哪些因素要包括在ACL中。为了说明问题,我们假设我们的用户都被分配了一个 level
和一个 status
。在这个例子中,级别的定义如下。
'levels' => [0, 'BEG', 'INT', 'ADV']
2. 状态可以表明他们在会员注册过程中的进展情况。例如,状态为0表示他们已经启动了会员注册程序,但尚未得到确认。状态为1时,表示他们的电子邮件地址已经确认,但他们还没有支付月费,以此类推。
3. 接下来,我们需要定义我们计划控制的资源。在本例中,我们将假设需要控制对网站上一系列网页的访问。据此,我们需要定义一个此类资源的数组。在ACL中,我们就可以参考键。
'pages' => [0 => 'sorry', 'logout' => 'logout', 'login' => 'auth',
1 => 'page1', 2 => 'page2', 3 => 'page3',
4 => 'page4', 5 => 'page5', 6 => 'page6',
7 => 'page7', 8 => 'page8', 9 => 'page9']
4. 最后,最重要的一项配置是根据级别和状态对页面进行分配。配置数组中使用的通用模板可能是这样的。
status => ['inherits' => <key>, 'pages' => [level => [pages allowed], etc.]]
5. 现在我们可以定义Acl类了。和之前一样,我们使用一些类,并定义适合访问控制的常量和属性。
namespace Application\Acl;
use InvalidArgumentException;
use Psr\Http\Message\RequestInterface;
use Application\MiddleWare\ { Constants, Response, TextStream };
class Acl
{
const DEFAULT_STATUS = '';
const DEFAULT_LEVEL = 0;
const DEFAULT_PAGE = 0;
const ERROR_ACL = 'ERROR: authorization error';
const ERROR_APP = 'ERROR: requested page not listed';
const ERROR_DEF =
'ERROR: must assign keys "levels", "pages" and "allowed"';
protected $default;
protected $levels;
protected $pages;
protected $allowed;
6. 在 __construct()
方法中,我们将分配数组分解为 $pages
(要控制的资源)、$levels
和 $allowed
(实际的分配)。如果数组不包括这三个子组件中的一个,就会抛出一个异常。
public function __construct(array $assignments)
{
$this->default = $assignments['default']
?? self::DEFAULT_PAGE;
$this->pages = $assignments['pages'] ?? FALSE;
$this->levels = $assignments['levels'] ?? FALSE;
$this->allowed = $assignments['allowed'] ?? FALSE;
if (!($this->pages && $this->levels && $this->allowed)) {
throw new InvalidArgumentException(self::ERROR_DEF);
}
}
7. 您可能已经注意到,我们允许继承。在$allowed
中,继承的键可以设置为数组中的另一个键。如果是这样,我们需要将它的值与当前正在检查的值合并。我们反向迭代$allowed
,每次通过循环合并任何继承的值。顺便说一下,这个方法也只是隔离适用于某个状态和级别的规则。
protected function mergeInherited($status, $level)
{
$allowed = $this->allowed[$status]['pages'][$level]
?? array();
for ($x = $status; $x > 0; $x--) {
$inherits = $this->allowed[$x]['inherits'];
if ($inherits) {
$subArray =
$this->allowed[$inherits]['pages'][$level]
?? array();
$allowed = array_merge($allowed, $subArray);
}
}
return $allowed;
}
8. 在处理授权时,我们会初始化一些变量,然后从原始请求URI中提取所请求的页面。如果页面参数不存在,我们设置一个400代码。
public function isAuthorized(RequestInterface $request)
{
$code = 401; // unauthorized
$text['page'] = $this->pages[$this->default];
$text['authorized'] = FALSE;
$page = $request->getUri()->getQueryParams()['page']
?? FALSE;
if ($page === FALSE) {
$code = 400; // bad request
9. 否则,我们对请求体内容进行解码,并获得状态和级别。然后我们就可以调用mergeInherited()
,它返回一个可以访问这个状态和级别的页面数组。
} else {
$params = json_decode(
$request->getBody()->getContents());
$status = $params->status ?? self::DEFAULT_LEVEL;
$level = $params->level ?? '*';
$allowed = $this->mergeInherited($status, $level);
10. 如果请求的页面在$allowed
数组中,我们就将状态码设置为200,并将授权设置连同请求的页面代码对应的网页一起返回。
if (in_array($page, $allowed)) {
$code = 200; // OK
$text['authorized'] = TRUE;
$text['page'] = $this->pages[$page];
} else {
$code = 401; }
}
11. 然后我们返回JSON编码的响应,我们就完成了。
$body = new TextStream(json_encode($text));
return (new Response())->withStatus($code)
->withBody($body);
}
}
如何运行...
之后,你将需要定义ApplicationA\cl\Acl
,这将在本文中讨论。现在移动到/path/to/source/for/this/chapter
文件夹,创建两个目录:public
和pages
。在pages
中,创建一系列PHP文件,如page1.php
、page2.php
等。下面是这些页面中的一个例子。
<?php // page 1 ?>
<h1>Page 1</h1>
<hr>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. etc.</p>
你也可以定义一个 menu.php
页面,它可以被包含在输出中。
<?php // menu ?>
<a href="?page=1">Page 1</a>
<a href="?page=2">Page 2</a>
<a href="?page=3">Page 3</a>
// etc.
logout.php
页面应该销毁会话。
<?php
$_SESSION['info'] = FALSE;
session_destroy();
?>
<a href="/">BACK</a>
auth.php
页面将显示一个登录界面(如前所述)。
<?= $auth->getLoginForm($action) ?>
然后可以创建一个配置文件,允许根据级别和状态来访问网页。为了便于说明,调用chap_09_middleware_acl_config.php
,并返回一个数组,看起来像这样。
<?php
$min = [0, 'logout'];
return [
'default' => 0, // default page
'levels' => [0, 'BEG', 'INT', 'ADV'],
'pages' => [0 => 'sorry',
'logout' => 'logout',
'login' => 'auth',
1 => 'page1', 2 => 'page2', 3 => 'page3',
4 => 'page4', 5 => 'page5', 6 => 'page6',
7 => 'page7', 8 => 'page8', 9 => 'page9'],
'allowed' => [
0 => ['inherits' => FALSE,
'pages' => [ '*' => $min, 'BEG' => $min,
'INT' => $min,'ADV' => $min]],
1 => ['inherits' => FALSE,
'pages' => ['*' => ['logout'],
'BEG' => [1, 'logout'],
'INT' => [1,2, 'logout'],
'ADV' => [1,2,3, 'logout']]],
2 => ['inherits' => 1,
'pages' => ['BEG' => [4],
'INT' => [4,5],
'ADV' => [4,5,6]]],
3 => ['inherits' => 2,
'pages' => ['BEG' => [7],
'INT' => [7,8],
'ADV' => [7,8,9]]]
]
];
最后,在公共文件夹中,定义index.php
,它设置了自动加载,并最终调用Authenticate
和Acl
类。就像其他示例一样,定义配置文件,设置自动加载,并使用某些类。另外,别忘了启动会话。
<?php
session_start();
session_regenerate_id();
define('DB_CONFIG_FILE', __DIR__ . '/../../config/db.config.php');
define('DB_TABLE', 'customer_09');
define('PAGE_DIR', __DIR__ . '/../pages');
define('SESSION_KEY', 'auth');
require __DIR__ . '/../../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/../..');
use Application\Database\Connection;
use Application\Acl\ { Authenticate, Acl };
use Application\MiddleWare\ { ServerRequest, Request, Constants, TextStream };
最佳实践
最好的做法是保护你的会话。一个简单的方法是使用 session_regenerate_id()
,它可以使现有的 PHP 会话标识符无效并生成一个新的标识符。因此,如果攻击者通过非法手段获得会话标识符,任何给定的会话标识符的有效时间窗口都会被保持在最小范围内。
现在你可以调出ACL配置,并为Authenticate
以及Acl
创建实例。
$config = require __DIR__ . '/../chap_09_middleware_acl_config.php';
$acl = new Acl($config);
$conn = new Connection(include DB_CONFIG_FILE);
$dbAuth = new DbTable($conn, DB_TABLE);
$auth = new Authenticate($dbAuth, SESSION_KEY);
接下来,定义入站和出站请求实例。
$incoming = new ServerRequest();
$incoming->initialize();
$outbound = new Request();
如果传入的请求方法是post
,则调用login()
方法处理认证。
if (strtolower($incoming->getMethod()) == Constants::METHOD_POST) {
$body = new TextStream(json_encode(
$incoming->getParsedBody()));
$response = $auth->login($outbound->withBody($body));
}
如果为认证定义的会话密钥被填充,就意味着用户已经成功认证。如果没有,我们编写一个匿名函数,后面会调用,其中包括认证登录页面。
$info = $_SESSION[SESSION_KEY] ?? FALSE;
if (!$info) {
$execute = function () use ($auth) {
include PAGE_DIR . '/auth.php';
};
否则,你可以继续进行ACL检查。首先需要从原始查询中找到用户想要访问的网页。
} else {
$query = $incoming->getServerParams()['QUERY_STRING'] ?? '';
然后,您可以重新编写 $outbound
请求,以包含这些信息。
$outbound->withBody(new TextStream(json_encode($info)));
$outbound->getUri()->withQuery($query);
接下来,你就可以检查授权了,提供出站请求作为参数。
$response = $acl->isAuthorized($outbound);
然后可以检查返回响应的授权参数,并编程匿名函数,如果OK则包含返回页参数,否则包含抱歉页。
$params = json_decode($response->getBody()->getContents());
$isAllowed = $params->authorized ?? FALSE;
if ($isAllowed) {
$execute = function () use ($response, $params) {
include PAGE_DIR .'/' . $params->page . '.php';
echo '<pre>', var_dump($response), '</pre>';
echo '<pre>', var_dump($_SESSION[SESSION_KEY]);
echo '</pre>';
};
} else {
$execute = function () use ($response) {
include PAGE_DIR .'/sorry.php';
echo '<pre>', var_dump($response), '</pre>';
echo '<pre>', var_dump($_SESSION[SESSION_KEY]);
echo '</pre>';
};
}
}
现在你需要做的就是设置表单动作,并将匿名函数包装在HTML中。
$action = $incoming->getServerParams()['PHP_SELF'];
?>
<!DOCTYPE html>
<head>
<title>PHP 7 Cookbook</title>
<meta http-equiv="content-type" content="text/html;charset=utf-8" />
</head>
<body>
<?php $execute(); ?>
</body>
</html>
为了测试它,你可以使用内置的PHP web服务器,但你需要使用-t标志来表明文档根是公开的。
cd /path/to/source/for/this/chapter
php -S localhost:8080 -t public
从浏览器中,您可以访问http://localhost:8080/ 。
如果试图访问任何页面,您会被重定向回登录页面。根据配置,status=1,level=BEG的用户只能访问第1页并注销。如果你以该用户身份登录时,试图访问第2页,这里是输出结果。
更多...
这个例子依靠$_SESSION
作为用户登录后唯一的认证手段。关于如何保护PHP会话的好例子,请参见第12章,提高网站安全,特别是题为保护PHP会话的示例。