activecollab/authentication

6.0.1 2023-11-07 11:38 UTC

README

Build Status

目录

认证用户是谁?

认证库建立在 activecollab/user 包之上。我们识别出三种类型的访问者

  1. 未识别的访问者 - 我们对他们的了解一无所知
  2. 已识别的访问者 - 当他们提供电子邮件地址时,我们识别了这些人
  3. 拥有账户的用户 - 在我们的应用程序中拥有实际账户的人

只有在我们应用程序中拥有账户的用户才能进行认证

访问用户

在集成此包时,编写一个实现 ActiveCollab\Authentication\AuthenticatedUser\RepositoryInterface 的类。实现此接口将使库能够根据用户的 ID 和用户名找到用户

<?php

namespace MyApp;

class MyUsersRepository implements \ActiveCollab\Authentication\AuthenticatedUser\RepositoryInterface
{
    /**
     * {@inheritdoc}
     */
    public function findById($user_id)
    {
        // Find and return user by ID
    }

    /**
     * {@inheritdoc}
     */
    public function findByUsername($username)
    {
        // Find and return user by username (can be an email address as well)
    }
}

授权者

授权者用于将用户凭据与特定认证服务(存储的用户、LDAP/AD、IdP 等)存储的数据进行授权

关键授权方法是 verifyCredentials。它接收一个包含凭据的数组,并期望在授权成功时返回 ActiveCollab\Authentication\AuthenticatedUser\AuthenticatedUserInterface 实例,或者在授权失败时返回 null。某些实现可能决定抛出异常,以明确区分授权失败的各种原因(用户未找到、密码无效、用户账户临时或永久挂起等)。

以下是一个授权者实现的示例,它从用户存储库中获取用户并验证用户的密码

<?php

namespace MyApp;

use ActiveCollab\Authentication\Authorizer\AuthorizerInterface;
use ActiveCollab\Authentication\AuthenticatedUser\RepositoryInterface;
use ActiveCollab\Authentication\Exception\InvalidPasswordException;
use ActiveCollab\Authentication\Exception\UserNotFoundException;
use InvalidArgumentException;

class MyAuthorizer implements AuthorizerInterface
{
    /**
     * @var RepositoryInterface
     */
    private $user_repository;

    /**
     * @param RepositoryInterface $user_repository
     */
    public function __construct(RepositoryInterface $user_repository)
    {
        $this->user_repository = $user_repository;
    }

    /**
     * {@inheritdoc}
     */
    public function verifyCredentials(array $credentials)
    {
        if (empty($credentials['username'])) {
            throw new InvalidArgumentException('Username not found in credentials array');
        }
        
        if (empty($credentials['password'])) {
            throw new InvalidArgumentException('Password not found in credentials array');
        }

        $user = $this->user_repository->findByUsername($credentials['username']);
        
        if (!$user) {
            throw new UserNotFoundException();
        }
        
        if (!$user->isValidPassword($credentials['password'])) {
            throw new InvalidPasswordException();        
        }

        return $user;
    }
}

请求感知授权者

请求感知授权者更进一步。它们提供了一个接收 PSR-7 请求、从其中提取凭据和默认有效负载的机制(或基于它们)。这对于授权者需要请求数据验证和解析时非常有用。例如,SAML 授权者需要解析 SAML 有效负载以从中提取相关凭据。

为了使授权者成为请求感知的,它还需要实现 ActiveCollab\Authentication\Authorizer\RequestAware\RequestAwareInterface,并实现可以接收 Psr\Http\Message\ServerRequestInterface 并返回处理结果的请求处理器

<?php

namespace MyApp;

use ActiveCollab\Authentication\Authorizer\AuthorizerInterface;
use ActiveCollab\Authentication\Authorizer\RequestAware\RequestAwareInterface;

class MyAuthorizer implements AuthorizerInterface, RequestAwareInterface
{
    /**
     * {@inheritdoc}
     */
    public function verifyCredentials(array $credentials)
    {
    }
    
    /**
     * {@inheritdoc}
     */
    public function getRequestProcessor()
    {
    }
}

异常感知授权者

授权者可以设置为异常感知。这种授权者具有 handleException() 方法,应该在授权异常时调用。对于系统将授权者视为异常感知,它需要实现 ActiveCollab\Authentication\Authorizer\ExceptionAware\ExceptionAwareInterface 接口。

