Skip to content

Commit af164e2

Browse files
committed
OXDEV-9078 Refactor validator and resend otp
1 parent 55c969c commit af164e2

10 files changed

Lines changed: 336 additions & 13 deletions

File tree

assets/out/src/js/module/resend-otp.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export class ResendOtp {
1010
submitButtonId = 'auth_submit',
1111
attemptsDisplayId = 'remaining-attempts',
1212
codeInputId = 'auth_code',
13+
// todo-high: use attempts from resend request response
1314
maxAttempts = 5,
1415
} = options;
1516

@@ -19,6 +20,7 @@ export class ResendOtp {
1920
this.attemptsDisplay = document.getElementById(attemptsDisplayId);
2021
this.codeInput = document.getElementById(codeInputId);
2122

23+
// todo-low: add admin setting for cooldown seconds
2224
this.cooldownSeconds = Number(button.dataset.cooldown || 60);
2325
this.maxAttempts = maxAttempts;
2426
this.url = button.dataset.url;

src/Authentication/TwoFactorAuth/Controller/TwoFactorAuthController.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
use OxidEsales\Eshop\Core\UtilsView;
1414
use OxidEsales\SecurityModule\Authentication\TwoFactorAuth\Exception\InvalidCodeException;
1515
use OxidEsales\SecurityModule\Authentication\TwoFactorAuth\Exception\ResendCooldownException;
16+
use OxidEsales\SecurityModule\Authentication\TwoFactorAuth\Service\TwoFAResendableInterface;
1617
use OxidEsales\SecurityModule\Authentication\TwoFactorAuth\Service\TwoFAServiceInterface;
1718
use OxidEsales\SecurityModule\Authentication\TwoFactorAuth\Service\TwoFAUserServiceInterface;
1819
use OxidEsales\SecurityModule\Authentication\TwoFactorAuth\Transput\AuthCodeRequestInterface;
@@ -55,6 +56,11 @@ public function handleOTP(): ?string
5556

5657
public function resendCode(): void
5758
{
59+
if (!$this->twoFAService instanceof TwoFAResendableInterface) {
60+
$this->jsonResponse->send(['success' => false], 405);
61+
return;
62+
}
63+
5864
$userId = $this->twoFAUserService->getPendingUserId();
5965
try {
6066
$this->twoFAService->resend($userId);
@@ -63,4 +69,10 @@ public function resendCode(): void
6369
$this->jsonResponse->send(['success' => false], 429);
6470
}
6571
}
72+
73+
public function render()
74+
{
75+
// todo-high: send attempts left for the user
76+
return parent::render();
77+
}
6678
}

src/Authentication/TwoFactorAuth/OTP/OtpFacade.php

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,17 @@
99

1010
namespace OxidEsales\SecurityModule\Authentication\TwoFactorAuth\OTP;
1111

12+
use DateTimeImmutable;
1213
use OxidEsales\SecurityModule\Authentication\TwoFactorAuth\Exception\ResendCooldownException;
1314
use OxidEsales\SecurityModule\Authentication\TwoFactorAuth\OTP\Notifier\Factory\OtpNotifierFactoryInterface;
1415
use OxidEsales\SecurityModule\Authentication\TwoFactorAuth\OTP\Service\OtpChallengeStateServiceInterface;
1516
use OxidEsales\SecurityModule\Authentication\TwoFactorAuth\OTP\Service\OtpCodeGeneratorServiceInterface;
1617
use OxidEsales\SecurityModule\Authentication\TwoFactorAuth\OTP\Service\OtpCodeValidatorServiceInterface;
1718
use OxidEsales\SecurityModule\Authentication\TwoFactorAuth\OTP\Service\OtpSendPolicyServiceInterface;
18-
use DateTimeImmutable;
19+
use OxidEsales\SecurityModule\Authentication\TwoFactorAuth\Service\TwoFAResendableInterface;
1920
use OxidEsales\SecurityModule\Authentication\TwoFactorAuth\Service\TwoFAServiceInterface;
2021

21-
class OtpFacade implements TwoFAServiceInterface
22+
class OtpFacade implements TwoFAServiceInterface, TwoFAResendableInterface
2223
{
2324
public function __construct(
2425
private OtpChallengeStateServiceInterface $stateService,
@@ -71,4 +72,12 @@ public function resend(string $userId): void
7172
$this->stateService->refreshChallengeState($userId, $code);
7273
$this->notifierFactory->create($userId)->notify($userId, $code);
7374
}
75+
76+
public function getRemainingAttempts(string $userId): int
77+
{
78+
$state = $this->stateService->getChallengeState($userId);
79+
$attempts = $state?->getAttempts() ?? 0;
80+
81+
return max(0, $this->codeValidator->getMaxAttempts() - $attempts);
82+
}
7483
}

src/Authentication/TwoFactorAuth/OTP/Service/OtpCodeValidatorService.php

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,52 @@
99

1010
namespace OxidEsales\SecurityModule\Authentication\TwoFactorAuth\OTP\Service;
1111

