使用中间件进行认证

中间件的一个非常重要的用途是提供认证。大多数基于网络的应用程序都需要通过用户名和密码来验证访问者的能力。通过将PSR-7标准整合到一个认证类中,你将使它在全局范围内通用,可以说,它足够安全,可以在任何提供符合PSR-7的请求和响应对象的框架中使用。

如何做…

1.我们首先定义一个Application\Acl\AuthenticateInterface类。我们使用这个接口来支持Adapter软件设计模式,使我们的Authenticate类更加通用,允许各种适配器,每个适配器都可以从不同的源头(例如,从一个文件,使用OAuth2,等等)获取认证。注意使用 PHP 7 的能力来定义返回值数据类型。

  1. namespace Application\Acl;
  2. use Psr\Http\Message\ { RequestInterface, ResponseInterface };
  3. interface AuthenticateInterface
  4. {
  5. public function login(RequestInterface $request) :
  6. ResponseInterface;
  7. }

{% hint style=”info” %} 请注意,通过定义一个需要符合PSR-7标准的请求并产生符合PSR-7标准的响应的方法,我们已经使这个接口普遍适用。 {% endhint %}

  1. 接下来,我们定义实现接口所需的login()方法的适配器。我们确保使用适当的类,并定义合适的常量和属性。构造函数使用了Application\Database\Connection,它在第5章,与数据库的交互中定义。
  1. namespace Application\Acl;
  2. use PDO;
  3. use Application\Database\Connection;
  4. use Psr\Http\Message\ { RequestInterface, ResponseInterface };
  5. use Application\MiddleWare\ { Response, TextStream };
  6. class DbTable implements AuthenticateInterface
  7. {
  8. const ERROR_AUTH = 'ERROR: authentication error';
  9. protected $conn;
  10. protected $table;
  11. public function __construct(Connection $conn, $tableName)
  12. {
  13. $this->conn = $conn;
  14. $this->table = $tableName;
  15. }
  1. 核心的login()方法从请求对象中提取用户名和密码。然后我们直接进行数据库查询。如果有匹配的信息,我们将用户信息存储在JSON编码的响应体中。
  1. public function login(RequestInterface $request) :
  2. ResponseInterface
  3. {
  4. $code = 401;
  5. $info = FALSE;
  6. $body = new TextStream(self::ERROR_AUTH);
  7. $params = json_decode($request->getBody()->getContents());
  8. $response = new Response();
  9. $username = $params->username ?? FALSE;
  10. if ($username) {
  11. $sql = 'SELECT * FROM ' . $this->table
  12. . ' WHERE email = ?';
  13. $stmt = $this->conn->pdo->prepare($sql);
  14. $stmt->execute([$username]);
  15. $row = $stmt->fetch(PDO::FETCH_ASSOC);
  16. if ($row) {
  17. if (password_verify($params->password,
  18. $row['password'])) {
  19. unset($row['password']);
  20. $body =
  21. new TextStream(json_encode($row));
  22. $response->withBody($body);
  23. $code = 202;
  24. $info = $row;
  25. }
  26. }
  27. }
  28. return $response->withBody($body)->withStatus($code);
  29. }
  30. }

{% hint style=”info” %} 最佳实践

