Skip to content

Commit 773c3b9

Browse files
committed
REFACTOR: Only use Session and Middleware for 2FA
1 parent 4ead71e commit 773c3b9

15 files changed

+242
-321
lines changed

Classes/Controller/AuthenticationController.php

Lines changed: 0 additions & 22 deletions
This file was deleted.

Classes/Controller/LoginController.php

Lines changed: 66 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,15 @@
1313
use Neos\Flow\Configuration\ConfigurationManager;
1414
use Neos\Flow\Mvc\Controller\ActionController;
1515
use Neos\Flow\Mvc\FlashMessage\FlashMessageService;
16-
use Neos\Flow\Security\Authentication\AuthenticationManagerInterface;
16+
use Neos\Flow\Security\Account;
1717
use Neos\Flow\Security\Context as SecurityContext;
18+
use Neos\Flow\Session\SessionManagerInterface;
1819
use Neos\Fusion\View\FusionView;
1920
use Neos\Neos\Domain\Repository\DomainRepository;
2021
use Neos\Neos\Domain\Repository\SiteRepository;
2122
use Sandstorm\NeosTwoFactorAuthentication\Domain\Model\SecondFactor;
2223
use Sandstorm\NeosTwoFactorAuthentication\Domain\Repository\SecondFactorRepository;
23-
use Sandstorm\NeosTwoFactorAuthentication\Security\Authentication\Token\UsernameAndPasswordWithSecondFactor;
24+
use Sandstorm\NeosTwoFactorAuthentication\Http\Middleware\SecondFactorMiddleware;
2425
use Sandstorm\NeosTwoFactorAuthentication\Service\TOTPService;
2526

2627
class LoginController extends ActionController
@@ -36,12 +37,6 @@ class LoginController extends ActionController
3637
*/
3738
protected $securityContext;
3839

39-
/**
40-
* @var AuthenticationManagerInterface
41-
* @Flow\Inject
42-
*/
43-
protected $authenticationManager;
44-
4540
/**
4641
* @var DomainRepository
4742
* @Flow\Inject
@@ -66,13 +61,19 @@ class LoginController extends ActionController
6661
*/
6762
protected $secondFactorRepository;
6863

64+
/**
65+
* @Flow\Inject(lazy=false)
66+
* @var SessionManagerInterface
67+
*/
68+
protected $sessionManager;
69+
6970
/**
7071
* This action decides which tokens are already authenticated
7172
* and decides which is next to authenticate
7273
*
7374
* ATTENTION: this code is copied from the Neos.Neos:LoginController
7475
*/
75-
public function askForSecondFactorAction(?string $username = null, bool $unauthorized = false)
76+
public function askForSecondFactorAction(?string $username = null)
7677
{
7778
$currentDomain = $this->domainRepository->findOneByActiveRequest();
7879
$currentSite = $currentDomain !== null ? $currentDomain->getSite() : $this->siteRepository->findDefault();
@@ -83,6 +84,51 @@ public function askForSecondFactorAction(?string $username = null, bool $unautho
8384
'site' => $currentSite,
8485
'flashMessages' => $this->flashMessageService->getFlashMessageContainerForRequest($this->request)->getMessagesAndFlush(),
8586
]);
87+
88+
// TODO: should we safe redirect to original request?
89+
}
90+
91+
public function checkOtpAction(string $otp)
92+
{
93+
$account = $this->securityContext->getAccount();
94+
95+
$isValidOtp = $this->enteredTokenMatchesAnySecondFactor($otp, $account);
96+
97+
// WHY: We need to check the OTP here and set the authentication status on the Session Object of 2FA package
98+
// see Sandstorm/NeosTwoFactor
99+
if ($isValidOtp) {
100+
$this->sessionManager->getCurrentSession()->putData(
101+
SecondFactorMiddleware::SESSION_OBJECT_ID,
102+
[SecondFactorMiddleware::SESSION_OBJECT_AUTH_STATUS => SecondFactorMiddleware::SECOND_FACTOR_AUTHENTICATED]
103+
);
104+
} else {
105+
// FIXME: not visible in View!
106+
$this->addFlashMessage('Invalid otp!', 'Error', Message::SEVERITY_ERROR);
107+
}
108+
109+
// TODO: should we safe redirect to original request?
110+
$this->redirect('index', 'Backend\Backend', 'Neos.Neos');
111+
}
112+
113+
/**
114+
* Check if the given token matches any registered second factor
115+
*
116+
* @param string $enteredSecondFactor
117+
* @param Account $account
118+
* @return bool
119+
*/
120+
private function enteredTokenMatchesAnySecondFactor(string $enteredSecondFactor, Account $account): bool
121+
{
122+
/** @var SecondFactor[] $secondFactors */
123+
$secondFactors = $this->secondFactorRepository->findByAccount($account);
124+
foreach ($secondFactors as $secondFactor) {
125+
$isValid = TOTPService::checkIfOtpIsValid($secondFactor->getSecret(), $enteredSecondFactor);
126+
if ($isValid) {
127+
return true;
128+
}
129+
}
130+
131+
return false;
86132
}
87133

