Skip to content

Commit c6e7243

Browse files
authored
Merge pull request sandstorm#14 from sandstorm/feature-enforce-2fa
FEATURE: Enforce 2FA
2 parents 02da4ab + a8575d8 commit c6e7243

22 files changed

+799
-322
lines changed

.gitignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# 3rd party sources
2+
Packages/
3+
vendor/
4+
5+
# composer
6+
composer.lock
7+
8+
# IDEs
9+
.idea/

Classes/Controller/AuthenticationController.php

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

Classes/Controller/BackendController.php

Lines changed: 51 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,22 @@
22

33
namespace Sandstorm\NeosTwoFactorAuthentication\Controller;
44

5-
use chillerlan\QRCode\QRCode;
6-
use chillerlan\QRCode\QROptions;
75
use Neos\Error\Messages\Message;
86
use Neos\Flow\Annotations as Flow;
7+
use Neos\Flow\Mvc\Exception\StopActionException;
98
use Neos\Flow\Mvc\FlashMessage\FlashMessageService;
9+
use Neos\Flow\Persistence\Exception\IllegalObjectTypeException;
1010
use Neos\Flow\Security\Context;
11+
use Neos\Flow\Session\Exception\SessionNotStartedException;
1112
use Neos\Neos\Controller\Module\AbstractModuleController;
1213
use Neos\Fusion\View\FusionView;
1314
use Neos\Neos\Domain\Model\User;
14-
use Neos\Neos\Domain\Repository\DomainRepository;
15-
use Neos\Neos\Domain\Repository\SiteRepository;
1615
use Neos\Party\Domain\Service\PartyService;
16+
use Sandstorm\NeosTwoFactorAuthentication\Domain\AuthenticationStatus;
1717
use Sandstorm\NeosTwoFactorAuthentication\Domain\Model\SecondFactor;
1818
use Sandstorm\NeosTwoFactorAuthentication\Domain\Model\Dto\SecondFactorDto;
1919
use Sandstorm\NeosTwoFactorAuthentication\Domain\Repository\SecondFactorRepository;
20+
use Sandstorm\NeosTwoFactorAuthentication\Service\SecondFactorSessionStorageService;
2021
use Sandstorm\NeosTwoFactorAuthentication\Service\TOTPService;
2122

2223
/**
@@ -43,25 +44,31 @@ class BackendController extends AbstractModuleController
4344
protected $partyService;
4445

4546
/**
46-
* @var DomainRepository
4747
* @Flow\Inject
48+
* @var FlashMessageService
4849
*/
49-
protected $domainRepository;
50+
protected $flashMessageService;
5051

5152
/**
5253
* @Flow\Inject
53-
* @var SiteRepository
54+
* @var SecondFactorSessionStorageService
5455
*/
55-
protected $siteRepository;
56+
protected $secondFactorSessionStorageService;
5657

5758
/**
5859
* @Flow\Inject
59-
* @var FlashMessageService
60+
* @var TOTPService
6061
*/
61-
protected $flashMessageService;
62+
protected $tOTPService;
6263

6364
protected $defaultViewObjectName = FusionView::class;
6465

66+
/**
67+
* @Flow\InjectConfiguration(path="enforceTwoFactorAuthentication")
68+
* @var bool
69+
*/
70+
protected $enforceTwoFactorAuthentication;
71+
6572
/**
6673
* used to list all second factors of the current user
6774
*/
@@ -87,40 +94,38 @@ public function indexAction()
8794

8895
$this->view->assignMultiple([
8996
'factorsAndPerson' => $factorsAndPerson,
90-
'flashMessages' => $this->flashMessageService->getFlashMessageContainerForRequest($this->request)->getMessagesAndFlush(),
97+
'flashMessages' => $this->flashMessageService
98+
->getFlashMessageContainerForRequest($this->request)
99+
->getMessagesAndFlush(),
91100
]);
92101
}
93102