如果需要以特定方式处理错误(例如,将用户重定向到外部 SSO),或者如果想要实施一些额外的保护措施(例如,如以下示例中所示的暴力登录保护),这将非常有用。

<?php

namespace MyApp;

use ActiveCollab\Authentication\Authorizer\AuthorizerInterface;
use ActiveCollab\Authentication\Authorizer\ExceptionAware\ExceptionAwareInterface;
use Exception;

class InvalidPasswordException extends Exception
{
}

class MyAuthorizer implements AuthorizerInterface, ExceptionAwareInterface
{
    /**
     * {@inheritdoc}
     */
    public function verifyCredentials(array $credentials)
    {
        if ($this->shouldCoolDown($credentials)) {
            return null;
        }
        
        if ($this->checkUserPassword($credentials['password'])) {
            // Proceed with auth
        } else {
            throw new InvalidPasswordException('Password not valid.');            
        }
    }
    
    /**
     * {@inheritdoc}
     */
    public function handleException(array $credentials, $error_or_exception)
    {
        if ($error_or_exception instanceof InvalidPasswordException) {
            $this->logPasswordFailure($credentials, $error_or_exception);
        }
    }
    
    private function shouldCoolDown(array $credentials)
    {
        // Return true if incorrect password is entered multiple times, so user needs to wait before they can proceed.
    }
    
    private function logPasswordFailure(array $credentials, $error)
    {
        // Log
    }
    
    private function checkUserPassword(array $credentials)
    {
        // Check if user password is OK.
    }
}

此外,异常处理可以委托给异常处理器。对于默认的 ExceptionAware 来检测授权者是否使用处理程序来处理异常,授权者需要实现 ActiveCollab\Authentication\Authorizer\ExceptionAware\DelegatesToHandler\DelegatesToHandlerInterface

所有内置的授权者都是异常感知的,并且可以接收处理程序

<?php

namespace MyApp;

use ActiveCollab\Authentication\Authorizer\AuthorizerInterface;
use ActiveCollab\Authentication\Authorizer\ExceptionAware\ExceptionHandler\ExceptionHandlerInterface;
use ActiveCollab\Authentication\Authorizer\LocalAuthorizer;
use Throwable;

class MyExceptionHandler implements ExceptionHandlerInterface
{
    public function handleException(array $credentials, Throwable $error_or_exception): void
    {
        // Do something with an exception.
    }
}

$local_authorizer = new LocalAuthorizer($user_repo, AuthorizerInterface::USERNAME_FORMAT_ALPHANUM, new MyExceptionHandler());

传输

在认证和授权步骤期间,此库返回封装了与给定处理步骤相关的所有相关认证元素的传输对象

  1. AuthenticationTransportInterface 在初始认证时返回。它可以空,当请求不包含任何嵌入的用户ID(令牌或会话)时,或者当系统在请求中找到有效ID时,它可以包含有关已认证用户、认证方式、使用的适配器等信息。
  2. AuthorizationTransportInterface 在用户向授权者提供其凭据时返回。
  3. CleanUpTransportInterface 在请求中找到ID,但已过期,需要清理时返回。
  4. DeauthenticationTransportInterface - 当用户请求从系统中注销时返回。

认证和授权传输可以应用于响应(和请求),以便使用适当的标识数据对其进行签名(例如,设置或扩展用户会话cookie)。

if (!$transport->isApplied()) {
    list ($request, $response) = $transport->applyTo($request, $response);
}

事件

认证实用程序会抛出事件,您可以为其编写处理器。以下是一个示例

<?php

namespace MyApp;

use ActiveCollab\Authentication\Authentication;

$auth = new Authentication([]);
$auth->onUserAuthorizationFailed(function(array $credentials) {
    // Log attempt for user's username.
});
$auth->onUserAuthorizationFailed(function(array $credentials) {
    // If third attempt, notify administrator that particular user has trouble logging in.
});
$auth->onUserAuthorizationFailed(function(array $credentials) {
    // If fifth attempt, block IP address for a couple of minutes, to cool it down.
});

如上例所示,您可以为同一事件提供多个处理器。以下事件可用

  1. onUserAuthenticated - (访问)用户通过其会话cookie、令牌等被识别,因此已认证。回调函数提供的参数是用户实例 [AuthenticatedUserInterface] 和认证结果 [AuthenticationResultInterface]。
  2. onUserAuthorized (登录)用户提供了有效的凭据,系统已授权。回调函数提供的参数是用户实例 [AuthenticatedUserInterface] 和认证结果 [AuthenticationResultInterface]。
  3. onUserAuthorizationFailed (登录失败)用户尝试授权,但提供的凭据无效,或由于其他原因(SSO服务中断等)授权失败。回调函数提供的参数是用户的凭据 [数组] 以及失败原因 ([Exception] 或 [Throwable])。
  4. onUserSet - 用户已设置 - 认证、授权器或应用程序使用自己的逻辑设置了用户。回调函数提供的参数是用户实例 [AuthenticatedUserInterface]。
  5. setOnUserDeauthenticated (注销)用户已注销。回调函数提供的参数是终止的认证方法 [AuthenticationResultInterface]。

认证中间件

AuthenticationInterface 接口假设实现将以这种方式进行,使其可以作为 PSR-7 中间件堆栈中的中间件来调用。这就是为什么在中间件堆栈模式中实现 __invoke 方法是接口的一部分。

接口的默认实现(ActiveCollab\Authentication\Authentication)是以这种方式实现的,即它在被调用时通过查看服务器请求来初始化认证。初始化过程将在请求中查找ID(令牌、会话cookie等,取决于使用的适配器),并在找到时加载适当的用户账户。用户和认证方式(令牌、会话等)被设置为请求属性(分别称为 authenticated_userauthenticated_with)并传递到中间件堆栈。您可以在内部中间件中检查这些属性

以下是一个检查已认证用户并返回401未授权状态的中间件示例

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

/**
 * @package ActiveCollab\Authentication\Middleware
 */
class CheckAuthMiddleware
{
    /**
     * @param  ServerRequestInterface $request
     * @param  ResponseInterface      $response
     * @param  callable|null          $next
     * @return ResponseInterface
     */
    public function __invoke(ServerRequestInterface $request, ResponseInterface $response, callable $next = null)
    {
        if (empty($request->getAttribute('authenticated_user'))) {
            return $response->withStatus(401);
        }

        if ($next) {
            $response = $next($request, $response);
        }

        return $response;
    }
}

在请求处理过程中,认证可以更改

  1. 用户可以登录
  2. 用户可以注销
  3. 系统可能需要清理认证工件(如cookie)

系统可以通过在值容器中提供适当的认证传输来封装有关这些事件的信息,并将它们传递给 ActiveCollab\Authentication\Middleware\ApplyAuthenticationMiddleware 来传达这些更改

<?php

use ActiveCollab\Authentication\Middleware\ApplyAuthenticationMiddleware;
use ActiveCollab\ValueContainer\Request\RequestValueContainer;

$middleware_stack->add(new ApplyAuthenticationMiddleware(
    new RequestValueContainer('authentication')
));

注意:值容器可以是任何实现 ActiveCollab\ValueContainer\ValueContainerInterface 的对象。此容器可以围绕DI容器或其他值存储方式包装。为了方便起见,我们提供了一个值容器,它围绕服务器请求(RequestValueContainer)包装,可以从中提取请求属性中的传输。

上述示例将指导 ApplyAuthenticationMiddleware 检查 authentication_transport 属性,并在找到时将其应用于请求和响应。

ApplyAuthenticationMiddleware 构造函数的第二个参数是 $apply_on_exit 参数。它允许您配置何时应用传输 - 是进入中间件栈时还是退出时。默认为 false(进入中间件栈时)

<?php

use ActiveCollab\Authentication\Middleware\ApplyAuthenticationMiddleware;
use ActiveCollab\ValueContainer\Request\RequestValueContainer;

$middleware_stack->add(new ApplyAuthenticationMiddleware(
    new RequestValueContainer('authentication'),
    true // Apply when exiting middleware stack.
));

注意:我们为什么在单独的中间件中这样做,而不是在认证中间件的退出部分做,是因为我们可能需要清理请求(例如,移除无效的cookie)。

Authentication middlewares

处理密码

哈希和验证密码