88134
/**
@@ -91,7 +137,7 @@ public function askForSecondFactorAction(?string $username = null, bool $unautho
91137
*
92138
* ATTENTION: this code is copied from the Neos.Neos:LoginController
93139
*/
94-
public function setupSecondFactorAction(?string $username = null, bool $unauthorized = false)
140+
public function setupSecondFactorAction(?string $username = null)
95141
{
96142
$otp = TOTPService::generateNewTotp();
97143
$secret = $otp->getSecret();
@@ -101,11 +147,7 @@ public function setupSecondFactorAction(?string $username = null, bool $unauthor
101147
$currentSiteName = $currentSite->getName();
102148
$urlEncodedSiteName = urlencode($currentSiteName);
103149

104-
$secondFactorAuthenticationTokens = $this->securityContext->getAuthenticationTokensOfType(UsernameAndPasswordWithSecondFactor::class);
105-
// TODO: error if empty
106-
// TODO: check token status (is authentication successful)
107-
108-
$userIdentifier = $secondFactorAuthenticationTokens[0]->getAccount()->getAccountIdentifier();
150+
$userIdentifier = $this->securityContext->getAccount()->getAccountIdentifier();
109151

110152
$oauthData = "otpauth://totp/$userIdentifier?secret=$secret&period=30&issuer=$urlEncodedSiteName";
111153
$qrCode = (new QRCode(new QROptions([
@@ -131,19 +173,14 @@ public function setupSecondFactorAction(?string $username = null, bool $unauthor
131173
*/
132174
public function createSecondFactorAction(string $secret, string $secondFactorFromApp)
133175
{
134-
// TODO: validate Token
135176
$isValid = TOTPService::checkIfOtpIsValid($secret, $secondFactorFromApp);
136177

137178
if (!$isValid) {
138179
$this->addFlashMessage('Submitted OTP was not correct', '', Message::SEVERITY_WARNING);
139180
$this->redirect('setupSecondFactor');
140181
}
141182

142-
$secondFactorAuthenticationTokens = $this->securityContext->getAuthenticationTokensOfType(UsernameAndPasswordWithSecondFactor::class);
143-
// TODO: error if empty
144-
// TODO: check token status (is authentication successful)
145-
146-
$account = $secondFactorAuthenticationTokens[0]->getAccount();
183+
$account = $this->securityContext->getAccount();
147184

148185
$secondFactor = new SecondFactor();
149186
$secondFactor->setAccount($account);
@@ -153,7 +190,14 @@ public function createSecondFactorAction(string $secret, string $secondFactorFro
153190
$this->persistenceManager->persistAll();
154191

155192
$this->addFlashMessage('Successfully created otp');
156-
// TODO: login because 2fa is set up with valid otp or force re-login with new otp
193+
194+
// TODO: Discuss: we could skip this to force the user to enter a otp again directly after setup
195+
$this->sessionManager->getCurrentSession()->putData(
196+
SecondFactorMiddleware::SESSION_OBJECT_ID,
197+
[SecondFactorMiddleware::SESSION_OBJECT_AUTH_STATUS => SecondFactorMiddleware::SECOND_FACTOR_AUTHENTICATED]
198+
);
199+
200+
// TODO: should we safe redirect to original request?
157201
$this->redirect('index', 'Backend\Backend', 'Neos.Neos');
158202
}
159203

Classes/Error/SecondFactorEnforcedSetupException.php

Lines changed: 0 additions & 12 deletions
This file was deleted.

Classes/Error/SecondFactorRequiredException.php

Lines changed: 0 additions & 12 deletions
This file was deleted.
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
<?php
2+
3+
namespace Sandstorm\NeosTwoFactorAuthentication\Http\Middleware;
4+
5+
use GuzzleHttp\Psr7\Response;
6+
use Neos\Flow\Security\Context as SecurityContext;
7+
use Neos\Flow\Session\SessionManagerInterface;
8+
use Psr\Http\Message\ResponseInterface;
9+
use Psr\Http\Message\ServerRequestInterface;
10+
use Psr\Http\Server\MiddlewareInterface;
11+
use Psr\Http\Server\RequestHandlerInterface;
12+
use Psr\Log\LoggerInterface;
13+
use Sandstorm\NeosTwoFactorAuthentication\Domain\Repository\SecondFactorRepository;
14+
use Neos\Flow\Annotations as Flow;
15+
16+
class SecondFactorMiddleware implements MiddlewareInterface
17+
{
18+
const SESSION_OBJECT_ID = 'Sandstorm/NeosTwoFactorAuthentication';
19+
const SESSION_OBJECT_AUTH_STATUS = 'authenticationStatus';
20+
21+
const SECOND_FACTOR_AUTHENTICATION_NEEDED = 'SECOND_FACTOR_AUTHENTICATION_NEEDED';
22+
const SECOND_FACTOR_AUTHENTICATED = 'SECOND_FACTOR_AUTHENTICATED';
23+
24+
/**
25+
* TODO: Why lazy false?
26+
* @Flow\Inject(lazy=false)
27+
* @var SessionManagerInterface
28+
*/
29+
protected $sessionManager;
30+
31+
/**
32+
* @Flow\Inject(lazy=false)
33+
* @var SecurityContext
34+
*/
35+
protected $securityContext;
36+
37+
/**
38+
* @Flow\Inject(lazy=false)
39+
* @var SecondFactorRepository
40+
*/
41+
protected $secondFactorRepository;
42+
43+
/**
44+
* @Flow\InjectConfiguration(path="enforceTwoFactorAuthentication")
45+
* @var bool
46+
*/
47+
protected $enforceTwoFactorAuthentication;
48+
49+
/**
50+
* @Flow\Inject(name="Neos.Flow:SecurityLogger")
51+
* @var LoggerInterface
52+
*/
53+
protected $securityLogger;
54+
55+
// TODO: break up into smaller functions to remove complexity
56+
public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface
57+
{
58+
$authenticationTokens = $this->securityContext->getAuthenticationTokens();
59+
60+
if (empty($authenticationTokens)) {
61+
$this->securityLogger->info(
62+
'Sandstorm/NeosTwoFactorAuthentication: ' .
63+
'No authentication tokens found, skipping second factor'
64+
);
65+
return $next->handle($request);
66+
}
67+
68+
// WHY: we currently only support 'Neos.Neos:Backend' provider (which ever is used) because the
69+
// second factor feature is currently only build for Neos Editors use case
70+
if (!array_key_exists('Neos.Neos:Backend', $authenticationTokens)) {
71+
$this->securityLogger->error(
72+
'Sandstorm/NeosTwoFactorAuthentication: ' .
73+
'No authentication token for "Neos.Neos:Backend" found, skipping second factor'
74+
);
75+
return $next->handle($request);
76+
}
77+
78+
$isAuthenticated = $authenticationTokens['Neos.Neos:Backend']->isAuthenticated();
79+
80+
// ignore unauthenticated requests
81+
if (!$isAuthenticated) {
82+
$this->securityLogger->info(
83+
'Sandstorm/NeosTwoFactorAuthentication: ' .
84+
'Not authenticated on "Neos.Neos:Backend" auth provider, skipping second factor'
85+
);
86+
return $next->handle($request);
87+
}
88+
89+
$account = $this->securityContext->getAccount();
90+
91+
// ignore if second factor is not enabled for account and second factor is not enforced
92+
if (
93+
!$this->secondFactorRepository->isEnabledForAccount($account)
94+
&& !$this->enforceTwoFactorAuthentication
95+
) {
96+
$this->securityLogger->debug(
97+
'Sandstorm/NeosTwoFactorAuthentication: ' .
98+
'Second factor not enforced and set up for account, skipping second factor'
99+
);
100+
return $next->handle($request);
101+
}
102+
103+
// get 2FA data from session
104+
$currentSession = $this->sessionManager->getCurrentSession();
105+
// TODO: ValueObject/DTO
106+
$twoFactorData = $currentSession->getData(self::SESSION_OBJECT_ID);
107+
108+
// if session has no 2FA data object, we initialize a default
109+
if (empty($twoFactorData)) {
110+
$currentSession->putData(
111+
self::SESSION_OBJECT_ID,
112+
[
113+
self::SESSION_OBJECT_AUTH_STATUS => self::SECOND_FACTOR_AUTHENTICATION_NEEDED,
114+
]
115+
);
116+
$twoFactorData = $currentSession->getData(self::SESSION_OBJECT_ID);
117+
}
118+
119+
// already authenticated
120+
if ($twoFactorData[self::SESSION_OBJECT_AUTH_STATUS] === self::SECOND_FACTOR_AUTHENTICATED) {
121+
return $next->handle($request);
122+
}
123+
124+
if (
125+
$this->secondFactorRepository->isEnabledForAccount($account)
126+
&& $twoFactorData[self::SESSION_OBJECT_AUTH_STATUS] === self::SECOND_FACTOR_AUTHENTICATION_NEEDED
127+
) {
128+
// TODO: discuss
129+
// WHY: We use the request URI as part of the state
130+
$isAskingFor2FA = str_ends_with($request->getUri()->getPath(), 'neos/two-factor-login');
131+
if ($isAskingFor2FA) {
132+
return $next->handle($request);
133+
}
134+
135+
if ($request->getMethod() === 'POST') {
136+
return new Response(401);
137+
}
138+
139+
return new Response(303, ['Location' => '/neos/two-factor-login']);
140+
}
141+
142+
if (
143+
$this->enforceTwoFactorAuthentication &&
144+
!$this->secondFactorRepository->isEnabledForAccount($account)
145+
) {
146+
// ignore if setup is in progress
147+
$isSettingUp2FA = str_ends_with($request->getUri()->getPath(), 'neos/setup-second-factor');
148+
if ($isSettingUp2FA) {
149+
return $next->handle($request);
150+
}
151+
152+
if ($request->getMethod() === 'POST') {
153+
return new Response(401);
154+
}
155+
156+
return new Response(303, ['Location' => '/neos/setup-second-factor']);
157+
}
158+
159+
// TODO: Throw here?
160+
die('this should not happen^^');
161+
}
162+
}

0 commit comments

Comments
 (0)