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 (https://github.com/prooph/service-bus-zfc-rbac-bridge/)