Skip to content

Commit c668d63

Browse files
chore: Add PR's requested changes and additional AI comments
1 parent 094bf65 commit c668d63

5 files changed

Lines changed: 285 additions & 21 deletions

File tree

app/Repositories/DoctrineUserTrustedDeviceRepository.php

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use App\libs\Auth\Models\UserTrustedDevice;
1515
use Auth\Repositories\IUserTrustedDeviceRepository;
1616
use Auth\User;
17+
use Doctrine\Common\Collections\Criteria;
1718

1819
final class DoctrineUserTrustedDeviceRepository
1920
extends ModelDoctrineRepository implements IUserTrustedDeviceRepository
@@ -23,20 +24,35 @@ protected function getBaseEntity()
2324
return UserTrustedDevice::class;
2425
}
2526

27+
private function buildActiveExpiryExpr(): \Doctrine\Common\Collections\Expr\CompositeExpression
28+
{
29+
$now = new \DateTime('now', new \DateTimeZone('UTC'));
30+
return Criteria::expr()->orX(
31+
Criteria::expr()->gt('expires_at', $now),
32+
Criteria::expr()->isNull('expires_at')
33+
);
34+
}
35+
2636
public function getActiveByUserAndIdentifier(User $user, string $deviceIdentifier): ?UserTrustedDevice
2737
{
28-
return $this->findOneBy([
29-
'user' => $user,
30-
'device_identifier' => $deviceIdentifier,
31-
'is_revoked' => false,
32-
]);
38+
$criteria = Criteria::create()
39+
->where(Criteria::expr()->eq('user', $user))
40+
->andWhere(Criteria::expr()->eq('device_identifier', $deviceIdentifier))
41+
->andWhere(Criteria::expr()->eq('is_revoked', false))
42+
->andWhere($this->buildActiveExpiryExpr())
43+
->setMaxResults(1);
44+
45+
$result = $this->matching($criteria)->first();
46+
return $result instanceof UserTrustedDevice ? $result : null;
3347
}
3448

3549
public function getActiveByUser(User $user): array
3650
{
37-
return $this->findBy([
38-
'user' => $user,
39-
'is_revoked' => false,
40-
]);
51+
$criteria = Criteria::create()
52+
->where(Criteria::expr()->eq('user', $user))
53+
->andWhere(Criteria::expr()->eq('is_revoked', false))
54+
->andWhere($this->buildActiveExpiryExpr());
55+
56+
return $this->matching($criteria)->toArray();
4157
}
4258
}

app/libs/Auth/Models/UserRecoveryCode.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@
1414

1515
use Auth\User;
1616
use Doctrine\ORM\Mapping AS ORM;
17+
use App\Repositories\DoctrineUserRecoveryCodeRepository;
1718

1819
#[ORM\Table(name: 'user_recovery_codes')]
19-
#[ORM\Entity(repositoryClass: \App\Repositories\DoctrineUserRecoveryCodeRepository::class)]
20+
#[ORM\Entity(repositoryClass: DoctrineUserRecoveryCodeRepository::class)]
2021
class UserRecoveryCode
2122
{
2223
#[ORM\Id]
@@ -25,7 +26,7 @@ class UserRecoveryCode
2526
protected $id;
2627

2728
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
28-
#[ORM\ManyToOne(targetEntity: \Auth\User::class)]
29+
#[ORM\ManyToOne(targetEntity: User::class)]
2930
private $user;
3031

3132
#[ORM\Column(name: 'code_hash', type: 'string', length: 255)]
@@ -63,5 +64,4 @@ public function markUsed(): void
6364
$this->used_at = new \DateTime('now', new \DateTimeZone('UTC'));
6465
}
6566

66-
public function __get($name) { return $this->{$name}; }
6767
}

