Thursday, September 1, 2016

Authorization and Event Sourcing with prooph and ZF2 / ZF3

Authorization with prooph components and event sourced aggregates roots are a common problem to solve. Here is a short explanation on how to do this using Zend\Authentication. First of all, we need an aggregate root class for it. Here is a minimal example with some basic properties.
<?php

declare (strict_types=1);

namespace My\Identity\Model\Identity

class Identity extends \Prooph\EventSourcing\AggregateRoot
{
    /**
     * @var IdentityId
     */
    protected $identityId;

    /**
     * @var EmailAddress
     */
    protected $emailAddress;

    /**
     * @var string
     */
    protected $passwordHash;

    /**
     * @var Role[]
     */
    protected $roles;

    public function login(string $password) : bool
    {
        if (password_verify($password, $this->passwordHash)) {
            $this->recordThat(IdentityLoggedIn::with($this->identityId));

            if (password_needs_rehash($this->passwordHash, PASSWORD_DEFAULT)) {
                $this->rehashPassword($password);
            }

            return true;
        }

        $this->recordThat(IdentityLoginDenied::with($this->identityId));

        return false;
    }

    public function logout()
    {
        $this->recordThat(IdentityLoggedOut::with($this->identityId));
    }

    public function rehashPassword(string $password)
    {
        $passwordHash = password_hash($password, PASSWORD_DEFAULT);

        $this->recordThat(IdentityPasswordWasRehashed::withData(
            $this->identityId,
            $this->passwordHash,
            $passwordHash
        ));
    }

    protected function whenIdentityLoggedIn(IdentityLoggedIn $event)
    {
    }

    protected function whenIdentityLoginDenied(IdentityLoginDenied $event)
    {
    }

    protected function whenIdentityLoggedOut(IdentityLoggedOut $event)
    {
    }

    protected function whenIdentityPasswordWasRehashed(IdentityPasswordWasRehashed $event)
    {
        $this->passwordHash = $event->newPasswordHash();
    }
}
Additionally, we will need a read only version of the aggregate root:
<?php

declare (strict_types=1);

namespace My\Identity\Model\Identity\ReadOnly;

use My\Identity\Model\Identity\IdentityId;
use My\Identity\Model\Identity\Role;
use My\SharedKernel\Model\EmailAddress;
use ZfcRbac\Identity\IdentityInterface;

class Identity implements IdentityInterface
{
    /**
     * @var IdentityId
     */
    protected $identityId;

    /**
     * @var EmailAddress
     */
    protected $emailAddress;

    /**
     * @var Role[]
     */
    protected $roles;

    public function __construct(
        IdentityId $identityId,
        EmailAddress $emailAddress,
        array $roles
    ) {
        Assertion::notEmpty($roles);
        Assertion::allIsInstanceOf($roles, Role::class);

        $this->identityId = $identityId;
        $this->emailAddress = $emailAddress;
        $this->roles = $roles;
    }

    public function identityId() : IdentityId
    {
        return $this->identityId;
    }

    public function emailAddress() : EmailAddress
    {
        return $this->emailAddress;
    }

    /**
     * Get the list of roles of this identity
     *
     * @return string[]
     */
    public function getRoles()
    {
        $roles = [];

        foreach ($this->roles as $role) {
            $roles[] = $role->getName();
        }

        return $roles;
    }

    public static function fromArray(array $data) : Identity
    {
        Assertion::keyExists($data, 'identityId');
        Assertion::keyExists($data, 'emailAddress');
        Assertion::keyExists($data, 'roles');
        Assertion::isArray($data['roles']);
        Assertion::notEmpty($data['roles']);

        return new self(
            IdentityId::fromString($id),
            new EmailAddress($data['emailAddress']),
            $roles
        );
    }
}
And a projector, too:
<?php

declare (strict_types=1);

namespace My\Identity\Projection\Identity;

use Assert\Assertion;
use My\Identity\Model\Identity\Event\IdentityWasCreated;
use My\Identity\Model\Identity\Event\IdentityPasswordWasRehashed;
use Doctrine\MongoDB\Collection;
use Doctrine\MongoDB\Connection;

class IdentityProjector
{
    /**
     * @var Connection
     */
    private $connection;

    /**
     * @var string
     */
    private $dbName;