永远不要用明文存储密码。当你需要进行密码匹配时,使用password_verify(),这样就可以否定重现密码哈希的必要性。 {% endhint %}

  1. Authenticate类是一个实现AuthenticationInterface的适配器类的封装器。相应地,构造函数将一个适配器类作为参数,以及一个作为密钥的字符串,认证信息存储在$_SESSION中。
  1. namespace Application\Acl;
  2. use Application\MiddleWare\ { Response, TextStream };
  3. use Psr\Http\Message\ { RequestInterface, ResponseInterface };
  4. class Authenticate
  5. {
  6. const ERROR_AUTH = 'ERROR: invalid token';
  7. const DEFAULT_KEY = 'auth';
  8. protected $adapter;
  9. protected $token;
  10. public function __construct(
  11. AuthenticateInterface $adapter, $key)
  12. {
  13. $this->key = $key;
  14. $this->adapter = $adapter;
  15. }
  1. 此外,我们还提供了一个带有安全令牌的登录表单,这有助于防止跨站点请求伪造(CSRF)攻击。
  1. public function getToken()
  2. {
  3. $this->token = bin2hex(random_bytes(16));
  4. $_SESSION['token'] = $this->token;
  5. return $this->token;
  6. }
  7. public function matchToken($token)
  8. {
  9. $sessToken = $_SESSION['token'] ?? date('Ymd');
  10. return ($token == $sessToken);
  11. }
  12. public function getLoginForm($action = NULL)
  13. {
  14. $action = ($action) ? 'action="' . $action . '" ' : '';
  15. $output = '<form method="post" ' . $action . '>';
  16. $output .= '<table><tr><th>Username</th><td>';
  17. $output .= '<input type="text" name="username" /></td>';
  18. $output .= '</tr><tr><th>Password</th><td>';
  19. $output .= '<input type="password" name="password" />';
  20. $output .= '</td></tr><tr><th>&nbsp;</th>';
  21. $output .= '<td><input type="submit" /></td>';
  22. $output .= '</tr></table>';
  23. $output .= '<input type="hidden" name="token" value="';
  24. $output .= $this->getToken() . '" />';
  25. $output .= '</form>';
  26. return $output;
  27. }
  1. 最后,该类中的login()方法会检查token是否有效。如果无效,则返回一个400响应。否则,适配器的login()方法将被调用。
  1. public function login(
  2. RequestInterface $request) : ResponseInterface
  3. {
  4. $params = json_decode($request->getBody()->getContents());
  5. $token = $params->token ?? FALSE;
  6. if (!($token && $this->matchToken($token))) {
  7. $code = 400;
  8. $body = new TextStream(self::ERROR_AUTH);
  9. $response = new Response($code, $body);
  10. } else {
  11. $response = $this->adapter->login($request);
  12. }
  13. if ($response->getStatusCode() >= 200
  14. && $response->getStatusCode() < 300) {
  15. $_SESSION[$this->key] =
  16. json_decode($response->getBody()->getContents());
  17. } else {
  18. $_SESSION[$this->key] = NULL;
  19. }
  20. return $response;
  21. }
  22. }

如何运行…

首先,一定要按照附录《定义PSR-7类》中定义的事例。接下来,继续定义本配方中所介绍的类,总结如下表。

Class 在这些步骤中
Application\Acl\AuthenticateInterface 1
Application\Acl\DbTable 2 - 3
Application\Acl\Authenticate 4 - 6

然后你可以定义一个chap_09_middleware_authenticate.php调用程序,设置自动加载并使用相应的类。

  1. <?php
  2. session_start();
  3. define('DB_CONFIG_FILE', __DIR__ . '/../config/db.config.php');
  4. define('DB_TABLE', 'customer_09');
  5. define('SESSION_KEY', 'auth');
  6. require __DIR__ . '/../Application/Autoload/Loader.php';
  7. Application\Autoload\Loader::init(__DIR__ . '/..');
  8. use Application\Database\Connection;
  9. use Application\Acl\ { DbTable, Authenticate };
  10. use Application\MiddleWare\ { ServerRequest, Request, Constants, TextStream };

现在您可以设置认证适配器和核心类了。

  1. $conn = new Connection(include DB_CONFIG_FILE);
  2. $dbAuth = new DbTable($conn, DB_TABLE);
  3. $auth = new Authenticate($dbAuth, SESSION_KEY);

一定要对传入的请求进行初始化,并将请求设置为认证类。

  1. $incoming = new ServerRequest();
  2. $incoming->initialize();
  3. $outbound = new Request();

检查传入类方法是否为POST。如果是,则向认证类传递一个请求。

  1. if ($incoming->getMethod() == Constants::METHOD_POST) {
  2. $body = new TextStream(json_encode(
  3. $incoming->getParsedBody()));
  4. $response = $auth->login($outbound->withBody($body));
  5. }
  6. $action = $incoming->getServerParams()['PHP_SELF'];
  7. ?>

显示逻辑是这样的。

  1. <?= $auth->getLoginForm($action) ?>

以下是无效验证尝试的输出。注意右边的401状态码。在这个例子中,你可以添加一个响应对象的var_dump()

使用中间件进行认证 - 图1

这里是一个成功的认证。

使用中间件进行认证 - 图2

更多…

有关如何避免CSRF和其他攻击的指导,请参见第12章《提高网站安全》。