12+
use DateTimeImmutable;
13+
use OxidEsales\SecurityModule\Authentication\TwoFactorAuth\Exception\AttemptLimitExceededException;
14+
use OxidEsales\SecurityModule\Authentication\TwoFactorAuth\Exception\InvalidCodeException;
15+
use OxidEsales\SecurityModule\Authentication\TwoFactorAuth\Exception\TimeExpiredException;
1216
// phpcs:ignore Generic.Files.LineLength
1317
use OxidEsales\SecurityModule\Authentication\TwoFactorAuth\OTP\Infrastructure\Repository\OtpChallengeStateRepositoryInterface;
1418

1519
class OtpCodeValidatorService implements OtpCodeValidatorServiceInterface
1620
{
21+
private const MAX_ATTEMPTS = 5;
22+
1723
public function __construct(
18-
/** @phpstan-ignore property.onlyWritten */
1924
private OtpChallengeStateRepositoryInterface $repository,
25+
private OtpCodeHasherServiceInterface $codeHasher,
2026
) {
2127
}
2228

2329
public function validateCode(
2430
string $userId,
2531
#[\SensitiveParameter] string $inputCode
2632
): void {
27-
// todo-critical: implement, also remove the phpstan-ignore then
33+
$state = $this->repository->findByUserId($userId);
34+
35+
if ($state === null) {
36+
throw new InvalidCodeException();
37+
}
38+
39+
if ($state->getAttempts() >= self::MAX_ATTEMPTS) {
40+
throw new AttemptLimitExceededException();
41+
}
42+
43+
if (new DateTimeImmutable() > $state->getExpiresAt()) {
44+
throw new TimeExpiredException();
45+
}
46+
47+
if ($this->codeHasher->hash($inputCode) !== $state->getCodeHash()) {
48+
$this->repository->incrementAttempts($userId);
49+
if (($state->getAttempts() + 1) >= self::MAX_ATTEMPTS) {
50+
throw new AttemptLimitExceededException();
51+
}
52+
throw new InvalidCodeException();
53+
}
54+
}
55+
56+
public function getMaxAttempts(): int
57+
{
58+
return self::MAX_ATTEMPTS;
2859
}
2960
}

src/Authentication/TwoFactorAuth/OTP/Service/OtpCodeValidatorServiceInterface.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,6 @@ public function validateCode(
2020
string $userId,
2121
#[\SensitiveParameter] string $inputCode
2222
): void;
23+
24+
public function getMaxAttempts(): int;
2325
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
/**
4+
* Copyright © OXID eSales AG. All rights reserved.
5+
* See LICENSE file for license details.
6+
*/
7+
8+
declare(strict_types=1);
9+
10+
namespace OxidEsales\SecurityModule\Authentication\TwoFactorAuth\Service;
11+
12+
use OxidEsales\SecurityModule\Authentication\TwoFactorAuth\Exception\ResendCooldownException;
13+
14+
interface TwoFAResendableInterface
15+
{
16+
/** @throws ResendCooldownException */
17+
public function resend(string $userId): void;
18+
19+
public function getRemainingAttempts(string $userId): int;
20+
}

src/Authentication/TwoFactorAuth/Service/TwoFAServiceInterface.php

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
namespace OxidEsales\SecurityModule\Authentication\TwoFactorAuth\Service;
99

1010
use OxidEsales\SecurityModule\Authentication\TwoFactorAuth\Exception\CodeValidationException;
11-
use OxidEsales\SecurityModule\Authentication\TwoFactorAuth\Exception\ResendCooldownException;
1211

1312
interface TwoFAServiceInterface
1413
{
@@ -22,9 +21,4 @@ public function invalidateChallenge(string $userId): void;
2221
* @throws CodeValidationException
2322
*/
2423
public function verify(string $userId, #[\SensitiveParameter] string $code): void;
25-
26-
// todo-low: consider extracting resend to a separate ResendableInterface,
27-
// not all 2FA methods support it (e.g. TOTP)
28-
/** @throws ResendCooldownException */
29-
public function resend(string $userId): void;
3024
}

tests/Unit/Authentication/TwoFactorAuth/Controller/TwoFactorAuthControllerTest.php

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
use OxidEsales\SecurityModule\Authentication\TwoFactorAuth\Controller\TwoFactorAuthController;
1414
use OxidEsales\SecurityModule\Authentication\TwoFactorAuth\Exception\InvalidCodeException;
1515
use OxidEsales\SecurityModule\Authentication\TwoFactorAuth\Exception\ResendCooldownException;
16+
use OxidEsales\SecurityModule\Authentication\TwoFactorAuth\Service\TwoFAResendableInterface;
1617
use OxidEsales\SecurityModule\Authentication\TwoFactorAuth\Service\TwoFAServiceInterface;
1718
use OxidEsales\SecurityModule\Authentication\TwoFactorAuth\Service\TwoFAUserServiceInterface;
1819
use OxidEsales\SecurityModule\Authentication\TwoFactorAuth\Transput\AuthCodeRequestInterface;
@@ -79,15 +80,18 @@ public function resendCodeSendsSuccessResponse(): void
7980
$twoFAUserServiceStub->method('getPendingUserId')
8081
->willReturn($userId = uniqid());
8182

82-
$twoFAServiceSpy = $this->createMock(TwoFAServiceInterface::class);
83+
$twoFAServiceSpy = $this->createMockForIntersectionOfInterfaces([
84+
TwoFAServiceInterface::class,
85+
TwoFAResendableInterface::class,
86+
]);
8387
$twoFAServiceSpy->expects($this->once())
8488
->method('resend')
8589
->with($userId);
8690

8791
$jsonResponseSpy = $this->createMock(JsonResponseInterface::class);
8892
$jsonResponseSpy->expects($this->once())
8993
->method('send')
90-
->with(['success' => true], 200);
94+
->with(['success' => true]);
9195

9296
$this->getSut(
9397
twoFAService: $twoFAServiceSpy,
@@ -103,7 +107,10 @@ public function resendCodeSends429OnCooldown(): void
103107
$twoFAUserServiceStub->method('getPendingUserId')
104108
->willReturn($userId = uniqid());
105109

106-
$twoFAServiceStub = $this->createMock(TwoFAServiceInterface::class);
110+
$twoFAServiceStub = $this->createMockForIntersectionOfInterfaces([
111+
TwoFAServiceInterface::class,
112+
TwoFAResendableInterface::class,
113+
]);
107114
$twoFAServiceStub->expects($this->once())
108115
->method('resend')
109116
->with($userId)
@@ -121,6 +128,20 @@ public function resendCodeSends429OnCooldown(): void
121128
)->resendCode();
122129
}
123130

