Skip to content

Commit bea74a0

Browse files
committed
Merge branch 'b-7.4.x-resend-code-OXDEV-10002' into b-7.4.x-2fa-OXDEV-9078
2 parents a022b93 + c398022 commit bea74a0

26 files changed

Lines changed: 470 additions & 28 deletions

File tree

assets/out/src/js/index.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { PasswordStrength } from "./module/password-validator.js";
77
import { PasswordGenerator } from "./module/password-generator.js";
88
import { CaptchaRefresh } from "./module/captcha-refresh.js";
99
import { CaptchaAudio } from "./module/captcha-audio.js";
10+
import { ResendOtp } from './module/resend-otp.js';
1011

1112
document.querySelectorAll("div[data-type='passwordStrength']").forEach((el) => {
1213
new PasswordStrength({
@@ -46,3 +47,14 @@ document.querySelectorAll('.captcha-play').forEach((el) => {
4647
fieldTarget: el
4748
})
4849
});
50+
51+
const resendBtn = document.getElementById('resend-btn');
52+
if (resendBtn) {
53+
const resendOtp = new ResendOtp(resendBtn);
54+
55+
// restore cooldown on refresh
56+
resendOtp.restoreIfNeeded();
57+
resendBtn.addEventListener('click', function () {
58+
resendOtp.resend();
59+
});
60+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/**
2+
* Copyright © OXID eSales AG. All rights reserved.
3+
* See LICENSE file for license details.
4+
*/
5+
6+
export class ResendOtp {
7+
constructor(button, { hintId = 'resend-hint' } = {}) {
8+
this.btn = button;
9+
this.hint = document.getElementById(hintId);
10+
11+
this.cooldownSeconds = Number(button.dataset.cooldown || 60);
12+
this.url = button.dataset.url;
13+
this.storageKey = `otp_resend_until_${this.url}`;
14+
15+
this.timer = null;
16+
}
17+
18+
async resend() {
19+
console.log('Resend OTP code requested');
20+
if (this.btn.disabled) return;
21+
22+
this.lock('Sending…');
23+
24+
try {
25+
await fetch(this.url, {
26+
method: 'POST',
27+
headers: { 'Content-Type': 'application/json' }
28+
});
29+
30+
const until = Date.now() + this.cooldownSeconds * 1000;
31+
this.storeUntil(until);
32+
this.startCooldown(until);
33+
34+
} catch (e) {
35+
console.error(e);
36+
this.unlock();
37+
this.setHint('Could not resend code.');
38+
}
39+
}
40+
41+
startCooldown(until) {
42+
clearInterval(this.timer);
43+
console.log(`Starting OTP resend cooldown until ${new Date(until).toISOString()}`);
44+
const tick = () => {
45+
const remaining = Math.ceil((until - Date.now()) / 1000);
46+
47+
if (remaining <= 0) {
48+
this.clearStoredUntil();
49+
this.unlock();
50+
clearInterval(this.timer);
51+
} else {
52+
this.lock(`Resend in ${remaining}s`);
53+
}
54+
};
55+
56+
tick();
57+
this.timer = setInterval(tick, 1000);
58+
}
59+
60+
lock(text) {
61+
this.btn.disabled = true;
62+
this.btn.textContent = text;
63+
}
64+
65+
unlock() {
66+
this.btn.disabled = false;
67+
this.btn.textContent = 'Resend code';
68+
}
69+
70+
setHint(text) {
71+
if (this.hint) this.hint.textContent = text;
72+
}
73+
74+
storeUntil(until) {
75+
localStorage.setItem(this.storageKey, until);
76+
}
77+
78+
getStoredUntil() {
79+
return Number(localStorage.getItem(this.storageKey));
80+
}
81+
82+
clearStoredUntil() {
83+
localStorage.removeItem(this.storageKey);
84+
}
85+
86+
restoreIfNeeded() {
87+
console.log('Restoring OTP resend cooldown if needed');
88+
const until = this.getStoredUntil();
89+
if (until && until > Date.now()) {
90+
this.startCooldown(until);
91+
}
92+
}
93+
}

metadata.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@
4444
'captcha' => \OxidEsales\SecurityModule\Captcha\Controller\CaptchaController::class,
4545
'password' => \OxidEsales\SecurityModule\PasswordPolicy\Controller\PasswordAjaxController::class,
4646
'oauth' => \OxidEsales\SecurityModule\Authentication\OAuth2\Controller\OAuthController::class,
47-
'twofactorauth' => \OxidEsales\SecurityModule\Authentication\TwoFactorAuth\Controller\TwoFactorAuthController::class,
4847
],
4948
'templates' => [
5049
],
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* Copyright © OXID eSales AG. All rights reserved.
7+
* See LICENSE file for license details.
8+
*/
9+
10+
namespace OxidEsales\SecurityModule\Migrations;
11+
12+
use Doctrine\DBAL\Schema\Schema;
13+
use Doctrine\Migrations\AbstractMigration;
14+
15+
final class Version20260114104913 extends AbstractMigration
16+
{
17+
public function up(Schema $schema): void
18+
{
19+
$this->addSql('ALTER TABLE `oxuser` ADD column `OESMOTPLASTSENT` DATETIME default NULL COMMENT "Last OTP sent timestamp"');
20+
}
21+
22+
public function down(Schema $schema): void
23+
{
24+
}
25+
}

src/Authentication/TwoFactorAuth/Controller/TwoFactorAuthController.php

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
use OxidEsales\Eshop\Application\Controller\FrontendController;
1313
use OxidEsales\Eshop\Core\Registry;
14+
use OxidEsales\SecurityModule\Authentication\TwoFactorAuth\Infrastructure\Repository\UserRepositoryInterface;
1415
use OxidEsales\SecurityModule\Authentication\TwoFactorAuth\Service\AuthorizeServiceInterface;
1516
use OxidEsales\SecurityModule\Authentication\TwoFactorAuth\Transput\AuthCodeRequestInterface;
1617

@@ -24,15 +25,19 @@ class TwoFactorAuthController extends FrontendController
2425
*/
2526
protected $_sThisTemplate = '@oe_security_module/templates/two_factor_auth';
2627

28+
public function __construct(
29+
private readonly AuthorizeServiceInterface $authService,
30+
private readonly AuthCodeRequestInterface $authCodeRequest,
31+
) {
32+
parent::__construct();
33+
}
34+
2735
public function handleOTP(): void
2836
{
29-
$OTPRequest = $this->getService(AuthCodeRequestInterface::class);
30-
3137
//todo: catch only OTP exception that will be shown to user, maybe some abstract OTP exception?
3238
try {
33-
$authorizeService = $this->getService(AuthorizeServiceInterface::class);
34-
$authorizeService->validate(
35-
$OTPRequest->getCode()
39+
$this->authService->validate(
40+
$this->authCodeRequest->getCode()
3641
);
3742

3843
//todo: redirect to originally requested page after successful OTP validation
@@ -42,4 +47,9 @@ public function handleOTP(): void
4247
Registry::getUtilsView()->addErrorToDisplay($e->getMessage());
4348
}
4449
}
50+
51+
public function resendCode(): void
52+
{
53+
$this->authService->generate();
54+
}
4555
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
services:
2+
_defaults:
3+
autowire: true
4+
public: false
5+
6+
OxidEsales\SecurityModule\Authentication\TwoFactorAuth\Controller\TwoFactorAuthController:
7+
public: true
8+
tags:
9+
- { name: 'oxid.view_controller', controller_key: 'twofactorauth' }

src/Authentication/TwoFactorAuth/DTO/User.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ public function __construct(
1818
private readonly int $attempts,
1919
private readonly ?string $code,
2020
private readonly ?DateTimeInterface $expiresAt,
21+
private readonly ?DateTimeInterface $lastSentAt
2122
) {
2223
}
2324

@@ -40,4 +41,9 @@ public function getExpiresAt(): ?DateTimeInterface
4041
{
4142
return $this->expiresAt;
4243
}
44+
45+
public function getLastSentAt(): ?DateTimeInterface
46+
{
47+
return $this->lastSentAt;
48+
}
4349
}

src/Authentication/TwoFactorAuth/DTO/UserInterface.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,6 @@ public function getCode(): ?string;
1818
public function getAttempts(): int;
1919

2020
public function getExpiresAt(): ?DateTimeInterface;
21+
22+
public function getLastSentAt(): ?DateTimeInterface;
2123
}

src/Authentication/TwoFactorAuth/Infrastructure/Repository/UserRepository.php

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@
1010
namespace OxidEsales\SecurityModule\Authentication\TwoFactorAuth\Infrastructure\Repository;
1111

1212
use DateTime;
13+
use DateTimeImmutable;
1314
use Doctrine\DBAL\Result;
1415
use OxidEsales\EshopCommunity\Internal\Framework\Database\QueryBuilderFactoryInterface;
16+
use OxidEsales\SecurityModule\Authentication\TwoFactorAuth\DTO\UserInterface;
1517
use OxidEsales\SecurityModule\Authentication\TwoFactorAuth\DTO\User as UserDTO;
1618
use OxidEsales\SecurityModule\Authentication\TwoFactorAuth\Exception\UserNotFoundException;
1719
use OxidEsales\SecurityModule\Authentication\TwoFactorAuth\Infrastructure\Factory\UserFactoryInterface;
@@ -24,14 +26,15 @@ public function __construct(
2426
) {
2527
}
2628

27-
public function getUserOTPData(string $userName): UserDTO
29+
public function getUserOTPData(string $userName): UserInterface
2830
{
2931
$builder = $this->queryBuilderFactory->create();
3032
$builder->select([
3133
'OXID',
3234
'OESMOTPCODE',
3335
'OESMOTPATTEMPTS',
34-
'OESMOTPEXPTIME'
36+
'OESMOTPEXPTIME',
37+
'OESMOTPLASTSENT',
3538
])
3639
->from('oxuser')
3740
->where('oxusername = :userName')
@@ -48,7 +51,8 @@ public function getUserOTPData(string $userName): UserDTO
4851
$userData['OXID'],
4952
$userData['OESMOTPATTEMPTS'],
5053
$userData['OESMOTPCODE'],
51-
$userData['OESMOTPEXPTIME'] ? new DateTime($userData['OESMOTPEXPTIME']) : null
54+
$userData['OESMOTPEXPTIME'] ? new DateTime($userData['OESMOTPEXPTIME']) : null,
55+
$userData['OESMOTPLASTSENT'] ? new DateTime($userData['OESMOTPLASTSENT']) : null,
5256
);
5357
}
5458

