教程 3: 加强INVO的安全

在这一节,我们继续讲解INVO的结构,我们会谈到关于身份验证实现、通过事件机制完成授权、插件和一个由Phalcon管理的访问控制列表(ACL)。

In this chapter, we continue explaining how INVO is structured, we’ll talk about the implementation of authentication, authorization using events and plugins and an access control list (ACL) managed by Phalcon.

登录到应用程序

“登录”功能将使我们可以使用后端控制器。分离前端和后端控制器是合乎逻辑的。所有的控制器都位于同一目录(app/controllers/)。

A “log in” facility will allow us to work on backend controllers. The separation between backend controllers and frontend ones is only logical. All controllers are located in the same directory (app/controllers/).

要想登录进入系统,用户必须有一个有效的用户名和密码。用户信息存储在INVO的数据库的“users”数据表中。

To enter the system, users must have a valid username and password. Users are stored in the table “users” in the database “invo”.

在我们开始一个会话之前,我们需要配置应用连接到数据库。在服务容器中创建一个名为“db”并且包含有连接信息的服务。我们使用自动加载器从配置文件中加载参数并配置好一个服务:

Before we can start a session, we need to configure the connection to the database in the application. A service called “db” is set up in the service container with the connection information. As with the autoloader, we are again taking parameters from the configuration file in order to configure a service:

  1. <?php
  2. use Phalcon\Db\Adapter\Pdo\Mysql as DbAdapter;
  3. // ...
  4. // Database connection is created based on parameters defined in the configuration file
  5. $di->set('db', function() use ($config) {
  6. return new DbAdapter(array(
  7. "host" => $config->database->host,
  8. "username" => $config->database->username,
  9. "password" => $config->database->password,
  10. "dbname" => $config->database->name
  11. ));
  12. });

在这里我们返回MySQL连接适配器的一个实例。如果需要我们可以额外添加一个日志记录服务、分析器或更改适配器设置,按照自己的需求修改。

Here, we return an instance of the MySQL connection adapter. If needed, you could do extra actions such as adding a logger, a profiler or change the adapter, setting it up as you want.

以下简单的表单(app/views/session/index.volt)用于让用户填写登录信息。已经删除一些HTML代码让例子看着更加简洁:

The following simple form (app/views/session/index.volt) requests the login information. We’ve removed some HTML code to make the example more concise:

  1. {{ form('session/start') }}
  2. <fieldset>
  3. <div>
  4. <label for="email">Username/Email</label>
  5. <div>
  6. {{ text_field('email') }}
  7. </div>
  8. </div>
  9. <div>
  10. <label for="password">Password</label>
  11. <div>
  12. {{ password_field('password') }}
  13. </div>
  14. </div>
  15. <div>
  16. {{ submit_button('Login') }}
  17. </div>
  18. </fieldset>
  19. </form>

与前面的教程中直接使用php相比我们现在开始使用 Volt。这是一个内置的模板引擎,Volt在 Jinja 启发下完成的,它提供一个更简单和友好的语法来创建模板。我们熟悉volt模板引擎不会花费太多时间。

Instead of using raw PHP as the previous tutorial, we started to use Volt. This is a built-in template engine inspired in Jinja providing a simpler and friendly syntax to create templates. It will not take too long before you become familiar with Volt.

(app/controllers/SessionController.php)中的SessionController::startAction函数验证用户提交的数据并且检查用户在数据库中是否有效:

The SessionController::startAction function (app/controllers/SessionController.php) has the task of validating the data entered in the form including checking for a valid user in the database:

  1. <?php
  2. class SessionController extends ControllerBase
  3. {
  4. // ...
  5. private function _registerSession($user)
  6. {
  7. $this->session->set('auth', array(
  8. 'id' => $user->id,
  9. 'name' => $user->name
  10. ));
  11. }
  12. /**
  13. * This action authenticate and logs an user into the application
  14. *
  15. */
  16. public function startAction()
  17. {
  18. if ($this->request->isPost()) {
  19. $email = $this->request->getPost('email');
  20. $password = $this->request->getPost('password');
  21. $user = Users::findFirst(array(
  22. "(email = :email: OR username = :email:) AND password = :password: AND active = 'Y'",
  23. 'bind' => array('email' => $email, 'password' => sha1($password))
  24. ));
  25. if ($user != false) {
  26. $this->_registerSession($user);
  27. $this->flash->success('Welcome ' . $user->name);
  28. return $this->forward('invoices/index');
  29. }
  30. $this->flash->error('Wrong email/password');
  31. }
  32. return $this->forward('session/index');
  33. }
  34. }