131+
#[Test]
132+
public function resendCodeSends405WhenServiceIsNotResendable(): void
133+
{
134+
$jsonResponseSpy = $this->createMock(JsonResponseInterface::class);
135+
$jsonResponseSpy->expects($this->once())
136+
->method('send')
137+
->with(['success' => false], 405);
138+
139+
$this->getSut(
140+
twoFAService: $this->createStub(TwoFAServiceInterface::class),
141+
jsonResponse: $jsonResponseSpy,
142+
)->resendCode();
143+
}
144+
124145
private function getSut(
125146
TwoFAServiceInterface $twoFAService = null,
126147
TwoFAUserServiceInterface $twoFAUserService = null,

tests/Unit/Authentication/TwoFactorAuth/OTP/OtpFacadeTest.php

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,54 @@ public function resendRefreshesStateAndNotifiesWhenAllowed(): void
229229
$sut->resend(userId: $userId);
230230
}
231231

232+
#[Test]
233+
public function getRemainingAttemptsReturnsMaxWhenNoChallengeState(): void
234+
{
235+
$stateServiceStub = $this->createStub(OtpChallengeStateServiceInterface::class);
236+
$stateServiceStub->method('getChallengeState')->willReturn(null);
237+
238+
$codeValidatorStub = $this->createStub(OtpCodeValidatorServiceInterface::class);
239+
$codeValidatorStub->method('getMaxAttempts')->willReturn(5);
240+
241+
$sut = $this->getSut(stateService: $stateServiceStub, codeValidator: $codeValidatorStub);
242+
243+
$this->assertSame(5, $sut->getRemainingAttempts(uniqid()));
244+
}
245+
246+
#[Test]
247+
public function getRemainingAttemptsSubtractsCurrentAttempts(): void
248+
{
249+
$stateStub = $this->createStub(OtpChallengeStateInterface::class);
250+
$stateStub->method('getAttempts')->willReturn(3);
251+
252+
$stateServiceStub = $this->createStub(OtpChallengeStateServiceInterface::class);
253+
$stateServiceStub->method('getChallengeState')->willReturn($stateStub);
254+
255+
$codeValidatorStub = $this->createStub(OtpCodeValidatorServiceInterface::class);
256+
$codeValidatorStub->method('getMaxAttempts')->willReturn(5);
257+
258+
$sut = $this->getSut(stateService: $stateServiceStub, codeValidator: $codeValidatorStub);
259+
260+
$this->assertSame(2, $sut->getRemainingAttempts(uniqid()));
261+
}
262+
263+
#[Test]
264+
public function getRemainingAttemptsReturnsZeroWhenAttemptsExceedMax(): void
265+
{
266+
$stateStub = $this->createStub(OtpChallengeStateInterface::class);
267+
$stateStub->method('getAttempts')->willReturn(7);
268+
269+
$stateServiceStub = $this->createStub(OtpChallengeStateServiceInterface::class);
270+
$stateServiceStub->method('getChallengeState')->willReturn($stateStub);
271+
272+
$codeValidatorStub = $this->createStub(OtpCodeValidatorServiceInterface::class);
273+
$codeValidatorStub->method('getMaxAttempts')->willReturn(5);
274+
275+
$sut = $this->getSut(stateService: $stateServiceStub, codeValidator: $codeValidatorStub);
276+
277+
$this->assertSame(0, $sut->getRemainingAttempts(uniqid()));
278+
}
279+
232280
private function getSut(
233281
OtpChallengeStateServiceInterface $stateService = null,
234282
OtpCodeValidatorServiceInterface $codeValidator = null,

0 commit comments

Comments
 (0)