    /**
     * @param Connection $connection
     * @param string $dbName
     */
    public function __construct(Connection $connection, $dbName)
    {
        Assertion::minLength($dbName, 1);

        $this->connection = $connection;
        $this->dbName = $dbName;
    }

    public function onIdentityWasCreated(IdentityWasCreated $event)
    {
        $roles = [];
        foreach ($event->roles() as $role) {
            $roles[] = $role->getName();
        }

        $data = [
            '_id' => $event->identityId()->toString(),
            'roles' => $roles,
            'emailAddress' => $event->emailAddress()->toString(),
        ];

        $collection = $this->identityReadCollection();

        $collection->insert($data);
    }

    public function onIdentityPasswordWasRehashed(IdentityPasswordWasRehashed $event)
    {
        $this->identityReadCollection()->update(
            [
                '_id' => $event->identityId()->toString(),
            ],
            [
                '$set' => [
                    'passwordHash' => $event->newPasswordHash(),
                ],
            ]
        );
    }

    private function identityReadCollection()
    {
        return $this->connection->selectCollection($this->dbName, 'identity');
    }
}
This is a very simple example, omitting the event classes and value objects. It might be worth adding some additional methods and/ or properties, when needed. The login command simply takes the email address and password as parameters, that's simple enough for us now, so what's needed is a command handler for Login / Logout.
<?php

declare (strict_types=1);

namespace My\Identity\Model\Identity\Handler;

use My\Identity\Model\Identity\Command\Login;
use My\Identity\Model\Identity\Command\Logout;
use Zend\Authentication\Adapter\ValidatableAdapterInterface as AuthAdapter;
use Zend\Authentication\AuthenticationService;

/**
 * Class LoginLogoutHandler
 * @package My\Identity\Model\Identity\Handler
 */
final class LoginLogoutHandler
{
    /**
     * @var AuthenticationService
     */
    private $authenticationService;

    /**
     * @var AuthAdapter;
     */
    private $authAdapter;

    public function __construct(
        AuthenticationService $authenticationService,
        AuthAdapter $authAdapter
    ) {
        $this->authenticationService = $authenticationService;
        $this->authAdapter = $authAdapter;
    }

    public function handleLogin(Login $command)
    {
        $this->authenticationService->clearIdentity();

        $this->authAdapter->setIdentity($command->emailAddress()->toString());
        $this->authAdapter->setCredential($command->password()->toString());

        $auth = $this->authenticationService->authenticate($this->authAdapter);

        if (! $auth->isValid()) {
            throw new \RuntimeException('not authorized');
        }
    }

    public function handleLogout(Logout $command)
    {
        $this->authenticationService->clearIdentity();
    }
}
That should be enough for now. We also need an implementation of Zend\Authentication\Storage\StorageInterface. In this case, we use MongoDB as backend.
<?php

declare (strict_types=1);

namespace My\Identity\Infrastructure;

use Assert\Assertion;
use My\Identity\Model\Identity\ReadOnly\Identity;
use Doctrine\MongoDB\Connection;
use Zend\Authentication\Storage\StorageInterface;

final class AuthenticationStorage implements StorageInterface
{
    /**
     * @var StorageInterface
     */
    private $storage;

    /**
     * @var Connection
     */
    private $connection;

    /**
     * @var string
     */
    private $dbName;

    /**
     * @var mixed
     */
    private $resolvedIdentity;

    /**
     * AuthenticationStorage constructor.
     * @param StorageInterface $storage
     * @param Connection $connection
     * @param string $dbName
     */
    public function __construct(StorageInterface $storage, Connection $connection, $dbName)
    {
        Assertion::minLength($dbName, 1);

        $this->storage = $storage;
        $this->connection = $connection;
        $this->dbName = $dbName;
    }

    /**
     * Returns true if and only if storage is empty
     *
     * @throws \Zend\Authentication\Exception\InvalidArgumentException If it is impossible to determine whether
     * storage is empty or not
     * @return boolean
     */
    public function isEmpty()
    {
        if ($this->storage->isEmpty()) {
            return true;
        }

        $identity = $this->read();

        if ($identity === null) {
            $this->clear();

            return true;
        }

        return false;
    }