为了简单起见,我们使用“sha1”算法加密用户密码然后保存在数据库中。然而,sha1算法不推荐在正式的应用中使用,推荐使用”bcrypt“。

For the sake of simplicity, we have used “sha1” to store the password hashes in the database, however, this algorithm is not recommended in real applications, use “bcrypt” instead.

注意,这里有多个控制器的公共属性可以访问:$this->flash, $this->request or $this->session。这些服务在之前的服务容器中被定义(app/config/services.php)。当这些服务被第一次访问后,他们就注入到了当前控制器中并作为其一部分。

Note that multiple public attributes are accessed in the controller like: $this->flash, $this->request or $this->session. These are services defined in the services container from earlier (app/config/services.php). When they’re accessed the first time, they are injected as part of the controller.

这些服务是“共享”的,这意味着我们总是能够访问同样的服务实例而不用去管我们在哪些地方调用过。

These services are “shared”, which means that we are always accessing the same instance regardless of the place where we invoke them.

例如,在下面代码中我们调用“session”服务,然后我们将用户标识存储在变量“auth”中:

For instance, here we invoke the “session” service and then we store the user identity in the variable “auth”:

  1. <?php
  2. $this->session->set('auth', array(
  3. 'id' => $user->id,
  4. 'name' => $user->name
  5. ));

本节另一个重要方面是如何验证用户的身份真实有效,首先我们验证请求是否是POST方法传过来的:

