diff --git a/app/Repositories/DoctrineTwoFactorAuditLogRepository.php b/app/Repositories/DoctrineTwoFactorAuditLogRepository.php new file mode 100644 index 00000000..e87779f6 --- /dev/null +++ b/app/Repositories/DoctrineTwoFactorAuditLogRepository.php @@ -0,0 +1,34 @@ +findBy( + ['user' => $user], + ['created_at' => 'DESC'], + $limit + ); + } +} diff --git a/app/Repositories/DoctrineUserRecoveryCodeRepository.php b/app/Repositories/DoctrineUserRecoveryCodeRepository.php new file mode 100644 index 00000000..b492a0f6 --- /dev/null +++ b/app/Repositories/DoctrineUserRecoveryCodeRepository.php @@ -0,0 +1,43 @@ +findBy([ + 'user' => $user, + 'used_at' => null, + ]); + } + + public function deleteAllForUser(User $user): int + { + $em = $this->getEntityManager(); + $qb = $em->createQueryBuilder() + ->delete(UserRecoveryCode::class, 'c') + ->where('c.user = :user') + ->setParameter('user', $user); + return (int) $qb->getQuery()->execute(); + } +} diff --git a/app/Repositories/DoctrineUserTrustedDeviceRepository.php b/app/Repositories/DoctrineUserTrustedDeviceRepository.php new file mode 100644 index 00000000..007ff67a --- /dev/null +++ b/app/Repositories/DoctrineUserTrustedDeviceRepository.php @@ -0,0 +1,58 @@ +orX( + Criteria::expr()->gt('expires_at', $now), + Criteria::expr()->isNull('expires_at') + ); + } + + public function getActiveByUserAndIdentifier(User $user, string $deviceIdentifier): ?UserTrustedDevice + { + $criteria = Criteria::create() + ->where(Criteria::expr()->eq('user', $user)) + ->andWhere(Criteria::expr()->eq('device_identifier', $deviceIdentifier)) + ->andWhere(Criteria::expr()->eq('is_revoked', false)) + ->andWhere($this->buildActiveExpiryExpr()) + ->setMaxResults(1); + + $result = $this->matching($criteria)->first(); + return $result instanceof UserTrustedDevice ? $result : null; + } + + public function getActiveByUser(User $user): array + { + $criteria = Criteria::create() + ->where(Criteria::expr()->eq('user', $user)) + ->andWhere(Criteria::expr()->eq('is_revoked', false)) + ->andWhere($this->buildActiveExpiryExpr()); + + return $this->matching($criteria)->toArray(); + } +} diff --git a/app/Repositories/RepositoriesProvider.php b/app/Repositories/RepositoriesProvider.php index 61e01b2d..15939b37 100644 --- a/app/Repositories/RepositoriesProvider.php +++ b/app/Repositories/RepositoriesProvider.php @@ -13,7 +13,10 @@ **/ use App\libs\Auth\Models\SpamEstimatorFeed; +use App\libs\Auth\Models\TwoFactorAuditLog; +use App\libs\Auth\Models\UserRecoveryCode; use App\libs\Auth\Models\UserRegistrationRequest; +use App\libs\Auth\Models\UserTrustedDevice; use App\libs\Auth\Repositories\IBannedIPRepository; use App\libs\Auth\Repositories\IGroupRepository; use App\libs\Auth\Repositories\ISpamEstimatorFeedRepository; @@ -32,7 +35,10 @@ use App\Repositories\IServerConfigurationRepository; use App\Repositories\IServerExtensionRepository; use Auth\Group; +use Auth\Repositories\ITwoFactorAuditLogRepository; use Auth\Repositories\IUserActionRepository; +use Auth\Repositories\IUserRecoveryCodeRepository; +use Auth\Repositories\IUserTrustedDeviceRepository; use Auth\User; use Auth\UserPasswordResetRequest; use Illuminate\Contracts\Support\DeferrableProvider; @@ -271,6 +277,27 @@ function () { } ); + App::singleton( + IUserTrustedDeviceRepository::class, + function () { + return EntityManager::getRepository(UserTrustedDevice::class); + } + ); + + App::singleton( + ITwoFactorAuditLogRepository::class, + function () { + return EntityManager::getRepository(TwoFactorAuditLog::class); + } + ); + + App::singleton( + IUserRecoveryCodeRepository::class, + function () { + return EntityManager::getRepository(UserRecoveryCode::class); + } + ); + } public function provides() @@ -304,6 +331,9 @@ public function provides() IStreamChatSSOProfileRepository::class, IOAuth2OTPRepository::class, IUserActionRepository::class, + IUserTrustedDeviceRepository::class, + ITwoFactorAuditLogRepository::class, + IUserRecoveryCodeRepository::class, ]; } } \ No newline at end of file diff --git a/app/libs/Auth/Models/TwoFactorAuditLog.php b/app/libs/Auth/Models/TwoFactorAuditLog.php new file mode 100644 index 00000000..120cc2ce --- /dev/null +++ b/app/libs/Auth/Models/TwoFactorAuditLog.php @@ -0,0 +1,93 @@ +created_at = new \DateTime('now', new \DateTimeZone('UTC')); + $this->metadata = null; + } + + public function getId(): int { return (int) $this->id; } + + public function getUser(): User { return $this->user; } + public function setUser(User $user): void { $this->user = $user; } + + public function getEventType(): string { return $this->event_type; } + public function setEventType(string $value): void { $this->event_type = $value; } + + public function getMethod(): string { return $this->method; } + public function setMethod(string $value): void { $this->method = $value; } + + public function getIpAddress(): string { return $this->ip_address; } + public function setIpAddress(string $value): void { $this->ip_address = $value; } + + public function getUserAgent(): string { return $this->user_agent; } + public function setUserAgent(string $value): void { $this->user_agent = $value; } + + public function getMetadata(): ?array { return $this->metadata; } + public function setMetadata(?array $value): void { $this->metadata = $value; } + + public function getCreatedAt(): \DateTime { return $this->created_at; } + + public function __get($name) { return $this->{$name}; } +} diff --git a/app/libs/Auth/Models/UserRecoveryCode.php b/app/libs/Auth/Models/UserRecoveryCode.php new file mode 100644 index 00000000..572a434a --- /dev/null +++ b/app/libs/Auth/Models/UserRecoveryCode.php @@ -0,0 +1,67 @@ +created_at = new \DateTime('now', new \DateTimeZone('UTC')); + $this->used_at = null; + } + + public function getId(): int { return (int) $this->id; } + + public function getUser(): User { return $this->user; } + public function setUser(User $user): void { $this->user = $user; } + + public function getCodeHash(): string { return $this->code_hash; } + public function setCodeHash(string $value): void { $this->code_hash = $value; } + + public function getUsedAt(): ?\DateTime { return $this->used_at; } + public function setUsedAt(?\DateTime $value): void { $this->used_at = $value; } + + public function getCreatedAt(): \DateTime { return $this->created_at; } + + public function isUsed(): bool { return !is_null($this->used_at); } + + public function markUsed(): void + { + $this->used_at = new \DateTime('now', new \DateTimeZone('UTC')); + } + +} diff --git a/app/libs/Auth/Models/UserTrustedDevice.php b/app/libs/Auth/Models/UserTrustedDevice.php new file mode 100644 index 00000000..09d300bd --- /dev/null +++ b/app/libs/Auth/Models/UserTrustedDevice.php @@ -0,0 +1,84 @@ + 0])] + private $is_revoked; + + public function __construct() + { + parent::__construct(); + $this->is_revoked = false; + } + + public function getUser(): User { return $this->user; } + public function setUser(User $user): void { $this->user = $user; } + + public function getDeviceIdentifier(): string { return $this->device_identifier; } + public function setDeviceIdentifier(string $value): void { $this->device_identifier = $value; } + + public function getDeviceName(): string { return $this->device_name; } + public function setDeviceName(string $value): void { $this->device_name = $value; } + + public function getIpAddress(): string { return $this->ip_address; } + public function setIpAddress(string $value): void { $this->ip_address = $value; } + + public function getUserAgent(): string { return $this->user_agent; } + public function setUserAgent(string $value): void { $this->user_agent = $value; } + + public function getTrustedAt(): \DateTime { return $this->trusted_at; } + public function setTrustedAt(\DateTime $value): void { $this->trusted_at = $value; } + + public function getExpiresAt(): \DateTime { return $this->expires_at; } + public function setExpiresAt(\DateTime $value): void { $this->expires_at = $value; } + + public function getLastSeenAt(): \DateTime { return $this->last_seen_at; } + public function setLastSeenAt(\DateTime $value): void { $this->last_seen_at = $value; } + + public function isRevoked(): bool { return (bool) $this->is_revoked; } + public function setIsRevoked(bool $value): void { $this->is_revoked = $value; } +} diff --git a/app/libs/Auth/Repositories/ITwoFactorAuditLogRepository.php b/app/libs/Auth/Repositories/ITwoFactorAuditLogRepository.php new file mode 100644 index 00000000..4948149b --- /dev/null +++ b/app/libs/Auth/Repositories/ITwoFactorAuditLogRepository.php @@ -0,0 +1,24 @@ +hasTable("users") && !$builder->hasColumn("users", "two_factor_enabled")) { + $builder->table('users', function (Table $table) { + $table->boolean('two_factor_enabled')->setNotnull(true)->setDefault(false); + $table->string('two_factor_method', 32)->setNotnull(true)->setDefault('email_otp'); + $table->dateTime('two_factor_enforced_at')->setNotnull(false)->setDefault(null); + }); + } + + // 2) Create user_trusted_devices + if (!$builder->hasTable("user_trusted_devices")) { + $builder->create('user_trusted_devices', function (Table $table) { + $table->increments('id'); + $table->timestamps(); + $table->bigInteger("user_id")->setUnsigned(true); + $table->string('device_identifier', 255); + $table->string('device_name', 255); + $table->string('ip_address', 45); + $table->text('user_agent'); + $table->dateTime('trusted_at'); + $table->dateTime('expires_at'); + $table->dateTime('last_seen_at'); + $table->boolean('is_revoked')->setNotnull(true)->setDefault(false); + $table->index(["user_id", "device_identifier"], "utd_user_device_idx"); + $table->index(["user_id", "is_revoked"], "utd_user_revoked_idx"); + $table->index(["expires_at"], "utd_expires_idx"); + $table->foreign("users", "user_id", "id", ["onDelete" => "CASCADE"]); + }); + } + + // 3) Create two_factor_audit_log + if (!$builder->hasTable("two_factor_audit_log")) { + $builder->create('two_factor_audit_log', function (Table $table) { + $table->increments('id'); + $table->dateTime('created_at'); + $table->bigInteger("user_id")->setUnsigned(true); + $table->string('event_type', 64); + $table->string('method', 32); + $table->string('ip_address', 45); + $table->text('user_agent'); + $table->json('metadata')->setNotnull(false)->setDefault(null); + $table->index(["user_id", "event_type", "created_at"], "tfa_user_event_created_idx"); + $table->index(["created_at"], "tfa_created_idx"); + $table->foreign("users", "user_id", "id", ["onDelete" => "CASCADE"]); + }); + } + + // 4) Create user_recovery_codes + if (!$builder->hasTable("user_recovery_codes")) { + $builder->create('user_recovery_codes', function (Table $table) { + $table->increments('id'); + $table->dateTime('created_at'); + $table->bigInteger("user_id")->setUnsigned(true); + $table->string('code_hash', 255); + $table->dateTime('used_at')->setNotnull(false)->setDefault(null); + $table->index(["user_id", "used_at"], "urc_user_used_idx"); + $table->foreign("users", "user_id", "id", ["onDelete" => "CASCADE"]); + }); + } + } + + public function down(Schema $schema): void + { + $builder = new Builder($schema); + + if ($builder->hasTable("user_recovery_codes")) { + $builder->drop('user_recovery_codes'); + } + if ($builder->hasTable("two_factor_audit_log")) { + $builder->drop('two_factor_audit_log'); + } + if ($builder->hasTable("user_trusted_devices")) { + $builder->drop('user_trusted_devices'); + } + if ($schema->hasTable("users") && $builder->hasColumn("users", "two_factor_enabled")) { + $builder->table('users', function (Table $table) { + $table->dropColumn('two_factor_enforced_at'); + $table->dropColumn('two_factor_method'); + $table->dropColumn('two_factor_enabled'); + }); + } + } +} diff --git a/database/migrations/Version20260424120000.php b/database/migrations/Version20260424120000.php new file mode 100644 index 00000000..898f425e --- /dev/null +++ b/database/migrations/Version20260424120000.php @@ -0,0 +1,44 @@ +addSql( + 'ALTER TABLE user_trusted_devices + DROP INDEX utd_user_device_idx, + ADD UNIQUE INDEX utd_user_device_uniq (user_id, device_identifier)' + ); + } + + public function down(Schema $schema): void + { + $this->addSql( + 'ALTER TABLE user_trusted_devices + DROP INDEX utd_user_device_uniq, + ADD INDEX utd_user_device_idx (user_id, device_identifier)' + ); + } +} diff --git a/tests/TwoFactorRepositoriesTest.php b/tests/TwoFactorRepositoriesTest.php new file mode 100644 index 00000000..dfea3e35 --- /dev/null +++ b/tests/TwoFactorRepositoriesTest.php @@ -0,0 +1,347 @@ +setFirstName('Test'); + $user->setLastName('TwoFactor'); + $user->setEmail('test.twofactor.' . uniqid() . '@test.invalid'); + $user->setAddress1('123 Test St'); + $user->setState('CA'); + $user->setCity('Testville'); + $user->setPostCode('00000'); + $user->setCountryIsoCode('US'); + $user->setPic(''); + $user->setLastLoginDate(new \DateTime('now', new \DateTimeZone('UTC'))); + EntityManager::persist($user); + EntityManager::flush(); + $this->user = $user; + } + + public function tearDown(): void + { + if ($this->user !== null) { + $managed = EntityManager::find(User::class, $this->user->getId()); + if ($managed !== null) { + EntityManager::remove($managed); + EntityManager::flush(); + } + } + parent::tearDown(); + } + + public function testTrustedDeviceRoundTrip(): void + { + $repo = App::make(IUserTrustedDeviceRepository::class); + + $now = new \DateTime('now', new \DateTimeZone('UTC')); + $expires = (clone $now)->modify('+30 days'); + $deviceId = hash('sha256', 'test-token-' . uniqid()); + + $device = new UserTrustedDevice(); + $device->setUser($this->user); + $device->setDeviceIdentifier($deviceId); + $device->setDeviceName('Chrome on MacOS'); + $device->setIpAddress('127.0.0.1'); + $device->setUserAgent('Mozilla/5.0 (test)'); + $device->setTrustedAt($now); + $device->setExpiresAt($expires); + $device->setLastSeenAt($now); + + EntityManager::persist($device); + EntityManager::flush(); + $id = $device->getId(); + $this->assertGreaterThan(0, $id); + + EntityManager::clear(); + + $found = $repo->getActiveByUserAndIdentifier($this->user, $deviceId); + $this->assertNotNull($found); + $this->assertEquals($deviceId, $found->getDeviceIdentifier()); + $this->assertFalse($found->isRevoked()); + + $active = $repo->getActiveByUser($this->user); + $this->assertNotEmpty($active); + + EntityManager::remove($found); + EntityManager::flush(); + } + + public function testAuditLogRoundTrip(): void + { + $repo = App::make(ITwoFactorAuditLogRepository::class); + + $entry = new TwoFactorAuditLog(); + $entry->setUser($this->user); + $entry->setEventType(TwoFactorAuditLog::EventChallengeIssued); + $entry->setMethod(TwoFactorAuditLog::MethodEmailOtp); + $entry->setIpAddress('10.0.0.1'); + $entry->setUserAgent('Mozilla/5.0 (test)'); + $entry->setMetadata(['challenge_id' => 'abc123']); + + EntityManager::persist($entry); + EntityManager::flush(); + $id = $entry->getId(); + $this->assertGreaterThan(0, $id); + + EntityManager::clear(); + + $recent = $repo->getRecentByUser($this->user, 10); + $this->assertNotEmpty($recent); + $found = null; + foreach ($recent as $row) { + if ($row->getId() === $id) { $found = $row; break; } + } + $this->assertNotNull($found); + $this->assertEquals(TwoFactorAuditLog::EventChallengeIssued, $found->getEventType()); + $this->assertEquals(['challenge_id' => 'abc123'], $found->getMetadata()); + + EntityManager::remove($found); + EntityManager::flush(); + } + + public function testRecoveryCodeRoundTrip(): void + { + $repo = App::make(IUserRecoveryCodeRepository::class); + + $code = new UserRecoveryCode(); + $code->setUser($this->user); + $code->setCodeHash(password_hash('TESTCODE', PASSWORD_BCRYPT)); + + EntityManager::persist($code); + EntityManager::flush(); + $id = $code->getId(); + $this->assertGreaterThan(0, $id); + $this->assertFalse($code->isUsed()); + + EntityManager::clear(); + + $unused = $repo->getUnusedByUser($this->user); + $this->assertNotEmpty($unused); + + $reload = EntityManager::find(UserRecoveryCode::class, $id); + $reload->markUsed(); + EntityManager::flush(); + $this->assertTrue($reload->isUsed()); + + $deleted = $repo->deleteAllForUser($this->user); + $this->assertEquals(1, $deleted); + } + + // ------------------------------------------------------------------------- + // Targeted behaviour tests + // ------------------------------------------------------------------------- + + public function testExpiredTrustedDeviceIsExcluded(): void + { + $repo = App::make(IUserTrustedDeviceRepository::class); + $now = new \DateTime('now', new \DateTimeZone('UTC')); + $expired = (clone $now)->modify('-1 minute'); + $deviceId = hash('sha256', 'expired-device-' . uniqid()); + + $device = $this->buildDevice($deviceId, $now, $expired); + EntityManager::persist($device); + EntityManager::flush(); + $id = $device->getId(); + EntityManager::clear(); + + $this->assertNull( + $repo->getActiveByUserAndIdentifier($this->user, $deviceId), + 'getActiveByUserAndIdentifier must return null for an expired device.' + ); + + $ids = array_map( + fn(UserTrustedDevice $d) => $d->getDeviceIdentifier(), + $repo->getActiveByUser($this->user) + ); + $this->assertNotContains($deviceId, $ids, 'getActiveByUser must not include expired devices.'); + + $stale = EntityManager::find(UserTrustedDevice::class, $id); + if ($stale) { EntityManager::remove($stale); EntityManager::flush(); } + } + + public function testRevokedTrustedDeviceIsExcluded(): void + { + $repo = App::make(IUserTrustedDeviceRepository::class); + $now = new \DateTime('now', new \DateTimeZone('UTC')); + $expires = (clone $now)->modify('+30 days'); + $deviceId = hash('sha256', 'revoked-device-' . uniqid()); + + $device = $this->buildDevice($deviceId, $now, $expires); + $device->setIsRevoked(true); + EntityManager::persist($device); + EntityManager::flush(); + $id = $device->getId(); + EntityManager::clear(); + + $this->assertNull( + $repo->getActiveByUserAndIdentifier($this->user, $deviceId), + 'getActiveByUserAndIdentifier must return null for a revoked device.' + ); + + $ids = array_map( + fn(UserTrustedDevice $d) => $d->getDeviceIdentifier(), + $repo->getActiveByUser($this->user) + ); + $this->assertNotContains($deviceId, $ids, 'getActiveByUser must not include revoked devices.'); + + $stale = EntityManager::find(UserTrustedDevice::class, $id); + if ($stale) { EntityManager::remove($stale); EntityManager::flush(); } + } + + public function testDuplicateDeviceIdentifierCannotOccur(): void + { + $connection = EntityManager::getConnection(); + $indexes = $connection->createSchemaManager()->listTableIndexes('user_trusted_devices'); + + $hasUnique = false; + foreach ($indexes as $index) { + if ($index->isUnique()) { + $cols = $index->getColumns(); + if (in_array('user_id', $cols) && in_array('device_identifier', $cols)) { + $hasUnique = true; + break; + } + } + } + + $this->assertTrue( + $hasUnique, + 'user_trusted_devices must have a UNIQUE index on (user_id, device_identifier).' + ); + } + + public function testRecoveryCodeDeletionRemovesUsedAndUnusedCodes(): void + { + $repo = App::make(IUserRecoveryCodeRepository::class); + + $unused = new UserRecoveryCode(); + $unused->setUser($this->user); + $unused->setCodeHash(password_hash('UNUSED_' . uniqid(), PASSWORD_BCRYPT)); + + $used = new UserRecoveryCode(); + $used->setUser($this->user); + $used->setCodeHash(password_hash('USED_' . uniqid(), PASSWORD_BCRYPT)); + $used->markUsed(); + + EntityManager::persist($unused); + EntityManager::persist($used); + EntityManager::flush(); + $unusedId = $unused->getId(); + $usedId = $used->getId(); + + $deleted = $repo->deleteAllForUser($this->user); + $this->assertGreaterThanOrEqual(2, $deleted, 'deleteAllForUser must remove both used and unused codes.'); + + EntityManager::clear(); + $this->assertNull( + EntityManager::find(UserRecoveryCode::class, $unusedId), + 'Unused recovery code must be deleted.' + ); + $this->assertNull( + EntityManager::find(UserRecoveryCode::class, $usedId), + 'Used recovery code must also be deleted.' + ); + } + + public function testAuditLogsReturnedMostRecentFirst(): void + { + $repo = App::make(ITwoFactorAuditLogRepository::class); + $createdIds = []; + + $timestamps = [ + new \DateTime('2020-01-01 01:00:00', new \DateTimeZone('UTC')), + new \DateTime('2020-01-01 02:00:00', new \DateTimeZone('UTC')), + new \DateTime('2020-01-01 03:00:00', new \DateTimeZone('UTC')), + ]; + + $setCreatedAt = static function (TwoFactorAuditLog $log, \DateTime $dt): void { + $prop = new \ReflectionProperty(TwoFactorAuditLog::class, 'created_at'); + $prop->setAccessible(true); + $prop->setValue($log, $dt); + }; + + foreach ($timestamps as $ts) { + $entry = new TwoFactorAuditLog(); + $entry->setUser($this->user); + $entry->setEventType(TwoFactorAuditLog::EventChallengeIssued); + $entry->setMethod(TwoFactorAuditLog::MethodEmailOtp); + $entry->setIpAddress('127.0.0.1'); + $entry->setUserAgent('Mozilla/5.0 (test)'); + $setCreatedAt($entry, $ts); + EntityManager::persist($entry); + EntityManager::flush(); + $createdIds[] = $entry->getId(); + } + + EntityManager::clear(); + + $all = $repo->getRecentByUser($this->user, 200); + $ours = array_values(array_filter($all, fn(TwoFactorAuditLog $e) => in_array($e->getId(), $createdIds))); + + $this->assertCount(3, $ours, 'All three seeded audit entries must be returned.'); + + for ($i = 0; $i < count($ours) - 1; $i++) { + $this->assertGreaterThanOrEqual( + $ours[$i + 1]->getCreatedAt()->getTimestamp(), + $ours[$i]->getCreatedAt()->getTimestamp(), + 'Audit logs must be ordered most-recent first.' + ); + } + + // cleanup + foreach ($createdIds as $logId) { + $log = EntityManager::find(TwoFactorAuditLog::class, $logId); + if ($log) { EntityManager::remove($log); } + } + EntityManager::flush(); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private function buildDevice(string $deviceId, \DateTime $now, \DateTime $expires): UserTrustedDevice + { + $device = new UserTrustedDevice(); + $device->setUser($this->user); + $device->setDeviceIdentifier($deviceId); + $device->setDeviceName('Test Browser'); + $device->setIpAddress('127.0.0.1'); + $device->setUserAgent('Mozilla/5.0 (test)'); + $device->setTrustedAt($now); + $device->setExpiresAt($expires); + $device->setLastSeenAt($now); + return $device; + } +}