From 6a2bd34bc42b9931c6bbaa38c4ea64732fc10cbc Mon Sep 17 00:00:00 2001 From: matiasperrone-exo Date: Thu, 16 Apr 2026 20:05:52 +0000 Subject: [PATCH] feat: Add MultiFactor Authentication feat(2fa): add migration for 2FA schema foundation (Phase I) feat(2fa): add UserTrustedDevice entity feat(2fa): add TwoFactorAuditLog entity feat(2fa): add UserRecoveryCode entity feat(2fa): add repository interfaces for 2FA entities feat(2fa): add Doctrine implementations for 2FA repositories feat(2fa): register 2FA repositories in service container test(2fa): add repository round-trip tests for 2FA entities --- .../DoctrineTwoFactorAuditLogRepository.php | 34 +++++ .../DoctrineUserRecoveryCodeRepository.php | 43 ++++++ .../DoctrineUserTrustedDeviceRepository.php | 42 ++++++ app/Repositories/RepositoriesProvider.php | 30 ++++ app/libs/Auth/Models/TwoFactorAuditLog.php | 93 ++++++++++++ app/libs/Auth/Models/UserRecoveryCode.php | 67 +++++++++ app/libs/Auth/Models/UserTrustedDevice.php | 86 +++++++++++ .../ITwoFactorAuditLogRepository.php | 24 +++ .../IUserRecoveryCodeRepository.php | 29 ++++ .../IUserTrustedDeviceRepository.php | 29 ++++ database/migrations/Version20260416194357.php | 114 ++++++++++++++ tests/TwoFactorRepositoriesTest.php | 141 ++++++++++++++++++ 12 files changed, 732 insertions(+) create mode 100644 app/Repositories/DoctrineTwoFactorAuditLogRepository.php create mode 100644 app/Repositories/DoctrineUserRecoveryCodeRepository.php create mode 100644 app/Repositories/DoctrineUserTrustedDeviceRepository.php create mode 100644 app/libs/Auth/Models/TwoFactorAuditLog.php create mode 100644 app/libs/Auth/Models/UserRecoveryCode.php create mode 100644 app/libs/Auth/Models/UserTrustedDevice.php create mode 100644 app/libs/Auth/Repositories/ITwoFactorAuditLogRepository.php create mode 100644 app/libs/Auth/Repositories/IUserRecoveryCodeRepository.php create mode 100644 app/libs/Auth/Repositories/IUserTrustedDeviceRepository.php create mode 100644 database/migrations/Version20260416194357.php create mode 100644 tests/TwoFactorRepositoriesTest.php 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..3131911b --- /dev/null +++ b/app/Repositories/DoctrineUserTrustedDeviceRepository.php @@ -0,0 +1,42 @@ +findOneBy([ + 'user' => $user, + 'device_identifier' => $deviceIdentifier, + 'is_revoked' => false, + ]); + } + + public function getActiveByUser(User $user): array + { + return $this->findBy([ + 'user' => $user, + 'is_revoked' => false, + ]); + } +} 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..a29ccfaa --- /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')); + } + + public function __get($name) { return $this->{$name}; } +} diff --git a/app/libs/Auth/Models/UserTrustedDevice.php b/app/libs/Auth/Models/UserTrustedDevice.php new file mode 100644 index 00000000..cf689f69 --- /dev/null +++ b/app/libs/Auth/Models/UserTrustedDevice.php @@ -0,0 +1,86 @@ + 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; } + + public function __get($name) { return $this->{$name}; } +} 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/tests/TwoFactorRepositoriesTest.php b/tests/TwoFactorRepositoriesTest.php new file mode 100644 index 00000000..cc0c369c --- /dev/null +++ b/tests/TwoFactorRepositoriesTest.php @@ -0,0 +1,141 @@ +user = $userRepo->findOneBy([]); + if (is_null($this->user)) { + $this->markTestSkipped('No User exists; database must be seeded.'); + } + } + + 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->assertGreaterThanOrEqual(1, $deleted); + } +}