Another important aspect of this section is how the user is validated as a valid one, first we validate whether the request has been made using method POST:

  1. <?php
  2. if ($this->request->isPost()) {

然后从表单接受参数:

Then, we receive the parameters from the form:

  1. <?php
  2. $email = $this->request->getPost('email');
  3. $password = $this->request->getPost('password');

然后检查是否有这个用户名或者邮件的用户并且密码相同:

Now, we have to check if there is one user with the same username or email and password:

  1. <?php
  2. $user = Users::findFirst(array(
  3. "(email = :email: OR username = :email:) AND password = :password: AND active = 'Y'",
  4. 'bind' => array('email' => $email, 'password' => sha1($password))
  5. ));

注意,这里使用了’绑定参数’,占位符:email: 和 :password: 通过参数“bind”完成绑定,并会被实际的值替换。安全的替换这些值可以防止SQL注入。

Note, the use of ‘bound parameters’, placeholders :email: and :password: are placed where values should be, then the values are ‘bound’ using the parameter ‘bind’. This safely replaces the values for those columns without having the risk of a SQL injection.

如果用户有效就会把他注册到会话中,然后引导用户到控制面板:

If the user is valid we register it in session and forwards him/her to the dashboard:

  1. <?php
  2. if ($user != false) {
  3. $this->_registerSession($user);
  4. $this->flash->success('Welcome ' . $user->name);
  5. return $this->forward('invoices/index');
  6. }

如果用户不存在会返回到用户登录页面:

If the user does not exist we forward the user back again to action where the form is displayed:

  1. <?php
  2. return $this->forward('session/index');

加强后端

后端是一个私有领域只有注册用户才可以访问。因此,必须检查只有注册用户访问这些控制器。如果不登录到应用程序中,尝试访问产品控制器(私有)我们将看到如下结果:

The backend is a private area where only registered users have access. Therefore, it is necessary to check that only registered users have access to these controllers. If you aren’t logged into the application and you try to access, for example, the products controller (which is private) you will see a screen like this:

../_images/invo-2.png

每次有人试图访问任何控制器/动作,应用验证当前会话用户的角色判断是否能够访问它,如果没有权限就会显示一个如上的消息并重定向到主页。

Every time someone attempts to access any controller/action, the application verifies that the current role (in session) has access to it, otherwise it displays a message like the above and forwards the flow to the home page.

现在让我们看看应用程序如何实现这一点。首先要知道的是有一个叫 分配器dispatching 的组件。路由 通知它去负责加载适当的控制器和执行相应的动作方法。

Now let’s find out how the application accomplishes this. The first thing to know is that there is a component called Dispatcher. It is informed about the route found by the Routing component. Then, it is responsible for loading the appropriate controller and execute the corresponding action method.

正常情况下,框架会自动创建分配器。在我们的例子中,我们要在执行所需的动作之前检查用户是否可以访问它,为了达到这个目标,我们需要在启动文件中用一个匿名函数替代这个组件:

Normally, the framework creates the Dispatcher automatically. In our case, we want to perform a verification before executing the required action, checking if the user has access to it or not. To achieve this, we have replaced the component by creating a function in the bootstrap:

  1. <?php
  2. use Phalcon\Mvc\Dispatcher;
  3. // ...
  4. /**
  5. * MVC dispatcher
  6. */
  7. $di->set('dispatcher', function() {
  8. // ...
  9. $dispatcher = new Dispatcher;
  10. return $dispatcher;
  11. });

我们现在可以完全控制应用程序中的分配器。框架中的许多组件能够触发让我们修改其内部操作流的事件。因为有依赖项注入器组件充当粘合剂,一个新的名为 事件管理器 的组件使我们能够拦截一个组件的事件,并将事件路由给它的侦听器。

We now have total control over the Dispatcher used in the application. Many components in the framework trigger events that allow us to modify their internal flow of operation. As the Dependency Injector component acts as glue for components, a new component called EventsManager allows us to intercept the events produced by a component, routing the events to listeners.

事件管理器

事件管理器 允许我们将侦听器附加到一个特定类型的事件。我们现在感兴趣的是“分配”事件。下面的代码过滤了所有由分配器产生的事件:

An EventsManager allows us to attach listeners to a particular type of event. The type that interests us now is “dispatch”. The following code filters all events produced by the Dispatcher:

  1. <?php
  2. use Phalcon\Mvc\Dispatcher;
  3. use Phalcon\Events\Manager as EventsManager;
  4. $di->set('dispatcher', function() {
  5. $eventsManager = new EventsManager;
  6. /**
  7. * Check if the user is allowed to access certain action using the SecurityPlugin
  8. */
  9. $eventsManager->attach('dispatch:beforeDispatch', new SecurityPlugin);
  10. /**
  11. * Handle exceptions and not-found exceptions using NotFoundPlugin
  12. */
  13. $eventsManager->attach('dispatch:beforeException', new NotFoundPlugin);
  14. $dispatcher = new Dispatcher;
  15. $dispatcher->setEventsManager($eventsManager);
  16. return $dispatcher;
  17. });

当”beforeDispatch”事件被触发时,下面的插件将会被通知:

When an event called “beforeDispatch” is triggered the following plugin will be notified:

  1. <?php
  2. /**
  3. * Check if the user is allowed to access certain action using the SecurityPlugin
  4. */
  5. $eventsManager->attach('dispatch:beforeDispatch', new SecurityPlugin);

当”beforeException”事件被触发时,另一个插件将会被通知:

When a “beforeException” is triggered then other plugin is notified:

  1. <?php
  2. /**
  3. * Handle exceptions and not-found exceptions using NotFoundPlugin
  4. */
  5. $eventsManager->attach('dispatch:beforeException', new NotFoundPlugin);

安全插件在(app/plugins/SecurityPlugin.php)这个目录。这个类实现了”beforeDispatch”这个方法。方法的名称和上面分配器里面的事件名称一样。

SecurityPlugin is a class located at (app/plugins/SecurityPlugin.php). This class implements the method “beforeDispatch”. This is the same name as one of the events produced in the Dispatcher:

  1. <?php
  2. use Phalcon\Acl;
  3. use Phalcon\Events\Event;
  4. use Phalcon\Mvc\User\Plugin;
  5. use Phalcon\Mvc\Dispatcher;
  6. class SecurityPlugin extends Plugin
  7. {
  8. // ...
  9. public function beforeDispatch(Event $event, Dispatcher $dispatcher)
  10. {
  11. // ...
  12. }
  13. }

钩子事件总是获得包含了产生事件的上下文信息($event)作为第一个参数,第二个参数是产生该事件的对象本身($dispatcher)。这不是强制性的,插件类是Phalcon\Mvc\User\Plugin的扩展,这样做更容易获得应用程序中可用的服务。

The hook events always receive a first parameter that contains contextual information of the event produced ($event) and a second one that is the object that produced the event itself ($dispatcher). It is not mandatory that plugins extend the class Phalcon\Mvc\User\Plugin, but by doing this they gain easier access to the services available in the application.

现在我们在当前会话中验证用户角色,使用ACL列表检查用户是否有访问权限。如果没有则重定向到主页:

Now, we’re verifying the role in the current session, checking if the user has access using the ACL list. If the user does not have access we redirect to the home screen as explained before:

  1. <?php
  2. use Phalcon\Acl;
  3. use Phalcon\Events\Event;
  4. use Phalcon\Mvc\User\Plugin;
  5. use Phalcon\Mvc\Dispatcher;
  6. class Security extends Plugin
  7. {
  8. // ...
  9. public function beforeExecuteRoute(Event $event, Dispatcher $dispatcher)
  10. {
  11. //Check whether the "auth" variable exists in session to define the active role
  12. $auth = $this->session->get('auth');
  13. if (!$auth) {
  14. $role = 'Guests';
  15. } else {
  16. $role = 'Users';
  17. }
  18. //Take the active controller/action from the dispatcher
  19. $controller = $dispatcher->getControllerName();
  20. $action = $dispatcher->getActionName();
  21. //Obtain the ACL list
  22. $acl = $this->getAcl();
  23. //Check if the Role have access to the controller (resource)
  24. $allowed = $acl->isAllowed($role, $controller, $action);
  25. if ($allowed != Acl::ALLOW) {
  26. //If he doesn't have access forward him to the index controller
  27. $this->flash->error("You don't have access to this module");
  28. $dispatcher->forward(
  29. array(
  30. 'controller' => 'index',
  31. 'action' => 'index'
  32. )
  33. );
  34. //Returning "false" we tell to the dispatcher to stop the current operation
  35. return false;
  36. }
  37. }
  38. }

创建ACL列表

在上面的例子中我们通过使用$this->getAcl()方法获得ACL。这种方法也是在插件中实现的。现在我们要逐步解释我们如何建立访问控制列表(ACL):

In the above example we have obtained the ACL using the method $this->_getAcl(). This method is also implemented in the Plugin. Now we are going to explain step-by-step how we built the access control list (ACL):

  1. <?php
  2. use Phalcon\Acl;
  3. use Phalcon\Acl\Role;
  4. use Phalcon\Acl\Adapter\Memory as AclList;
  5. // Create the ACL
  6. $acl = new AclList();
  7. // The default action is DENY access
  8. $acl->setDefaultAction(Acl::DENY);
  9. // Register two roles, Users is registered users
  10. // and guests are users without a defined identity
  11. $roles = array(
  12. 'users' => new Role('Users'),
  13. 'guests' => new Role('Guests')
  14. );
  15. foreach ($roles as $role) {
  16. $acl->addRole($role);
  17. }

现在我们为每个区域分别定义资源。控制器名称为资源名,控制器中的方法就是访问该资源的权限名:

Now, we define the resources for each area respectively. Controller names are resources and their actions are accesses for the resources:

  1. <?php
  2. use Phalcon\Acl\Resource;
  3. // ...
  4. // Private area resources (backend)
  5. $privateResources = array(
  6. 'companies' => array('index', 'search', 'new', 'edit', 'save', 'create', 'delete'),
  7. 'products' => array('index', 'search', 'new', 'edit', 'save', 'create', 'delete'),
  8. 'producttypes' => array('index', 'search', 'new', 'edit', 'save', 'create', 'delete'),
  9. 'invoices' => array('index', 'profile')
  10. );
  11. foreach ($privateResources as $resource => $actions) {
  12. $acl->addResource(new Resource($resource), $actions);
  13. }
  14. // Public area resources (frontend)
  15. $publicResources = array(
  16. 'index' => array('index'),
  17. 'about' => array('index'),
  18. 'register' => array('index'),
  19. 'errors' => array('show404', 'show500'),
  20. 'session' => array('index', 'register', 'start', 'end'),
  21. 'contact' => array('index', 'send')
  22. );
  23. foreach ($publicResources as $resource => $actions) {
  24. $acl->addResource(new Resource($resource), $actions);
  25. }

ACL现在能够识别所有的控制器及其相关的动作。“Users”角色的用户应该能够访问所有的前端和后端资源。“Guests”的角色只有访问公共区域:

The ACL now have knowledge of the existing controllers and their related actions. Role “Users” has access to all the resources of both frontend and backend. The role “Guests” only has access to the public area:

  1. <?php
  2. // Grant access to public areas to both users and guests
  3. foreach ($roles as $role) {
  4. foreach ($publicResources as $resource => $actions) {
  5. $acl->allow($role->getName(), $resource, '*');
  6. }
  7. }
  8. // Grant access to private area only to role Users
  9. foreach ($privateResources as $resource => $actions) {
  10. foreach ($actions as $action) {
  11. $acl->allow('Users', $resource, $action);
  12. }
  13. }

Hooray!,ACL控制列表完成了,在下一节我们会看到在phalcon中CRUD是如何实现的以及我们如何自定义它们。

Hooray!, the ACL is now complete. In next chapter, we will see how a CRUD is implemented in Phalcon and how you can customize it.