94103
/**
95104
* show the form to register a new second factor
96105
*/
97-
public function newAction()
106+
public function newAction(): void
98107
{
99108
$otp = TOTPService::generateNewTotp();
100109
$secret = $otp->getSecret();
101-
102-
$currentDomain = $this->domainRepository->findOneByActiveRequest();
103-
$currentSite = $currentDomain !== null ? $currentDomain->getSite() : $this->siteRepository->findDefault();
104-
$currentSiteName = $currentSite->getName();
105-
$urlEncodedSiteName = urlencode($currentSiteName);
106-
107-
$userIdentifier = $this->securityContext->getAccount()->getAccountIdentifier();
108-
$oauthData = "otpauth://totp/$userIdentifier?secret=$secret&period=30&issuer=$urlEncodedSiteName";
109-
$qrCode = (new QRCode(new QROptions([
110-
'outputType' => QRCode::OUTPUT_MARKUP_SVG
111-
])))->render($oauthData);
110+
$qrCode = $this->tOTPService->generateQRCodeForTokenAndAccount($otp, $this->securityContext->getAccount());
112111

113112
$this->view->assignMultiple([
114113
'secret' => $secret,
115114
'qrCode' => $qrCode,
116-
'flashMessages' => $this->flashMessageService->getFlashMessageContainerForRequest($this->request)->getMessagesAndFlush(),
115+
'flashMessages' => $this->flashMessageService
116+
->getFlashMessageContainerForRequest($this->request)
117+
->getMessagesAndFlush(),
117118
]);
118119
}
119120

120121
/**
121122
* save the registered second factor
123+
*
124+
* @throws SessionNotStartedException
125+
* @throws IllegalObjectTypeException
126+
* @throws StopActionException
122127
*/
123-
public function createAction(string $secret, string $secondFactorFromApp)
128+
public function createAction(string $secret, string $secondFactorFromApp): void
124129
{
125130
$isValid = TOTPService::checkIfOtpIsValid($secret, $secondFactorFromApp);
126131

@@ -129,12 +134,9 @@ public function createAction(string $secret, string $secondFactorFromApp)
129134
$this->redirect('new');
130135
}
131136

132-
$secondFactor = new SecondFactor();
133-
$secondFactor->setAccount($this->securityContext->getAccount());
134-
$secondFactor->setSecret($secret);
135-
$secondFactor->setType(SecondFactor::TYPE_TOTP);
136-
$this->secondFactorRepository->add($secondFactor);
137-
$this->persistenceManager->persistAll();
137+
$this->secondFactorRepository->createSecondFactorForAccount($secret, $this->securityContext->getAccount());
138+
139+
$this->secondFactorSessionStorageService->setAuthenticationStatus(AuthenticationStatus::AUTHENTICATED);
138140

