RFC: AccountInterface and Authentication rework

Continuing the discussion from Could we build a version of Flow without doctrine orm / database requirement:

I would like to suggest a major conceptual change in the whole authentication implementation in Flow & Neos.
tl;dr I suggest a different mental model, code-wise the change is not that big, see code examples below

Lets start with the problem space and collect some issues we currently have:

  • The security framework is bound to the Account entity thus requires persistence & a database to be setup (the main reason for the original thread to be posted)
  • It’s not possible to authenticate the same account with different auth types (e.g. username/password & basic auth) leading to quirky work-arounds and bugs
  • When implementing 3rd party authentication providers you have to create an Account entity instance that must not be persisted (which is not only weird but error-prone)

IMO it was a bad idea to bind the account to its auth provider. And as a follow-up to have the Party (i.e. Neos Users) be related to multiple accounts potentially.
Especially the latter doesn’t really make sense if you think about it:
What permissions do you currently have if two accounts are authenticated that belong to the same party but have different roles?

I think we should built upon @sorenmalling’s great work on the AccountIdentifier but also change some fundamental behavior by extracting credentials from the account.
And instead of binding an account to an Authentication Provider we could introduce the notion of an Authentication Realm (e.g. “Neos Backend” or “Some custom FE login”, …).

As a result the AccountInterface would become as simple as:

interface AccountInterface
{

    public function getIdentifier(): AccountIdentifier;

    public function getRealm(): AuthenticationRealm;

    public function getRoles(): Roles;

}

And there would be a corresponding entity, for example:

/**
 * @Flow\Entity
 */
final class PersistedAccount implements AccountInterface
{
    // ...
}

…and an implementation for “3rd party accounts” (SSO etc):

final class TransientAccount implements AccountInterface
{
    // ...
}

For the default username/password authentication we would have one more entity that encapsulates what we currently have as accountIdentifier and credentialsSource, for example:

/**
 * @Flow\Entity
 */
final class UsernameAndPassword
{
    public function getAccountIdentifier(): AccountIdentifier
    {
        // ...
    }

    public function getRealm(): AuthenticationRealm
    {
        // ...
    }

    public function getUsername(): Username
    {
        // ...
    }

    public function getHashedPassword(): HashedPassword
    {
        // ...
    }
}

For backwards compatibility we could keep the current Account entity and turn it into an adapter, like:

/**
 * @Flow\Entity
 * @deprecated
 */
class Account implements AccountInterface
{

    public function getCredentialsSource(): ?string
    {
        $credentials = $this->usernameAndPasswordRepository->findOneByAccountIdentifier($this->accountIdentifier);
        return $credentials ? $credentials->getHashedPassword()->toString() : null;
    }
    
    // and so on
}

The PersistedUsernamePasswordProvider::authenticate() would change from (simplified):

$account = $this->accountRepository->findActiveByAccountIdentifierAndAuthenticationProviderName($username, $this->name);

$authenticationToken->setAuthenticationStatus(TokenInterface::WRONG_CREDENTIALS);

if ($account === null) {
    // error
}

if (!$this->hashService->validatePassword($password, $account->getCredentialsSource())) {
    // error
}

to (simplified):

$realm = $this->options['realm'] ?? $this->name;
$accountCredentials = $this->usernameAndPasswordRepository->findOneByUsernameAndRealm($username, $realm);
if (!$this->hashService->validatePassword($password, $accountCredentials->getHashedPassword())) {
    // error
}
$account = $this->persistedAccountRepository->findActiveByAccountIdentifierAndRealm($credentials->getAccountIdentifier(), $realm);

In order to implement parallel authentication mechanisms we’d no longer need one account per provider (which doesn’t make sense as described above). Instead we could specify the “realm” in the corresponding provider (potentially with the provider name as default value for b/c and easier configuration):

Neos:
  Flow:
    security:
      authentication:
        providers:
          'Acme.SomePackage:Default':
            provider: PersistedUsernamePasswordProvider
            token: UsernamePassword
          'Acme.SomePackage:Default.HttpBasic':
            provider: PersistedUsernamePasswordProvider
            providerOptions:
              realm: 'Acme.SomePackage:Default'
            token: UsernamePasswordHttpBasic
            entryPoint: HttpBasic

And finally, in Neos, we’d change User::getAccounts(): Account[] to User::getAccount(): AccountInterface

nobody interested? :no_mouth: