Skip to content

Commit b160df7

Browse files
committed
WIP FEATURE: Add OTP check to Neos relogin process
1 parent 4ba560f commit b160df7

26 files changed

Lines changed: 1680 additions & 129 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,8 @@ vendor/
55
# composer
66
composer.lock
77

8+
# node
9+
node_modules/
10+
811
# IDEs
912
.idea/

Classes/Controller/BackendController.php

Lines changed: 9 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -30,59 +30,51 @@
3030
class BackendController extends AbstractModuleController
3131
{
3232
/**
33-
* @var SecondFactorRepository
3433
* @Flow\Inject
3534
*/
36-
protected $secondFactorRepository;
35+
protected SecondFactorRepository $secondFactorRepository;
3736

3837
/**
39-
* @var Context
4038
* @Flow\Inject
4139
*/
42-
protected $securityContext;
40+
protected Context $securityContext;
4341

4442
/**
45-
* @var PartyService
4643
* @Flow\Inject
4744
*/
48-
protected $partyService;
45+
protected PartyService $partyService;
4946

5047
/**
5148
* @Flow\Inject
52-
* @var FlashMessageService
5349
*/
54-
protected $flashMessageService;
50+
protected FlashMessageService $flashMessageService;
5551

5652
/**
5753
* @Flow\Inject
58-
* @var SecondFactorSessionStorageService
5954
*/
60-
protected $secondFactorSessionStorageService;
55+
protected SecondFactorSessionStorageService $secondFactorSessionStorageService;
6156

6257
/**
6358
* @Flow\Inject
64-
* @var TOTPService
6559
*/
66-
protected $tOTPService;
60+
protected TOTPService $tOTPService;
6761

6862
/**
6963
* @Flow\Inject
70-
* @var Translator
7164
*/
72-
protected $translator;
65+
protected Translator $translator;
7366

7467
protected $defaultViewObjectName = FusionView::class;
7568

7669
/**
7770
* @Flow\Inject
78-
* @var SecondFactorService
7971
*/
80-
protected $secondFactorService;
72+
protected SecondFactorService $secondFactorService;
8173

8274
/**
8375
* used to list all second factors of the current user
8476
*/
85-
public function indexAction()
77+
public function indexAction(): void
8678
{
8779
$account = $this->securityContext->getAccount();
8880

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

177-
/**
178-
* @param SecondFactor $secondFactor
179-
* @return void
180-
*/
181169
public function deleteAction(SecondFactor $secondFactor): void
182170
{
183171
$account = $this->securityContext->getAccount();
@@ -227,7 +215,6 @@ public function deleteAction(SecondFactor $secondFactor): void
227215
}
228216

229217
/**
230-
* @return array
231218
* @throws InvalidConfigurationTypeException
232219
*/
233220
protected function getNeosSettings(): array

Classes/Controller/LoginController.php

Lines changed: 15 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,6 @@
22

33
namespace Sandstorm\NeosTwoFactorAuthentication\Controller;
44

5-
/*
6-
* This file is part of the Sandstorm.NeosTwoFactorAuthentication package.
7-
*/
8-
95
use Neos\Error\Messages\Message;
106
use Neos\Flow\Annotations as Flow;
117
use Neos\Flow\Configuration\ConfigurationManager;
@@ -15,15 +11,14 @@
1511
use Neos\Flow\Mvc\Exception\StopActionException;
1612
use Neos\Flow\Mvc\FlashMessage\FlashMessageService;
1713
use Neos\Flow\Persistence\Exception\IllegalObjectTypeException;
18-
use Neos\Flow\Security\Account;
1914
use Neos\Flow\Security\Context as SecurityContext;
2015
use Neos\Flow\Session\Exception\SessionNotStartedException;
2116
use Neos\Fusion\View\FusionView;
2217
use Neos\Neos\Domain\Repository\DomainRepository;
2318
use Neos\Neos\Domain\Repository\SiteRepository;
2419
use Sandstorm\NeosTwoFactorAuthentication\Domain\AuthenticationStatus;
25-
use Sandstorm\NeosTwoFactorAuthentication\Domain\Model\SecondFactor;
2620
use Sandstorm\NeosTwoFactorAuthentication\Domain\Repository\SecondFactorRepository;
21+
use Sandstorm\NeosTwoFactorAuthentication\Service\SecondFactorService;
2722
use Sandstorm\NeosTwoFactorAuthentication\Service\SecondFactorSessionStorageService;
2823
use Sandstorm\NeosTwoFactorAuthentication\Service\TOTPService;
2924

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

3732
/**
38-
* @var SecurityContext
3933
* @Flow\Inject
4034
*/
41-
protected $securityContext;
35+
protected SecurityContext $securityContext;
36+
37+
/**
38+
* @Flow\Inject
39+
*/
40+
protected DomainRepository $domainRepository;
4241

4342
/**
44-
* @var DomainRepository
4543
* @Flow\Inject
4644
*/
47-
protected $domainRepository;
45+
protected SiteRepository $siteRepository;
4846

4947
/**
5048
* @Flow\Inject
51-
* @var SiteRepository
5249
*/
53-
protected $siteRepository;
50+
protected FlashMessageService $flashMessageService;
5451

5552
/**
5653
* @Flow\Inject
57-
* @var FlashMessageService
5854
*/
59-
protected $flashMessageService;
55+
protected SecondFactorRepository $secondFactorRepository;
6056

6157
/**
62-
* @var SecondFactorRepository
6358
* @Flow\Inject
6459
*/
65-
protected $secondFactorRepository;
60+
protected SecondFactorSessionStorageService $secondFactorSessionStorageService;
6661

6762
/**
6863
* @Flow\Inject
69-
* @var SecondFactorSessionStorageService
7064
*/
71-
protected $secondFactorSessionStorageService;
65+
protected TOTPService $tOTPService;
7266

7367
/**
7468
* @Flow\Inject
75-
* @var TOTPService
7669
*/
77-
protected $tOTPService;
70+
protected SecondFactorService $secondFactorService;
7871

7972
/**
8073
* @Flow\Inject
81-
* @var Translator
8274
*/
83-
protected $translator;
75+
protected Translator $translator;
8476

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

115-
$isValidOtp = $this->enteredTokenMatchesAnySecondFactor($otp, $account);
107+
$isValidOtp = $this->secondFactorService->validateOtpForAccount($otp, $account);
116108

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

177169
/**
178-
* @param string $secret
179-
* @param string $secondFactorFromApp
180-
* @return void
181170
* @throws IllegalObjectTypeException
182171
* @throws SessionNotStartedException
183172
* @throws StopActionException
@@ -238,7 +227,6 @@ public function cancelLoginAction(): void
238227
}
239228

240229
/**
241-
* @return array
242230
* @throws InvalidConfigurationTypeException
243231
*/
244232
protected function getNeosSettings(): array
@@ -250,24 +238,4 @@ protected function getNeosSettings(): array
250238
);
251239
}
252240