    /**
     * Returns the contents of storage
     *
     * Behavior is undefined when storage is empty.
     *
     * @throws \Zend\Authentication\Exception\InvalidArgumentException If reading contents from storage is impossible
     * @return mixed
     */
    public function read()
    {
        if (null !== $this->resolvedIdentity) {
            return $this->resolvedIdentity;
        }

        $identity = $this->connection->selectCollection($this->dbName, 'identity')->findOne([
            '_id' => $this->storage->read()
        ]);

        if (! $identity) {
            $this->resolvedIdentity = null;

            return;
        }

        $this->resolvedIdentity = Identity::fromArray($identity);

        return $this->resolvedIdentity;
    }

    /**
     * Writes $contents to storage
     *
     * @param  mixed $contents
     * @throws \Zend\Authentication\Exception\InvalidArgumentException If writing $contents to storage is impossible
     * @return void
     */
    public function write($contents)
    {
        $this->resolvedIdentity = null;
        $this->storage->write($contents);
    }

    /**
     * Clears contents from storage
     *
     * @throws \Zend\Authentication\Exception\InvalidArgumentException If clearing contents from storage is impossible
     * @return void
     */
    public function clear()
    {
        $this->resolvedIdentity = null;
        $this->storage->clear();
    }
}
Next we need an implementation of Zend\Authentication\Adapter\ValidatableAdapterInterface:
<?php

declare (strict_types=1);

namespace My\Identity\Infrastructure;

use Assert\Assertion;
use My\Identity\Model\Identity\IdentityCollection;
use My\Identity\Model\Identity\IdentityId;
use My\SharedKernel\Model\StringLiteral;
use Doctrine\MongoDB\Connection;
use MongoRegex;
use Zend\Authentication\Adapter\AbstractAdapter;
use Zend\Authentication\Result;

final class ZendMongoDbAuthAdapter extends AbstractAdapter
{
    /**
     * @var IdentityCollection
     */
    private $identityCollection;

    /**
     * @var Connection
     */
    private $connection;

    /**
     * @var string
     */
    private $dbName;

    /**
     * $authenticateResultInfo
     *
     * @var array
     */
    private $authenticateResultInfo = null;

    /**
     * ZendMongoDbAuthAdapter constructor.
     * @param IdentityCollection $identityCollection
     * @param Connection $connection
     * @param string $dbName
     */
    public function __construct(
        IdentityCollection $identityCollection,
        Connection $connection,
        $dbName
    ) {
        Assertion::minLength($dbName, 1);
        $this->identityCollection = $identityCollection;
        $this->connection = $connection;
        $this->dbName = $dbName;
    }

    /**
     * Performs an authentication attempt
     *
     * @return \Zend\Authentication\Result
     * @throws \Zend\Authentication\Adapter\Exception\ExceptionInterface If authentication cannot be performed
     */
    public function authenticate()
    {
        $this->authenticateResultInfo = [
            'code'     => Result::FAILURE,
            'identity' => $this->identity,
            'messages' => []
        ];

        $collection = $this->connection->selectCollection($this->dbName, 'identity');

        $resultIdentities = $collection->find(
            [
                'emailAddress' => new MongoRegex('/^' . $this->getIdentity() . '$/i')
            ],
            [
                '_id' => true
            ]
        )->toArray();

        if (($authResult = $this->authenticateValidateResultSet($resultIdentities)) instanceof Result) {
            return $authResult;
        }

        $identity = current($resultIdentities);

        return $this->authenticateValidateResult($identity);
    }

    /**
     * authenticateValidateResultSet() - This method attempts to make
     * certain that only one record was returned in the resultset
     *
     * @param  array $resultIdentities
     * @return bool|\Zend\Authentication\Result
     */
    private function authenticateValidateResultSet(array $resultIdentities)
    {
        if (count($resultIdentities) < 1) {
            $this->authenticateResultInfo['code']       = Result::FAILURE_IDENTITY_NOT_FOUND;
            $this->authenticateResultInfo['messages'][] = 'A record with the supplied identity could not be found.';

            return $this->authenticateCreateAuthResult();
        } elseif (count($resultIdentities) > 1) {
            $this->authenticateResultInfo['code']       = Result::FAILURE_IDENTITY_AMBIGUOUS;
            $this->authenticateResultInfo['messages'][] = 'More than one record matches the supplied identity.';

            return $this->authenticateCreateAuthResult();
        }

        return true;
    }

