Skip to content

Commit

Permalink
Merge pull request #28 from vcg-development/main
Browse files Browse the repository at this point in the history
Add options to enforce 2FA for user roles and/or authentication providers
  • Loading branch information
JamesAlias authored Mar 27, 2024
2 parents 4d899a9 + 2ff42cb commit 59b3149
Show file tree
Hide file tree
Showing 6 changed files with 113 additions and 36 deletions.
20 changes: 9 additions & 11 deletions Classes/Controller/BackendController.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
use Sandstorm\NeosTwoFactorAuthentication\Domain\Model\SecondFactor;
use Sandstorm\NeosTwoFactorAuthentication\Domain\Model\Dto\SecondFactorDto;
use Sandstorm\NeosTwoFactorAuthentication\Domain\Repository\SecondFactorRepository;
use Sandstorm\NeosTwoFactorAuthentication\Service\SecondFactorService;
use Sandstorm\NeosTwoFactorAuthentication\Service\SecondFactorSessionStorageService;
use Sandstorm\NeosTwoFactorAuthentication\Service\TOTPService;

Expand Down Expand Up @@ -71,10 +72,10 @@ class BackendController extends AbstractModuleController
protected $defaultViewObjectName = FusionView::class;

/**
* @Flow\InjectConfiguration(path="enforceTwoFactorAuthentication")
* @var bool
* @Flow\Inject
* @var SecondFactorService
*/
protected $enforceTwoFactorAuthentication;
protected $secondFactorService;

/**
* used to list all second factors of the current user
Expand Down Expand Up @@ -177,14 +178,11 @@ public function deleteAction(SecondFactor $secondFactor): void
{
$account = $this->securityContext->getAccount();

if (
$this->securityContext->hasRole('Neos.Neos:Administrator')
|| $secondFactor->getAccount() === $account
) {
if (
$this->enforceTwoFactorAuthentication
&& count($this->secondFactorRepository->findByAccount($account)) <= 1
) {
$isAdministrator = $this->securityContext->hasRole('Neos.Neos:Administrator');
$isOwner = $secondFactor->getAccount() === $account;

if ($isAdministrator || $isOwner) {
if (!$this->secondFactorService->canOneSecondFactorBeDeletedForAccount($account)) {
$this->addFlashMessage(
$this->translator->translateById(
'module.index.delete.flashMessage.cannotRemoveLastSecondFactor',
Expand Down
6 changes: 0 additions & 6 deletions Classes/Domain/Repository/SecondFactorRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,6 @@
*/
class SecondFactorRepository extends Repository
{
public function isEnabledForAccount(Account $account): bool
{
$factors = $this->findByAccount($account);
return count($factors) > 0;
}

/**
* @throws IllegalObjectTypeException
*/
Expand Down
30 changes: 11 additions & 19 deletions Classes/Http/Middleware/SecondFactorMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;
use Sandstorm\NeosTwoFactorAuthentication\Domain\AuthenticationStatus;
use Sandstorm\NeosTwoFactorAuthentication\Domain\Repository\SecondFactorRepository;
use Sandstorm\NeosTwoFactorAuthentication\Service\SecondFactorService;
use Sandstorm\NeosTwoFactorAuthentication\Service\SecondFactorSessionStorageService;

class SecondFactorMiddleware implements MiddlewareInterface
Expand All @@ -30,12 +30,6 @@ class SecondFactorMiddleware implements MiddlewareInterface
*/
protected $securityContext;

/**
* @Flow\Inject
* @var SecondFactorRepository
*/
protected $secondFactorRepository;

/**
* @Flow\Inject
* @var ActionRequestFactory
Expand All @@ -55,10 +49,10 @@ class SecondFactorMiddleware implements MiddlewareInterface
protected $secondFactorSessionStorageService;

/**
* @Flow\InjectConfiguration(path="enforceTwoFactorAuthentication")
* @var bool
* @Flow\Inject
* @var SecondFactorService
*/
protected $enforceTwoFactorAuthentication;
protected SecondFactorService $secondFactorService;

/**
* This middleware checks if the user is authenticated with a second factor "if necessary".
Expand Down Expand Up @@ -124,6 +118,7 @@ class SecondFactorMiddleware implements MiddlewareInterface
*/
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{

$authenticationTokens = $this->securityContext->getAuthenticationTokens();

// 1. Skip, if no authentication tokens are present, because we're not on a secured route.
Expand Down Expand Up @@ -153,11 +148,11 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface

$account = $this->securityContext->getAccount();

$isEnabledForAccount = $this->secondFactorService->isSecondFactorEnabledForAccount($account);
$isEnforcedForAccount = $this->secondFactorService->isSecondFactorEnforcedForAccount($account);

// 4. Skip, if second factor is not set up for account and not enforced via settings.
if (
!$this->secondFactorRepository->isEnabledForAccount($account)
&& !$this->enforceTwoFactorAuthentication
) {
if (!$isEnabledForAccount && !$isEnforcedForAccount) {
$this->log('Second factor not enabled for account and not enforced by system, skipping second factor.');

return $handler->handle($request);
Expand All @@ -177,7 +172,7 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface
// 6. Redirect to 2FA login, if second factor is set up for account but not authenticated.
// Skip, if already on 2FA login route.
if (
$this->secondFactorRepository->isEnabledForAccount($account)
$isEnabledForAccount
&& $authenticationStatus === AuthenticationStatus::AUTHENTICATION_NEEDED
) {
// WHY: We use the request URI as state here to keep the middleware from entering a redirect loop.
Expand All @@ -197,10 +192,7 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface

// 7. Redirect to 2FA setup, if second factor is not set up for account but is enforced by system.
// Skip, if already on 2FA setup route.
if (
$this->enforceTwoFactorAuthentication &&
!$this->secondFactorRepository->isEnabledForAccount($account)
) {
if ($isEnforcedForAccount && !$isEnabledForAccount) {
// WHY: We use the request URI as state here to keep the middleware from entering a redirect loop.
$isSettingUp2FA = str_ends_with($request->getUri()->getPath(), self::SECOND_FACTOR_SETUP_URI);
if ($isSettingUp2FA) {
Expand Down
79 changes: 79 additions & 0 deletions Classes/Service/SecondFactorService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php

namespace Sandstorm\NeosTwoFactorAuthentication\Service;

use Neos\Flow\Annotations as Flow;
use Neos\Flow\Security\Account;
use Sandstorm\NeosTwoFactorAuthentication\Domain\Repository\SecondFactorRepository;

class SecondFactorService
{
/**
* @Flow\InjectConfiguration(path="enforceTwoFactorAuthentication")
* @var bool
*/
protected $enforceTwoFactorAuthentication;

/**
* @Flow\InjectConfiguration(path="enforce2FAForAuthenticationProviders")
* @var array
*/
protected $enforce2FAForAuthenticationProviders;

/**
* @Flow\InjectConfiguration(path="enforce2FAForRoles")
* @var array
*/
protected $enforce2FAForRoles;

/**
* @Flow\Inject
* @var SecondFactorRepository
*/
protected $secondFactorRepository;

/**
* Check if the second factor is enforced for the given account.
*
* The second factor is enforced if:
* - it is enforced for all accounts or
* - it is enforced for a role of the account or
* - it is enforced for the authentication provider of the account
*/
public function isSecondFactorEnforcedForAccount(Account $account): bool
{
$isEnforcedForAll = $this->enforceTwoFactorAuthentication;
$isEnforcedForRoles = count(array_intersect(
array_map(fn($item) => $item->getIdentifier(), $account->getRoles()),
$this->enforce2FAForRoles
));
$isEnforcedForAuthenticationProviders = in_array(
$account->getAuthenticationProviderName(),
$this->enforce2FAForAuthenticationProviders
);

return $isEnforcedForAll || $isEnforcedForRoles || $isEnforcedForAuthenticationProviders;
}

/**
* Check if the account has setup at least 1 second factor.
*/
public function isSecondFactorEnabledForAccount(Account $account): bool
{
$factors = $this->secondFactorRepository->findByAccount($account);
return count($factors) > 0;
}

/**
* Check if the account can delete 1 second factor.
*
* Second factor can only be deleted if it is not enforced for the account or if the account has multiple factors.
*/
public function canOneSecondFactorBeDeletedForAccount(Account $account): bool
{
$isEnforcedForAccount = $this->isSecondFactorEnforcedForAccount($account);
$hasMultipleFactors = count($this->secondFactorRepository->findByAccount($account)) > 1;

return !$isEnforcedForAccount || $hasMultipleFactors;
}
}
4 changes: 4 additions & 0 deletions Configuration/Settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,9 @@ Sandstorm:
NeosTwoFactorAuthentication:
# enforce 2FA for all users
enforceTwoFactorAuthentication: false
# enforce 2FA for specific authentication providers
enforce2FAForAuthenticationProviders : []
# enforce 2FA for specific roles
enforce2FAForRoles: []
# (optional) if set this will be used as a naming convention for the TOTP. If empty the Site name will be used
issuerName: ''
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@ Sandstorm:
```
With this setting, no user can login into the CMS without setting up a second factor first.
In addition, you can enforce 2FA for specific authentication providers and/or roles by adding following to your `Settings.yaml`
```yml
Sandstorm:
NeosTwoFactorAuthentication:
# enforce 2FA for specific authentication providers
enforce2FAForAuthenticationProviders : ['Neos.Neos:Backend']
# enforce 2FA for specific roles
enforce2FAForRoles: ['Neos.Neos:Administrator']
```

### Issuer Naming
To override the default sitename as issuer label, you can define one via the configuration settings:
```yml
Expand Down

0 comments on commit 59b3149

Please sign in to comment.