139141
$this->addFlashMessage('Successfully created otp');
140142
$this->redirect('index');
@@ -144,15 +146,28 @@ public function createAction(string $secret, string $secondFactorFromApp)
144146
* @param SecondFactor $secondFactor
145147
* @return void
146148
*/
147-
public function deleteAction(SecondFactor $secondFactor)
149+
public function deleteAction(SecondFactor $secondFactor): void
148150
{
151+
$account = $this->securityContext->getAccount();
152+
149153
if (
150154
$this->securityContext->hasRole('Neos.Neos:Administrator')
151-
|| $secondFactor->getAccount() === $this->securityContext->getAccount()
155+
|| $secondFactor->getAccount() === $account
152156
) {
153-
$this->secondFactorRepository->remove($secondFactor);
154-
$this->persistenceManager->persistAll();
155-
$this->addFlashMessage('Second factor was deleted');
157+
if (
158+
$this->enforceTwoFactorAuthentication
159+
&& count($this->secondFactorRepository->findByAccount($account)) <= 1
160+
) {
161+
$this->addFlashMessage(
162+
'Can not remove last second factor! Second factor is enforced, you need at least one!',
163+
'Error',
164+
Message::SEVERITY_ERROR
165+
);
166+
} else {
167+
$this->secondFactorRepository->remove($secondFactor);
168+
$this->persistenceManager->persistAll();
169+
$this->addFlashMessage('Second factor was deleted');
170+
}
156171
}
157172

158173
$this->redirect('index');

Classes/Controller/LoginController.php

Lines changed: 140 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,25 @@
66
* This file is part of the Sandstorm.NeosTwoFactorAuthentication package.
77
*/
88

9+
use Neos\Error\Messages\Message;
910
use Neos\Flow\Annotations as Flow;
1011
use Neos\Flow\Configuration\ConfigurationManager;
12+
use Neos\Flow\Configuration\Exception\InvalidConfigurationTypeException;
1113
use Neos\Flow\Mvc\Controller\ActionController;
14+
use Neos\Flow\Mvc\Exception\StopActionException;
1215
use Neos\Flow\Mvc\FlashMessage\FlashMessageService;
13-
use Neos\Flow\Security\Authentication\AuthenticationManagerInterface;
16+
use Neos\Flow\Persistence\Exception\IllegalObjectTypeException;
17+
use Neos\Flow\Security\Account;
1418
use Neos\Flow\Security\Context as SecurityContext;
19+
use Neos\Flow\Session\Exception\SessionNotStartedException;
1520
use Neos\Fusion\View\FusionView;
1621
use Neos\Neos\Domain\Repository\DomainRepository;
1722
use Neos\Neos\Domain\Repository\SiteRepository;
23+
use Sandstorm\NeosTwoFactorAuthentication\Domain\AuthenticationStatus;
24+
use Sandstorm\NeosTwoFactorAuthentication\Domain\Model\SecondFactor;
25+
use Sandstorm\NeosTwoFactorAuthentication\Domain\Repository\SecondFactorRepository;
26+
use Sandstorm\NeosTwoFactorAuthentication\Service\SecondFactorSessionStorageService;
27+
use Sandstorm\NeosTwoFactorAuthentication\Service\TOTPService;
1828

1929
class LoginController extends ActionController
2030
{
@@ -29,12 +39,6 @@ class LoginController extends ActionController
2939
*/
3040
protected $securityContext;
3141

32-
/**
33-
* @var AuthenticationManagerInterface
34-
* @Flow\Inject
35-
*/
36-
protected $authenticationManager;
37-
3842
/**
3943
* @var DomainRepository
4044
* @Flow\Inject
@@ -53,27 +57,154 @@ class LoginController extends ActionController
5357
*/
5458
protected $flashMessageService;
5559

60+
/**
61+
* @var SecondFactorRepository
62+
* @Flow\Inject
63+
*/
64+
protected $secondFactorRepository;
65+
66+
/**
67+
* @Flow\Inject
68+
* @var SecondFactorSessionStorageService
69+
*/
70+
protected $secondFactorSessionStorageService;
71+
72+
/**
73+
* @Flow\Inject
74+
* @var TOTPService
75+
*/
76+
protected $tOTPService;
77+
78+
/**
79+
* This action decides which tokens are already authenticated
80+
* and decides which is next to authenticate
81+
*
82+
* ATTENTION: this code is copied from the Neos.Neos:LoginController
83+
*/
84+
public function askForSecondFactorAction(?string $username = null): void
85+
{
86+
$currentDomain = $this->domainRepository->findOneByActiveRequest();
87+
$currentSite = $currentDomain !== null ? $currentDomain->getSite() : $this->siteRepository->findDefault();
88+
89+
$this->view->assignMultiple([
90+
'styles' => array_filter($this->getNeosSettings()['userInterface']['backendLoginForm']['stylesheets']),
91+
'username' => $username,
92+
'site' => $currentSite,
93+
'flashMessages' => $this->flashMessageService
94+
->getFlashMessageContainerForRequest($this->request)
95+
->getMessagesAndFlush(),
96+
]);
97+
}
98+
99+
/**
100+
* @throws StopActionException
101+
* @throws SessionNotStartedException
102+
*/
103+
public function checkSecondFactorAction(string $otp): void
104+
{
105+
$account = $this->securityContext->getAccount();
106+
107+
$isValidOtp = $this->enteredTokenMatchesAnySecondFactor($otp, $account);
108+
109+
if ($isValidOtp) {
110+
$this->secondFactorSessionStorageService->setAuthenticationStatus(AuthenticationStatus::AUTHENTICATED);
111+
} else {
112+
// FIXME: not visible in View!
113+
$this->addFlashMessage('Invalid OTP!', 'Error', Message::SEVERITY_ERROR);
114+
}
115+
116+
$originalRequest = $this->securityContext->getInterceptedRequest();
117+
if ($originalRequest !== null) {
118+
$this->redirectToRequest($originalRequest);
119+
}
120+
121+
$this->redirect('index', 'Backend\Backend', 'Neos.Neos');
122+
}
123+
56124
/**
57125
* This action decides which tokens are already authenticated
58126
* and decides which is next to authenticate
59127
*
60128
* ATTENTION: this code is copied from the Neos.Neos:LoginController
61129
*/
62-
public function askForSecondFactorAction(?string $username = null, bool $unauthorized = false)
130+
public function setupSecondFactorAction(?string $username = null): void
63131
{
132+
$otp = TOTPService::generateNewTotp();
133+
$secret = $otp->getSecret();
134+
$qrCode = $this->tOTPService->generateQRCodeForTokenAndAccount($otp, $this->securityContext->getAccount());
135+
64136
$currentDomain = $this->domainRepository->findOneByActiveRequest();
65137
$currentSite = $currentDomain !== null ? $currentDomain->getSite() : $this->siteRepository->findDefault();
66138

67139
$this->view->assignMultiple([
68140
'styles' => array_filter($this->getNeosSettings()['userInterface']['backendLoginForm']['stylesheets']),
69141
'username' => $username,
70142
'site' => $currentSite,
71-
'flashMessages' => $this->flashMessageService->getFlashMessageContainerForRequest($this->request)->getMessagesAndFlush(),
143+
'secret' => $secret,
144+
'qrCode' => $qrCode,
145+
'flashMessages' => $this->flashMessageService
146+
->getFlashMessageContainerForRequest($this->request)
147+
->getMessagesAndFlush(),
72148
]);
73149
}
74150

151+
/**
152+
* @param string $secret
153+
* @param string $secondFactorFromApp
154+
* @return void
155+
* @throws IllegalObjectTypeException
156+
* @throws SessionNotStartedException
157+
* @throws StopActionException
158+
*/
159+
public function createSecondFactorAction(string $secret, string $secondFactorFromApp): void
160+
{
161+
$isValid = TOTPService::checkIfOtpIsValid($secret, $secondFactorFromApp);
162+
163+
if (!$isValid) {
164+
$this->addFlashMessage('Submitted OTP was not correct.', '', Message::SEVERITY_WARNING);
165+
$this->redirect('setupSecondFactor');
166+
}
167+
168+
$account = $this->securityContext->getAccount();
169+
170+
$this->secondFactorRepository->createSecondFactorForAccount($secret, $account);
171+
172+
$this->addFlashMessage('Successfully created otp.');
173+
174+
$this->secondFactorSessionStorageService->setAuthenticationStatus(AuthenticationStatus::AUTHENTICATED);
175+
176+
$originalRequest = $this->securityContext->getInterceptedRequest();
177+
if ($originalRequest !== null) {
178+
$this->redirectToRequest($originalRequest);
179+
}
180+
181+
$this->redirect('index', 'Backend\Backend', 'Neos.Neos');
182+
}
183+
184+
/**
185+
* Check if the given token matches any registered second factor
186+
*
187+
* @param string $enteredSecondFactor
188+
* @param Account $account
189+
* @return bool
190+
*/
191+
private function enteredTokenMatchesAnySecondFactor(string $enteredSecondFactor, Account $account): bool
192+
{
193+
/** @var SecondFactor[] $secondFactors */
194+
$secondFactors = $this->secondFactorRepository->findByAccount($account);
195+
foreach ($secondFactors as $secondFactor) {
196+
$isValid = TOTPService::checkIfOtpIsValid($secondFactor->getSecret(), $enteredSecondFactor);
197+
if ($isValid) {
198+
return true;
199+
}
200+
}
201+
202+
return false;
203+
}
204+
75205
/**
76206
* @return array
207+
* @throws InvalidConfigurationTypeException
77208
*/
78209
protected function getNeosSettings(): array
79210
{

0 commit comments

Comments
 (0)