app/libs/Auth/Models/UserTrustedDevice.php

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,4 @@ public function setLastSeenAt(\DateTime $value): void { $this->last_seen_at = $v
8181

8282
public function isRevoked(): bool { return (bool) $this->is_revoked; }
8383
public function setIsRevoked(bool $value): void { $this->is_revoked = $value; }
84-
85-
public function __get($name) { return $this->{$name}; }
8684
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php namespace Database\Migrations;
2+
/**
3+
* Copyright 2026 OpenStack Foundation
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
**/
14+
use Doctrine\Migrations\AbstractMigration;
15+
use Doctrine\DBAL\Schema\Schema as Schema;
16+
17+
/**
18+
* Class Version20260424120000
19+
* @package Database\Migrations
20+
*
21+
* Enforce uniqueness of (user_id, device_identifier) on user_trusted_devices.
22+
* Replaces the plain index utd_user_device_idx with a unique one so that a
23+
* given user can never accumulate duplicate device rows.
24+
*/
25+
final class Version20260424120000 extends AbstractMigration
26+
{
27+
public function up(Schema $schema): void
28+
{
29+
$this->addSql(
30+
'ALTER TABLE user_trusted_devices
31+
DROP INDEX utd_user_device_idx,
32+
ADD UNIQUE INDEX utd_user_device_uniq (user_id, device_identifier)'
33+
);
34+
}
35+
36+
public function down(Schema $schema): void
37+
{
38+
$this->addSql(
39+
'ALTER TABLE user_trusted_devices
40+
DROP INDEX utd_user_device_uniq,
41+
ADD INDEX utd_user_device_idx (user_id, device_identifier)'
42+
);
43+
}
44+
}

tests/TwoFactorRepositoriesTest.php

Lines changed: 213 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
use Auth\Repositories\ITwoFactorAuditLogRepository;
1818
use Auth\Repositories\IUserRecoveryCodeRepository;
1919
use Auth\Repositories\IUserTrustedDeviceRepository;
20-
use Auth\Repositories\IUserRepository;
2120
use Auth\User;
2221
use Illuminate\Support\Facades\App;
2322
use LaravelDoctrine\ORM\Facades\EntityManager;
@@ -33,12 +32,32 @@ class TwoFactorRepositoriesTest extends TestCase
3332
public function setUp(): void
3433
{
3534
parent::setUp();
36-
// Pull any persisted user; tests don't assert on user fields, only on FK linkage
37-
$userRepo = App::make(IUserRepository::class);
38-
$this->user = $userRepo->findOneBy([]);
39-
if (is_null($this->user)) {
40-
$this->markTestSkipped('No User exists; database must be seeded.');
35+
$user = new User();
36+
$user->setFirstName('Test');
37+
$user->setLastName('TwoFactor');
38+
$user->setEmail('test.twofactor.' . uniqid() . '@test.invalid');
39+
$user->setAddress1('123 Test St');
40+
$user->setState('CA');
41+
$user->setCity('Testville');
42+
$user->setPostCode('00000');
43+
$user->setCountryIsoCode('US');
44+
$user->setPic('');
45+
$user->setLastLoginDate(new \DateTime('now', new \DateTimeZone('UTC')));
46+
EntityManager::persist($user);
47+
EntityManager::flush();
48+
$this->user = $user;
49+
}
50+
51+
public function tearDown(): void
52+
{
53+
if ($this->user !== null) {
54+
$managed = EntityManager::find(User::class, $this->user->getId());
55+
if ($managed !== null) {
56+
EntityManager::remove($managed);
57+
EntityManager::flush();
58+
}
4159
}
60+
parent::tearDown();
4261
}
4362

4463
public function testTrustedDeviceRoundTrip(): void
@@ -136,6 +155,193 @@ public function testRecoveryCodeRoundTrip(): void
136155
$this->assertTrue($reload->isUsed());
137156

138157
$deleted = $repo->deleteAllForUser($this->user);
139-
$this->assertGreaterThanOrEqual(1, $deleted);
158+
$this->assertEquals(1, $deleted);
159+
}
160+
161+
// -------------------------------------------------------------------------
162+
// Targeted behaviour tests
163+
// -------------------------------------------------------------------------
164+
165+
public function testExpiredTrustedDeviceIsExcluded(): void
166+
{
167+
$repo = App::make(IUserTrustedDeviceRepository::class);
168+
$now = new \DateTime('now', new \DateTimeZone('UTC'));
169+
$expired = (clone $now)->modify('-1 minute');
170+
$deviceId = hash('sha256', 'expired-device-' . uniqid());
171+
172+
$device = $this->buildDevice($deviceId, $now, $expired);
173+
EntityManager::persist($device);
174+
EntityManager::flush();
175+
$id = $device->getId();
176+
EntityManager::clear();
177+
178+
$this->assertNull(
179+
$repo->getActiveByUserAndIdentifier($this->user, $deviceId),
180+
'getActiveByUserAndIdentifier must return null for an expired device.'
181+
);
182+
183+
$ids = array_map(
184+
fn(UserTrustedDevice $d) => $d->getDeviceIdentifier(),
185+
$repo->getActiveByUser($this->user)
186+
);
187+
$this->assertNotContains($deviceId, $ids, 'getActiveByUser must not include expired devices.');
188+
189+
$stale = EntityManager::find(UserTrustedDevice::class, $id);
190+
if ($stale) { EntityManager::remove($stale); EntityManager::flush(); }
191+
}
192+
193+
public function testRevokedTrustedDeviceIsExcluded(): void
194+
{
195+
$repo = App::make(IUserTrustedDeviceRepository::class);
196+
$now = new \DateTime('now', new \DateTimeZone('UTC'));
197+
$expires = (clone $now)->modify('+30 days');
198+
$deviceId = hash('sha256', 'revoked-device-' . uniqid());
199+
200+
$device = $this->buildDevice($deviceId, $now, $expires);
201+
$device->setIsRevoked(true);
202+
EntityManager::persist($device);
203+
EntityManager::flush();
204+
$id = $device->getId();
205+
EntityManager::clear();
206+
207+
$this->assertNull(
208+
$repo->getActiveByUserAndIdentifier($this->user, $deviceId),
209+
'getActiveByUserAndIdentifier must return null for a revoked device.'
210+
);
211+
212+
$ids = array_map(
213+
fn(UserTrustedDevice $d) => $d->getDeviceIdentifier(),
214+
$repo->getActiveByUser($this->user)
215+
);
216+
$this->assertNotContains($deviceId, $ids, 'getActiveByUser must not include revoked devices.');
217+
218+
$stale = EntityManager::find(UserTrustedDevice::class, $id);
219+
if ($stale) { EntityManager::remove($stale); EntityManager::flush(); }
220+
}
221+
222+
public function testDuplicateDeviceIdentifierCannotOccur(): void
223+
{
224+
$connection = EntityManager::getConnection();
225+
$indexes = $connection->createSchemaManager()->listTableIndexes('user_trusted_devices');
226+
227+
$hasUnique = false;
228+
foreach ($indexes as $index) {
229+
if ($index->isUnique()) {
230+
$cols = $index->getColumns();
231+
if (in_array('user_id', $cols) && in_array('device_identifier', $cols)) {
232+
$hasUnique = true;
233+
break;
234+
}
235+
}
236+
}
237+
238+
$this->assertTrue(
239+
$hasUnique,
240+
'user_trusted_devices must have a UNIQUE index on (user_id, device_identifier).'
241+
);
242+
}
243+
244+
public function testRecoveryCodeDeletionRemovesUsedAndUnusedCodes(): void
245+
{
246+
$repo = App::make(IUserRecoveryCodeRepository::class);
247+
248+
$unused = new UserRecoveryCode();
249+
$unused->setUser($this->user);
250+
$unused->setCodeHash(password_hash('UNUSED_' . uniqid(), PASSWORD_BCRYPT));
251+
252+
$used = new UserRecoveryCode();
253+
$used->setUser($this->user);
254+
$used->setCodeHash(password_hash('USED_' . uniqid(), PASSWORD_BCRYPT));
255+
$used->markUsed();
256+
257+
EntityManager::persist($unused);
258+
EntityManager::persist($used);
259+
EntityManager::flush();
260+
$unusedId = $unused->getId();
261+
$usedId = $used->getId();
262+
263+
$deleted = $repo->deleteAllForUser($this->user);
264+
$this->assertGreaterThanOrEqual(2, $deleted, 'deleteAllForUser must remove both used and unused codes.');
265+
266+
EntityManager::clear();
267+
$this->assertNull(
268+
EntityManager::find(UserRecoveryCode::class, $unusedId),
269+
'Unused recovery code must be deleted.'
270+
);
271+
$this->assertNull(
272+
EntityManager::find(UserRecoveryCode::class, $usedId),
273+
'Used recovery code must also be deleted.'
274+
);
275+
}
276+
277+
public function testAuditLogsReturnedMostRecentFirst(): void
278+
{
279+
$repo = App::make(ITwoFactorAuditLogRepository::class);
280+
$createdIds = [];
281+
282+
$timestamps = [
283+
new \DateTime('2020-01-01 01:00:00', new \DateTimeZone('UTC')),
284+
new \DateTime('2020-01-01 02:00:00', new \DateTimeZone('UTC')),
285+
new \DateTime('2020-01-01 03:00:00', new \DateTimeZone('UTC')),
286+
];
287+
288+
$setCreatedAt = static function (TwoFactorAuditLog $log, \DateTime $dt): void {
289+
$prop = new \ReflectionProperty(TwoFactorAuditLog::class, 'created_at');
290+
$prop->setAccessible(true);
291+
$prop->setValue($log, $dt);
292+
};
293+
294+
foreach ($timestamps as $ts) {
295+
$entry = new TwoFactorAuditLog();
296+
$entry->setUser($this->user);
297+
$entry->setEventType(TwoFactorAuditLog::EventChallengeIssued);
298+
$entry->setMethod(TwoFactorAuditLog::MethodEmailOtp);
299+
$entry->setIpAddress('127.0.0.1');
300+
$entry->setUserAgent('Mozilla/5.0 (test)');
301+
$setCreatedAt($entry, $ts);
302+
EntityManager::persist($entry);
303+
EntityManager::flush();
304+
$createdIds[] = $entry->getId();
305+
}
306+
307+
EntityManager::clear();
308+
309+
$all = $repo->getRecentByUser($this->user, 200);
310+
$ours = array_values(array_filter($all, fn(TwoFactorAuditLog $e) => in_array($e->getId(), $createdIds)));
311+
312+
$this->assertCount(3, $ours, 'All three seeded audit entries must be returned.');
313+
314+
for ($i = 0; $i < count($ours) - 1; $i++) {
315+
$this->assertGreaterThanOrEqual(
316+
$ours[$i + 1]->getCreatedAt()->getTimestamp(),
317+
$ours[$i]->getCreatedAt()->getTimestamp(),
318+
'Audit logs must be ordered most-recent first.'
319+
);
320+
}
321+
322+
// cleanup
323+
foreach ($createdIds as $logId) {
324+
$log = EntityManager::find(TwoFactorAuditLog::class, $logId);
325+
if ($log) { EntityManager::remove($log); }
326+
}
327+
EntityManager::flush();
328+
}
329+
330+
// -------------------------------------------------------------------------
331+
// Helpers
332+
// -------------------------------------------------------------------------
333+
334+
private function buildDevice(string $deviceId, \DateTime $now, \DateTime $expires): UserTrustedDevice
335+
{
336+
$device = new UserTrustedDevice();
337+
$device->setUser($this->user);
338+
$device->setDeviceIdentifier($deviceId);
339+
$device->setDeviceName('Test Browser');
340+
$device->setIpAddress('127.0.0.1');
341+
$device->setUserAgent('Mozilla/5.0 (test)');
342+
$device->setTrustedAt($now);
343+
$device->setExpiresAt($expires);
344+
$device->setLastSeenAt($now);
345+
return $device;
140346
}
141347
}

0 commit comments

Comments
 (0)