密码可以使用以下三种机制之一进行哈希处理

  1. PHP 内置的 password_* 函数。这是默认且推荐的方法
  2. 使用 PBKDF2
  3. 使用 SHA1

后两种仅出于兼容性考虑,因此您可以将哈希密码过渡到 PHP 的密码管理系统,如果您还没有这样做的话。密码管理器的 needsRehash() 方法将始终建议重新哈希 PBKDF2 和 SHA1 哈希的密码。

示例

$manager = new PasswordManager('global salt, if needed');

$hash = $manager->hash('easy to remember, hard to guess');

if ($manager->verify('easy to remember, hard to guess', $hash, PasswordManagerInterface::HASHED_WITH_PHP)) {
    print "All good\n";
} else {
    print "Not good\n";
}

库提供了一种方法来检查密码是否需要重新哈希,通常在您成功检查用户提供的密码是否正确后

$manager = new PasswordManager('global salt, if needed');

if ($manager->verify($user_provided_password, $hash_from_storage, PasswordManagerInterface::HASHED_WITH_PHP)) {
    if ($manager->needsRehash($hash_from_storage, PasswordManagerInterface::HASHED_WITH_PHP)) {
        // Update hash in our data storage
    }
    
    // Proceed with user authentication
} else {
    print "Invalid password\n";
}

密码策略

所有密码都会与密码策略进行验证。默认情况下,策略会接受任何非空字符串

(new PasswordStrengthValidator())->isPasswordValid('weak', new PasswordPolicy()); // Will return TRUE

策略可以强制执行以下规则

  1. 密码长度超过 N 个字符
  2. 密码至少包含一个数字
  3. 密码包含大小写字母(大写和小写)
  4. 密码至少包含以下符号之一: ,.;:!$\%^&~@#*

以下是一个强制执行所有规则的示例

// Weak password, not accepted
(new PasswordStrengthValidator())->isPasswordValid('weak', new PasswordPolicy(32, true, true, true));
 
// Strong password, accepted
(new PasswordStrengthValidator())->isPasswordValid('BhkXuemYY#WMdU;QQd4QpXpcEjbw2XHP', new PasswordPolicy(32, true, true, true));

密码策略实现了 \JsonSerializable 接口,可以使用 json_encode() 安全地将其编码为 JSON。

生成随机密码

密码强度验证器还可以用于准备符合提供策略要求的密码

$validator = new PasswordStrengthValidator();
$policy = new PasswordPolicy(32, true, true, true);

// Prepare 32 characters long password that mixes case, numbers and symbols
$password = $validator->generateValidPassword(32, $policy); 

密码生成器默认使用字母和数字,除非提供的密码策略需要符号。

请注意,如果生成器在 10000 次尝试后仍无法准备密码,则可能会抛出异常。

登录策略

登录策略被适配器用于发布其登录页面设置。这些设置包括

  1. 用户名字段的格式。支持的值是 emailusername
  2. 适配器是否支持“记住我”选项以用于扩展会话
  3. 用户是否可以更改密码
  4. 登录、登出、密码重置和更新个人资料 URL。这些 URL 由实现离站认证的适配器使用,因此使用这些适配器的应用程序可以将用户重定向到正确的页面。

以下示例展示了如何使用设置器调用配置不同的设置。您也可以在构造新的 LoginPolicy 实例时设置所有这些设置

$login_policy = (new LoginPolicy())
    ->setUsernameFormat(LoginPolicyInterface::USERNAME_FORMAT_EMAIL)
    ->setRememberExtendsSession(true)
    ->setIsPasswordChangeEnabled(true)
    ->setIsPasswordRecoveryEnabled(true)
    ->setExternalLoginUrl('http://idp.example.com/login')
    ->setExternalLogoutUrl('http://idp.example.com/logout')
    ->setExternalChangePasswordUrl('http://idp.example.com/change-password')
    ->setExternalUpdateProfileUrl('http://idp.example.com/update-profile');

登录策略实现了 \JsonSerializable 接口,可以使用 json_encode() 安全地将其编码为 JSON。

待办事项

  1. 考虑添加之前使用的密码存储库,以便库可以强制执行密码不重复策略。
  2. 移除已弃用的 AuthenticationInterface::setOnAuthenciatedUserChanged()