Skip to content

Commit adc84b4

Browse files
committed
OXDEV-9078 Add a challenge state repository
1 parent 3b9be2d commit adc84b4

3 files changed

Lines changed: 341 additions & 0 deletions

File tree

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
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\OTP\Infrastructure\Repository;
11+
12+
use DateTimeImmutable;
13+
use OxidEsales\EshopCommunity\Internal\Framework\Database\QueryBuilderFactoryInterface;
14+
use OxidEsales\SecurityModule\Authentication\TwoFactorAuth\OTP\DTO\OtpChallengeState;
15+
use OxidEsales\SecurityModule\Authentication\TwoFactorAuth\OTP\DTO\OtpChallengeStateInterface;
16+
17+
class OtpChallengeStateRepository implements OtpChallengeStateRepositoryInterface
18+
{
19+
private const TABLE = 'oesm_2fa_otp';
20+
private const DB_DATETIME_FORMAT = 'Y-m-d H:i:s';
21+
22+
public function __construct(
23+
private QueryBuilderFactoryInterface $queryBuilderFactory,
24+
) {
25+
}
26+
27+
public function findByUserId(string $userId): ?OtpChallengeStateInterface
28+
{
29+
$builder = $this->queryBuilderFactory->create();
30+
$builder->select('*')
31+
->from(self::TABLE)
32+
->where('OXUSERID = :userId')
33+
->setParameter('userId', $userId);
34+
35+
/** @var \Doctrine\DBAL\Result $result */
36+
$result = $builder->execute();
37+
$row = $result->fetchAssociative();
38+
39+
if (!$row) {
40+
return null;
41+
}
42+
43+
return new OtpChallengeState(
44+
userId: $row['OXUSERID'],
45+
codeHash: $row['CODE_HASH'],
46+
attempts: (int)$row['ATTEMPTS'],
47+
lastSentAt: $row['LAST_SENT_AT'] ? new DateTimeImmutable($row['LAST_SENT_AT']) : null,
48+
expiresAt: new DateTimeImmutable($row['EXPIRES_AT']),
49+
verifiedAt: $row['VERIFIED_AT'] ? new DateTimeImmutable($row['VERIFIED_AT']) : null,
50+
);
51+
}
52+
53+
public function createChallengeState(string $userId, string $codeHash, DateTimeImmutable $expiresAt): void
54+
{
55+
$this->deleteByUserId($userId);
56+
57+
$builder = $this->queryBuilderFactory->create();
58+
$builder->insert(self::TABLE)
59+
->values([
60+
'OXUSERID' => ':userId',
61+
'CODE_HASH' => ':codeHash',
62+
'ATTEMPTS' => '0',
63+
'LAST_SENT_AT' => ':lastSentAt',
64+
'EXPIRES_AT' => ':expiresAt',
65+
'VERIFIED_AT' => 'NULL',
66+
])
67+
->setParameters([
68+
'userId' => $userId,
69+
'codeHash' => $codeHash,
70+
'lastSentAt' => (new DateTimeImmutable())->format(self::DB_DATETIME_FORMAT),
71+
'expiresAt' => $expiresAt->format(self::DB_DATETIME_FORMAT),
72+
]);
73+
74+
$builder->execute();
75+
}
76+
77+
public function markVerified(string $userId): void
78+
{
79+
$builder = $this->queryBuilderFactory->create();
80+
$builder->update(self::TABLE)
81+
->set('VERIFIED_AT', ':verifiedAt')
82+
->where('OXUSERID = :userId')
83+
->setParameters([
84+
'userId' => $userId,
85+
'verifiedAt' => (new DateTimeImmutable())->format(self::DB_DATETIME_FORMAT),
86+
]);
87+
88+
$builder->execute();
89+
}
90+
91+
public function markResent(string $userId, DateTimeImmutable $expiresAt): void
92+
{
93+
$builder = $this->queryBuilderFactory->create();
94+
$builder->update(self::TABLE)
95+
->set('LAST_SENT_AT', ':lastSentAt')
96+
->set('EXPIRES_AT', ':expiresAt')
97+
->where('OXUSERID = :userId')
98+
->setParameters([
99+
'userId' => $userId,
100+
'lastSentAt' => (new DateTimeImmutable())->format(self::DB_DATETIME_FORMAT),
101+
'expiresAt' => $expiresAt->format(self::DB_DATETIME_FORMAT),
102+
]);
103+
104+
$builder->execute();
105+
}
106+
107+
public function incrementAttempts(string $userId): void
108+
{
109+
$builder = $this->queryBuilderFactory->create();
110+
$builder->update(self::TABLE)
111+
->set('ATTEMPTS', 'ATTEMPTS + 1')
112+
->where('OXUSERID = :userId')
113+
->setParameter('userId', $userId);
114+
115+
$builder->execute();
116+
}
117+
118+
public function deleteChallengeState(string $userId): void
119+
{
120+
$this->deleteByUserId($userId);
121+
}
122+
123+
private function deleteByUserId(string $userId): void
124+
{
125+
$builder = $this->queryBuilderFactory->create();
126+
$builder->delete(self::TABLE)
127+
->where('OXUSERID = :userId')
128+
->setParameter('userId', $userId);
129+
130+
$builder->execute();
131+
}
132+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
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\OTP\Infrastructure\Repository;
11+
12+
use DateTimeImmutable;
13+
use OxidEsales\SecurityModule\Authentication\TwoFactorAuth\OTP\DTO\OtpChallengeStateInterface;
14+
15+
interface OtpChallengeStateRepositoryInterface
16+
{
17+
public function findByUserId(string $userId): ?OtpChallengeStateInterface;
18+
19+
public function createChallengeState(string $userId, string $codeHash, DateTimeImmutable $expiresAt): void;
20+
21+
public function markVerified(string $userId): void;
22+
23+
public function markResent(string $userId, DateTimeImmutable $expiresAt): void;
24+
25+
public function incrementAttempts(string $userId): void;
26+
27+
public function deleteChallengeState(string $userId): void;
28+
}
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
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\Tests\Integration\Authentication\TwoFactorAuth\OTP\Infrastructure\Repository;
11+
12+
use DateTimeImmutable;
13+
use OxidEsales\EshopCommunity\Internal\Framework\Database\QueryBuilderFactoryInterface;
14+
use OxidEsales\SecurityModule\Authentication\TwoFactorAuth\OTP\DTO\OtpChallengeStateInterface;
15+
use OxidEsales\SecurityModule\Authentication\TwoFactorAuth\OTP\Infrastructure\Repository\OtpChallengeStateRepository;
16+
use OxidEsales\SecurityModule\Authentication\TwoFactorAuth\OTP\Infrastructure\Repository\OtpChallengeStateRepositoryInterface;
17+
use OxidEsales\SecurityModule\Tests\Integration\IntegrationTestCase;
18+
use PHPUnit\Framework\Attributes\Test;
19+
20+
class OtpChallengeStateRepositoryTest extends IntegrationTestCase
21+
{
22+
public function setUp(): void
23+
{
24+
parent::setUp();
25+
26+
$this->cleanupTable();
27+
}
28+
29+
#[Test]
30+
public function findByUserIdReturnsNullWhenNotFound(): void
31+
{
32+
$sut = $this->getSut();
33+
34+
$result = $sut->findByUserId(uniqid());
35+
36+
$this->assertNull($result);
37+
}
38+
39+
#[Test]
40+
public function createChallengeStateAndFind(): void
41+
{
42+
$sut = $this->getSut();
43+
44+
$sut->createChallengeState(
45+
userId: $userId = uniqid(),
46+
codeHash: $codeHash = uniqid(),
47+
expiresAt: $expiresAt = new DateTimeImmutable('+5 minutes'),
48+
);
49+
50+
$result = $sut->findByUserId($userId);
51+
52+
$this->assertInstanceOf(OtpChallengeStateInterface::class, $result);
53+
$this->assertSame($userId, $result->getUserId());
54+
$this->assertSame($codeHash, $result->getCodeHash());
55+
$this->assertSame(0, $result->getAttempts());
56+
$this->assertNotNull($result->getLastSentAt());
57+
$this->assertSame($expiresAt->format('Y-m-d H:i:s'), $result->getExpiresAt()->format('Y-m-d H:i:s'));
58+
$this->assertNull($result->getVerifiedAt());
59+
}
60+
61+
#[Test]
62+
public function createChallengeStateOverwritesExistingChallengeState(): void
63+
{
64+
$sut = $this->getSut();
65+
66+
$sut->createChallengeState(
67+
userId: $userId = uniqid(),
68+
codeHash: 'first_hash',
69+
expiresAt: new DateTimeImmutable('+5 minutes'),
70+
);
71+
72+
$sut->createChallengeState(
73+
userId: $userId,
74+
codeHash: 'second_hash',
75+
expiresAt: $secondExpiresAt = new DateTimeImmutable('+10 minutes'),
76+
);
77+
78+
$result = $sut->findByUserId($userId);
79+
80+
$this->assertSame('second_hash', $result->getCodeHash());
81+
$this->assertSame(
82+
$secondExpiresAt->format('Y-m-d H:i:s'),
83+
$result->getExpiresAt()->format('Y-m-d H:i:s')
84+
);
85+
}
86+
87+
#[Test]
88+
public function markVerifiedSetsVerifiedAt(): void
89+
{
90+
$sut = $this->getSut();
91+
$sut->createChallengeState(
92+
userId: $userId = uniqid(),
93+
codeHash: uniqid(),
94+
expiresAt: new DateTimeImmutable('+5 minutes'),
95+
);
96+
97+
$sut->markVerified($userId);
98+
99+
$result = $sut->findByUserId($userId);
100+
$this->assertNotNull($result->getVerifiedAt());
101+
}
102+
103+
#[Test]
104+
public function markResentUpdatesLastSentAtAndExpiresAt(): void
105+
{
106+
$sut = $this->getSut();
107+
$sut->createChallengeState(
108+
userId: $userId = uniqid(),
109+
codeHash: uniqid(),
110+
expiresAt: new DateTimeImmutable('+5 minutes'),
111+
);
112+
113+
$sut->markResent(
114+
userId: $userId,
115+
expiresAt: $newExpiresAt = new DateTimeImmutable('+10 minutes'),
116+
);
117+
118+
$result = $sut->findByUserId($userId);
119+
$this->assertGreaterThanOrEqual(
120+
new DateTimeImmutable('-2 seconds'),
121+
$result->getLastSentAt()
122+
);
123+
$this->assertSame($newExpiresAt->format('Y-m-d H:i:s'), $result->getExpiresAt()->format('Y-m-d H:i:s'));
124+
}
125+
126+
#[Test]
127+
public function incrementAttempts(): void
128+
{
129+
$sut = $this->getSut();
130+
$sut->createChallengeState(
131+
userId: $userId = uniqid(),
132+
codeHash: uniqid(),
133+
expiresAt: new DateTimeImmutable('+5 minutes'),
134+
);
135+
136+
$sut->incrementAttempts($userId);
137+
138+
$result = $sut->findByUserId($userId);
139+
$this->assertSame(1, $result->getAttempts());
140+
}
141+
142+
#[Test]
143+
public function deleteChallengeStateRemovesChallenge(): void
144+
{
145+
$sut = $this->getSut();
146+
$sut->createChallengeState(
147+
userId: $userId = uniqid(),
148+
codeHash: uniqid(),
149+
expiresAt: new DateTimeImmutable('+5 minutes'),
150+
);
151+
152+
$sut->deleteChallengeState($userId);
153+
154+
$this->assertNull($sut->findByUserId($userId));
155+
}
156+
157+
#[Test]
158+
public function deleteChallengeStateNonExistentUserDoesNotExplode(): void
159+
{
160+
$sut = $this->getSut();
161+
162+
$sut->deleteChallengeState(uniqid());
163+
164+
$this->addToAssertionCount(1);
165+
}
166+
167+
private function getSut(): OtpChallengeStateRepositoryInterface
168+
{
169+
return new OtpChallengeStateRepository(
170+
queryBuilderFactory: $this->get(QueryBuilderFactoryInterface::class),
171+
);
172+
}
173+
174+
private function cleanupTable(): void
175+
{
176+
$this->get(QueryBuilderFactoryInterface::class)
177+
->create()
178+
->getConnection()
179+
->executeStatement('DELETE FROM oesm_2fa_otp');
180+
}
181+
}

0 commit comments

Comments
 (0)