253-
/**
254-
* Check if the given token matches any registered second factor
255-
*
256-
* @param string $enteredSecondFactor
257-
* @param Account $account
258-
* @return bool
259-
*/
260-
private function enteredTokenMatchesAnySecondFactor(string $enteredSecondFactor, Account $account): bool
261-
{
262-
/** @var SecondFactor[] $secondFactors */
263-
$secondFactors = $this->secondFactorRepository->findByAccount($account);
264-
foreach ($secondFactors as $secondFactor) {
265-
$isValid = TOTPService::checkIfOtpIsValid($secondFactor->getSecret(), $enteredSecondFactor);
266-
if ($isValid) {
267-
return true;
268-
}
269-
}
270-
271-
return false;
272-
}
273241
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
<?php
2+
3+
namespace Sandstorm\NeosTwoFactorAuthentication\Controller;
4+
5+
use Neos\Flow\Annotations as Flow;
6+
use Neos\Flow\Mvc\Controller\ActionController;
7+
use Neos\Flow\Mvc\View\JsonView;
8+
use Neos\Flow\Security\Context as SecurityContext;
9+
use Sandstorm\NeosTwoFactorAuthentication\Domain\AuthenticationStatus;
10+
use Sandstorm\NeosTwoFactorAuthentication\Service\SecondFactorService;
11+
use Sandstorm\NeosTwoFactorAuthentication\Service\SecondFactorSessionStorageService;
12+
13+
class ReloginApiController extends ActionController
14+
{
15+
/**
16+
* @var string
17+
*/
18+
protected $defaultViewObjectName = JsonView::class;
19+
20+
/**
21+
* @var array<string>
22+
*/
23+
protected $supportedMediaTypes = ['application/json'];
24+
25+
/**
26+
* @Flow\Inject
27+
*/
28+
protected SecurityContext $securityContext;
29+
30+
/**
31+
* @Flow\Inject
32+
*/
33+
protected SecondFactorService $secondFactorService;
34+
35+
/**
36+
* @Flow\Inject
37+
*/
38+
protected SecondFactorSessionStorageService $secondFactorSessionStorageService;
39+
40+
/**
41+
* Returns whether the currently authenticated account requires a second factor.
42+
*/
43+
public function secondFactorStatusAction(): void
44+
{
45+
$account = $this->securityContext->getAccount();
46+
if ($account === null) {
47+
$this->response->setStatusCode(401);
48+
$this->view->assign('value', ['error' => 'Not authenticated']);
49+
return;
50+
}
51+
52+
$required = $this->secondFactorService->isSecondFactorEnabledForAccount($account);
53+
$this->view->assign('value', ['secondFactorRequired' => $required]);
54+
}
55+
56+
/**
57+
* Validates the submitted OTP and sets the session to AUTHENTICATED on success.
58+
*
59+
* CSRF protection is skipped because after session timeout + re-login, the CSRF token
60+
* context may not match. The endpoint is still protected by session authentication
61+
* and policy authorization (Policy.yaml).
62+
*
63+
* @Flow\SkipCsrfProtection
64+
*/
65+
public function verifySecondFactorAction(): void
66+
{
67+
$account = $this->securityContext->getAccount();
68+
if ($account === null) {
69+
$this->response->setStatusCode(401);
70+
$this->view->assign('value', ['success' => false, 'error' => 'Not authenticated']);
71+
return;
72+
}
73+
74+
// Read the raw body — rewind the stream first since Flow may have already read it
75+
$httpRequest = $this->request->getHttpRequest();
76+
$bodyStream = $httpRequest->getBody();
77+
$bodyStream->rewind();
78+
$body = json_decode($bodyStream->getContents(), true);
79+
$otp = $body['otp'] ?? '';
80+
81+
if ($otp === '') {
82+
$this->response->setStatusCode(400);
83+
$this->view->assign('value', ['success' => false, 'error' => 'Missing OTP']);
84+
return;
85+
}
86+
87+
$isValid = $this->secondFactorService->validateOtpForAccount($otp, $account);
88+
89+
if ($isValid) {
90+
$this->secondFactorSessionStorageService->setAuthenticationStatus(AuthenticationStatus::AUTHENTICATED);
91+
$this->view->assign('value', ['success' => true]);
92+
return;
93+
}
94+
95+
$this->response->setStatusCode(401);
96+
$this->view->assign('value', ['success' => false, 'error' => 'Invalid OTP']);
97+
}
98+
}

Classes/Domain/Model/Dto/SecondFactorDto.php

Lines changed: 4 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,11 @@
55
use Neos\Neos\Domain\Model\User;
66
use Sandstorm\NeosTwoFactorAuthentication\Domain\Model\SecondFactor;
77

8-
class SecondFactorDto
8+
readonly class SecondFactorDto
99
{
10-
protected SecondFactor $secondFactor;
11-
12-
protected User $user;
13-
14-
public function __construct(SecondFactor $secondFactor, User $user = null)
15-
{
16-
$this->user = $user;
17-
$this->secondFactor = $secondFactor;
18-
}
19-
20-
public function getSecondFactor(): SecondFactor
21-
{
22-
return $this->secondFactor;
23-
}
24-
25-
public function getUser(): User
10+
public function __construct(
11+
public SecondFactor $secondFactor,
12+
public User $user)
2613
{
27-
return $this->user;
2814
}
2915
}

0 commit comments

Comments
 (0)