@@ -81,9 +85,10 @@ public function resetCodeFields(string $userId): void
8185
$userModel = $this->userFactory->create();
8286
$userModel->load($userId);
8387
$userModel->assign([
84-
'OESMOTPCODE' => '',
85-
'OESMOTPEXPTIME' => 0,
88+
'OESMOTPCODE' => null,
89+
'OESMOTPEXPTIME' => null,
8690
'OESMOTPATTEMPTS' => 0,
91+
'OESMOTPLASTSENT' => null,
8792
]);
8893
$userModel->save();
8994
}
@@ -102,4 +107,14 @@ public function getUserPasswordHash(string $userName): ?string
102107

103108
return $userPass ?: null;
104109
}
110+
111+
public function markOtpAsSent(string $userId): void
112+
{
113+
$userModel = $this->userFactory->create();
114+
$userModel->load($userId);
115+
$userModel->assign([
116+
'OESMOTPLASTSENT' => (new DateTimeImmutable())->format('Y-m-d H:i:s')
117+
]);
118+
$userModel->save();
119+
}
105120
}

src/Authentication/TwoFactorAuth/Infrastructure/Repository/UserRepositoryInterface.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@
88
namespace OxidEsales\SecurityModule\Authentication\TwoFactorAuth\Infrastructure\Repository;
99

1010
use DateTime;
11-
use OxidEsales\SecurityModule\Authentication\TwoFactorAuth\DTO\User as UserDTO;
11+
use OxidEsales\SecurityModule\Authentication\TwoFactorAuth\DTO\UserInterface;
1212

1313
interface UserRepositoryInterface
1414
{
15-
public function getUserOTPData(string $userName): UserDTO;
15+
public function getUserOTPData(string $userName): UserInterface;
1616

1717
public function updateAttempts(string $userId, int $attempts): void;
1818

@@ -21,4 +21,6 @@ public function resetCodeFields(string $userId): void;
2121
public function addOTPtoUser(string $userId, string $otp, DateTime $expiresAt): bool;
2222

2323
public function getUserPasswordHash(string $userId): ?string;
24+
25+
public function markOtpAsSent(string $userId): void;
2426
}

0 commit comments

Comments
 (0)