Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,8 @@ vendor/
# composer
composer.lock

# node
node_modules/

# IDEs
.idea/
31 changes: 9 additions & 22 deletions Classes/Controller/BackendController.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,59 +30,51 @@
class BackendController extends AbstractModuleController
{
/**
* @var SecondFactorRepository
* @Flow\Inject
*/
protected $secondFactorRepository;
protected SecondFactorRepository $secondFactorRepository;

/**
* @var Context
* @Flow\Inject
*/
protected $securityContext;
protected Context $securityContext;

/**
* @var PartyService
* @Flow\Inject
*/
protected $partyService;
protected PartyService $partyService;

/**
* @Flow\Inject
* @var FlashMessageService
*/
protected $flashMessageService;
protected FlashMessageService $flashMessageService;

/**
* @Flow\Inject
* @var SecondFactorSessionStorageService
*/
protected $secondFactorSessionStorageService;
protected SecondFactorSessionStorageService $secondFactorSessionStorageService;

/**
* @Flow\Inject
* @var TOTPService
*/
protected $tOTPService;
protected TOTPService $tOTPService;

/**
* @Flow\Inject
* @var Translator
*/
protected $translator;
protected Translator $translator;

protected $defaultViewObjectName = FusionView::class;

/**
* @Flow\Inject
* @var SecondFactorService
*/
protected $secondFactorService;
protected SecondFactorService $secondFactorService;

/**
* used to list all second factors of the current user
*/
public function indexAction()
public function indexAction(): void
{
$account = $this->securityContext->getAccount();

Expand Down Expand Up @@ -174,10 +166,6 @@ public function createAction(string $secret, string $secondFactorFromApp): void
$this->redirect('index');
}

/**
* @param SecondFactor $secondFactor
* @return void
*/
public function deleteAction(SecondFactor $secondFactor): void
{
$account = $this->securityContext->getAccount();
Expand Down Expand Up @@ -227,7 +215,6 @@ public function deleteAction(SecondFactor $secondFactor): void
}

/**
* @return array
* @throws InvalidConfigurationTypeException
*/
protected function getNeosSettings(): array
Expand Down
62 changes: 20 additions & 42 deletions Classes/Controller/LoginController.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,6 @@

namespace Sandstorm\NeosTwoFactorAuthentication\Controller;

/*
* This file is part of the Sandstorm.NeosTwoFactorAuthentication package.
*/

use Neos\Error\Messages\Message;
use Neos\Flow\Annotations as Flow;
use Neos\Flow\Configuration\ConfigurationManager;
Expand All @@ -15,15 +11,14 @@
use Neos\Flow\Mvc\Exception\StopActionException;
use Neos\Flow\Mvc\FlashMessage\FlashMessageService;
use Neos\Flow\Persistence\Exception\IllegalObjectTypeException;
use Neos\Flow\Security\Account;
use Neos\Flow\Security\Context as SecurityContext;
use Neos\Flow\Session\Exception\SessionNotStartedException;
use Neos\Fusion\View\FusionView;
use Neos\Neos\Domain\Repository\DomainRepository;
use Neos\Neos\Domain\Repository\SiteRepository;
use Sandstorm\NeosTwoFactorAuthentication\Domain\AuthenticationStatus;
use Sandstorm\NeosTwoFactorAuthentication\Domain\Model\SecondFactor;
use Sandstorm\NeosTwoFactorAuthentication\Domain\Repository\SecondFactorRepository;
use Sandstorm\NeosTwoFactorAuthentication\Service\SecondFactorService;
use Sandstorm\NeosTwoFactorAuthentication\Service\SecondFactorSessionStorageService;
use Sandstorm\NeosTwoFactorAuthentication\Service\TOTPService;

Expand All @@ -35,52 +30,49 @@ class LoginController extends ActionController
protected $defaultViewObjectName = FusionView::class;

/**
* @var SecurityContext
* @Flow\Inject
*/
protected $securityContext;
protected SecurityContext $securityContext;

/**
* @Flow\Inject
*/
protected DomainRepository $domainRepository;

/**
* @var DomainRepository
* @Flow\Inject
*/
protected $domainRepository;
protected SiteRepository $siteRepository;

/**
* @Flow\Inject
* @var SiteRepository
*/
protected $siteRepository;
protected FlashMessageService $flashMessageService;

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

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

/**
* @Flow\Inject
* @var SecondFactorSessionStorageService
*/
protected $secondFactorSessionStorageService;
protected TOTPService $tOTPService;

/**
* @Flow\Inject
* @var TOTPService
*/
protected $tOTPService;
protected SecondFactorService $secondFactorService;

/**
* @Flow\Inject
* @var Translator
*/
protected $translator;
protected Translator $translator;

/**
* This action decides which tokens are already authenticated
Expand Down Expand Up @@ -112,7 +104,7 @@ public function checkSecondFactorAction(string $otp): void
{
$account = $this->securityContext->getAccount();

$isValidOtp = $this->enteredTokenMatchesAnySecondFactor($otp, $account);
$isValidOtp = $this->secondFactorService->validateOtpForAccount($otp, $account);

if ($isValidOtp) {
$this->secondFactorSessionStorageService->setAuthenticationStatus(AuthenticationStatus::AUTHENTICATED);
Expand Down Expand Up @@ -175,9 +167,6 @@ public function setupSecondFactorAction(?string $username = null): void
}

/**
* @param string $secret
* @param string $secondFactorFromApp
* @return void
* @throws IllegalObjectTypeException
* @throws SessionNotStartedException
* @throws StopActionException
Expand Down Expand Up @@ -228,28 +217,16 @@ public function createSecondFactorAction(string $secret, string $secondFactorFro
}

/**
* Check if the given token matches any registered second factor
*
* @param string $enteredSecondFactor
* @param Account $account
* @return bool
* @throws StopActionException
*/
private function enteredTokenMatchesAnySecondFactor(string $enteredSecondFactor, Account $account): bool
public function cancelLoginAction(): void
{
/** @var SecondFactor[] $secondFactors */
$secondFactors = $this->secondFactorRepository->findByAccount($account);
foreach ($secondFactors as $secondFactor) {
$isValid = TOTPService::checkIfOtpIsValid($secondFactor->getSecret(), $enteredSecondFactor);
if ($isValid) {
return true;
}
}
$this->secondFactorSessionStorageService->cancelLoginAttempt();

return false;
$this->redirect('index', 'Backend\Backend', 'Neos.Neos');
}

/**
* @return array
* @throws InvalidConfigurationTypeException
*/
protected function getNeosSettings(): array
Expand All @@ -260,4 +237,5 @@ protected function getNeosSettings(): array
'Neos.Neos'
);
}

}
98 changes: 98 additions & 0 deletions Classes/Controller/ReloginApiController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php

namespace Sandstorm\NeosTwoFactorAuthentication\Controller;

use Neos\Flow\Annotations as Flow;
use Neos\Flow\Mvc\Controller\ActionController;
use Neos\Flow\Mvc\View\JsonView;
use Neos\Flow\Security\Context as SecurityContext;
use Sandstorm\NeosTwoFactorAuthentication\Domain\AuthenticationStatus;
use Sandstorm\NeosTwoFactorAuthentication\Service\SecondFactorService;
use Sandstorm\NeosTwoFactorAuthentication\Service\SecondFactorSessionStorageService;

class ReloginApiController extends ActionController
{
/**
* @var string
*/
protected $defaultViewObjectName = JsonView::class;

/**
* @var array<string>
*/
protected $supportedMediaTypes = ['application/json'];

/**
* @Flow\Inject
*/
protected SecurityContext $securityContext;

/**
* @Flow\Inject
*/
protected SecondFactorService $secondFactorService;

/**
* @Flow\Inject
*/
protected SecondFactorSessionStorageService $secondFactorSessionStorageService;

/**
* Returns whether the currently authenticated account requires a second factor.
*/
public function secondFactorStatusAction(): void
{
$account = $this->securityContext->getAccount();
if ($account === null) {
$this->response->setStatusCode(401);
$this->view->assign('value', ['error' => 'Not authenticated']);
return;
}

$required = $this->secondFactorService->isSecondFactorEnabledForAccount($account);
$this->view->assign('value', ['secondFactorRequired' => $required]);
}

/**
* Validates the submitted OTP and sets the session to AUTHENTICATED on success.
*
* CSRF protection is skipped because after session timeout + re-login, the CSRF token
* context may not match. The endpoint is still protected by session authentication
* and policy authorization (Policy.yaml).
*
* @Flow\SkipCsrfProtection
*/
public function verifySecondFactorAction(): void
{
$account = $this->securityContext->getAccount();
if ($account === null) {
$this->response->setStatusCode(401);
$this->view->assign('value', ['success' => false, 'error' => 'Not authenticated']);
return;
}

// Read the raw body — rewind the stream first since Flow may have already read it
$httpRequest = $this->request->getHttpRequest();
$bodyStream = $httpRequest->getBody();
$bodyStream->rewind();
$body = json_decode($bodyStream->getContents(), true);
$otp = $body['otp'] ?? '';

if ($otp === '') {
$this->response->setStatusCode(400);
$this->view->assign('value', ['success' => false, 'error' => 'Missing OTP']);
return;
}

$isValid = $this->secondFactorService->validateOtpForAccount($otp, $account);

if ($isValid) {
$this->secondFactorSessionStorageService->setAuthenticationStatus(AuthenticationStatus::AUTHENTICATED);
$this->view->assign('value', ['success' => true]);
return;
}

$this->response->setStatusCode(401);
$this->view->assign('value', ['success' => false, 'error' => 'Invalid OTP']);
}
}
7 changes: 3 additions & 4 deletions Classes/Domain/AuthenticationStatus.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@

namespace Sandstorm\NeosTwoFactorAuthentication\Domain;

// FIXME: Refactor to enum once we only support PHP >= 8.1
class AuthenticationStatus
enum AuthenticationStatus: string
{
const AUTHENTICATION_NEEDED = 'AUTHENTICATION_NEEDED';
const AUTHENTICATED = 'AUTHENTICATED';
case AUTHENTICATION_NEEDED = 'AUTHENTICATION_NEEDED';
case AUTHENTICATED = 'AUTHENTICATED';
}
28 changes: 4 additions & 24 deletions Classes/Domain/Model/Dto/SecondFactorDto.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,31 +5,11 @@
use Neos\Neos\Domain\Model\User;
use Sandstorm\NeosTwoFactorAuthentication\Domain\Model\SecondFactor;

class SecondFactorDto
readonly class SecondFactorDto
{
protected SecondFactor $secondFactor;

protected User $user;

public function __construct(SecondFactor $secondFactor, User $user = null)
{
$this->user = $user;
$this->secondFactor = $secondFactor;
}

/**
* @return SecondFactor|string
*/
public function getSecondFactor(): SecondFactor
{
return $this->secondFactor;
}

/**
* @return User
*/
public function getUser(): User
public function __construct(
public SecondFactor $secondFactor,
public User $user)
{
return $this->user;
}
}
Loading