PHP应用程序在MVC模式中构建安全API
Posted 嘶吼专业版
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了PHP应用程序在MVC模式中构建安全API相关的知识,希望对你有一定的参考价值。
继续工作
在本系列文章的第一部分和第二部分我介绍了一些我们构建API所需要的基础库和基本概念。现在我们将进入本系列文章的第三部分,在这之前,我想再回顾一下第一和第二部分的内容,总结一些可以帮助我们走的更长远的一些东西。我相信你已经注意到(在这个Git 仓库中查看本系列文章的“第二部分”的分支上的代码)在我们的index.php文件中的代码量有点大。我们已经定义了主应用程序,并为自定义处理程序更改了一些配置选项。即便只是简单的使用这些代码,保存到一个文件里也会变得有点冗长。(以上部分如需查阅可在原文中查阅)
使用MVC设计模式
在这个系列的文章中我们实现了很多功能,你可以将这些功能全部保存到一个文件中,不过,这将成为日后进行代码维护的“恶梦”。为了帮助我们解决便于代码维护的问题,我将使用一个用来处理大型应用程序的方法:模型/视图/控制器设计模式。
如果你还不熟悉这种结构,请看下面的简单介绍:
· 模型表示要处理的数据。在大多数数据库驱动的应用程序中,它们将与表直接关联,每个实体类型之间都存在着关系。
· 视图表示应用程序的输出,即客户端的html,在我们本系列文章中的API的输出是JSON或XML。
· 控制器是将模型和视图绑定在一起的“粘合剂”,并在将值发送到视图进行输出之前对值进行一些额外的处理。
这种结构的目标是基于单一责任原则将应用程序的功能分解成块。应用程序中的每个类/对象只能做一件事情。还有其他的部分被包含在功能更强大的MVC框架中,如服务提供者和其他业务逻辑处理程序,但我们在这里会使用简单的几个功能。虽然我们现在做的事情会涉及到一些额外的处理,但总的来说,我们将坚持使用纯粹的MVC组件。
我们将通过一些中间件功能扩展这个MVC结构,这一点我们在第一部分中简要的介绍过,让我们创建可重复使用的单用途的功能模块,这样我们就可以在整个系统中重复使用。
从我的朋友那得到的一点帮助
在PHP生态系统中有大量的MVC框架,我们可能会使用其中任何一个来完成我们在这里做的大部分工作。
正如你已经看到的那样,Slim框架为我们的应用程序提供了最主要的“骨架”,使我们能够将URL中的请求路由到正确的功能上。正如它的名字一样,这就是它所带来的所有功能。还有其他一些我们会用到的功能,主要是请求和响应处理。
vlucas/phpdotenv
该库用于从.env文件读取定义的内容(默认为当前目录)。这些.env文件包含你的应用程序的设置,并且可以将应用程序的设置保留在代码之外。然后将它们加载到$_ENV变量中,以便在应用程序中的任何地方都可以轻松引用。
aura/session
默认情况下,Slim是不附带会话处理程序的,使用PHP自己的$_SESSION功能可能会有点混乱。相反,我已经选择使用Aura组件集合中的这个包来帮助会话功能保持简洁。它在$_SESSION内部使用处理程序,所以它仍然使用相同的功能,只是会提供一个友好的界面。
illuminate/database
这是Laravel框架中的数据库组件,这个组件能使数据库表中的数据变得更简单。它是一个ORM(对象关系映射器)工具,它使用ActiveRecord结构来引用数据库中表示的实体和集合。该软件包还包括了我们将用于设置我们的连接的功能——Capsule。
doctrine/dbal
这个库需要使用Laravel数据库组件进行一些手动数据库查询。虽然从一开始可能不需要这个组件,但如果需要更复杂的查询,那么它将会派上用场。
robmorgan/phinx
最后,我们将安装Phinx数据库迁移管理器。这个Illuminate/database包在创建表之后需要处理表的所有事情,但我们仍然需要创建它们。Phinx可以轻松的根据需要运行或回滚迁移,并且比使用一大堆原始SQL语句更不容易出错。
要全部安装以上这些组件,可以执行下面这条简单的命令:
> composer require vlucas/phpdotenv aura/session illuminate/database doctrine/dbal robmorgan/phinx
./composer.json has been updated
Loading composer repositories with package information
Updating dependencies (including require-dev)
[...]
Writing lock file
Generating autoload files
这些软件包存在着许多其他的依赖关系,有几个来自于Symfony和Doctrine。不过不要太担心这些依赖关系。即使他们都与Slim一起安装, vendor/目录的大小也只有11MB,这比起任何其他应用程序来说都比较小。
你可能会问,为什么我们会需要这些程序包?所有这一切难道都不能用简单的PHP和SQL来完成吗?这个问题的答案是,这些程序包使得这些功能的开发更快速,因为它们已经经过很好的测试。
“应用程序”结构
现在让我们开始构建的过程吧,看看我们的应用程序将会是什么样子的,我们成功地移动了所有的东西,现在把它分解成各个功能部件。
App/
--> Controller/
--> Model/
--> View/
--> Middleware/
bootstrap/
--> app.php
--> db.php
--> routes.php
templates/
public/
db/
让我们一起来看看这个结构。我们的主要命名空间是App应用程序文件。这是App/目录下的所有文件,包括控制器,模型和任何可能需要的视图辅助类文件。在bootstrap目录的内部,我们将为我们的应用程序提供主要的配置文件。包括了一些基本的应用程序设置(如系列文章第一部分中的处理程序)和Slim应用程序配置。数据库连接信息将存放在db配置文件中,路由设置将在routes配置文件中。
最后的'templates'目录,可以存放任何我们可能需要的视图模板,该db目录将用于存放Phinx迁移的文件,public是放置了我们的前端控制器index.php文件的目录。
请注意,我们正在使用一个子目录作为文档的根目录。这有助于防止一些安全问题,例如.env中包含的各种敏感信息的文件可以直接在Web中访问。
如果你对这些目录不熟悉,你也不要担心,在文章的后面,我将带你操作每一步,并解释在任何一步中都发生了些什么。
现在要花点时间进行目录的创建:
mkdir App
mkdir bootstrap
mkdir templates
mkdir public
mkdir db
迁移
现在我们在index.php文件中已经定义了一些代码:
· 应用程序的引导
· 路由处理
· 根路径/请求的请求/响应处理程序
构建bootstrap
我们要把已有的代码进行修改,并把它们分解成我们想要的新结构。首先我们将从bootstrap开始。我们来看一下这个代码,把它移到一个bootstrap/app.php文件中,看起来像这样:
<?php
session_start();
require_once '../vendor/autoload.php';
$dotenv = new DotenvDotenv(BASE_PATH);
$dotenv->load();
$app = new SlimApp();
$container = $app->getContainer();
// Make the custom App autoloader
spl_autoload_register(function($class) {
$classFile = APP_PATH.'/../'.str_replace('', '/', $class).'.php';
if (!is_file($classFile)) {
throw new Exception('Cannot load class: '.$class);
}
require_once $classFile;
});
// Autoload in our controllers into the container
foreach (new DirectoryIterator(APP_PATH.'/Controller') as $fileInfo) {
if($fileInfo->isDot()) continue;
$class = 'AppController'.str_replace('.php', '', $fileInfo->getFilename());
$container[$class] = function($c) use ($class){
return new $class();
};
}
$container['notFoundHandler'] = function($container) {
return function ($request, $response) use ($container) {
return $container['response']
->withStatus(404)
->withHeader('Content-Type', 'application/json')
->write(json_encode(['error' => 'Resource not valid']));
};
};
$container['errorHandler'] = function($container) {
return function ($request, $response, $exception = null) use ($container) {
$code = 500;
$message = 'There was an error';
if ($exception !== null) {
$code = $exception->getCode();
$message = $exception->getMessage();
}
// Use this for debugging purposes
/*error_log($exception->getMessage().' in '.$exception->getFile().' - ('
.$exception->getLine().', '.get_class($exception).')');*/
return $container['response']
->withStatus($code)
->withHeader('Content-Type', 'application/json')
->write(json_encode([
'success' => false,
'error' => $message
]));
};
};
$container['notAllowedHandler'] = function($container) {
return function ($request, $response) use ($container) {
return $container['response']
->withStatus(401)
->withHeader('Content-Type', 'application/json')
->write(json_encode(['error' => 'Method not allowed']));
};
};
这是从我们之前创建的代码中复制粘贴的。在这里,我们正在创建应用程序,获取容器并设置我们的自定义处理程序,用于异常和未找到(404)/不允许(405)的问题。但是,文件开始处有一些额外的代码需要添加。
首先,在我们定义之前,你会注意到SlimApp调用了DotenvDotenv和它的load方法。这个方法会在根目录中的.env查找要加载的文件。我在系列文章中提到过vlucas/phpdotenv这个包,这就是我们使用它的地方。继续往下看,在这个项目的根目录(和public/不是一个级别)中,创建一个名为.env的文件,文件内容如下:
DB_HOST=localhost
DB_NAME=database_name
DB_USER=database_user
DB_PASS=database_password
以上内容为我们提供了我们稍后设置数据库连接会用到的更新模板。这些值将在运行时通过Dotenv处理程序加载到$_ENV变量中并在整个应用程序中使用。
如果你忘记了设置.env文件或这个文件位于一个错误的位置,则该程序包会抛出异常,并且你的应用程序将无法继续执行。
接下来让我们来看看自定义自动加载器。由于我们想要在App应用程序的各个部分中引用命名空间中的类,因此我们需要添加一个自定义的自动加载器来处理这些请求。我们利用spl_autoload_register函数来定义这个自动加载器,并使用它的APP_PATH找到匹配的文件。
下面的代码是Slim在使用控制器时需要的东西。正如我之前提到过的,Slim大量使用依赖注入容器来做很多的事情。这当然也包括了当从路由引用时解析控制器和动作方法。在我们的根路由示例中,我们只是直接输出了一些东西,但是可以很容易地转换成如下所示的代码:
<?php
class IndexController
{
public function index()
{
echo 'index!';
}
}
$app->get('/', 'IndexController:index');
上面定义的GET请求路由是Slim用于将HTTP请求正确的路由到IndexController中的index方法。但是,为了实现这一点,我们需要预先加载控制器。DirectoryIterator就是负责预加载的类,它会列出AppController目录的文件并加载到容器中。这样就可以轻松的定义我们的路由了。
编写前置控制器
现在我们将把我们的前置控制器放在public/index.php文件中。因为我们需要从我们的引导文件中引入代码,所以我们将把它包含在文件的起始位置处,并设置一些我们以后可以使用的其他常量:
<?php
define('BASE_PATH', __DIR__.'/..');
define('APP_PATH', BASE_PATH.'/App');
require_once BASE_PATH.'/vendor/autoload.php';
// Autorequire everything in BASE_PATH/bootstrap, loading app first - most important
require_once BASE_PATH.'/bootstrap/app.php';
foreach (new DirectoryIterator(BASE_PATH.'/bootstrap') as $fileInfo) {
if($fileInfo->isDot()) continue;
require_once $fileInfo->getPathname();
}
$app->run();
正如你在上面的代码中看到的,首先我们定义了可以跨应用程序使用的两个常量:BASE_PATH定义了Web应用程序的根目录(和public/是一个级别的), APP_PATH指向根目录下的App/文件夹。下面我们需要使用Composer将 BASE_PATH指向的路径作为源进行自动加载。
再往下一点的代码块会首先加载我们先前创建的引导文件bootstrap/app.php,这个文件定义了应用程序和处理程序。然后,使用DirectoryIterator加载bootstrap/目录中的任何文件。这样我们会在后面就能够更容易的添加更多的配置设置,包括我们的数据库和路由配置,而无需将它们手动包含在引导文件中。
public/index.php示例文件中的最后一行代码是调用应用程序对象上的run方法。这个方法是告诉Slim应该处理传入请求并输出响应(请求生命周期)的方法。
设置请求路由
现在我们已经编写了引导代码和前置控制器,我们需要使用新的MVC结构重新定义默认的/根路由。在bootstrap/目录中创建一个新文件:bootstrap/routes.php。这个文件由我们的bootstrap/app.php自动加载:
<?php
$app->get('/', 'AppControllerIndexController:index');
为了重新定义默认的/根路由,需要将/请求指向IndexController。由于我们已经将这些控制器注入到了我们的容器中,因此Slim可以解析这个文件并将其发送到需要的地方。我们稍后会在这个控制器中再添加一些功能。现在我们需要设置一个配置文件和数据库配置。
定义数据库配置
现在我们将创建数据库配置,利用Laravel's Enloquent包中附带的“Capsule”功能,就可以在Laravel应用程序之外使用Eloquent功能。由于我们已经使用.env文件定义了我们的数据库连接信息,所以我们在这里需要做的是通过一些代码来设置"Capsule":
<?php
$dbconfig = [
'driver' => 'mysql',
'host' => $_ENV['DB_HOST'],
'database' => $_ENV['DB_NAME'],
'username' => $_ENV['DB_USER'],
'password' => $_ENV['DB_PASS'],
'charset' => 'utf8',
'collation' => 'utf8_unicode_ci',
'prefix' => '',
];
$capsule = new IlluminateDatabaseCapsuleManager;
$capsule->addConnection($dbconfig);
$capsule->setAsGlobal();
$capsule->bootEloquent();
我在本教程中使用的是MySQL,但也可以使用其他数据库。请参阅Laravel手册以确定当前支持哪些数据库。在上面的代码中,我们首先从.env文件中定义的$dbconfig数组变量中加载的值来创建数据库配置。将凭证信息保存在环境变量中可以防止敏感信息泄露。
最后,我们通过$capsule对象的addConnection方法创建并传递数据库配置。最后两行代码能够使我们在全局应用程序中无缝地使用Eloquent的功能。
把代码放在一起
我们正在进入这个系列最为重要的部分。由于我们之前已经把一些重要的事情准备好了,所以把这些功能合并起来就比较容易了。
我们先从“base”控制器开始,这个控制器包含了一些简单的方法,然后我们可以在所有的控制器中调用。一些OOP / MVC的纯粹主义者可能会不赞同这个想法。创建一个新的文件AppControllerBaseController.php包含如下代码:
<?php
namespace AppController;
class BaseController
{
protected $container;
/**
* Initialize the controller with the container
*
* @param SlimContainer $container Container instance
*/
public function __construct(SlimContainer $container)
{
$this->container = $container;
}
/**
* Magic method to get things off of the container by referencing
* them as properties on the current object
*/
public function __get($property)
{
// Special property fetch for user
if ($property == 'user') {
return $user = $this->container->get('session')->get('user');
}
if (isset($this->container, $property)) {
return $this->container->$property;
}
return null;
}
/**
* Handle the response and put it into a standard JSON structure
*
* @param boolean $status Pass/fail status of the request
* @param string $message Message to put in the response [optional]
* @param array $addl Set of additional information to add to the response [optional]
*/
public function jsonResponse($status, $message = null, array $addl = [])
{
$output = ['success' => $status];
if ($message !== null) {
$output['message'] = $message;
}
if (!empty($addl)) {
$output = array_merge($output, $addl);
}
$response = $this->response->withHeader('Content-type', 'application/json');
$body = $response->getBody();
$body->write(json_encode($output));
return $response;
}
/**
* Handle a failure response
*
* @param string $message Message to put in response [optional]
* @param array $addl Set of additional information to add to the response [optional]
*/
public function jsonFail($message = null, array $addl = [])
{
return $this->jsonResponse(false, $message, $addl);
}
/**
* Handle a success response
*
* @param string $message Message to put in response [optional]
* @param array $addl Set of additional information to add to the response [optional]
*/
public function jsonSuccess($message = null, array $addl = [])
{
return $this->jsonResponse(true, $message, $addl);
}
}
我们的BaseController只是定义了一些辅助方法,例如JSON响应的输出标准化。jsonSuccess和jsonFail只是jsonResponse方法的抽象方法。
另外还定义了__get方法。这是一种PHP魔术方法,当从不存在或不是公开的对象请求属性时将调用此方法。在这种情况下,我们希望能够从容器中获得更多的东西。此外,它还有一些额外的代码,例如让用户注销会话等。
此外,你还将注意到,我们正在使用BaseController的__construct方法接收当前容器的初始化实例。Slim在调用控制器时自动执行此操作,这使得基本控制器和扩展它的类都可以访问到该控制器。
接下来,我们将创建IndexController来处理/请求,所以AppControllerIndexController.php文件的代码如下:
<?php
namespace AppController;
class IndexController extends AppControllerBaseController
{
public function index()
{
return $this->jsonSuccess('Hello world!');
}
}
你会注意到我们已经利用jsonSuccess方法返回了一个 “Hello world!” 。
发起请求
现在,一切都已准备就绪,你可以通过简单的HTTP调用来测试调用API的结果。首先,我们使用之前用过的PHP内置的Web服务器来启动应用程序:
cd public/
php -S localhost:8000
{
success: true,
message: "Hello world!"
}
或者,你也可以使用curl来发起请求:
$ curl http://localhost:8000
{"success":true,"message":"Hello world!"}
写在最后
在这一部分中我做了很多代码重构的事情,并为API应用程序增加了复杂性。我知道创建一个“简单的”API似乎有点不太可能,但是请相信我,当我们添加其他功能时,你就会觉得更容易了。
和之前一样,你可以通过查看GitHub仓库,获取我们创建的最新版本的API代码: https://github.com/psecio/secure-api。master分支是最新的版本,每个“part *”分支是该系列中每一部分的代码。如果你在本地创建的代码中出现了错误,请在仓库中找到正确的代码,看看它们之间是否存在差异。
最后,我们需要回顾一下,这个系列的第三部分所做的大部分事情都是在重构应用程序,目的是使得在未来的构建工作中能简单地整合多个API,为今后的事情奠定基础。通过这种重构,我们可以开始了解一些有趣的事情:用户登录的设计以及使用某些中间件来让工作变得更加简单。
资源
请点击下方“阅读原文”查看。
以上是关于PHP应用程序在MVC模式中构建安全API的主要内容,如果未能解决你的问题,请参考以下文章
将 Angular 和 .net core web api 安全地集成到现有的 MVC 5 应用程序中