    /**
     * Creates a Zend\Authentication\Result object from the information that
     * has been collected during the authenticate() attempt.
     *
     * @return Result
     */
    private function authenticateCreateAuthResult()
    {
        return new Result(
            $this->authenticateResultInfo['code'],
            $this->authenticateResultInfo['identity'],
            $this->authenticateResultInfo['messages']
        );
    }

    /**
     * authenticateValidateResult() - This method attempts to validate that
     * the record in the resultset is indeed a record that matched the
     * identity provided to this adapter.
     *
     * @param  array $resultIdentity
     * @return Result
     */
    private function authenticateValidateResult($resultIdentity)
    {
        $identity = $this->identityCollection->get(IdentityId::fromString($resultIdentity['_id']));

        if (! $identity) {
            $this->authenticateResultInfo['code']       = Result::FAILURE_IDENTITY_NOT_FOUND;
            $this->authenticateResultInfo['messages'][] = 'Supplied identity not found.';

            return $this->authenticateCreateAuthResult();
        }

        if (! $identity->login(new StringLiteral($this->getCredential()))) {
            $this->authenticateResultInfo['code']       = Result::FAILURE_CREDENTIAL_INVALID;
            $this->authenticateResultInfo['messages'][] = 'Supplied credential is invalid.';

            return $this->authenticateCreateAuthResult();
        }

        $this->authenticateResultInfo['code']       = Result::SUCCESS;
        $this->authenticateResultInfo['identity']   = $identity->identityId()->toString();
        $this->authenticateResultInfo['messages'][] = 'Authentication successful.';

        return $this->authenticateCreateAuthResult();
    }
}
Now we need two little factories to create our infrastructure:
<?php

declare (strict_types=1);

namespace My\Identity\Container\Infrastructure;

use My\Identity\Infrastructure\AuthenticationStorage;
use Interop\Container\ContainerInterface;
use Zend\Authentication\Storage\Session;

final class AuthenticationStorageFactory
{
    public function __invoke(ContainerInterface $container) : AuthenticationStorage
    {
        $dbName = $container->get('Config')['projection_database'];

        return new AuthenticationStorage(
            new Session(),
            $container->get('doctrine_mongo_connection'),
            $dbName
        );
    }
}
and this one:
<?php

declare (strict_types=1);

namespace My\Identity\Container\Infrastructure;

use My\Identity\Infrastructure\AuthenticationStorage;
use Interop\Container\ContainerInterface;
use Zend\Authentication\AuthenticationService;

final class AuthenticationServiceFactory
{
    public function __invoke(ContainerInterface $container) : AuthenticationService
    {
        return new AuthenticationService($container->get(AuthenticationStorage::class));
    }
}
Last thing we need to do, is configure the service manager accordingly:
<?php
return [
    'factories' => [
        \My\Identity\Infrastructure\AuthenticationStorage::class => \My\Identity\Container\Infrastructure\AuthenticationStorageFactory::class,
        \Zend\Authentication\AuthenticationService::class => \My\Identity\Container\Infrastructure\AuthenticationServiceFactory::class,
        // for prooph's guard plugins:
        \Prooph\ServiceBus\Plugin\Guard\RouteGuard::class => \Prooph\ServiceBus\Container\Plugin\Guard\RouteGuardFactory::class,
        \Prooph\ServiceBus\Plugin\Guard\FinalizeGuard::class => \Prooph\ServiceBus\Container\Plugin\Guard\FinalizeGuardFactory::class,
        \Prooph\ServiceBus\Plugin\Guard\AuthorizationService::class => \Prooph\ServiceBusZfcRbacBridge\Container\ZfcRbacAuthorizationServiceBridgeFactory::class,
    ],
];
So when I did not forget anything, that's it! With the last 3 lines of service manager config, you can even use prooph's ServiceBus ZFC-RBAC-bridge

2 comments:

  1. If you want your `Identity` aggregate to be testable you could change your `login` function to also take a `PasswordInterface` interface as a second argument.
    In your tests you would then have an implementation of the `PasswordInterface` whose `create` method would simply return the password and the verify method checks that the password and hash are equivalent.
    This would require that your `ZendMongoDbAuthAdapter` (and other adapters?) has a reference to a `PasswordInterface` but that's easily solved using your container.

    ReplyDelete
    Replies
    1. Yes for sure. I just wanted to hold this sample implementation as simple as possible.

      Delete