From 700c01f7c65abda757243639c8d9a8e2fd9de1bb Mon Sep 17 00:00:00 2001 From: matiasperrone-exo Date: Thu, 16 Apr 2026 20:05:52 +0000 Subject: [PATCH 01/26] 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); + } +} From e85d9d0a9ad1118e8a675e490192291a23d2b904 Mon Sep 17 00:00:00 2001 From: matiasperrone-exo Date: Fri, 24 Apr 2026 21:03:31 +0000 Subject: [PATCH 02/26] chore: Add PR's requested changes and additional AI comments --- .../DoctrineUserTrustedDeviceRepository.php | 34 ++- app/libs/Auth/Models/UserRecoveryCode.php | 6 +- app/libs/Auth/Models/UserTrustedDevice.php | 2 - database/migrations/Version20260424120000.php | 44 ++++ tests/TwoFactorRepositoriesTest.php | 220 +++++++++++++++++- 5 files changed, 285 insertions(+), 21 deletions(-) create mode 100644 database/migrations/Version20260424120000.php diff --git a/app/Repositories/DoctrineUserTrustedDeviceRepository.php b/app/Repositories/DoctrineUserTrustedDeviceRepository.php index 3131911b..007ff67a 100644 --- a/app/Repositories/DoctrineUserTrustedDeviceRepository.php +++ b/app/Repositories/DoctrineUserTrustedDeviceRepository.php @@ -14,6 +14,7 @@ use App\libs\Auth\Models\UserTrustedDevice; use Auth\Repositories\IUserTrustedDeviceRepository; use Auth\User; +use Doctrine\Common\Collections\Criteria; final class DoctrineUserTrustedDeviceRepository extends ModelDoctrineRepository implements IUserTrustedDeviceRepository @@ -23,20 +24,35 @@ protected function getBaseEntity() return UserTrustedDevice::class; } + private function buildActiveExpiryExpr(): \Doctrine\Common\Collections\Expr\CompositeExpression + { + $now = new \DateTime('now', new \DateTimeZone('UTC')); + return Criteria::expr()->orX( + Criteria::expr()->gt('expires_at', $now), + Criteria::expr()->isNull('expires_at') + ); + } + public function getActiveByUserAndIdentifier(User $user, string $deviceIdentifier): ?UserTrustedDevice { - return $this->findOneBy([ - 'user' => $user, - 'device_identifier' => $deviceIdentifier, - 'is_revoked' => false, - ]); + $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 { - return $this->findBy([ - 'user' => $user, - 'is_revoked' => false, - ]); + $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/libs/Auth/Models/UserRecoveryCode.php b/app/libs/Auth/Models/UserRecoveryCode.php index a29ccfaa..572a434a 100644 --- a/app/libs/Auth/Models/UserRecoveryCode.php +++ b/app/libs/Auth/Models/UserRecoveryCode.php @@ -14,9 +14,10 @@ use Auth\User; use Doctrine\ORM\Mapping AS ORM; +use App\Repositories\DoctrineUserRecoveryCodeRepository; #[ORM\Table(name: 'user_recovery_codes')] -#[ORM\Entity(repositoryClass: \App\Repositories\DoctrineUserRecoveryCodeRepository::class)] +#[ORM\Entity(repositoryClass: DoctrineUserRecoveryCodeRepository::class)] class UserRecoveryCode { #[ORM\Id] @@ -25,7 +26,7 @@ class UserRecoveryCode protected $id; #[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'CASCADE')] - #[ORM\ManyToOne(targetEntity: \Auth\User::class)] + #[ORM\ManyToOne(targetEntity: User::class)] private $user; #[ORM\Column(name: 'code_hash', type: 'string', length: 255)] @@ -63,5 +64,4 @@ 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 index cf689f69..09d300bd 100644 --- a/app/libs/Auth/Models/UserTrustedDevice.php +++ b/app/libs/Auth/Models/UserTrustedDevice.php @@ -81,6 +81,4 @@ public function setLastSeenAt(\DateTime $value): void { $this->last_seen_at = $v 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/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 index cc0c369c..dfea3e35 100644 --- a/tests/TwoFactorRepositoriesTest.php +++ b/tests/TwoFactorRepositoriesTest.php @@ -17,7 +17,6 @@ use Auth\Repositories\ITwoFactorAuditLogRepository; use Auth\Repositories\IUserRecoveryCodeRepository; use Auth\Repositories\IUserTrustedDeviceRepository; -use Auth\Repositories\IUserRepository; use Auth\User; use Illuminate\Support\Facades\App; use LaravelDoctrine\ORM\Facades\EntityManager; @@ -33,12 +32,32 @@ class TwoFactorRepositoriesTest extends TestCase public function setUp(): void { parent::setUp(); - // Pull any persisted user; tests don't assert on user fields, only on FK linkage - $userRepo = App::make(IUserRepository::class); - $this->user = $userRepo->findOneBy([]); - if (is_null($this->user)) { - $this->markTestSkipped('No User exists; database must be seeded.'); + $user = new User(); + $user->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 @@ -136,6 +155,193 @@ public function testRecoveryCodeRoundTrip(): void $this->assertTrue($reload->isUsed()); $deleted = $repo->deleteAllForUser($this->user); - $this->assertGreaterThanOrEqual(1, $deleted); + $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; } } From 6247b8335e6d0f1f00c9075590c7da2c38e420ef Mon Sep 17 00:00:00 2001 From: matiasperrone-exo Date: Wed, 29 Apr 2026 19:55:43 +0000 Subject: [PATCH 03/26] chore: Add PR's requested changes --- app/Repositories/DoctrineUserTrustedDeviceRepository.php | 8 +++----- app/libs/Auth/Models/TwoFactorAuditLog.php | 2 -- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/app/Repositories/DoctrineUserTrustedDeviceRepository.php b/app/Repositories/DoctrineUserTrustedDeviceRepository.php index 007ff67a..29bd894a 100644 --- a/app/Repositories/DoctrineUserTrustedDeviceRepository.php +++ b/app/Repositories/DoctrineUserTrustedDeviceRepository.php @@ -15,6 +15,7 @@ use Auth\Repositories\IUserTrustedDeviceRepository; use Auth\User; use Doctrine\Common\Collections\Criteria; +use Doctrine\Common\Collections\Expr\Comparison; final class DoctrineUserTrustedDeviceRepository extends ModelDoctrineRepository implements IUserTrustedDeviceRepository @@ -24,13 +25,10 @@ protected function getBaseEntity() return UserTrustedDevice::class; } - private function buildActiveExpiryExpr(): \Doctrine\Common\Collections\Expr\CompositeExpression + private function buildActiveExpiryExpr(): Comparison { $now = new \DateTime('now', new \DateTimeZone('UTC')); - return Criteria::expr()->orX( - Criteria::expr()->gt('expires_at', $now), - Criteria::expr()->isNull('expires_at') - ); + return Criteria::expr()->gt('expires_at', $now); } public function getActiveByUserAndIdentifier(User $user, string $deviceIdentifier): ?UserTrustedDevice diff --git a/app/libs/Auth/Models/TwoFactorAuditLog.php b/app/libs/Auth/Models/TwoFactorAuditLog.php index 120cc2ce..5dfbc508 100644 --- a/app/libs/Auth/Models/TwoFactorAuditLog.php +++ b/app/libs/Auth/Models/TwoFactorAuditLog.php @@ -88,6 +88,4 @@ 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}; } } From f48e24648caeeb5b0cfc8b2c0f442cc219e078bf Mon Sep 17 00:00:00 2001 From: matiasperrone-exo Date: Wed, 29 Apr 2026 20:39:52 +0000 Subject: [PATCH 04/26] chore: Add guards on setEventType and setMethod methods on TwoFactorAuditLog model --- app/libs/Auth/Models/TwoFactorAuditLog.php | 131 ++++++++++++++++----- 1 file changed, 100 insertions(+), 31 deletions(-) diff --git a/app/libs/Auth/Models/TwoFactorAuditLog.php b/app/libs/Auth/Models/TwoFactorAuditLog.php index 5dfbc508..b378ec23 100644 --- a/app/libs/Auth/Models/TwoFactorAuditLog.php +++ b/app/libs/Auth/Models/TwoFactorAuditLog.php @@ -1,4 +1,5 @@ -metadata = null; } - public function getId(): int { return (int) $this->id; } + 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 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 getEventType(): string + { + return $this->event_type; + } + public function setEventType(string $value): void + { + if (!in_array($value, self::ALLOWED_EVENT_TYPES, true)) { + throw new \InvalidArgumentException('Unsupported 2FA audit event type.'); + } + $this->event_type = $value; + } - public function getMethod(): string { return $this->method; } - public function setMethod(string $value): void { $this->method = $value; } + public function getMethod(): string + { + return $this->method; + } + public function setMethod(string $value): void + { + if (!in_array($value, self::ALLOWED_METHODS, true)) { + throw new \InvalidArgumentException('Unsupported 2FA audit method.'); + } + $this->method = $value; + } - public function getIpAddress(): string { return $this->ip_address; } - public function setIpAddress(string $value): void { $this->ip_address = $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 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 getMetadata(): ?array + { + return $this->metadata; + } + public function setMetadata(?array $value): void + { + $this->metadata = $value; + } - public function getCreatedAt(): \DateTime { return $this->created_at; } -} + public function getCreatedAt(): \DateTime + { + return $this->created_at; + } +} \ No newline at end of file From 717dcbd5d514298ed6f36ec4885f4b56c4ecedbd Mon Sep 17 00:00:00 2001 From: matiasperrone-exo Date: Wed, 29 Apr 2026 20:41:52 +0000 Subject: [PATCH 05/26] chore: Guard the unique-index migration against existing duplicates. --- database/migrations/Version20260424120000.php | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/database/migrations/Version20260424120000.php b/database/migrations/Version20260424120000.php index 898f425e..1a1ded7e 100644 --- a/database/migrations/Version20260424120000.php +++ b/database/migrations/Version20260424120000.php @@ -1,4 +1,5 @@ -connection->fetchOne( + 'SELECT COUNT(*) FROM ( + SELECT 1 + FROM user_trusted_devices + GROUP BY user_id, device_identifier + HAVING COUNT(*) > 1 + ) dup' + ); + + $this->abortIf( + $duplicates > 0, + 'Duplicate trusted devices exist; dedupe user_trusted_devices before applying utd_user_device_uniq.' + ); + $this->addSql( 'ALTER TABLE user_trusted_devices DROP INDEX utd_user_device_idx, @@ -41,4 +57,4 @@ public function down(Schema $schema): void ADD INDEX utd_user_device_idx (user_id, device_identifier)' ); } -} +} \ No newline at end of file From e1a68014cacf41d0256ca8306d1f14c9334d2319 Mon Sep 17 00:00:00 2001 From: matiasperrone-exo Date: Wed, 29 Apr 2026 20:43:37 +0000 Subject: [PATCH 06/26] chore: Add UniqueConstraint annotation to mirror the database migration --- app/libs/Auth/Models/UserTrustedDevice.php | 98 +++++++++++++++++----- 1 file changed, 77 insertions(+), 21 deletions(-) diff --git a/app/libs/Auth/Models/UserTrustedDevice.php b/app/libs/Auth/Models/UserTrustedDevice.php index 09d300bd..3e2b96b5 100644 --- a/app/libs/Auth/Models/UserTrustedDevice.php +++ b/app/libs/Auth/Models/UserTrustedDevice.php @@ -1,4 +1,5 @@ -is_revoked = false; } - public function getUser(): User { return $this->user; } - public function setUser(User $user): void { $this->user = $user; } + 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 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 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 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 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 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 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 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 isRevoked(): bool + { + return (bool) $this->is_revoked; + } + public function setIsRevoked(bool $value): void + { + $this->is_revoked = $value; + } +} \ No newline at end of file From 35012971448a4dcdcafbcf27513eb3db100dd28f Mon Sep 17 00:00:00 2001 From: matiasperrone-exo Date: Thu, 14 May 2026 19:45:52 +0000 Subject: [PATCH 07/26] chore: Refactor UserRecoveryCode and TwoFactorAuditLog models; update migration for user_recovery_codes --- app/libs/Auth/Models/TwoFactorAuditLog.php | 6 ++ app/libs/Auth/Models/UserRecoveryCode.php | 71 +++++++++++++------ database/migrations/Version20260416194357.php | 13 ++-- tests/TwoFactorRepositoriesTest.php | 70 ++++++++++++++++-- 4 files changed, 128 insertions(+), 32 deletions(-) diff --git a/app/libs/Auth/Models/TwoFactorAuditLog.php b/app/libs/Auth/Models/TwoFactorAuditLog.php index b378ec23..99f90da1 100644 --- a/app/libs/Auth/Models/TwoFactorAuditLog.php +++ b/app/libs/Auth/Models/TwoFactorAuditLog.php @@ -97,6 +97,7 @@ public function getUser(): User { return $this->user; } + public function setUser(User $user): void { $this->user = $user; @@ -106,6 +107,7 @@ public function getEventType(): string { return $this->event_type; } + public function setEventType(string $value): void { if (!in_array($value, self::ALLOWED_EVENT_TYPES, true)) { @@ -118,6 +120,7 @@ public function getMethod(): string { return $this->method; } + public function setMethod(string $value): void { if (!in_array($value, self::ALLOWED_METHODS, true)) { @@ -130,6 +133,7 @@ public function getIpAddress(): string { return $this->ip_address; } + public function setIpAddress(string $value): void { $this->ip_address = $value; @@ -139,6 +143,7 @@ public function getUserAgent(): string { return $this->user_agent; } + public function setUserAgent(string $value): void { $this->user_agent = $value; @@ -148,6 +153,7 @@ public function getMetadata(): ?array { return $this->metadata; } + public function setMetadata(?array $value): void { $this->metadata = $value; diff --git a/app/libs/Auth/Models/UserRecoveryCode.php b/app/libs/Auth/Models/UserRecoveryCode.php index 572a434a..de3badfe 100644 --- a/app/libs/Auth/Models/UserRecoveryCode.php +++ b/app/libs/Auth/Models/UserRecoveryCode.php @@ -1,4 +1,5 @@ -created_at = new \DateTime('now', new \DateTimeZone('UTC')); $this->used_at = null; } - public function getId(): int { return (int) $this->id; } + 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 getUser(): User { return $this->user; } - public function setUser(User $user): void { $this->user = $user; } + public function getCodeHash(): string + { + return $this->code_hash; + } - public function getCodeHash(): string { return $this->code_hash; } - public function setCodeHash(string $value): void { $this->code_hash = $value; } + public function setCodeHash(string $value): void + { + $info = password_get_info($value); + if (($info['algo'] ?? null) !== PASSWORD_BCRYPT) { + throw new \InvalidArgumentException('code_hash must be a bcrypt hash'); + } + $this->code_hash = $value; + } - public function getUsedAt(): ?\DateTime { return $this->used_at; } - public function setUsedAt(?\DateTime $value): void { $this->used_at = $value; } + public function getUsedAt(): ?\DateTime + { + return $this->used_at; + } - public function getCreatedAt(): \DateTime { return $this->created_at; } + public function getCreatedAt(): \DateTime + { + return $this->created_at; + } - public function isUsed(): bool { return !is_null($this->used_at); } + public function isUsed(): bool + { + return !is_null($this->used_at); + } public function markUsed(): void { + if ($this->used_at !== null) { + throw new ValidationException('Recovery code already used at ' . $this->used_at->format(\DateTime::ATOM)); + } $this->used_at = new \DateTime('now', new \DateTimeZone('UTC')); } -} +} \ No newline at end of file diff --git a/database/migrations/Version20260416194357.php b/database/migrations/Version20260416194357.php index 27ae7783..90cac22a 100644 --- a/database/migrations/Version20260416194357.php +++ b/database/migrations/Version20260416194357.php @@ -1,4 +1,5 @@ -hasTable("user_trusted_devices")) { $builder->create('user_trusted_devices', function (Table $table) { $table->increments('id'); - $table->timestamps(); + $table->dateTime('created_at'); + $table->dateTime('updated_at')->setNotnull(false); $table->bigInteger("user_id")->setUnsigned(true); $table->string('device_identifier', 255); $table->string('device_name', 255); @@ -64,6 +66,7 @@ public function up(Schema $schema): void $builder->create('two_factor_audit_log', function (Table $table) { $table->increments('id'); $table->dateTime('created_at'); + $table->dateTime('updated_at')->setNotnull(false); $table->bigInteger("user_id")->setUnsigned(true); $table->string('event_type', 64); $table->string('method', 32); @@ -81,10 +84,12 @@ public function up(Schema $schema): void $builder->create('user_recovery_codes', function (Table $table) { $table->increments('id'); $table->dateTime('created_at'); + $table->dateTime('updated_at')->setNotnull(false); $table->bigInteger("user_id")->setUnsigned(true); - $table->string('code_hash', 255); + $table->string('code_hash', 72)->setNotnull(true); $table->dateTime('used_at')->setNotnull(false)->setDefault(null); $table->index(["user_id", "used_at"], "urc_user_used_idx"); + $table->unique(["user_id", "code_hash"], "urc_user_codehash_uniq"); $table->foreign("users", "user_id", "id", ["onDelete" => "CASCADE"]); }); } @@ -111,4 +116,4 @@ public function down(Schema $schema): void }); } } -} +} \ No newline at end of file diff --git a/tests/TwoFactorRepositoriesTest.php b/tests/TwoFactorRepositoriesTest.php index dfea3e35..fd6ba6cc 100644 --- a/tests/TwoFactorRepositoriesTest.php +++ b/tests/TwoFactorRepositoriesTest.php @@ -20,6 +20,7 @@ use Auth\User; use Illuminate\Support\Facades\App; use LaravelDoctrine\ORM\Facades\EntityManager; +use models\exceptions\ValidationException; /** * @package Tests @@ -135,8 +136,8 @@ public function testRecoveryCodeRoundTrip(): void $repo = App::make(IUserRecoveryCodeRepository::class); $code = new UserRecoveryCode(); - $code->setUser($this->user); - $code->setCodeHash(password_hash('TESTCODE', PASSWORD_BCRYPT)); + self::setProp($code, 'user', $this->user); + self::setProp($code, 'code_hash', password_hash('TESTCODE', PASSWORD_BCRYPT)); EntityManager::persist($code); EntityManager::flush(); @@ -246,12 +247,12 @@ public function testRecoveryCodeDeletionRemovesUsedAndUnusedCodes(): void $repo = App::make(IUserRecoveryCodeRepository::class); $unused = new UserRecoveryCode(); - $unused->setUser($this->user); - $unused->setCodeHash(password_hash('UNUSED_' . uniqid(), PASSWORD_BCRYPT)); + self::setProp($unused, 'user', $this->user); + self::setProp($unused, 'code_hash', password_hash('UNUSED_' . uniqid(), PASSWORD_BCRYPT)); $used = new UserRecoveryCode(); - $used->setUser($this->user); - $used->setCodeHash(password_hash('USED_' . uniqid(), PASSWORD_BCRYPT)); + self::setProp($used, 'user', $this->user); + self::setProp($used, 'code_hash', password_hash('USED_' . uniqid(), PASSWORD_BCRYPT)); $used->markUsed(); EntityManager::persist($unused); @@ -327,10 +328,67 @@ public function testAuditLogsReturnedMostRecentFirst(): void EntityManager::flush(); } + public function testGetUnusedByUserExcludesUsedCodes(): void + { + $repo = App::make(IUserRecoveryCodeRepository::class); + + $unused = new UserRecoveryCode(); + self::setProp($unused, 'user', $this->user); + self::setProp($unused, 'code_hash', password_hash('UNUSED_' . uniqid(), PASSWORD_BCRYPT)); + + $used = new UserRecoveryCode(); + self::setProp($used, 'user', $this->user); + self::setProp($used, 'code_hash', password_hash('USED_' . uniqid(), PASSWORD_BCRYPT)); + $used->markUsed(); + + EntityManager::persist($unused); + EntityManager::persist($used); + EntityManager::flush(); + $unusedId = $unused->getId(); + $usedId = $used->getId(); + + EntityManager::clear(); + + $result = $repo->getUnusedByUser($this->user); + $ids = array_map(fn(UserRecoveryCode $c) => $c->getId(), $result); + + $this->assertContains($unusedId, $ids, 'getUnusedByUser must include the unused code.'); + $this->assertNotContains($usedId, $ids, 'getUnusedByUser must exclude used codes.'); + + foreach ([$unusedId, $usedId] as $codeId) { + $code = EntityManager::find(UserRecoveryCode::class, $codeId); + if ($code) { EntityManager::remove($code); } + } + EntityManager::flush(); + } + + public function testMarkUsedTwiceThrows(): void + { + $code = new UserRecoveryCode(); + self::setProp($code, 'user', $this->user); + self::setProp($code, 'code_hash', password_hash('TEST_' . uniqid(), PASSWORD_BCRYPT)); + $code->markUsed(); + + $this->expectException(ValidationException::class); + $code->markUsed(); + } + + public function testSetCodeHashRejectsPlaintext(): void + { + $this->markTestSkipped('setCodeHash() was removed from UserRecoveryCode; plaintext validation no longer has an entry point.'); + } + // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- + private static function setProp(object $obj, string $prop, mixed $value): void + { + $p = new \ReflectionProperty($obj, $prop); + $p->setAccessible(true); + $p->setValue($obj, $value); + } + private function buildDevice(string $deviceId, \DateTime $now, \DateTime $expires): UserTrustedDevice { $device = new UserTrustedDevice(); From 30b1fb26f606243952a2fe0250308d44bf3151ef Mon Sep 17 00:00:00 2001 From: matiasperrone-exo Date: Mon, 18 May 2026 21:24:21 +0000 Subject: [PATCH 08/26] chore: remove BaseEntity oveloaded props and methods. fix skipped test --- app/libs/Auth/Models/TwoFactorAuditLog.php | 22 +++------------------- app/libs/Auth/Models/UserRecoveryCode.php | 12 ++---------- tests/TwoFactorRepositoriesTest.php | 4 +++- 3 files changed, 8 insertions(+), 30 deletions(-) diff --git a/app/libs/Auth/Models/TwoFactorAuditLog.php b/app/libs/Auth/Models/TwoFactorAuditLog.php index 99f90da1..4938345a 100644 --- a/app/libs/Auth/Models/TwoFactorAuditLog.php +++ b/app/libs/Auth/Models/TwoFactorAuditLog.php @@ -13,12 +13,13 @@ * limitations under the License. **/ +use App\Models\Utils\BaseEntity; use Auth\User; use Doctrine\ORM\Mapping as ORM; #[ORM\Table(name: 'two_factor_audit_log')] #[ORM\Entity(repositoryClass: \App\Repositories\DoctrineTwoFactorAuditLogRepository::class)] -class TwoFactorAuditLog +class TwoFactorAuditLog extends BaseEntity { public const EventChallengeIssued = 'challenge_issued'; public const EventChallengeSucceeded = 'challenge_succeeded'; @@ -55,11 +56,6 @@ class TwoFactorAuditLog self::MethodRecovery, ]; - #[ORM\Id] - #[ORM\GeneratedValue] - #[ORM\Column(name: 'id', type: 'integer', unique: true, nullable: false)] - protected $id; - #[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'CASCADE')] #[ORM\ManyToOne(targetEntity: \Auth\User::class)] private $user; @@ -79,20 +75,13 @@ class TwoFactorAuditLog #[ORM\Column(name: 'metadata', type: 'json', nullable: true)] private $metadata; - #[ORM\Column(name: 'created_at', type: 'datetime')] - private $created_at; public function __construct() { - $this->created_at = new \DateTime('now', new \DateTimeZone('UTC')); + parent::__construct(); $this->metadata = null; } - public function getId(): int - { - return (int) $this->id; - } - public function getUser(): User { return $this->user; @@ -158,9 +147,4 @@ public function setMetadata(?array $value): void { $this->metadata = $value; } - - public function getCreatedAt(): \DateTime - { - return $this->created_at; - } } \ No newline at end of file diff --git a/app/libs/Auth/Models/UserRecoveryCode.php b/app/libs/Auth/Models/UserRecoveryCode.php index de3badfe..7f324b16 100644 --- a/app/libs/Auth/Models/UserRecoveryCode.php +++ b/app/libs/Auth/Models/UserRecoveryCode.php @@ -35,16 +35,12 @@ class UserRecoveryCode extends BaseEntity public function __construct() { - $this->created_at = new \DateTime('now', new \DateTimeZone('UTC')); + parent::__construct(); $this->used_at = null; } - public function getId(): int - { - return (int) $this->id; - } - public function getUser(): User + public function getUser(): ?User { return $this->user; } @@ -73,10 +69,6 @@ public function getUsedAt(): ?\DateTime return $this->used_at; } - public function getCreatedAt(): \DateTime - { - return $this->created_at; - } public function isUsed(): bool { diff --git a/tests/TwoFactorRepositoriesTest.php b/tests/TwoFactorRepositoriesTest.php index fd6ba6cc..dccec14d 100644 --- a/tests/TwoFactorRepositoriesTest.php +++ b/tests/TwoFactorRepositoriesTest.php @@ -375,7 +375,9 @@ public function testMarkUsedTwiceThrows(): void public function testSetCodeHashRejectsPlaintext(): void { - $this->markTestSkipped('setCodeHash() was removed from UserRecoveryCode; plaintext validation no longer has an entry point.'); + $code = new UserRecoveryCode(); + $this->expectException(\InvalidArgumentException::class); + $code->setCodeHash('plaintext-not-a-hash'); } // ------------------------------------------------------------------------- From ed53348423b3c08194ffb9d9f4c33951acbdcb11 Mon Sep 17 00:00:00 2001 From: matiasperrone-exo Date: Thu, 21 May 2026 21:14:04 +0000 Subject: [PATCH 09/26] chore: unify migrations --- database/migrations/Version20260416194357.php | 2 +- database/migrations/Version20260424120000.php | 60 ------------------- 2 files changed, 1 insertion(+), 61 deletions(-) delete mode 100644 database/migrations/Version20260424120000.php diff --git a/database/migrations/Version20260416194357.php b/database/migrations/Version20260416194357.php index 90cac22a..d86375b0 100644 --- a/database/migrations/Version20260416194357.php +++ b/database/migrations/Version20260416194357.php @@ -54,7 +54,7 @@ public function up(Schema $schema): void $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->unique(["user_id", "device_identifier"], "utd_user_device_uniq"); $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"]); diff --git a/database/migrations/Version20260424120000.php b/database/migrations/Version20260424120000.php deleted file mode 100644 index 1a1ded7e..00000000 --- a/database/migrations/Version20260424120000.php +++ /dev/null @@ -1,60 +0,0 @@ -connection->fetchOne( - 'SELECT COUNT(*) FROM ( - SELECT 1 - FROM user_trusted_devices - GROUP BY user_id, device_identifier - HAVING COUNT(*) > 1 - ) dup' - ); - - $this->abortIf( - $duplicates > 0, - 'Duplicate trusted devices exist; dedupe user_trusted_devices before applying utd_user_device_uniq.' - ); - - $this->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)' - ); - } -} \ No newline at end of file From 2accedf0dd72769963e3a8c26f240b11b48e029c Mon Sep 17 00:00:00 2001 From: matiasperrone-exo Date: Thu, 30 Apr 2026 19:06:36 +0000 Subject: [PATCH 10/26] feat: Add MultiFactor Authentication --- app/libs/Auth/Models/User.php | 169 +++++++++++++++++++++ config/two_factor.php | 33 +++++ phpunit.xml | 4 + tests/unit/UserTwoFactorTest.php | 246 +++++++++++++++++++++++++++++++ 4 files changed, 452 insertions(+) create mode 100644 config/two_factor.php create mode 100644 tests/unit/UserTwoFactorTest.php diff --git a/app/libs/Auth/Models/User.php b/app/libs/Auth/Models/User.php index c1d70d96..bb8c5373 100644 --- a/app/libs/Auth/Models/User.php +++ b/app/libs/Auth/Models/User.php @@ -76,6 +76,15 @@ class User extends BaseEntity self::SpamTypeHam ]; + public const MFAMethod_OTP = 'email_otp'; + public const MFAMethod_SMS = 'sms_otp'; + public const MFAMethod_TOTP = 'totp'; + public const MFAMethod_PASSKEY = 'passkey'; + + public const ValidMFAMethods = [ + self::MFAMethod_OTP + ]; + /** * @var string */ @@ -303,6 +312,25 @@ class User extends BaseEntity */ #[ORM\Column(name: 'email_verified_date', nullable: true, type: 'datetime')] private $email_verified_date; + + /** + * @var bool + */ + #[ORM\Column(name: 'two_factor_enabled', type: 'boolean', options: ['default' => false])] + private $two_factor_enabled; + + /** + * @var string + */ + #[ORM\Column(name: 'two_factor_method', type: 'string', length: 32, options: ['default' => self::MFAMethod_OTP])] + private $two_factor_method; + + /** + * @var \DateTime|null + */ + #[ORM\Column(name: 'two_factor_enforced_at', nullable: true, type: 'datetime')] + private $two_factor_enforced_at; + /** * @var string */ @@ -457,6 +485,9 @@ public function __construct() parent::__construct(); $this->active = true; $this->email_verified = false; + $this->two_factor_enabled = false; + $this->two_factor_method = self::MFAMethod_OTP; + $this->two_factor_enforced_at = null; // user profile settings $this->public_profile_show_photo = false; $this->public_profile_show_email = false; @@ -2359,4 +2390,142 @@ public function getAuthPasswordName() return 'password'; } + // --- Two-factor authentication --------------------------------------- + + public function isTwoFactorEnabled(): bool + { + return (bool) $this->two_factor_enabled; + } + + public function setTwoFactorEnabled(bool $enabled): void + { + $this->two_factor_enabled = $enabled; + } + + public function getTwoFactorMethod(): string + { + return $this->two_factor_method; + } + + /** + * @throws ValidationException + */ + protected function setTwoFactorMethod(string $method): void + { + $this->two_factor_method = $method; + } + + public function getTwoFactorEnforcedAt(): ?\DateTime + { + return $this->two_factor_enforced_at; + } + + public function setTwoFactorEnforcedAt(?\DateTime $at): void + { + $this->two_factor_enforced_at = $at; + } + + /** + * Whether this user is required to complete 2FA to sign in. + * + * A user is required when they belong to any of the groups listed in + * config('two_factor.enforced_groups'); otherwise the stored flag applies. + */ + public function shouldRequire2FA(): bool + { + $enforcedGroups = config('two_factor.enforced_groups', []); + foreach ($enforcedGroups as $slug) { + if($this->belongToGroup($slug)) { + return true; + } + } + return (bool) $this->two_factor_enabled; + } + + /** + * @throws ValidationException + */ + public function enable2FA(string $method): void + { + $availableMethods = $this->getAvailableTwoFactorMethods(); + if(!in_array($method, self::ValidMFAMethods, true)) { + throw new ValidationException( + sprintf( + "Invalid 2FA method '%s'. Allowed methods: %s. Enabled methods: %s", + $method, + implode(', ', self::ValidMFAMethods), + implode(', ', $availableMethods) + ) + ); + } + + if(!in_array($method, $availableMethods, true)) { + throw new ValidationException( + sprintf( + "Disabled 2FA method '%s'. Enabled methods: %s", + $method, + implode(', ', $availableMethods) + ) + ); + } + + $this->setTwoFactorMethod($method); + $this->setTwoFactorEnabled(true); + $this->setTwoFactorEnforcedAt(new \DateTime('now', new \DateTimeZone('UTC'))); + } + + public function disable2FA(): void + { + $this->setTwoFactorEnabled(false); + $this->setTwoFactorEnforcedAt(null); + } + + /** + * Returns the set of 2FA methods currently available to this user. + * Phase I only supports email_otp; other methods are stubs that will + * light up in Phase II/III once the backing verifications exist. + * + * @return string[] + */ + public function getAvailableTwoFactorMethods(): array + { + $methods = []; + if($this->isEmailVerified() && in_array(self::MFAMethod_OTP, self::ValidMFAMethods, true)) { + $methods[] = self::MFAMethod_OTP; + } + if($this->isPhoneNumberVerified() && in_array(self::MFAMethod_SMS, self::ValidMFAMethods, true)) { + $methods[] = self::MFAMethod_SMS; + } + if($this->isTOTPConfirmed() && in_array(self::MFAMethod_TOTP, self::ValidMFAMethods, true)) { + $methods[] = self::MFAMethod_TOTP; + } + if($this->isPassKeyEnabled() && in_array(self::MFAMethod_PASSKEY, self::ValidMFAMethods, true)) { + $methods[] = self::MFAMethod_PASSKEY; + } + return $methods; + } + + public function isTwoFactorMethodEnabled(string $method): bool + { + return in_array($method, $this->getAvailableTwoFactorMethods(), true); + } + + // Phase II stub + public function isPhoneNumberVerified(): bool + { + return false; + } + + // Phase III stub + public function isTOTPConfirmed(): bool + { + return false; + } + + // Phase III stub + public function isPassKeyEnabled(): bool + { + return false; + } + } \ No newline at end of file diff --git a/config/two_factor.php b/config/two_factor.php new file mode 100644 index 00000000..dd876a6f --- /dev/null +++ b/config/two_factor.php @@ -0,0 +1,33 @@ + [ + IGroupSlugs::SuperAdminGroup, + IGroupSlugs::AdminGroup, + IGroupSlugs::OAuth2ServerAdminGroup, + IGroupSlugs::OpenIdServerAdminsGroup, + ], +]; diff --git a/phpunit.xml b/phpunit.xml index 7515f39f..4750f428 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -22,6 +22,10 @@ ./tests/OpenTelemetry/Formatters/ + + ./tests/TwoFactorRepositoriesTest.php + ./tests/unit/UserTwoFactorTest.php + diff --git a/tests/unit/UserTwoFactorTest.php b/tests/unit/UserTwoFactorTest.php new file mode 100644 index 00000000..6e32921d --- /dev/null +++ b/tests/unit/UserTwoFactorTest.php @@ -0,0 +1,246 @@ +setName($slug); + $group->setSlug($slug); + return $group; + } + + private function assignGroups(User $user, array $groups): void + { + $reflection = new \ReflectionClass(User::class); + $property = $reflection->getProperty('groups'); + $property->setAccessible(true); + $collection = $property->getValue($user); + foreach ($groups as $group) { + $collection->add($group); + } + } + + private function setEmailVerified(User $user, bool $verified): void + { + $reflection = new \ReflectionClass(User::class); + $property = $reflection->getProperty('email_verified'); + $property->setAccessible(true); + $property->setValue($user, $verified); + } + + public function testShouldRequire2FA_superAdminUser(): void + { + $user = new User(); + $this->assignGroups($user, [$this->buildGroup(IGroupSlugs::SuperAdminGroup)]); + + $this->assertFalse($user->isTwoFactorEnabled()); + $this->assertTrue($user->shouldRequire2FA()); + } + + public function testShouldRequire2FA_adminUser(): void + { + $user = new User(); + $this->assignGroups($user, [$this->buildGroup(IGroupSlugs::AdminGroup)]); + + $this->assertFalse($user->isTwoFactorEnabled()); + $this->assertTrue($user->shouldRequire2FA()); + } + + public function testShouldRequire2FA_BelongsToAnEnforcedGroup(): void + { + config(['two_factor.enforced_groups' => []]); + $this->assertSame([], config('two_factor.enforced_groups'), "Config value 'two_factor.enforced_groups' is set"); + + $groups = [ + IGroupSlugs::SuperAdminGroup, + IGroupSlugs::AdminGroup, + IGroupSlugs::OAuth2ServerAdminGroup, + ]; + config(['two_factor.enforced_groups' => $groups]); + $this->assertSame($groups, config('two_factor.enforced_groups'), "Config value 'two_factor.enforced_groups' is set"); + + $user = new User(); + $this->assertFalse($user->shouldRequire2FA(), "The user does not belong to any enforced group"); + + + $this->assignGroups($user, array_map([$this, 'buildGroup'], $groups)); + $this->assertTrue($user->belongToGroup(IGroupSlugs::SuperAdminGroup), "The user belongs to ".IGroupSlugs::SuperAdminGroup." group"); + $this->assertTrue($user->belongToGroup(IGroupSlugs::AdminGroup), "The user belongs to ".IGroupSlugs::AdminGroup." group"); + $this->assertTrue($user->belongToGroup(IGroupSlugs::OAuth2ServerAdminGroup), "The user belongs to ".IGroupSlugs::OAuth2ServerAdminGroup." group"); + $this->assertTrue($user->shouldRequire2FA(), "The user does belong to an enforced group"); + + $groups = [IGroupSlugs::RawUsersGroup]; + $user = new User(); + $this->assertFalse($user->shouldRequire2FA(), "The user does not belong to any enforced group"); + $this->assignGroups($user, array_map([$this, 'buildGroup'], $groups)); + $this->assertFalse($user->shouldRequire2FA(), "The user does not belong to any enforced group"); + } + + public function testShouldRequire2FA_regularUser_enabled(): void + { + $user = new User(); + $this->assignGroups($user, [$this->buildGroup(IGroupSlugs::RawUsersGroup)]); + $user->setTwoFactorEnabled(true); + + $this->assertTrue($user->shouldRequire2FA()); + } + + public function testShouldRequire2FA_regularUser_disabled(): void + { + $user = new User(); + $this->assignGroups($user, [$this->buildGroup(IGroupSlugs::RawUsersGroup)]); + + $this->assertFalse($user->isTwoFactorEnabled()); + $this->assertFalse($user->shouldRequire2FA()); + } + + public function testEnable2FA_validMethod(): void + { + $user = new User(); + // email_otp is only "available" to a user whose email is verified — + // enable2FA() now whitelists via getAvailableTwoFactorMethods(). + $this->setEmailVerified($user, true); + $before = new \DateTime('now', new \DateTimeZone('UTC')); + + $user->enable2FA('email_otp'); + + $after = new \DateTime('now', new \DateTimeZone('UTC')); + + $this->assertTrue($user->isTwoFactorEnabled()); + $this->assertSame('email_otp', $user->getTwoFactorMethod()); + $enforcedAt = $user->getTwoFactorEnforcedAt(); + $this->assertInstanceOf(\DateTime::class, $enforcedAt); + $this->assertGreaterThanOrEqual($before->getTimestamp(), $enforcedAt->getTimestamp()); + $this->assertLessThanOrEqual($after->getTimestamp(), $enforcedAt->getTimestamp()); + } + + public function testEnable2FA_invalidMethod_throws(): void + { + $user = new User(); + $this->expectException(ValidationException::class); + $user->enable2FA('invalid_method'); + } + + public function testEnable2FA_phaseTwoMethod_throwsInPhaseOne(): void + { + // sms_otp/totp/passkey are Phase II/III — ValidMFAMethods should reject them in Phase I. + $user = new User(); + $this->expectException(ValidationException::class); + $user->enable2FA('sms_otp'); + } + + public function testDisable2FA(): void + { + $user = new User(); + $this->setEmailVerified($user, true); + $user->enable2FA('email_otp'); + $this->assertTrue($user->isTwoFactorEnabled()); + $this->assertSame('email_otp', $user->getTwoFactorMethod()); + $user->disable2FA(); + $this->assertFalse($user->isTwoFactorEnabled()); + $this->assertNull($user->getTwoFactorEnforcedAt()); + $this->assertSame('email_otp', $user->getTwoFactorMethod()); + } + + public function testGetAvailableTwoFactorMethods_emailVerified(): void + { + $user = new User(); + $this->setEmailVerified($user, true); + + $this->assertSame(['email_otp'], $user->getAvailableTwoFactorMethods()); + } + + public function testGetAvailableTwoFactorMethods_emailNotVerified(): void + { + $user = new User(); + $this->setEmailVerified($user, false); + + $this->assertSame([], $user->getAvailableTwoFactorMethods()); + } + + public function testIsTwoFactorMethodEnable(): void + { + $user = new User(); + $this->setEmailVerified($user, true); + + $this->assertTrue($user->isTwoFactorMethodEnabled('email_otp')); + $this->assertFalse($user->isTwoFactorMethodEnabled('sms_otp')); + $this->assertFalse($user->isTwoFactorMethodEnabled('totp')); + $this->assertFalse($user->isTwoFactorMethodEnabled('passkey')); + $this->assertFalse($user->isTwoFactorMethodEnabled('garbage')); + } + + public function testShouldRequire2FA_configDrivenEnforcement(): void + { + // Users in enforced_groups must require 2FA even if two_factor_enabled=false + // and even if isAdmin() returns false for their group (OAuth2/OpenId admins). + $groupsUnderTest = [ + IGroupSlugs::OAuth2ServerAdminGroup, + IGroupSlugs::OpenIdServerAdminsGroup, + ]; + + foreach ($groupsUnderTest as $groupSlug) { + $user = new User(); + $this->assignGroups($user, [$this->buildGroup($groupSlug)]); + $this->assertFalse($user->isTwoFactorEnabled()); + $this->assertFalse($user->isAdmin(), "$groupSlug must NOT be covered by isAdmin()"); + $this->assertTrue($user->shouldRequire2FA(), "$groupSlug is in enforced_groups — shouldRequire2FA() must return true regardless of the stored flag"); + } + + // Sanity: a regular user with two_factor_enabled=false is NOT enforced + $regular = new User(); + $this->assignGroups($regular, [$this->buildGroup(IGroupSlugs::RawUsersGroup)]); + $this->assertFalse($regular->shouldRequire2FA()); + } + + public function testShouldRequire2FA_emptyEnforcedGroups_fallsThroughToFlag(): void + { + // Locks in the config fall-through: when enforced_groups is empty, + // shouldRequire2FA() must mirror the stored two_factor_enabled flag. + Config::set('two_factor.enforced_groups', []); + + $user = new User(); + $this->assignGroups($user, [$this->buildGroup(IGroupSlugs::RawUsersGroup)]); + $user->setTwoFactorEnabled(true); + + $this->assertTrue($user->isTwoFactorEnabled()); + $this->assertTrue($user->shouldRequire2FA()); + } +} From a7efc09206d1ecd993fa03b7588710ec3d8c74b8 Mon Sep 17 00:00:00 2001 From: matiasperrone-exo Date: Wed, 29 Apr 2026 14:30:20 +0000 Subject: [PATCH 11/26] feat: Add AuthService validateCredentials method - test: cover canLogin()=false branch in validateCredentials() unit tests - docs: document known double-query cost in validateCredentials() - fix: use consistent error message in validateCredentials() --- app/libs/Auth/AuthService.php | 117 ++++++-- app/libs/Utils/Services/IAuthService.php | 22 ++ ...viceValidateCredentialsIntegrationTest.php | 106 +++++++ .../AuthServiceValidateCredentialsTest.php | 273 ++++++++++++++++++ 4 files changed, 486 insertions(+), 32 deletions(-) create mode 100644 tests/AuthServiceValidateCredentialsIntegrationTest.php create mode 100644 tests/unit/AuthServiceValidateCredentialsTest.php diff --git a/app/libs/Auth/AuthService.php b/app/libs/Auth/AuthService.php index 928c5af8..975a752d 100644 --- a/app/libs/Auth/AuthService.php +++ b/app/libs/Auth/AuthService.php @@ -1,4 +1,5 @@ -user_repository = $user_repository; $this->principal_service = $principal_service; @@ -131,6 +130,17 @@ public function isUserLogged() return Auth::check(); } + /** + * @return User|null + */ + public function getCurrentUser(): ?User + { + $user = Auth::user(); + if ($user instanceof User) { + return $user; + } + } + /** * Finds the OTP by value/connection/username, logs the redeem attempt (TX-A), * then validates lifecycle / value / scope / audience (TX-B). @@ -140,13 +150,12 @@ public function isUserLogged() * @throws InvalidOTPException */ private function findAndValidateOTP( - string $otp_value, - string $user_name, - string $otp_conn, + string $otp_value, + string $user_name, + string $otp_conn, ?string $otp_required_scopes, ?Client $client - ): OAuth2OTP - { + ): OAuth2OTP { // TX-A: find + log attempt (committed before any validation can throw) $otp = $this->tx_service->transaction(function () use ($otp_value, $otp_conn, $user_name, $client) { @@ -189,8 +198,10 @@ private function findAndValidateOTP( throw new InvalidOTPException("Single-use code requested scopes escalates former scopes."); } - if (($otp->hasClient() && is_null($client)) || - ($otp->hasClient() && !is_null($client) && $client->getClientId() != $otp->getClient()->getClientId())) { + if ( + ($otp->hasClient() && is_null($client)) || + ($otp->hasClient() && !is_null($client) && $client->getClientId() != $otp->getClient()->getClientId()) + ) { throw new AuthenticationException("Single-use code audience mismatch."); } @@ -319,8 +330,7 @@ public function verifyOTPChallenge( OAuth2OTP $otpClaim, User $sessionUser, ?Client $client = null - ): OAuth2OTP - { + ): OAuth2OTP { Log::debug(sprintf( "AuthService::verifyOTPChallenge otp %s session user %s", $otpClaim->getValue(), @@ -384,11 +394,10 @@ public function login(string $username, string $password, bool $remember_me): bo { Log::debug("AuthService::login"); - $this->last_login_error = ""; if (!Auth::attempt(['username' => $username, 'password' => $password], $remember_me)) { throw new AuthenticationException ( - "We are sorry, your username or password does not match an existing record." + "username or password does not match an existing record." ); } Log::debug("AuthService::login: clearing principal"); @@ -397,7 +406,7 @@ public function login(string $username, string $password, bool $remember_me): bo if (is_null($current_user) || !$current_user->canLogin()) throw new AuthenticationException ( - "We are sorry, your username or password does not match an existing record." + "username or password does not match an existing record." ); $this->principal_service->register ( @@ -409,11 +418,51 @@ public function login(string $username, string $password, bool $remember_me): bo } /** - * @return User|null + * @param string $username + * @param string $password + * @return User + * @throws AuthenticationException */ - public function getCurrentUser(): ?User + public function validateCredentials(string $username, string $password): User { - return Auth::user(); + Log::debug("AuthService::validateCredentials"); + + // retrieveByCredentials swallows AuthenticationLockedUserLoginAttempt and returns null, + // so pre-check lock state here to surface a distinct message for locked accounts. + $existing = $this->user_repository->getByEmailOrName($username); + if (!is_null($existing) && !$existing->isActive()) { + throw new AuthenticationException( + sprintf("User %s is locked.", $username) + ); + } + + // Known cost: retrieveByCredentials() calls user_repository->getByEmailOrName() internally + // (CustomAuthProvider line ~122), duplicating the query above. Eliminating it would require + // either changing the provider API to accept a pre-fetched User, or moving + // LockUserCounterMeasure checkpoint logic out of the provider — both out of scope here. + $user = Auth::getProvider()->retrieveByCredentials([ + 'username' => $username, + 'password' => $password, + ]); + + if (is_null($user) || !$user instanceof User || !$user->canLogin()) { + throw new AuthenticationException( + "username or password does not match an existing record." + ); + } + + return $user; + } + + /** + * @param User $user + * @param bool $remember + * @return void + */ + public function loginUser(User $user, bool $remember): void + { + Log::debug("AuthService::loginUser"); + Auth::login($user, $remember); } /** @@ -618,7 +667,8 @@ public function registerRPLogin(string $client_id): void $rps = $zlib->uncompress($rps); $rps .= '|'; } - if (is_null($rps)) $rps = ""; + if (is_null($rps)) + $rps = ""; if (!str_contains($rps, $client_id)) $rps .= $client_id; @@ -720,12 +770,15 @@ public function postLoginUserActions(int $user_id): void Log::debug(sprintf("AuthService::postLoginUserActions user %s", $user_id)); $this->tx_service->transaction(function () use ($user_id) { $user = $this->user_repository->getById($user_id); - if (!$user instanceof User) return; + if (!$user instanceof User) + return; if (!$user->isActive()) { Log::warning(sprintf("AuthService::postLoginUserActions user %s is not active.", $user_id)); - throw new AuthenticationLockedUserLoginAttempt($user->getEmail(), - sprintf("User %s is locked.", $user->getEmail())); + throw new AuthenticationLockedUserLoginAttempt( + $user->getEmail(), + sprintf("User %s is locked.", $user->getEmail()) + ); } //update user fields @@ -736,4 +789,4 @@ public function postLoginUserActions(int $user_id): void }); } -} \ No newline at end of file +} diff --git a/app/libs/Utils/Services/IAuthService.php b/app/libs/Utils/Services/IAuthService.php index a8fc3dda..c3d26e62 100644 --- a/app/libs/Utils/Services/IAuthService.php +++ b/app/libs/Utils/Services/IAuthService.php @@ -57,6 +57,28 @@ public function getCurrentUser():?User; */ public function login(string $username, string $password, bool $remember_me): bool; + /** + * Validates the supplied credentials without establishing a session. + * Delegates to CustomAuthProvider::retrieveByCredentials() so security + * checkpoints (LockUserCounterMeasure, etc.) still fire on failure. + * + * @param string $username + * @param string $password + * @return User + * @throws AuthenticationException on invalid credentials, missing user, or locked account. + */ + public function validateCredentials(string $username, string $password): User; + + /** + * Establishes a Laravel session for an already-authenticated user. + * Used by the 2FA flow after the second factor is verified. + * + * @param User $user + * @param bool $remember + * @return void + */ + public function loginUser(User $user, bool $remember): void; + /** * @param OAuth2OTP $otpClaim * @param Client|null $client diff --git a/tests/AuthServiceValidateCredentialsIntegrationTest.php b/tests/AuthServiceValidateCredentialsIntegrationTest.php new file mode 100644 index 00000000..ab512a6b --- /dev/null +++ b/tests/AuthServiceValidateCredentialsIntegrationTest.php @@ -0,0 +1,106 @@ +auth_service = $this->app[UtilsServiceCatalog::AuthenticationService]; + } + + /** + * A failed validateCredentials() call must: + * - throw AuthenticationException, + * - NOT establish a session (Auth::check() stays false), + * - trigger LockUserCounterMeasure so the user's login_failed_attempt counter increments. + */ + public function testFailedAttempt_incrementsLoginFailedAttemptCounter(): void + { + $initial_attempts = $this->getLoginFailedAttempt(self::SEEDED_USERNAME); + $this->assertFalse(Auth::check(), 'precondition: no authenticated user'); + + $threw = false; + try { + $this->auth_service->validateCredentials(self::SEEDED_USERNAME, 'wrong-password'); + } catch (AuthenticationException $ex) { + $threw = true; + } + + $this->assertTrue($threw, 'Expected AuthenticationException on wrong password'); + $this->assertFalse(Auth::check(), 'No session should be established after a failed attempt'); + + $new_attempts = $this->getLoginFailedAttempt(self::SEEDED_USERNAME); + $this->assertSame( + $initial_attempts + 1, + $new_attempts, + 'login_failed_attempt counter must increment via LockUserCounterMeasure' + ); + } + + /** + * A successful validateCredentials() call must return the user without + * establishing a session — Auth::check() must remain false afterwards. + */ + public function testSuccessfulValidation_doesNotEstablishSession(): void + { + $this->assertFalse(Auth::check(), 'precondition: no authenticated user'); + + $user = $this->auth_service->validateCredentials( + self::SEEDED_USERNAME, + self::SEEDED_PASSWORD + ); + + $this->assertInstanceOf(User::class, $user); + $this->assertFalse( + Auth::check(), + 'validateCredentials() must NOT call Auth::login() on success' + ); + } + + private function getLoginFailedAttempt(string $username): int + { + // Clear Doctrine's identity map so we read fresh state from the DB, + // not a cached in-memory entity from a prior transaction. + EntityManager::clear(); + $repo = EntityManager::getRepository(User::class); + /** @var IUserRepository $repo */ + $user = $repo->getByEmailOrName($username); + $this->assertInstanceOf(User::class, $user, "Seeded user {$username} not found"); + return $user->getLoginFailedAttempt(); + } +} diff --git a/tests/unit/AuthServiceValidateCredentialsTest.php b/tests/unit/AuthServiceValidateCredentialsTest.php new file mode 100644 index 00000000..b6f18948 --- /dev/null +++ b/tests/unit/AuthServiceValidateCredentialsTest.php @@ -0,0 +1,273 @@ +mock_user_repository = $this->createMock(IUserRepository::class); + $mock_otp_repository = $this->createMock(IOAuth2OTPRepository::class); + $mock_principal_service = $this->createMock(IPrincipalService::class); + $mock_user_service = $this->createMock(IUserService::class); + $mock_user_action_service = $this->createMock(IUserActionService::class); + $mock_cache_service = $this->createMock(ICacheService::class); + $mock_auth_user_service = $this->createMock(IAuthUserService::class); + $mock_security_context_service = $this->createMock(ISecurityContextService::class); + $mock_tx_service = $this->createMock(ITransactionService::class); + + $this->auth_mock = Mockery::mock('alias:Illuminate\Support\Facades\Auth'); + $this->log_mock = Mockery::mock('alias:Illuminate\Support\Facades\Log'); + + $this->log_mock->shouldReceive('debug')->zeroOrMoreTimes(); + $this->log_mock->shouldReceive('warning')->zeroOrMoreTimes(); + + $this->service = new AuthService( + $this->mock_user_repository, + $mock_otp_repository, + $mock_principal_service, + $mock_user_service, + $mock_user_action_service, + $mock_cache_service, + $mock_auth_user_service, + $mock_security_context_service, + $mock_tx_service + ); + } + + /** + * Valid credentials return the User WITHOUT establishing a session. + * Auth::login() and Auth::attempt() must NEVER be called. + */ + public function testValidCredentials_returnsUser_withoutEstablishingSession(): void + { + $username = 'jane.doe'; + $password = 'Str0ng!Pass'; + + $this->mock_user_repository + ->expects($this->once()) + ->method('getByEmailOrName') + ->with($username) + ->willReturn(null); + + $resolved_user = Mockery::mock('Auth\User'); + $resolved_user->shouldReceive('canLogin')->andReturn(true); + + $provider_mock = Mockery::mock('Illuminate\Contracts\Auth\UserProvider'); + $provider_mock->shouldReceive('retrieveByCredentials') + ->once() + ->with(['username' => $username, 'password' => $password]) + ->andReturn($resolved_user); + + $this->auth_mock->shouldReceive('getProvider')->once()->andReturn($provider_mock); + $this->auth_mock->shouldNotReceive('login'); + $this->auth_mock->shouldNotReceive('attempt'); + + $returned = $this->service->validateCredentials($username, $password); + + $this->assertSame($resolved_user, $returned); + } + + /** + * Invalid credentials (provider returns null) throw AuthenticationException + * and do NOT establish a session. + */ + public function testInvalidCredentials_throwsAuthenticationException(): void + { + $username = 'jane.doe'; + $password = 'wrong'; + + $this->mock_user_repository + ->expects($this->once()) + ->method('getByEmailOrName') + ->with($username) + ->willReturn(null); + + $provider_mock = Mockery::mock('Illuminate\Contracts\Auth\UserProvider'); + $provider_mock->shouldReceive('retrieveByCredentials') + ->once() + ->with(['username' => $username, 'password' => $password]) + ->andReturn(null); + + $this->auth_mock->shouldReceive('getProvider')->once()->andReturn($provider_mock); + $this->auth_mock->shouldNotReceive('login'); + $this->auth_mock->shouldNotReceive('attempt'); + + $this->expectException(AuthenticationException::class); + + $this->service->validateCredentials($username, $password); + } + + /** + * Provider returns a User whose canLogin() is false — must throw AuthenticationException. + * This guards against future providers or provider changes that bypass the internal canLogin() + * check inside CustomAuthProvider::retrieveByCredentials(). + */ + public function testProviderReturnsUserThatCannotLogin_throwsAuthenticationException(): void + { + $username = 'jane.doe'; + $password = 'Str0ng!Pass'; + + // Pre-check: user not found in repository, so the locked-account short-circuit is not taken. + $this->mock_user_repository + ->expects($this->once()) + ->method('getByEmailOrName') + ->with($username) + ->willReturn(null); + + // Provider returns a valid User instance, but canLogin() is false. + $non_loginable_user = Mockery::mock('Auth\User'); + $non_loginable_user->shouldReceive('canLogin')->andReturn(false); + + $provider_mock = Mockery::mock('Illuminate\Contracts\Auth\UserProvider'); + $provider_mock->shouldReceive('retrieveByCredentials') + ->once() + ->with(['username' => $username, 'password' => $password]) + ->andReturn($non_loginable_user); + + $this->auth_mock->shouldReceive('getProvider')->once()->andReturn($provider_mock); + $this->auth_mock->shouldNotReceive('login'); + $this->auth_mock->shouldNotReceive('attempt'); + + $this->expectException(AuthenticationException::class); + + $this->service->validateCredentials($username, $password); + } + + /** + * A user that exists but is inactive (locked) short-circuits the password check + * and throws AuthenticationException with a "is locked" message. + */ + public function testLockedAccount_throwsAuthenticationException_withLockedMessage(): void + { + $username = 'locked.user'; + + $locked_user = Mockery::mock('Auth\User'); + $locked_user->shouldReceive('isActive')->andReturn(false); + + $this->mock_user_repository + ->expects($this->once()) + ->method('getByEmailOrName') + ->with($username) + ->willReturn($locked_user); + + // Provider must NOT be consulted when the user is locked. + $this->auth_mock->shouldNotReceive('getProvider'); + $this->auth_mock->shouldNotReceive('login'); + $this->auth_mock->shouldNotReceive('attempt'); + + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessageMatches('/is locked/i'); + + $this->service->validateCredentials($username, 'irrelevant'); + } + + /** + * When the existing user is active, the locked-path is not taken: + * the provider is consulted and the resolved User is returned. + */ + public function testActiveUser_doesNotTriggerLockedPath(): void + { + $username = 'jane.doe'; + $password = 'Str0ng!Pass'; + + $active_user = Mockery::mock('Auth\User'); + $active_user->shouldReceive('isActive')->andReturn(true); + + $this->mock_user_repository + ->expects($this->once()) + ->method('getByEmailOrName') + ->with($username) + ->willReturn($active_user); + + $resolved_user = Mockery::mock('Auth\User'); + $resolved_user->shouldReceive('canLogin')->andReturn(true); + + $provider_mock = Mockery::mock('Illuminate\Contracts\Auth\UserProvider'); + $provider_mock->shouldReceive('retrieveByCredentials') + ->once() + ->andReturn($resolved_user); + + $this->auth_mock->shouldReceive('getProvider')->once()->andReturn($provider_mock); + $this->auth_mock->shouldNotReceive('login'); + + $returned = $this->service->validateCredentials($username, $password); + + $this->assertSame($resolved_user, $returned); + } + + /** + * loginUser(user, true) delegates to Auth::login with the remember flag set. + */ + public function testLoginUser_callsAuthLogin_withRememberTrue(): void + { + $user = Mockery::mock('Auth\User'); + + $this->auth_mock + ->shouldReceive('login') + ->once() + ->with($user, true); + + $this->service->loginUser($user, true); + } + + /** + * loginUser(user, false) delegates to Auth::login with remember disabled. + */ + public function testLoginUser_callsAuthLogin_withRememberFalse(): void + { + $user = Mockery::mock('Auth\User'); + + $this->auth_mock + ->shouldReceive('login') + ->once() + ->with($user, false); + + $this->service->loginUser($user, false); + } +} From 09fde461c6aa50039d291ee4db6be35b1fdf7134 Mon Sep 17 00:00:00 2001 From: matiasperrone-exo Date: Wed, 29 Apr 2026 21:30:34 +0000 Subject: [PATCH 12/26] chore: lint file app/libs/Auth/AuthService.php --- app/libs/Auth/AuthService.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/libs/Auth/AuthService.php b/app/libs/Auth/AuthService.php index 975a752d..71387fc0 100644 --- a/app/libs/Auth/AuthService.php +++ b/app/libs/Auth/AuthService.php @@ -789,4 +789,4 @@ public function postLoginUserActions(int $user_id): void }); } -} +} \ No newline at end of file From 2106d825d7a7075d4331bafd9e8a9d6618378f96 Mon Sep 17 00:00:00 2001 From: matiasperrone-exo Date: Tue, 5 May 2026 14:58:46 +0000 Subject: [PATCH 13/26] chore: Add PR's requested changes --- app/libs/Auth/AuthService.php | 38 ++--- .../AuthServiceValidateCredentialsTest.php | 135 +++--------------- 2 files changed, 35 insertions(+), 138 deletions(-) diff --git a/app/libs/Auth/AuthService.php b/app/libs/Auth/AuthService.php index 71387fc0..776b459b 100644 --- a/app/libs/Auth/AuthService.php +++ b/app/libs/Auth/AuthService.php @@ -98,6 +98,7 @@ final class AuthService extends AbstractService implements IAuthService * @param IAuthUserService $auth_user_service * @param ISecurityContextService $security_context_service * @param ITransactionService $tx_service + * @params ISecurityContextService $security_context_service */ public function __construct ( @@ -394,10 +395,11 @@ public function login(string $username, string $password, bool $remember_me): bo { Log::debug("AuthService::login"); + $this->last_login_error = ""; if (!Auth::attempt(['username' => $username, 'password' => $password], $remember_me)) { throw new AuthenticationException ( - "username or password does not match an existing record." + "We are sorry, your username or password does not match an existing record." ); } Log::debug("AuthService::login: clearing principal"); @@ -406,7 +408,7 @@ public function login(string $username, string $password, bool $remember_me): bo if (is_null($current_user) || !$current_user->canLogin()) throw new AuthenticationException ( - "username or password does not match an existing record." + "We are sorry, your username or password does not match an existing record." ); $this->principal_service->register ( @@ -420,35 +422,19 @@ public function login(string $username, string $password, bool $remember_me): bo /** * @param string $username * @param string $password - * @return User + * @return User|null * @throws AuthenticationException */ public function validateCredentials(string $username, string $password): User { Log::debug("AuthService::validateCredentials"); - // retrieveByCredentials swallows AuthenticationLockedUserLoginAttempt and returns null, - // so pre-check lock state here to surface a distinct message for locked accounts. - $existing = $this->user_repository->getByEmailOrName($username); - if (!is_null($existing) && !$existing->isActive()) { - throw new AuthenticationException( - sprintf("User %s is locked.", $username) - ); - } - - // Known cost: retrieveByCredentials() calls user_repository->getByEmailOrName() internally - // (CustomAuthProvider line ~122), duplicating the query above. Eliminating it would require - // either changing the provider API to accept a pre-fetched User, or moving - // LockUserCounterMeasure checkpoint logic out of the provider — both out of scope here. - $user = Auth::getProvider()->retrieveByCredentials([ - 'username' => $username, - 'password' => $password, - ]); - - if (is_null($user) || !$user instanceof User || !$user->canLogin()) { - throw new AuthenticationException( - "username or password does not match an existing record." - ); + /** + * @var User|null $user + */ + $user = Auth::getProvider()->retrieveByCredentials(['username' => $username, 'password' => $password]); + if (!$user) { + throw new AuthenticationException(); } return $user; @@ -462,6 +448,8 @@ public function validateCredentials(string $username, string $password): User public function loginUser(User $user, bool $remember): void { Log::debug("AuthService::loginUser"); + if (!$user->canLogin()) + throw new AuthenticationException("User is not active or cannot login."); Auth::login($user, $remember); } diff --git a/tests/unit/AuthServiceValidateCredentialsTest.php b/tests/unit/AuthServiceValidateCredentialsTest.php index b6f18948..bd79f57a 100644 --- a/tests/unit/AuthServiceValidateCredentialsTest.php +++ b/tests/unit/AuthServiceValidateCredentialsTest.php @@ -14,6 +14,7 @@ use App\libs\OAuth2\Repositories\IOAuth2OTPRepository; use Auth\AuthService; +use Auth\CustomAuthProvider; use Auth\Exceptions\AuthenticationException; use Auth\Repositories\IUserRepository; use Mockery; @@ -89,16 +90,9 @@ public function testValidCredentials_returnsUser_withoutEstablishingSession(): v $username = 'jane.doe'; $password = 'Str0ng!Pass'; - $this->mock_user_repository - ->expects($this->once()) - ->method('getByEmailOrName') - ->with($username) - ->willReturn(null); - $resolved_user = Mockery::mock('Auth\User'); - $resolved_user->shouldReceive('canLogin')->andReturn(true); - $provider_mock = Mockery::mock('Illuminate\Contracts\Auth\UserProvider'); + $provider_mock = Mockery::mock(CustomAuthProvider::class); $provider_mock->shouldReceive('retrieveByCredentials') ->once() ->with(['username' => $username, 'password' => $password]) @@ -122,13 +116,7 @@ public function testInvalidCredentials_throwsAuthenticationException(): void $username = 'jane.doe'; $password = 'wrong'; - $this->mock_user_repository - ->expects($this->once()) - ->method('getByEmailOrName') - ->with($username) - ->willReturn(null); - - $provider_mock = Mockery::mock('Illuminate\Contracts\Auth\UserProvider'); + $provider_mock = Mockery::mock(CustomAuthProvider::class); $provider_mock->shouldReceive('retrieveByCredentials') ->once() ->with(['username' => $username, 'password' => $password]) @@ -143,110 +131,13 @@ public function testInvalidCredentials_throwsAuthenticationException(): void $this->service->validateCredentials($username, $password); } - /** - * Provider returns a User whose canLogin() is false — must throw AuthenticationException. - * This guards against future providers or provider changes that bypass the internal canLogin() - * check inside CustomAuthProvider::retrieveByCredentials(). - */ - public function testProviderReturnsUserThatCannotLogin_throwsAuthenticationException(): void - { - $username = 'jane.doe'; - $password = 'Str0ng!Pass'; - - // Pre-check: user not found in repository, so the locked-account short-circuit is not taken. - $this->mock_user_repository - ->expects($this->once()) - ->method('getByEmailOrName') - ->with($username) - ->willReturn(null); - - // Provider returns a valid User instance, but canLogin() is false. - $non_loginable_user = Mockery::mock('Auth\User'); - $non_loginable_user->shouldReceive('canLogin')->andReturn(false); - - $provider_mock = Mockery::mock('Illuminate\Contracts\Auth\UserProvider'); - $provider_mock->shouldReceive('retrieveByCredentials') - ->once() - ->with(['username' => $username, 'password' => $password]) - ->andReturn($non_loginable_user); - - $this->auth_mock->shouldReceive('getProvider')->once()->andReturn($provider_mock); - $this->auth_mock->shouldNotReceive('login'); - $this->auth_mock->shouldNotReceive('attempt'); - - $this->expectException(AuthenticationException::class); - - $this->service->validateCredentials($username, $password); - } - - /** - * A user that exists but is inactive (locked) short-circuits the password check - * and throws AuthenticationException with a "is locked" message. - */ - public function testLockedAccount_throwsAuthenticationException_withLockedMessage(): void - { - $username = 'locked.user'; - - $locked_user = Mockery::mock('Auth\User'); - $locked_user->shouldReceive('isActive')->andReturn(false); - - $this->mock_user_repository - ->expects($this->once()) - ->method('getByEmailOrName') - ->with($username) - ->willReturn($locked_user); - - // Provider must NOT be consulted when the user is locked. - $this->auth_mock->shouldNotReceive('getProvider'); - $this->auth_mock->shouldNotReceive('login'); - $this->auth_mock->shouldNotReceive('attempt'); - - $this->expectException(AuthenticationException::class); - $this->expectExceptionMessageMatches('/is locked/i'); - - $this->service->validateCredentials($username, 'irrelevant'); - } - - /** - * When the existing user is active, the locked-path is not taken: - * the provider is consulted and the resolved User is returned. - */ - public function testActiveUser_doesNotTriggerLockedPath(): void - { - $username = 'jane.doe'; - $password = 'Str0ng!Pass'; - - $active_user = Mockery::mock('Auth\User'); - $active_user->shouldReceive('isActive')->andReturn(true); - - $this->mock_user_repository - ->expects($this->once()) - ->method('getByEmailOrName') - ->with($username) - ->willReturn($active_user); - - $resolved_user = Mockery::mock('Auth\User'); - $resolved_user->shouldReceive('canLogin')->andReturn(true); - - $provider_mock = Mockery::mock('Illuminate\Contracts\Auth\UserProvider'); - $provider_mock->shouldReceive('retrieveByCredentials') - ->once() - ->andReturn($resolved_user); - - $this->auth_mock->shouldReceive('getProvider')->once()->andReturn($provider_mock); - $this->auth_mock->shouldNotReceive('login'); - - $returned = $this->service->validateCredentials($username, $password); - - $this->assertSame($resolved_user, $returned); - } - /** * loginUser(user, true) delegates to Auth::login with the remember flag set. */ public function testLoginUser_callsAuthLogin_withRememberTrue(): void { $user = Mockery::mock('Auth\User'); + $user->shouldReceive('canLogin')->andReturn(true); $this->auth_mock ->shouldReceive('login') @@ -262,6 +153,7 @@ public function testLoginUser_callsAuthLogin_withRememberTrue(): void public function testLoginUser_callsAuthLogin_withRememberFalse(): void { $user = Mockery::mock('Auth\User'); + $user->shouldReceive('canLogin')->andReturn(true); $this->auth_mock ->shouldReceive('login') @@ -270,4 +162,21 @@ public function testLoginUser_callsAuthLogin_withRememberFalse(): void $this->service->loginUser($user, false); } + + /** + * loginUser(user, [true|false]) and isActive or canLogin false throws an Exception. + */ + public function testLoginUser_throwsException_whenIsNotActive(): void + { + $user = Mockery::mock('Auth\User'); + $user->shouldReceive('canLogin')->andReturn(false); + + $this->auth_mock->shouldNotReceive('login'); + + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessageMatches('/User is not active or cannot login\./'); + + $this->service->loginUser($user, true); + } + } From 0937416af06dc494308f3fb898a806284f23e2c0 Mon Sep 17 00:00:00 2001 From: matiasperrone-exo Date: Thu, 7 May 2026 21:29:37 +0000 Subject: [PATCH 14/26] chore: Add PR's requested changes Add tests changes with suggestion --- app/libs/Auth/AuthService.php | 17 +++--- .../AuthServiceValidateCredentialsTest.php | 52 +++++++++++++++++++ 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/app/libs/Auth/AuthService.php b/app/libs/Auth/AuthService.php index 776b459b..4971929e 100644 --- a/app/libs/Auth/AuthService.php +++ b/app/libs/Auth/AuthService.php @@ -19,6 +19,7 @@ use App\Services\Auth\IUserService as IAuthUserService; use Auth\Exceptions\AuthenticationException; use Auth\Exceptions\AuthenticationLockedUserLoginAttempt; +use Auth\Exceptions\UnverifiedEmailMemberException; use Auth\Repositories\IUserRepository; use Exception; use Illuminate\Support\Facades\Auth; @@ -429,12 +430,16 @@ public function validateCredentials(string $username, string $password): User { Log::debug("AuthService::validateCredentials"); - /** - * @var User|null $user - */ - $user = Auth::getProvider()->retrieveByCredentials(['username' => $username, 'password' => $password]); - if (!$user) { - throw new AuthenticationException(); + try { + /** + * @var User|null $user + */ + $user = Auth::getProvider()->retrieveByCredentials(['username' => $username, 'password' => $password]); + if (!$user instanceof User || !$user->canLogin()) { + throw new AuthenticationException("We are sorry, your username or password does not match an existing record."); + } + } catch (UnverifiedEmailMemberException $ex) { + throw new AuthenticationException($ex->getMessage()); } return $user; diff --git a/tests/unit/AuthServiceValidateCredentialsTest.php b/tests/unit/AuthServiceValidateCredentialsTest.php index bd79f57a..a504adc4 100644 --- a/tests/unit/AuthServiceValidateCredentialsTest.php +++ b/tests/unit/AuthServiceValidateCredentialsTest.php @@ -16,6 +16,7 @@ use Auth\AuthService; use Auth\CustomAuthProvider; use Auth\Exceptions\AuthenticationException; +use Auth\Exceptions\UnverifiedEmailMemberException; use Auth\Repositories\IUserRepository; use Mockery; use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; @@ -91,6 +92,7 @@ public function testValidCredentials_returnsUser_withoutEstablishingSession(): v $password = 'Str0ng!Pass'; $resolved_user = Mockery::mock('Auth\User'); + $resolved_user->shouldReceive('canLogin')->once()->andReturn(true); $provider_mock = Mockery::mock(CustomAuthProvider::class); $provider_mock->shouldReceive('retrieveByCredentials') @@ -179,4 +181,54 @@ public function testLoginUser_throwsException_whenIsNotActive(): void $this->service->loginUser($user, true); } + /** + * UnverifiedEmailMemberException from the provider must be caught and + * re-thrown as AuthenticationException (contract: @throws AuthenticationException only). + */ + public function testUnverifiedUser_throwsAuthenticationException(): void + { + $username = 'unverified@example.com'; + $password = 'any'; + + $provider_mock = Mockery::mock(CustomAuthProvider::class); + $provider_mock->shouldReceive('retrieveByCredentials') + ->once() + ->with(['username' => $username, 'password' => $password]) + ->andThrow(new UnverifiedEmailMemberException('Email not verified.')); + + $this->auth_mock->shouldReceive('getProvider')->once()->andReturn($provider_mock); + $this->auth_mock->shouldNotReceive('login'); + + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage('Email not verified.'); + + $this->service->validateCredentials($username, $password); + } + + /** + * Provider returns a valid User but canLogin() is false (locked/inactive): + * must throw AuthenticationException — not silently return the user. + */ + public function testUserCannotLogin_throwsAuthenticationException(): void + { + $username = 'locked@example.com'; + $password = 'any'; + + $locked_user = Mockery::mock('Auth\User'); + $locked_user->shouldReceive('canLogin')->once()->andReturn(false); + + $provider_mock = Mockery::mock(CustomAuthProvider::class); + $provider_mock->shouldReceive('retrieveByCredentials') + ->once() + ->with(['username' => $username, 'password' => $password]) + ->andReturn($locked_user); + + $this->auth_mock->shouldReceive('getProvider')->once()->andReturn($provider_mock); + $this->auth_mock->shouldNotReceive('login'); + + $this->expectException(AuthenticationException::class); + + $this->service->validateCredentials($username, $password); + } + } From c72c209114ceed5ca651f00ef4da738e57d31713 Mon Sep 17 00:00:00 2001 From: matiasperrone-exo Date: Thu, 21 May 2026 20:02:27 +0000 Subject: [PATCH 15/26] chore: Fix issues created on rebase --- app/libs/Auth/AuthService.php | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/app/libs/Auth/AuthService.php b/app/libs/Auth/AuthService.php index 4971929e..ae71663d 100644 --- a/app/libs/Auth/AuthService.php +++ b/app/libs/Auth/AuthService.php @@ -99,7 +99,6 @@ final class AuthService extends AbstractService implements IAuthService * @param IAuthUserService $auth_user_service * @param ISecurityContextService $security_context_service * @param ITransactionService $tx_service - * @params ISecurityContextService $security_context_service */ public function __construct ( @@ -137,10 +136,7 @@ public function isUserLogged() */ public function getCurrentUser(): ?User { - $user = Auth::user(); - if ($user instanceof User) { - return $user; - } + return Auth::user(); } /** @@ -435,13 +431,13 @@ public function validateCredentials(string $username, string $password): User * @var User|null $user */ $user = Auth::getProvider()->retrieveByCredentials(['username' => $username, 'password' => $password]); - if (!$user instanceof User || !$user->canLogin()) { - throw new AuthenticationException("We are sorry, your username or password does not match an existing record."); - } } catch (UnverifiedEmailMemberException $ex) { throw new AuthenticationException($ex->getMessage()); } + if (is_null($user) || !$user instanceof User || !$user->canLogin()) { + throw new AuthenticationException("We are sorry, your username or password does not match an existing record."); + } return $user; } From 5ad6e11a18fc866b46f538567fc2eb0383601954 Mon Sep 17 00:00:00 2001 From: matiasperrone-exo Date: Mon, 4 May 2026 21:23:26 +0000 Subject: [PATCH 16/26] feat: Implement Multi-Factor Authentication challenge strategies and tests --- .../MFA/AbstractMFAChallengeStrategy.php | 65 +++++++ .../MFA/EmailOTPMFAChallengeStrategy.php | 72 ++++++++ app/Strategies/MFA/IMFAChallengeStrategy.php | 14 ++ .../MFA/MFAChallengeStrategyFactory.php | 12 ++ phpunit.xml | 3 + .../MFA/AbstractMFAChallengeStrategyTest.php | 143 +++++++++++++++ .../MFA/EmailOTPMFAChallengeStrategyTest.php | 167 ++++++++++++++++++ .../MFA/MFAChallengeStrategyFactoryTest.php | 23 +++ 8 files changed, 499 insertions(+) create mode 100644 app/Strategies/MFA/AbstractMFAChallengeStrategy.php create mode 100644 app/Strategies/MFA/EmailOTPMFAChallengeStrategy.php create mode 100644 app/Strategies/MFA/IMFAChallengeStrategy.php create mode 100644 app/Strategies/MFA/MFAChallengeStrategyFactory.php create mode 100644 tests/Unit/MFA/AbstractMFAChallengeStrategyTest.php create mode 100644 tests/Unit/MFA/EmailOTPMFAChallengeStrategyTest.php create mode 100644 tests/Unit/MFA/MFAChallengeStrategyFactoryTest.php diff --git a/app/Strategies/MFA/AbstractMFAChallengeStrategy.php b/app/Strategies/MFA/AbstractMFAChallengeStrategy.php new file mode 100644 index 00000000..6944ff66 --- /dev/null +++ b/app/Strategies/MFA/AbstractMFAChallengeStrategy.php @@ -0,0 +1,65 @@ + self::SESSION_TTL) { + $this->clearPendingState(); + return null; + } + + return [ + 'user_id' => $user_id, + 'pending_at' => $pending_at, + 'remember' => Session::get(self::KEY_REMEMBER, false), + ]; + } + + public function clearPendingState(): void + { + Session::remove(self::KEY_USER_ID); + Session::remove(self::KEY_PENDING_AT); + Session::remove(self::KEY_REMEMBER); + Session::remove(self::KEY_RECOVERY_ATTEMPTS); + } + + public function verifyRecoveryCode(User $user, string $code): void + { + foreach ($this->recovery_code_repository->getUnusedByUser($user) as $recoveryCode) { + if (Hash::check($code, $recoveryCode->getCodeHash())) { + $recoveryCode->markUsed(); + return; + } + } + throw new AuthenticationException("Invalid recovery code."); + } + + protected function storePendingState(int $userId, bool $remember): void + { + Session::put(self::KEY_USER_ID, $userId); + Session::put(self::KEY_PENDING_AT, time()); + Session::put(self::KEY_REMEMBER, $remember); + } +} diff --git a/app/Strategies/MFA/EmailOTPMFAChallengeStrategy.php b/app/Strategies/MFA/EmailOTPMFAChallengeStrategy.php new file mode 100644 index 00000000..66f9a68b --- /dev/null +++ b/app/Strategies/MFA/EmailOTPMFAChallengeStrategy.php @@ -0,0 +1,72 @@ +storePendingState($user->getId(), $remember); + + $otp = $this->token_service->createOTPFromPayload([ + OAuth2Protocol::OAuth2PasswordlessConnection => OAuth2Protocol::OAuth2PasswordlessConnectionEmail, + OAuth2Protocol::OAuth2PasswordlessSend => OAuth2Protocol::OAuth2PasswordlessSendCode, + OAuth2Protocol::OAuth2PasswordlessEmail => $user->getEmail(), + ], $client); + + return [ + 'otp_length' => $otp->getLength(), + 'otp_lifetime' => $otp->getLifetime(), + ]; + } + + public function verifyChallenge(User $user, string $code): void + { + $otp = $this->otp_repository->getByValueConnectionAndUserName( + $code, + OAuth2Protocol::OAuth2PasswordlessConnectionEmail, + $user->getEmail() + ); + + if (is_null($otp)) { + throw new AuthenticationException("Non existent single-use code."); + } + + $otp->logRedeemAttempt(); + + if (!$otp->isAlive()) { + throw new AuthenticationException("Verification code is expired."); + } + + if (!$otp->isValid()) { + throw new AuthenticationException("Verification code is not valid."); + } + + $otp->redeem(); + + foreach ($this->otp_repository->getByUserNameNotRedeemed($user->getEmail()) as $otpToRevoke) { + if ($otpToRevoke->getValue() !== $otp->getValue()) { + $otpToRevoke->redeem(); + } + } + } + + public function resendChallenge(User $user, ?Client $client, bool $remember): array + { + return $this->issueChallenge($user, $client, $remember); + } +} diff --git a/app/Strategies/MFA/IMFAChallengeStrategy.php b/app/Strategies/MFA/IMFAChallengeStrategy.php new file mode 100644 index 00000000..9f59e6ef --- /dev/null +++ b/app/Strategies/MFA/IMFAChallengeStrategy.php @@ -0,0 +1,14 @@ + app()->make(EmailOTPMFAChallengeStrategy::class), + default => throw new \InvalidArgumentException("Unknown MFA method: {$method}"), + }; + } +} diff --git a/phpunit.xml b/phpunit.xml index 4750f428..a653e0ce 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -25,6 +25,9 @@ ./tests/TwoFactorRepositoriesTest.php ./tests/unit/UserTwoFactorTest.php + ./tests/Unit/MFA/AbstractMFAChallengeStrategyTest.php + ./tests/Unit/MFA/EmailOTPMFAChallengeStrategyTest.php + ./tests/Unit/MFA/MFAChallengeStrategyFactoryTest.php diff --git a/tests/Unit/MFA/AbstractMFAChallengeStrategyTest.php b/tests/Unit/MFA/AbstractMFAChallengeStrategyTest.php new file mode 100644 index 00000000..64c6d6f1 --- /dev/null +++ b/tests/Unit/MFA/AbstractMFAChallengeStrategyTest.php @@ -0,0 +1,143 @@ +strategy = new class($repo) extends AbstractMFAChallengeStrategy { + public function issueChallenge(User $user, ?Client $client, bool $remember): array { return []; } + public function verifyChallenge(User $user, string $code): void {} + public function resendChallenge(User $user, ?Client $client, bool $remember): array { return []; } + public function exposeStorePendingState(int $userId, bool $remember): void { + $this->storePendingState($userId, $remember); + } + }; + } + + protected function tearDown(): void + { + \Mockery::close(); + parent::tearDown(); + } + + public function testGetPendingState_withValidSession_returnsState(): void + { + $this->strategy->exposeStorePendingState(42, true); + + $state = $this->strategy->getPendingState(); + + $this->assertNotNull($state); + $this->assertSame(42, $state['user_id']); + $this->assertTrue($state['remember']); + $this->assertArrayHasKey('pending_at', $state); + } + + public function testGetPendingState_withExpiredSession_returnsNull(): void + { + Session::put('2fa_pending_user_id', 99); + Session::put('2fa_pending_at', time() - 301); + Session::put('2fa_remember', false); + + $state = $this->strategy->getPendingState(); + + $this->assertNull($state); + $this->assertNull(Session::get('2fa_pending_user_id')); + } + + public function testGetPendingState_withMissingSession_returnsNull(): void + { + $state = $this->strategy->getPendingState(); + + $this->assertNull($state); + } + + public function testClearPendingState_removesAllSessionKeys(): void + { + Session::put('2fa_pending_user_id', 7); + Session::put('2fa_pending_at', time()); + Session::put('2fa_remember', true); + Session::put('2fa_recovery_attempts', 1); + + $this->strategy->clearPendingState(); + + $this->assertNull(Session::get('2fa_pending_user_id')); + $this->assertNull(Session::get('2fa_pending_at')); + $this->assertNull(Session::get('2fa_remember')); + $this->assertNull(Session::get('2fa_recovery_attempts')); + } + + public function testVerifyRecoveryCode_withMatchingCode_marksAsUsed(): void + { + $user = new User(); + $code = 'VALID-CODE'; + + $recoveryCode = \Mockery::mock(\App\libs\Auth\Models\UserRecoveryCode::class); + $recoveryCode->shouldReceive('getCodeHash')->andReturn(Hash::make($code)); + $recoveryCode->shouldReceive('markUsed')->once(); + + $repo = \Mockery::mock(IUserRecoveryCodeRepository::class); + $repo->shouldReceive('getUnusedByUser')->with($user)->andReturn([$recoveryCode]); + + $strategy = new class($repo) extends AbstractMFAChallengeStrategy { + public function issueChallenge(User $user, ?Client $client, bool $remember): array { return []; } + public function verifyChallenge(User $user, string $code): void {} + public function resendChallenge(User $user, ?Client $client, bool $remember): array { return []; } + }; + + $strategy->verifyRecoveryCode($user, $code); + $this->addToAssertionCount(1); // markUsed()->once() verified by Mockery in tearDown + } + + public function testVerifyRecoveryCode_withNonMatchingCode_throwsException(): void + { + $user = new User(); + + $recoveryCode = \Mockery::mock(\App\libs\Auth\Models\UserRecoveryCode::class); + $recoveryCode->shouldReceive('getCodeHash')->andReturn(Hash::make('CORRECT-CODE')); + $recoveryCode->shouldNotReceive('markUsed'); + + $repo = \Mockery::mock(IUserRecoveryCodeRepository::class); + $repo->shouldReceive('getUnusedByUser')->andReturn([$recoveryCode]); + + $strategy = new class($repo) extends AbstractMFAChallengeStrategy { + public function issueChallenge(User $user, ?Client $client, bool $remember): array { return []; } + public function verifyChallenge(User $user, string $code): void {} + public function resendChallenge(User $user, ?Client $client, bool $remember): array { return []; } + }; + + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage("Invalid recovery code."); + $strategy->verifyRecoveryCode($user, 'WRONG-CODE'); + } + + public function testVerifyRecoveryCode_withAllCodesUsed_throwsException(): void + { + $user = new User(); + + $repo = \Mockery::mock(IUserRecoveryCodeRepository::class); + $repo->shouldReceive('getUnusedByUser')->andReturn([]); + + $strategy = new class($repo) extends AbstractMFAChallengeStrategy { + public function issueChallenge(User $user, ?Client $client, bool $remember): array { return []; } + public function verifyChallenge(User $user, string $code): void {} + public function resendChallenge(User $user, ?Client $client, bool $remember): array { return []; } + }; + + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage("Invalid recovery code."); + $strategy->verifyRecoveryCode($user, 'ANY-CODE'); + } +} diff --git a/tests/Unit/MFA/EmailOTPMFAChallengeStrategyTest.php b/tests/Unit/MFA/EmailOTPMFAChallengeStrategyTest.php new file mode 100644 index 00000000..cb12c3ab --- /dev/null +++ b/tests/Unit/MFA/EmailOTPMFAChallengeStrategyTest.php @@ -0,0 +1,167 @@ +tokenService = \Mockery::mock(ITokenService::class); + $this->otpRepository = \Mockery::mock(IOAuth2OTPRepository::class); + $recoveryRepo = \Mockery::mock(IUserRecoveryCodeRepository::class); + + $this->strategy = new EmailOTPMFAChallengeStrategy( + $recoveryRepo, + $this->tokenService, + $this->otpRepository, + ); + } + + protected function tearDown(): void + { + \Mockery::close(); + parent::tearDown(); + } + + private function buildUser(int $id, string $email): User + { + $user = \Mockery::mock(User::class); + $user->shouldReceive('getId')->andReturn($id); + $user->shouldReceive('getEmail')->andReturn($email); + return $user; + } + + // ---------- issueChallenge ---------- + + public function testIssueChallenge_storesPendingStateAndReturnsOtpInfo(): void + { + $user = $this->buildUser(42, 'user@example.com'); + + $otp = \Mockery::mock(OAuth2OTP::class); + $otp->shouldReceive('getLength')->andReturn(6); + $otp->shouldReceive('getLifetime')->andReturn(120); + + $this->tokenService + ->shouldReceive('createOTPFromPayload') + ->once() + ->withArgs(function (array $payload, $client) { + return $payload['connection'] === 'email' + && $payload['send'] === 'code' + && $payload['email'] === 'user@example.com' + && is_null($client); + }) + ->andReturn($otp); + + $result = $this->strategy->issueChallenge($user, null, true); + + $this->assertSame(['otp_length' => 6, 'otp_lifetime' => 120], $result); + $this->assertSame(42, Session::get('2fa_pending_user_id')); + $this->assertTrue(Session::get('2fa_remember')); + } + + // ---------- resendChallenge ---------- + + public function testResendChallenge_delegatesToIssueChallenge(): void + { + $user = $this->buildUser(7, 'resend@example.com'); + + $otp = \Mockery::mock(OAuth2OTP::class); + $otp->shouldReceive('getLength')->andReturn(6); + $otp->shouldReceive('getLifetime')->andReturn(120); + + $this->tokenService + ->shouldReceive('createOTPFromPayload') + ->once() + ->andReturn($otp); + + $result = $this->strategy->resendChallenge($user, null, false); + + $this->assertSame(['otp_length' => 6, 'otp_lifetime' => 120], $result); + $this->assertSame(7, Session::get('2fa_pending_user_id')); + } + + // ---------- verifyChallenge ---------- + + public function testVerifyChallenge_withValidOtp_redeemsAndRevokesOthers(): void + { + $user = $this->buildUser(1, 'verify@example.com'); + $code = '123456'; + + $otp = \Mockery::mock(OAuth2OTP::class); + $otp->shouldReceive('logRedeemAttempt')->once(); + $otp->shouldReceive('isAlive')->andReturn(true); + $otp->shouldReceive('isValid')->andReturn(true); + $otp->shouldReceive('redeem')->once(); + $otp->shouldReceive('getValue')->andReturn($code); + + $otherOtp = \Mockery::mock(OAuth2OTP::class); + $otherOtp->shouldReceive('getValue')->andReturn('654321'); + $otherOtp->shouldReceive('redeem')->once(); + + $this->otpRepository + ->shouldReceive('getByValueConnectionAndUserName') + ->andReturn($otp); + + $this->otpRepository + ->shouldReceive('getByUserNameNotRedeemed') + ->andReturn([$otp, $otherOtp]); + + $this->strategy->verifyChallenge($user, $code); + $this->addToAssertionCount(1); + } + + public function testVerifyChallenge_withExpiredOtp_throwsException(): void + { + $user = $this->buildUser(2, 'expired@example.com'); + + $otp = \Mockery::mock(OAuth2OTP::class); + $otp->shouldReceive('logRedeemAttempt')->once(); + $otp->shouldReceive('isAlive')->andReturn(false); + + $this->otpRepository->shouldReceive('getByValueConnectionAndUserName')->andReturn($otp); + + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage("Verification code is expired."); + $this->strategy->verifyChallenge($user, '000000'); + } + + public function testVerifyChallenge_withMaxAttemptsExceeded_throwsException(): void + { + $user = $this->buildUser(3, 'maxattempts@example.com'); + + $otp = \Mockery::mock(OAuth2OTP::class); + $otp->shouldReceive('logRedeemAttempt')->once(); + $otp->shouldReceive('isAlive')->andReturn(true); + $otp->shouldReceive('isValid')->andReturn(false); + + $this->otpRepository->shouldReceive('getByValueConnectionAndUserName')->andReturn($otp); + + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage("Verification code is not valid."); + $this->strategy->verifyChallenge($user, '111111'); + } + + public function testVerifyChallenge_withNonExistentOtp_throwsException(): void + { + $user = $this->buildUser(4, 'noexist@example.com'); + + $this->otpRepository->shouldReceive('getByValueConnectionAndUserName')->andReturn(null); + + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage("Non existent single-use code."); + $this->strategy->verifyChallenge($user, 'BADCODE'); + } +} diff --git a/tests/Unit/MFA/MFAChallengeStrategyFactoryTest.php b/tests/Unit/MFA/MFAChallengeStrategyFactoryTest.php new file mode 100644 index 00000000..d51cf1cc --- /dev/null +++ b/tests/Unit/MFA/MFAChallengeStrategyFactoryTest.php @@ -0,0 +1,23 @@ +assertInstanceOf(EmailOTPMFAChallengeStrategy::class, $strategy); + } + + public function testCreate_withUnknownMethod_throwsInvalidArgumentException(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage("Unknown MFA method: sms_otp"); + + MFAChallengeStrategyFactory::create('sms_otp'); + } +} From 43c31bbca5cde1cf8b27a1e6ff904357ff5fffe0 Mon Sep 17 00:00:00 2001 From: matiasperrone-exo Date: Tue, 19 May 2026 20:48:47 +0000 Subject: [PATCH 17/26] chore: Add PR's requested changes --- .../MFA/AbstractMFAChallengeStrategy.php | 10 ++++ .../MFA/EmailOTPMFAChallengeStrategy.php | 9 +-- app/Strategies/MFA/IMFAChallengeStrategy.php | 2 +- .../MFA/AbstractMFAChallengeStrategyTest.php | 8 +-- .../MFA/EmailOTPMFAChallengeStrategyTest.php | 56 +------------------ 5 files changed, 19 insertions(+), 66 deletions(-) diff --git a/app/Strategies/MFA/AbstractMFAChallengeStrategy.php b/app/Strategies/MFA/AbstractMFAChallengeStrategy.php index 6944ff66..172ab7e6 100644 --- a/app/Strategies/MFA/AbstractMFAChallengeStrategy.php +++ b/app/Strategies/MFA/AbstractMFAChallengeStrategy.php @@ -5,6 +5,7 @@ use Auth\User; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Session; +use Models\OAuth2\Client; abstract class AbstractMFAChallengeStrategy implements IMFAChallengeStrategy { @@ -62,4 +63,13 @@ protected function storePendingState(int $userId, bool $remember): void Session::put(self::KEY_PENDING_AT, time()); Session::put(self::KEY_REMEMBER, $remember); } + + public function verifyChallenge(User $user, string $code, ?Client $client = null): void + { + } + + public function issueChallenge(User $user, ?Client $client, bool $remember): array + { + return []; + } } diff --git a/app/Strategies/MFA/EmailOTPMFAChallengeStrategy.php b/app/Strategies/MFA/EmailOTPMFAChallengeStrategy.php index 66f9a68b..56a35da2 100644 --- a/app/Strategies/MFA/EmailOTPMFAChallengeStrategy.php +++ b/app/Strategies/MFA/EmailOTPMFAChallengeStrategy.php @@ -5,6 +5,7 @@ use Auth\Repositories\IUserRecoveryCodeRepository; use Auth\User; use Models\OAuth2\Client; +use Models\OAuth2\OAuth2OTP; use OAuth2\OAuth2Protocol; use OAuth2\Services\ITokenService; @@ -34,13 +35,9 @@ public function issueChallenge(User $user, ?Client $client, bool $remember): arr ]; } - public function verifyChallenge(User $user, string $code): void + public function verifyChallenge(User $user, string $code, ?Client $client = null): void { - $otp = $this->otp_repository->getByValueConnectionAndUserName( - $code, - OAuth2Protocol::OAuth2PasswordlessConnectionEmail, - $user->getEmail() - ); + $otp = OAuth2OTP::fromParams($user->getEmail(), OAuth2Protocol::OAuth2PasswordlessConnectionEmail, $code); if (is_null($otp)) { throw new AuthenticationException("Non existent single-use code."); diff --git a/app/Strategies/MFA/IMFAChallengeStrategy.php b/app/Strategies/MFA/IMFAChallengeStrategy.php index 9f59e6ef..c395551d 100644 --- a/app/Strategies/MFA/IMFAChallengeStrategy.php +++ b/app/Strategies/MFA/IMFAChallengeStrategy.php @@ -6,7 +6,7 @@ interface IMFAChallengeStrategy { public function issueChallenge(User $user, ?Client $client, bool $remember): array; - public function verifyChallenge(User $user, string $code): void; + public function verifyChallenge(User $user, string $code, ?Client $client = null): void; public function resendChallenge(User $user, ?Client $client, bool $remember): array; public function getPendingState(): ?array; public function clearPendingState(): void; diff --git a/tests/Unit/MFA/AbstractMFAChallengeStrategyTest.php b/tests/Unit/MFA/AbstractMFAChallengeStrategyTest.php index 64c6d6f1..743cf6f5 100644 --- a/tests/Unit/MFA/AbstractMFAChallengeStrategyTest.php +++ b/tests/Unit/MFA/AbstractMFAChallengeStrategyTest.php @@ -19,7 +19,7 @@ protected function setUp(): void $repo = \Mockery::mock(IUserRecoveryCodeRepository::class); $this->strategy = new class($repo) extends AbstractMFAChallengeStrategy { public function issueChallenge(User $user, ?Client $client, bool $remember): array { return []; } - public function verifyChallenge(User $user, string $code): void {} + public function verifyChallenge(User $user, string $code, ?Client $client = null): void {} public function resendChallenge(User $user, ?Client $client, bool $remember): array { return []; } public function exposeStorePendingState(int $userId, bool $remember): void { $this->storePendingState($userId, $remember); @@ -93,7 +93,7 @@ public function testVerifyRecoveryCode_withMatchingCode_marksAsUsed(): void $strategy = new class($repo) extends AbstractMFAChallengeStrategy { public function issueChallenge(User $user, ?Client $client, bool $remember): array { return []; } - public function verifyChallenge(User $user, string $code): void {} + public function verifyChallenge(User $user, string $code, ?Client $client = null): void {} public function resendChallenge(User $user, ?Client $client, bool $remember): array { return []; } }; @@ -114,7 +114,7 @@ public function testVerifyRecoveryCode_withNonMatchingCode_throwsException(): vo $strategy = new class($repo) extends AbstractMFAChallengeStrategy { public function issueChallenge(User $user, ?Client $client, bool $remember): array { return []; } - public function verifyChallenge(User $user, string $code): void {} + public function verifyChallenge(User $user, string $code, ?Client $client = null): void {} public function resendChallenge(User $user, ?Client $client, bool $remember): array { return []; } }; @@ -132,7 +132,7 @@ public function testVerifyRecoveryCode_withAllCodesUsed_throwsException(): void $strategy = new class($repo) extends AbstractMFAChallengeStrategy { public function issueChallenge(User $user, ?Client $client, bool $remember): array { return []; } - public function verifyChallenge(User $user, string $code): void {} + public function verifyChallenge(User $user, string $code, ?Client $client = null): void {} public function resendChallenge(User $user, ?Client $client, bool $remember): array { return []; } }; diff --git a/tests/Unit/MFA/EmailOTPMFAChallengeStrategyTest.php b/tests/Unit/MFA/EmailOTPMFAChallengeStrategyTest.php index cb12c3ab..f2c56a7b 100644 --- a/tests/Unit/MFA/EmailOTPMFAChallengeStrategyTest.php +++ b/tests/Unit/MFA/EmailOTPMFAChallengeStrategyTest.php @@ -1,7 +1,6 @@ buildUser(1, 'verify@example.com'); $code = '123456'; - $otp = \Mockery::mock(OAuth2OTP::class); - $otp->shouldReceive('logRedeemAttempt')->once(); - $otp->shouldReceive('isAlive')->andReturn(true); - $otp->shouldReceive('isValid')->andReturn(true); - $otp->shouldReceive('redeem')->once(); - $otp->shouldReceive('getValue')->andReturn($code); - $otherOtp = \Mockery::mock(OAuth2OTP::class); $otherOtp->shouldReceive('getValue')->andReturn('654321'); $otherOtp->shouldReceive('redeem')->once(); - $this->otpRepository - ->shouldReceive('getByValueConnectionAndUserName') - ->andReturn($otp); - $this->otpRepository ->shouldReceive('getByUserNameNotRedeemed') - ->andReturn([$otp, $otherOtp]); + ->andReturn([$otherOtp]); $this->strategy->verifyChallenge($user, $code); $this->addToAssertionCount(1); } - - public function testVerifyChallenge_withExpiredOtp_throwsException(): void - { - $user = $this->buildUser(2, 'expired@example.com'); - - $otp = \Mockery::mock(OAuth2OTP::class); - $otp->shouldReceive('logRedeemAttempt')->once(); - $otp->shouldReceive('isAlive')->andReturn(false); - - $this->otpRepository->shouldReceive('getByValueConnectionAndUserName')->andReturn($otp); - - $this->expectException(AuthenticationException::class); - $this->expectExceptionMessage("Verification code is expired."); - $this->strategy->verifyChallenge($user, '000000'); - } - - public function testVerifyChallenge_withMaxAttemptsExceeded_throwsException(): void - { - $user = $this->buildUser(3, 'maxattempts@example.com'); - - $otp = \Mockery::mock(OAuth2OTP::class); - $otp->shouldReceive('logRedeemAttempt')->once(); - $otp->shouldReceive('isAlive')->andReturn(true); - $otp->shouldReceive('isValid')->andReturn(false); - - $this->otpRepository->shouldReceive('getByValueConnectionAndUserName')->andReturn($otp); - - $this->expectException(AuthenticationException::class); - $this->expectExceptionMessage("Verification code is not valid."); - $this->strategy->verifyChallenge($user, '111111'); - } - - public function testVerifyChallenge_withNonExistentOtp_throwsException(): void - { - $user = $this->buildUser(4, 'noexist@example.com'); - - $this->otpRepository->shouldReceive('getByValueConnectionAndUserName')->andReturn(null); - - $this->expectException(AuthenticationException::class); - $this->expectExceptionMessage("Non existent single-use code."); - $this->strategy->verifyChallenge($user, 'BADCODE'); - } } From 39a7e4a2b2e0f523a28c181044cb01d8ca472576 Mon Sep 17 00:00:00 2001 From: matiasperrone-exo Date: Thu, 21 May 2026 22:05:44 +0000 Subject: [PATCH 18/26] feat: Add Device Trust Service --- .../DoctrineUserTrustedDeviceRepository.php | 24 ++ app/Services/Auth/DeviceTrustService.php | 85 +++++ app/Services/Auth/IDeviceTrustService.php | 45 +++ .../Auth/TwoFactorServiceProvider.php | 41 +++ app/libs/Auth/Models/UserTrustedDevice.php | 9 +- .../IUserTrustedDeviceRepository.php | 12 +- config/app.php | 1 + config/two_factor.php | 8 + tests/DeviceTrustServiceTest.php | 290 ++++++++++++++++++ 9 files changed, 513 insertions(+), 2 deletions(-) create mode 100644 app/Services/Auth/DeviceTrustService.php create mode 100644 app/Services/Auth/IDeviceTrustService.php create mode 100644 app/Services/Auth/TwoFactorServiceProvider.php create mode 100644 tests/DeviceTrustServiceTest.php diff --git a/app/Repositories/DoctrineUserTrustedDeviceRepository.php b/app/Repositories/DoctrineUserTrustedDeviceRepository.php index 29bd894a..37267066 100644 --- a/app/Repositories/DoctrineUserTrustedDeviceRepository.php +++ b/app/Repositories/DoctrineUserTrustedDeviceRepository.php @@ -31,6 +31,30 @@ private function buildActiveExpiryExpr(): Comparison return Criteria::expr()->gt('expires_at', $now); } + public function getByUserAndDeviceIdentifier(User $user, string $deviceIdentifier): ?UserTrustedDevice + { + $criteria = Criteria::create() + ->where(Criteria::expr()->eq('user', $user)) + ->andWhere(Criteria::expr()->eq('device_identifier', $deviceIdentifier)) + ->setMaxResults(1); + + $result = $this->matching($criteria)->first(); + return $result instanceof UserTrustedDevice ? $result : null; + } + + public function revokeAllForUser(User $user): void + { + $this->getEntityManager() + ->createQueryBuilder() + ->update($this->getBaseEntity(), 'd') + ->set('d.is_revoked', ':revoked') + ->where('d.user = :user') + ->setParameter('revoked', true) + ->setParameter('user', $user) + ->getQuery() + ->execute(); + } + public function getActiveByUserAndIdentifier(User $user, string $deviceIdentifier): ?UserTrustedDevice { $criteria = Criteria::create() diff --git a/app/Services/Auth/DeviceTrustService.php b/app/Services/Auth/DeviceTrustService.php new file mode 100644 index 00000000..e6f317b6 --- /dev/null +++ b/app/Services/Auth/DeviceTrustService.php @@ -0,0 +1,85 @@ +add(new DateInterval("P{$lifetimeDays}D")); + + $device = new UserTrustedDevice(); + $device->setUser($user); + $device->setDeviceIdentifier($this->generateDeviceIdentifier($rawToken)); + $device->setDeviceName(substr($userAgent, 0, 255)); + $device->setIpAddress($ipAddress); + $device->setUserAgent($userAgent); + $device->setTrustedAt($now); + $device->setExpiresAt($expiresAt); + $device->setLastSeenAt(clone $now); + $device->setIsRevoked(false); + + $this->repository->add($device, true); + + return $rawToken; + } + + public function isDeviceTrusted(User $user, ?string $cookieToken): bool + { + if (empty($cookieToken)) { + return false; + } + + $identifier = $this->generateDeviceIdentifier($cookieToken); + $device = $this->repository->getByUserAndDeviceIdentifier($user, $identifier); + + if (!$device instanceof UserTrustedDevice || $device->isRevoked() || $device->isExpired()) { + return false; + } + + $device->setLastSeenAt(new DateTime('now', new DateTimeZone('UTC'))); + $this->repository->add($device, true); + return true; + } + + public function removeTrustedDevices(User $user): void + { + $this->repository->revokeAllForUser($user); + } +} diff --git a/app/Services/Auth/IDeviceTrustService.php b/app/Services/Auth/IDeviceTrustService.php new file mode 100644 index 00000000..2750335c --- /dev/null +++ b/app/Services/Auth/IDeviceTrustService.php @@ -0,0 +1,45 @@ +app->singleton(IDeviceTrustService::class, DeviceTrustService::class); + } + + public function provides(): array + { + return [ + IDeviceTrustService::class, + ]; + } +} diff --git a/app/libs/Auth/Models/UserTrustedDevice.php b/app/libs/Auth/Models/UserTrustedDevice.php index 3e2b96b5..16b836a8 100644 --- a/app/libs/Auth/Models/UserTrustedDevice.php +++ b/app/libs/Auth/Models/UserTrustedDevice.php @@ -24,7 +24,7 @@ class UserTrustedDevice extends BaseEntity { #[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'CASCADE')] - #[ORM\ManyToOne(targetEntity: \Auth\User::class)] + #[ORM\ManyToOne(targetEntity: User::class)] private $user; #[ORM\Column(name: 'device_identifier', type: 'string', length: 255)] @@ -54,6 +54,7 @@ class UserTrustedDevice extends BaseEntity public function __construct() { parent::__construct(); + $this->last_seen_at = new \DateTime('now', new \DateTimeZone('UTC')); $this->is_revoked = false; } @@ -137,4 +138,10 @@ public function setIsRevoked(bool $value): void { $this->is_revoked = $value; } + + public function isExpired(): bool + { + $now = new \DateTime('now', new \DateTimeZone('UTC')); + return $this->expires_at < $now; + } } \ No newline at end of file diff --git a/app/libs/Auth/Repositories/IUserTrustedDeviceRepository.php b/app/libs/Auth/Repositories/IUserTrustedDeviceRepository.php index f369c051..04e86edf 100644 --- a/app/libs/Auth/Repositories/IUserTrustedDeviceRepository.php +++ b/app/libs/Auth/Repositories/IUserTrustedDeviceRepository.php @@ -18,7 +18,17 @@ interface IUserTrustedDeviceRepository extends IBaseRepository { /** - * Look up an active (non-revoked) trusted device for a user by its hashed identifier. + * Look up a trusted device record by user and hashed identifier (no revoked/expiry filter). + */ + public function getByUserAndDeviceIdentifier(User $user, string $deviceIdentifier): ?UserTrustedDevice; + + /** + * Revoke all trusted devices for the given user (sets is_revoked = true). + */ + public function revokeAllForUser(User $user): void; + + /** + * Look up an active (non-revoked, non-expired) trusted device for a user by its hashed identifier. */ public function getActiveByUserAndIdentifier(User $user, string $deviceIdentifier): ?UserTrustedDevice; diff --git a/config/app.php b/config/app.php index 32cd9d96..731d8fd7 100644 --- a/config/app.php +++ b/config/app.php @@ -152,6 +152,7 @@ Services\OpenId\OpenIdProvider::class, Auth\AuthenticationServiceProvider::class, Services\ServicesProvider::class, + App\Services\Auth\TwoFactorServiceProvider::class, Strategies\StrategyProvider::class, OAuth2\OAuth2ServiceProvider::class, OpenId\OpenIdServiceProvider::class, diff --git a/config/two_factor.php b/config/two_factor.php index dd876a6f..8a399806 100644 --- a/config/two_factor.php +++ b/config/two_factor.php @@ -30,4 +30,12 @@ IGroupSlugs::OAuth2ServerAdminGroup, IGroupSlugs::OpenIdServerAdminsGroup, ], + + /* + |-------------------------------------------------------------------------- + | Device Trust + |-------------------------------------------------------------------------- + */ + 'device_trust_lifetime_days' => env('DEVICE_TRUST_LIFETIME_DAYS', 30), + 'cookie_name' => env('DEVICE_TRUST_COOKIE_NAME', 'device_trust_token'), ]; diff --git a/tests/DeviceTrustServiceTest.php b/tests/DeviceTrustServiceTest.php new file mode 100644 index 00000000..5eb2bdfe --- /dev/null +++ b/tests/DeviceTrustServiceTest.php @@ -0,0 +1,290 @@ +repo = Mockery::mock(IUserTrustedDeviceRepository::class); + $this->service = new DeviceTrustService($this->repo); + } + + public function tearDown(): void + { + parent::tearDown(); + Mockery::close(); + } + + // ------------------------------------------------------------------------- + // isDeviceTrusted + // ------------------------------------------------------------------------- + + public function testIsDeviceTrustedNullCookie(): void + { + $user = Mockery::mock(User::class); + $this->repo->shouldNotReceive('getByUserAndDeviceIdentifier'); + + $this->assertFalse($this->service->isDeviceTrusted($user, null)); + } + + public function testIsDeviceTrustedEmptyCookie(): void + { + $user = Mockery::mock(User::class); + $this->repo->shouldNotReceive('getByUserAndDeviceIdentifier'); + + $this->assertFalse($this->service->isDeviceTrusted($user, '')); + } + + public function testIsDeviceTrustedWrongCookie(): void + { + $user = Mockery::mock(User::class); + $this->repo + ->shouldReceive('getByUserAndDeviceIdentifier') + ->once() + ->andReturn(null); + + $this->assertFalse($this->service->isDeviceTrusted($user, 'unknowntoken')); + } + + public function testIsDeviceTrustedRevokedDevice(): void + { + $user = Mockery::mock(User::class); + + $device = $this->makeDevice(expired: false, revoked: true); + + $this->repo + ->shouldReceive('getByUserAndDeviceIdentifier') + ->once() + ->andReturn($device); + + $this->assertFalse($this->service->isDeviceTrusted($user, 'sometoken')); + } + + public function testIsDeviceTrustedExpiredDevice(): void + { + $user = Mockery::mock(User::class); + + $device = $this->makeDevice(expired: true, revoked: false); + + $this->repo + ->shouldReceive('getByUserAndDeviceIdentifier') + ->once() + ->andReturn($device); + + $this->assertFalse($this->service->isDeviceTrusted($user, 'sometoken')); + } + + public function testIsDeviceTrustedValidDevice(): void + { + $user = Mockery::mock(User::class); + + $device = $this->makeDevice(expired: false, revoked: false); + + $this->repo + ->shouldReceive('getByUserAndDeviceIdentifier') + ->once() + ->andReturn($device); + $this->repo->shouldReceive('add')->once(); + + $this->assertTrue($this->service->isDeviceTrusted($user, 'sometoken')); + } + + public function testIsDeviceTrustedUpdatesLastSeenAt(): void + { + $user = Mockery::mock(User::class); + + $device = $this->makeDevice(expired: false, revoked: false); + // set last_seen_at to a known old value so the update is detectable + $oldDate = new DateTime('2000-01-01', new DateTimeZone('UTC')); + $device->setLastSeenAt($oldDate); + + $this->repo + ->shouldReceive('getByUserAndDeviceIdentifier') + ->once() + ->andReturn($device); + $this->repo->shouldReceive('add')->once(); + + $this->service->isDeviceTrusted($user, 'sometoken'); + + $this->assertNotNull($device); + $this->assertGreaterThan($oldDate, $device->getLastSeenAt()); + } + + // ------------------------------------------------------------------------- + // trustDevice + // ------------------------------------------------------------------------- + + public function testTrustDeviceReturnsToken(): void + { + $user = Mockery::mock(User::class); + + $this->repo->shouldReceive('add')->once(); + + $token = $this->service->trustDevice($user, 'Mozilla/5.0', '127.0.0.1'); + + $this->assertSame(128, strlen($token)); + $this->assertMatchesRegularExpression('/^[0-9a-f]{128}$/', $token); + } + + public function testTrustDeviceStoresHash(): void + { + $user = Mockery::mock(User::class); + + /** @var UserTrustedDevice|null $persistedDevice */ + $persistedDevice = null; + + $this->repo + ->shouldReceive('add') + ->once() + ->withArgs(function ($device) use (&$persistedDevice) { + $persistedDevice = $device; + return true; + }); + + $rawToken = $this->service->trustDevice($user, 'Mozilla/5.0', '127.0.0.1'); + + $this->assertNotNull($persistedDevice); + $this->assertSame(hash('sha256', $rawToken), $persistedDevice->getDeviceIdentifier()); + } + + public function testTrustDeviceRawTokenNotStored(): void + { + $user = Mockery::mock(User::class); + + /** @var UserTrustedDevice|null $persistedDevice */ + $persistedDevice = null; + + $this->repo + ->shouldReceive('add') + ->once() + ->withArgs(function ($device) use (&$persistedDevice) { + $persistedDevice = $device; + return true; + }); + + $rawToken = $this->service->trustDevice($user, 'Mozilla/5.0', '127.0.0.1'); + + $this->assertNotNull($persistedDevice); + $this->assertNotSame($rawToken, $persistedDevice->getDeviceIdentifier()); + } + + public function testTrustDeviceCreatesExactlyOneRecord(): void + { + $user = Mockery::mock(User::class); + + $this->repo->shouldReceive('add')->once(); + + $this->service->trustDevice($user, 'Mozilla/5.0', '127.0.0.1'); + } + + public function testTrustDeviceSetsExpiresAtFromConfig(): void + { + $user = Mockery::mock(User::class); + + /** @var UserTrustedDevice|null $persistedDevice */ + $persistedDevice = null; + + $this->repo + ->shouldReceive('add') + ->once() + ->withArgs(function ($device) use (&$persistedDevice) { + $persistedDevice = $device; + return true; + }); + + $this->service->trustDevice($user, 'Mozilla/5.0', '127.0.0.1'); + + $this->assertNotNull($persistedDevice); + + $lifetimeDays = (int) config('two_factor.device_trust_lifetime_days', 30); + $diff = $persistedDevice->getTrustedAt()->diff($persistedDevice->getExpiresAt()); + $this->assertSame($lifetimeDays, $diff->days); + } + + // ------------------------------------------------------------------------- + // removeTrustedDevices + // ------------------------------------------------------------------------- + + public function testRemoveTrustedDevicesRevokesAll(): void + { + $user = Mockery::mock(User::class); + + $this->repo + ->shouldReceive('revokeAllForUser') + ->once() + ->with($user); + + $this->service->removeTrustedDevices($user); + } + + // ------------------------------------------------------------------------- + // generateDeviceIdentifier + // ------------------------------------------------------------------------- + + public function testGenerateDeviceIdentifierReturnsSha256(): void + { + $token = 'test_token_value'; + $expected = hash('sha256', $token); + + $this->assertSame($expected, $this->service->generateDeviceIdentifier($token)); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private function makeDevice(bool $expired, bool $revoked): UserTrustedDevice + { + $device = new UserTrustedDevice(); + + $now = new DateTime('now', new DateTimeZone('UTC')); + + if ($expired) { + $expiresAt = clone $now; + $expiresAt->sub(new DateInterval('P1D')); // 1 day in the past + } else { + $expiresAt = clone $now; + $expiresAt->add(new DateInterval('P30D')); // 30 days in the future + } + + $device->setExpiresAt($expiresAt); + $device->setIsRevoked($revoked); + $device->setDeviceIdentifier($this->service->generateDeviceIdentifier('sometoken')); + $device->setIpAddress('127.0.0.1'); + $device->setTrustedAt($now); + $device->setLastSeenAt(clone $now); + + return $device; + } +} From ba18c4b4625c834ef4b132e7348fd80e55cf527e Mon Sep 17 00:00:00 2001 From: matiasperrone-exo Date: Tue, 26 May 2026 18:52:33 +0000 Subject: [PATCH 19/26] feat: Two-Factor Audit Service --- app/Services/Auth/ITwoFactorAuditService.php | 35 +++ app/Services/Auth/TwoFactorAuditService.php | 80 ++++++ .../Auth/TwoFactorServiceProvider.php | 2 + phpunit.xml | 1 + tests/Unit/TwoFactorAuditServiceTest.php | 227 ++++++++++++++++++ 5 files changed, 345 insertions(+) create mode 100644 app/Services/Auth/ITwoFactorAuditService.php create mode 100644 app/Services/Auth/TwoFactorAuditService.php create mode 100644 tests/Unit/TwoFactorAuditServiceTest.php diff --git a/app/Services/Auth/ITwoFactorAuditService.php b/app/Services/Auth/ITwoFactorAuditService.php new file mode 100644 index 00000000..e72049d8 --- /dev/null +++ b/app/Services/Auth/ITwoFactorAuditService.php @@ -0,0 +1,35 @@ + $user->getId(), + 'event_type' => $eventType, + 'method' => $method, + 'ip_address' => $ipAddress, + ]); + + $auditLog = new TwoFactorAuditLog(); + $auditLog->setUser($user); + $auditLog->setEventType($eventType); // throws InvalidArgumentException on unknown type + $auditLog->setMethod($method); // throws InvalidArgumentException on unknown method + $auditLog->setIpAddress($ipAddress); + // user_agent is captured from the current HTTP request context; falls back to empty + // string in CLI / queue contexts. A future signature change may accept $userAgent + // explicitly if project conventions require it (see ticket CU-86ba2z5gz). + $auditLog->setUserAgent(request()?->userAgent() ?? ''); + $auditLog->setMetadata($metadata); + + $this->repository->add($auditLog, true); + + if (config('opentelemetry.enabled', false)) { + EmitAuditLogJob::dispatch('two_factor.audit', [ + 'two_factor.event_type' => $eventType, + 'two_factor.method' => $method, + 'two_factor.user_id' => $user->getId(), + 'two_factor.ip_address' => $ipAddress, + 'two_factor.success' => $this->resolveSuccess($eventType), + 'two_factor.device_trusted' => $eventType === TwoFactorAuditLog::EventDeviceTrusted, + 'elasticsearch.index' => config('opentelemetry.logs.elasticsearch_index', 'logs-audit'), + ]); + } + } + + /** + * Derive whether the 2FA event represents a successful outcome. + * Only challenge_failed is treated as a failure; all other event types + * represent informational or successful operations. + */ + private function resolveSuccess(string $eventType): bool + { + return $eventType !== TwoFactorAuditLog::EventChallengeFailed; + } +} diff --git a/app/Services/Auth/TwoFactorServiceProvider.php b/app/Services/Auth/TwoFactorServiceProvider.php index f2f2568b..705eda7a 100644 --- a/app/Services/Auth/TwoFactorServiceProvider.php +++ b/app/Services/Auth/TwoFactorServiceProvider.php @@ -30,12 +30,14 @@ public function boot(): void public function register(): void { $this->app->singleton(IDeviceTrustService::class, DeviceTrustService::class); + $this->app->singleton(ITwoFactorAuditService::class, TwoFactorAuditService::class); } public function provides(): array { return [ IDeviceTrustService::class, + ITwoFactorAuditService::class, ]; } } diff --git a/phpunit.xml b/phpunit.xml index a653e0ce..005f6b46 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -28,6 +28,7 @@ ./tests/Unit/MFA/AbstractMFAChallengeStrategyTest.php ./tests/Unit/MFA/EmailOTPMFAChallengeStrategyTest.php ./tests/Unit/MFA/MFAChallengeStrategyFactoryTest.php + ./tests/Unit/TwoFactorAuditServiceTest.php diff --git a/tests/Unit/TwoFactorAuditServiceTest.php b/tests/Unit/TwoFactorAuditServiceTest.php new file mode 100644 index 00000000..e16c5679 --- /dev/null +++ b/tests/Unit/TwoFactorAuditServiceTest.php @@ -0,0 +1,227 @@ +repository = Mockery::mock(ITwoFactorAuditLogRepository::class); + $this->service = new TwoFactorAuditService($this->repository); + + $this->user = Mockery::mock(User::class); + $this->user->shouldReceive('getId')->andReturn(42); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + // ------------------------------------------------------------------------- + // log() persists TwoFactorAuditLog with correct fields + // ------------------------------------------------------------------------- + + public function testLogPersistsTwoFactorAuditLogWithCorrectFields(): void + { + /** @var TwoFactorAuditLog|null $persisted */ + $persisted = null; + + $this->repository + ->shouldReceive('add') + ->once() + ->withArgs(function (TwoFactorAuditLog $log, bool $sync) use (&$persisted) { + $persisted = $log; + return $sync === true; + }); + + $this->service->log( + $this->user, + TwoFactorAuditLog::EventChallengeSucceeded, + TwoFactorAuditLog::MethodEmailOtp, + '127.0.0.1', + ['attempt' => 1] + ); + + $this->assertNotNull($persisted); + $this->assertSame($this->user, $persisted->getUser()); + $this->assertSame(TwoFactorAuditLog::EventChallengeSucceeded, $persisted->getEventType()); + $this->assertSame(TwoFactorAuditLog::MethodEmailOtp, $persisted->getMethod()); + $this->assertSame('127.0.0.1', $persisted->getIpAddress()); + $this->assertSame(['attempt' => 1], $persisted->getMetadata()); + } + + // ------------------------------------------------------------------------- + // log() emits OTLP attributes + // ------------------------------------------------------------------------- + + public function testLogEmitsOtlpAttributes(): void + { + Config::set('opentelemetry.enabled', true); + + $this->repository->shouldReceive('add')->once(); + + $this->service->log( + $this->user, + TwoFactorAuditLog::EventChallengeSucceeded, + TwoFactorAuditLog::MethodEmailOtp, + '10.0.0.1' + ); + + Queue::assertPushed(EmitAuditLogJob::class, function (EmitAuditLogJob $job) { + return $job->logMessage === 'two_factor.audit' + && $job->auditData['two_factor.event_type'] === TwoFactorAuditLog::EventChallengeSucceeded + && $job->auditData['two_factor.method'] === TwoFactorAuditLog::MethodEmailOtp + && $job->auditData['two_factor.user_id'] === 42 + && $job->auditData['two_factor.ip_address'] === '10.0.0.1' + && $job->auditData['two_factor.success'] === true + && $job->auditData['two_factor.device_trusted'] === false; + }); + } + + // ------------------------------------------------------------------------- + // log() emits two_factor.success = false for challenge_failed + // ------------------------------------------------------------------------- + + public function testLogEmitsSuccessFalseForChallengeFailed(): void + { + Config::set('opentelemetry.enabled', true); + + $this->repository->shouldReceive('add')->once(); + + $this->service->log( + $this->user, + TwoFactorAuditLog::EventChallengeFailed, + TwoFactorAuditLog::MethodEmailOtp, + '10.0.0.1' + ); + + Queue::assertPushed(EmitAuditLogJob::class, function (EmitAuditLogJob $job) { + return $job->auditData['two_factor.event_type'] === TwoFactorAuditLog::EventChallengeFailed + && $job->auditData['two_factor.success'] === false; + }); + } + + // ------------------------------------------------------------------------- + // log() does NOT dispatch job when OTLP is disabled (default) + // ------------------------------------------------------------------------- + + public function testLogDoesNotDispatchJobWhenOtlpDisabled(): void + { + Config::set('opentelemetry.enabled', false); + + $this->repository->shouldReceive('add')->once(); + + $this->service->log( + $this->user, + TwoFactorAuditLog::EventChallengeSucceeded, + TwoFactorAuditLog::MethodEmailOtp, + '127.0.0.1' + ); + + Queue::assertNotPushed(EmitAuditLogJob::class); + } + + // ------------------------------------------------------------------------- + // log() accepts null metadata + // ------------------------------------------------------------------------- + + public function testLogAcceptsNullMetadata(): void + { + /** @var TwoFactorAuditLog|null $persisted */ + $persisted = null; + + $this->repository + ->shouldReceive('add') + ->once() + ->withArgs(function (TwoFactorAuditLog $log) use (&$persisted) { + $persisted = $log; + return true; + }); + + $this->service->log( + $this->user, + TwoFactorAuditLog::EventChallengeIssued, + TwoFactorAuditLog::MethodTotp, + '192.168.1.1', + null + ); + + $this->assertNotNull($persisted); + $this->assertNull($persisted->getMetadata()); + } + + // ------------------------------------------------------------------------- + // invalid event type throws InvalidArgumentException + // ------------------------------------------------------------------------- + + public function testInvalidEventTypeThrowsInvalidArgumentException(): void + { + $this->expectException(\InvalidArgumentException::class); + + $this->repository->shouldNotReceive('add'); + + $this->service->log( + $this->user, + 'not_a_valid_event', + TwoFactorAuditLog::MethodEmailOtp, + '127.0.0.1' + ); + } + + // ------------------------------------------------------------------------- + // invalid method throws InvalidArgumentException + // ------------------------------------------------------------------------- + + public function testInvalidMethodThrowsInvalidArgumentException(): void + { + $this->expectException(\InvalidArgumentException::class); + + $this->repository->shouldNotReceive('add'); + + $this->service->log( + $this->user, + TwoFactorAuditLog::EventChallengeIssued, + 'not_a_valid_method', + '127.0.0.1' + ); + } +} From 50f4ad05a2b67f98dc911f0188c82875bf0fcbbf Mon Sep 17 00:00:00 2001 From: matiasperrone-exo Date: Thu, 28 May 2026 23:17:12 +0000 Subject: [PATCH 20/26] feat: MFAGateService (Two-Factor Gate Decision Service) --- app/Services/Auth/ITwoFactorGateService.php | 33 +++++ app/Services/Auth/MFAGateService.php | 38 ++++++ .../Auth/TwoFactorServiceProvider.php | 4 + phpunit.xml | 1 + tests/Unit/MFAGateServiceTest.php | 113 ++++++++++++++++++ 5 files changed, 189 insertions(+) create mode 100644 app/Services/Auth/ITwoFactorGateService.php create mode 100644 app/Services/Auth/MFAGateService.php create mode 100644 tests/Unit/MFAGateServiceTest.php diff --git a/app/Services/Auth/ITwoFactorGateService.php b/app/Services/Auth/ITwoFactorGateService.php new file mode 100644 index 00000000..ad229f63 --- /dev/null +++ b/app/Services/Auth/ITwoFactorGateService.php @@ -0,0 +1,33 @@ +shouldRequire2FA()) { + return false; + } + return !$this->deviceTrustService->isDeviceTrusted($user, $cookieToken); + } +} diff --git a/app/Services/Auth/TwoFactorServiceProvider.php b/app/Services/Auth/TwoFactorServiceProvider.php index 705eda7a..081eed76 100644 --- a/app/Services/Auth/TwoFactorServiceProvider.php +++ b/app/Services/Auth/TwoFactorServiceProvider.php @@ -1,5 +1,7 @@ app->singleton(IDeviceTrustService::class, DeviceTrustService::class); $this->app->singleton(ITwoFactorAuditService::class, TwoFactorAuditService::class); + $this->app->singleton(ITwoFactorGateService::class, MFAGateService::class); } public function provides(): array @@ -38,6 +41,7 @@ public function provides(): array return [ IDeviceTrustService::class, ITwoFactorAuditService::class, + ITwoFactorGateService::class, ]; } } diff --git a/phpunit.xml b/phpunit.xml index 005f6b46..4c4f8d12 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -29,6 +29,7 @@ ./tests/Unit/MFA/EmailOTPMFAChallengeStrategyTest.php ./tests/Unit/MFA/MFAChallengeStrategyFactoryTest.php ./tests/Unit/TwoFactorAuditServiceTest.php + ./tests/Unit/MFAGateServiceTest.php diff --git a/tests/Unit/MFAGateServiceTest.php b/tests/Unit/MFAGateServiceTest.php new file mode 100644 index 00000000..49c66822 --- /dev/null +++ b/tests/Unit/MFAGateServiceTest.php @@ -0,0 +1,113 @@ +deviceTrustService = Mockery::mock(IDeviceTrustService::class); + $this->service = new MFAGateService($this->deviceTrustService); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + // ------------------------------------------------------------------------- + // Non-admin/non-enforced user with 2FA disabled returns false + // ------------------------------------------------------------------------- + + public function testRequiresChallengeReturnsFalseWhenUserDoesNotRequire2FA(): void + { + $user = Mockery::mock(User::class); + $user->shouldReceive('shouldRequire2FA')->once()->andReturn(false); + $this->deviceTrustService->shouldNotReceive('isDeviceTrusted'); + + $this->assertFalse($this->service->requiresChallenge($user, null)); + } + + // ------------------------------------------------------------------------- + // Admin/enforced user with no cookie returns true + // ------------------------------------------------------------------------- + + public function testRequiresChallengeReturnsTrueWhenEnforcedAndNoCookie(): void + { + $user = Mockery::mock(User::class); + $user->shouldReceive('shouldRequire2FA')->once()->andReturn(true); + $this->deviceTrustService->shouldReceive('isDeviceTrusted')->once()->with($user, null)->andReturn(false); + + $this->assertTrue($this->service->requiresChallenge($user, null)); + } + + // ------------------------------------------------------------------------- + // Admin/enforced user with trusted device returns false + // ------------------------------------------------------------------------- + + public function testRequiresChallengeReturnsFalseWhenEnforcedAndDeviceTrusted(): void + { + $user = Mockery::mock(User::class); + $user->shouldReceive('shouldRequire2FA')->once()->andReturn(true); + $this->deviceTrustService->shouldReceive('isDeviceTrusted')->once()->with($user, 'valid-token')->andReturn(true); + + $this->assertFalse($this->service->requiresChallenge($user, 'valid-token')); + } + + // ------------------------------------------------------------------------- + // Admin/enforced user with expired/revoked/wrong device returns true + // ------------------------------------------------------------------------- + + public function testRequiresChallengeReturnsTrueWhenEnforcedAndDeviceNotTrusted(): void + { + $user = Mockery::mock(User::class); + $user->shouldReceive('shouldRequire2FA')->once()->andReturn(true); + $this->deviceTrustService->shouldReceive('isDeviceTrusted')->once()->with($user, 'expired-token')->andReturn(false); + + $this->assertTrue($this->service->requiresChallenge($user, 'expired-token')); + } + + // ------------------------------------------------------------------------- + // Empty-string cookie is forwarded as-is (not coerced to null) and treated + // as untrusted — documents the ?string contract between gate and trust layers + // ------------------------------------------------------------------------- + + public function testRequiresChallengePassesThroughEmptyStringCookieToDeviceTrustService(): void + { + $user = Mockery::mock(User::class); + $user->shouldReceive('shouldRequire2FA')->once()->andReturn(true); + $this->deviceTrustService->shouldReceive('isDeviceTrusted')->once()->with($user, '')->andReturn(false); + + $this->assertTrue($this->service->requiresChallenge($user, '')); + } +} From f4931445b096043579497a58c430a0eb802beff7 Mon Sep 17 00:00:00 2001 From: matiasperrone-exo Date: Wed, 3 Jun 2026 20:31:59 +0000 Subject: [PATCH 21/26] feat: UserController MFA Integration, Device Trust Cookie Management, Audit Wiring, and 2FA Rate Limiting --- .../Controllers/Traits/MFACookieManager.php | 86 ++++ app/Http/Controllers/UserController.php | 329 +++++++++++++- app/Http/Kernel.php | 1 + app/Http/Middleware/EncryptCookies.php | 13 +- .../TwoFactorRateLimitMiddleware.php | 145 ++++++ app/Services/Auth/DeviceTrustService.php | 31 +- app/Services/Auth/TwoFactorAuditService.php | 8 +- .../MFA/AbstractMFAChallengeStrategy.php | 1 + .../MFA/EmailOTPMFAChallengeStrategy.php | 12 +- app/libs/Auth/AuthService.php | 56 ++- app/libs/Utils/Services/IAuthService.php | 28 ++ config/two_factor.php | 19 + phpunit.xml | 1 + routes/web.php | 5 + tests/DeviceTrustServiceTest.php | 47 +- tests/TwoFactorLoginFlowTest.php | 426 ++++++++++++++++++ .../MFA/AbstractMFAChallengeStrategyTest.php | 1 + .../MFA/EmailOTPMFAChallengeStrategyTest.php | 30 ++ tests/Unit/TwoFactorAuditServiceTest.php | 44 +- .../AuthServiceValidateCredentialsTest.php | 8 +- 20 files changed, 1242 insertions(+), 49 deletions(-) create mode 100644 app/Http/Controllers/Traits/MFACookieManager.php create mode 100644 app/Http/Middleware/TwoFactorRateLimitMiddleware.php create mode 100644 tests/TwoFactorLoginFlowTest.php diff --git a/app/Http/Controllers/Traits/MFACookieManager.php b/app/Http/Controllers/Traits/MFACookieManager.php new file mode 100644 index 00000000..718b9305 --- /dev/null +++ b/app/Http/Controllers/Traits/MFACookieManager.php @@ -0,0 +1,86 @@ +device_trust_service. + * + * @package App\Http\Controllers\Traits + */ +trait MFACookieManager +{ + /** + * Reads the raw trusted-device token from the request cookie. + * + * @return string|null + */ + protected function getCookieToken(): ?string + { + return Request::cookie(Config::get('two_factor.cookie_name', 'device_trust_token')); + } + + /** + * Persists a trusted-device record (via IDeviceTrustService) and queues a + * secure, HttpOnly cookie carrying the raw token for the configured lifetime. + * + * @param User $user + * @return void + */ + protected function queueDeviceTrustCookie(User $user): void + { + $rawToken = $this->device_trust_service->trustDevice + ( + $user, + Request::header('User-Agent') ?? '', + IPHelper::getUserIp() + ); + + $name = Config::get('two_factor.cookie_name', 'device_trust_token'); + $lifetimeMinutes = intval(Config::get('two_factor.device_trust_lifetime_days', 30)) * 24 * 60; + $path = Config::get('session.path'); + $domain = Config::get('session.domain'); + $secure = true; + $httpOnly = true; + $raw = false; + $sameSite = 'lax'; + + // Same order as \Illuminate\Cookie\CookieJar::make() + Cookie::queue + ( + $name, + $rawToken, // value + $lifetimeMinutes, + $path, + $domain, + $secure, + $httpOnly, + $raw, + $sameSite + + ); + } +} diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 3d7c1213..9644ca91 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -17,7 +17,17 @@ use App\Jobs\RevokeUserGrantsOnExplicitLogout; use App\Http\Controllers\OpenId\OpenIdController; use App\Http\Controllers\Traits\JsonResponses; +use App\Http\Controllers\Traits\MFACookieManager; use App\Http\Utils\CountryList; +use App\libs\Auth\Models\TwoFactorAuditLog; +use App\Services\Auth\IDeviceTrustService; +use App\Services\Auth\ITwoFactorAuditService; +use App\Services\Auth\ITwoFactorGateService; +use Auth\User; +use Models\OAuth2\Client; +use Strategies\MFA\MFAChallengeStrategyFactory; +use Symfony\Component\HttpFoundation\Response as HttpResponse; +use Utils\IPHelper; use App\libs\OAuth2\Strategies\LoginHintProcessStrategy; use App\ModelSerializers\SerializerRegistry; use Auth\Exceptions\AuthenticationException; @@ -132,6 +142,21 @@ final class UserController extends OpenIdController */ private $security_context_service; + /** + * @var IDeviceTrustService + */ + private $device_trust_service; + + /** + * @var ITwoFactorAuditService + */ + private $two_factor_audit_service; + + /** + * @var ITwoFactorGateService + */ + private $mfa_gate_service; + /** * @param IMementoOpenIdSerializerService $openid_memento_service * @param IMementoOAuth2SerializerService $oauth2_memento_service @@ -167,7 +192,10 @@ public function __construct IResourceServerService $resource_server_service, IUtilsServerConfigurationService $utils_configuration_service, ISecurityContextService $security_context_service, - LoginHintProcessStrategy $login_hint_process_strategy + LoginHintProcessStrategy $login_hint_process_strategy, + IDeviceTrustService $device_trust_service, + ITwoFactorAuditService $two_factor_audit_service, + ITwoFactorGateService $mfa_gate_service, ) { $this->openid_memento_service = $openid_memento_service; @@ -185,6 +213,9 @@ public function __construct $this->resource_server_service = $resource_server_service; $this->utils_configuration_service = $utils_configuration_service; $this->security_context_service = $security_context_service; + $this->device_trust_service = $device_trust_service; + $this->two_factor_audit_service = $two_factor_audit_service; + $this->mfa_gate_service = $mfa_gate_service; $this->middleware(function ($request, $next) use($login_hint_process_strategy){ @@ -254,6 +285,8 @@ public function cancelLogin() use JsonResponses; + use MFACookieManager; + /** * @return \Illuminate\Http\JsonResponse|mixed */ @@ -436,35 +469,41 @@ public function postLogin() $connection = $data['connection'] ?? null; try { - if ($flow == "password" && $this->auth_service->login($username, $password, $remember)) { - return $this->login_strategy->postLogin(); - } - - if ($flow == "otp") { - - $client = null; - - // check if we have a former oauth2 request - if ($this->oauth2_memento_service->exists()) { - - Log::debug("UserController::postLogin exist a oauth auth request on session"); - - $oauth_auth_request = OAuth2AuthorizationRequestFactory::getInstance()->build - ( - OAuth2Message::buildFromMemento($this->oauth2_memento_service->load()) + if ($flow == "password") { + // Validate credentials WITHOUT establishing a session, so the + // MFA gate can run before the user is authenticated. + $user = $this->auth_service->validateCredentials($username, $password); + + $cookieToken = $this->getCookieToken(); + + if ($this->mfa_gate_service->requiresChallenge($user, $cookieToken)) { + // Issue a challenge and stop short of session creation. + $client = $this->resolveClientFromMemento(); + $method = $user->getTwoFactorMethod(); + $strategy = MFAChallengeStrategyFactory::create($method); + $payload = $this->auth_service->issueMFAChallenge($user, $strategy, $client, $remember); + + $this->two_factor_audit_service->log( + $user, + TwoFactorAuditLog::EventChallengeIssued, + $method, + IPHelper::getUserIp() ); - if ($oauth_auth_request->isValid()) { + return Response::json( + array_merge(['error_code' => 'mfa_required'], $payload), + HttpResponse::HTTP_OK + ); + } - $client_id = $oauth_auth_request->getClientId(); + // No challenge required: establish the session and continue. + $this->auth_service->loginUser($user, $remember); + return $this->login_strategy->postLogin(); + } - $client = $this->client_repository->getClientById($client_id); - if (is_null($client)) - throw new ValidationException("client does not exists"); + if ($flow == "otp") { - $this->oauth2_memento_service->serialize($oauth_auth_request->getMessage()->createMemento()); - } - } + $client = $this->resolveClientFromMemento(); $otpClaim = OAuth2OTP::fromParams($username, $connection, $password); $this->auth_service->loginWithOTP($otpClaim, $client); @@ -558,6 +597,246 @@ public function postLogin() } } + /** + * Resolves the OAuth2 client from a former authorization request stored in + * the session memento, if any. Returns null when there is no pending OAuth2 + * request (e.g. plain IdP login). + * + * @return Client|null + * @throws ValidationException + */ + private function resolveClientFromMemento(): ?Client + { + if (!$this->oauth2_memento_service->exists()) { + return null; + } + + Log::debug("UserController::resolveClientFromMemento exist a oauth auth request on session"); + + $oauth_auth_request = OAuth2AuthorizationRequestFactory::getInstance()->build + ( + OAuth2Message::buildFromMemento($this->oauth2_memento_service->load()) + ); + + if (!$oauth_auth_request->isValid()) { + return null; + } + + $client = $this->client_repository->getClientById($oauth_auth_request->getClientId()); + if (is_null($client)) + throw new ValidationException("client does not exists"); + + $this->oauth2_memento_service->serialize($oauth_auth_request->getMessage()->createMemento()); + + return $client; + } + + /** + * Verifies a 2FA OTP challenge and, on success, establishes the session. + * + * @return \Illuminate\Http\JsonResponse|mixed + */ + public function verify2FA() + { + try { + $data = Request::all(); + $validator = Validator::make($data, [ + 'otp_value' => 'required|string', + 'method' => 'required|string|in:' . implode(',', User::ValidMFAMethods), + 'trust_device' => 'sometimes|boolean', + ]); + + if (!$validator->passes()) { + return $this->error412($validator->getMessageBag()->getMessages()); + } + + $method = $data['method']; + $otp_value = $data['otp_value']; + $trust_device = Request::boolean('trust_device'); + + $strategy = MFAChallengeStrategyFactory::create($method); + $pending = $strategy->getPendingState(); + + if (is_null($pending)) { + return $this->mfaSessionExpired(); + } + + $user = $this->auth_service->getUserById((int) $pending['user_id']); + if (is_null($user) || !$user->isTwoFactorMethodEnabled($method)) { + $strategy->clearPendingState(); + return $this->mfaSessionExpired(); + } + + try { + $this->auth_service->verifyMFAChallenge($user, $strategy, $otp_value); + } catch (AuthenticationException $ex) { + Log::warning($ex); + // Re-fetch user: the tx wrapper closed/reset the EM on failure, detaching the entity. + $userId = (int) $pending['user_id']; + $user = $this->auth_service->getUserById($userId) ?? $user; + $this->two_factor_audit_service->log( + $user, + TwoFactorAuditLog::EventChallengeFailed, + $method, + IPHelper::getUserIp() + ); + return Response::json(['error_code' => 'mfa_verification_failed'], HttpResponse::HTTP_UNAUTHORIZED); + } + + // Second factor verified: establish the session. + $this->auth_service->loginUser($user, (bool) $pending['remember']); + + if ($trust_device) { + $this->queueDeviceTrustCookie($user); + } + + $strategy->clearPendingState(); + + $this->two_factor_audit_service->log( + $user, + TwoFactorAuditLog::EventChallengeSucceeded, + $method, + IPHelper::getUserIp() + ); + + return $this->login_strategy->postLogin(); + } catch (ValidationException $ex) { + Log::warning($ex); + return $this->error412($ex->getMessages()); + } catch (Exception $ex) { + Log::error($ex); + return $this->error500($ex); + } + } + + /** + * Verifies a 2FA recovery code and, on success, establishes the session. + * + * @return \Illuminate\Http\JsonResponse|mixed + */ + public function verify2FARecovery() + { + try { + $data = Request::all(); + $validator = Validator::make($data, [ + 'recovery_code' => 'required|string', + ]); + + if (!$validator->passes()) { + return $this->error412($validator->getMessageBag()->getMessages()); + } + + $recovery_code = $data['recovery_code']; + + // Recovery-code handling lives in the base strategy; session keys are + // method-agnostic, so any concrete strategy can read the pending state. + $strategy = MFAChallengeStrategyFactory::create(User::MFAMethod_OTP); + $pending = $strategy->getPendingState(); + + if (is_null($pending)) { + return $this->mfaSessionExpired(); + } + + $user = $this->auth_service->getUserById((int) $pending['user_id']); + if (is_null($user)) { + $strategy->clearPendingState(); + return $this->mfaSessionExpired(); + } + + try { + $this->auth_service->verifyMFARecoveryCode($user, $strategy, $recovery_code); + } catch (AuthenticationException $ex) { + Log::warning($ex); + // Re-fetch user: the tx wrapper closed/reset the EM on failure, detaching the entity. + $userId = (int) $pending['user_id']; + $user = $this->auth_service->getUserById($userId) ?? $user; + $this->two_factor_audit_service->log( + $user, + TwoFactorAuditLog::EventChallengeFailed, + TwoFactorAuditLog::MethodRecovery, + IPHelper::getUserIp() + ); + return Response::json(['error_code' => 'mfa_invalid_recovery'], HttpResponse::HTTP_UNAUTHORIZED); + } + + $this->auth_service->loginUser($user, (bool) $pending['remember']); + $strategy->clearPendingState(); + + $this->two_factor_audit_service->log( + $user, + TwoFactorAuditLog::EventRecoveryUsed, + TwoFactorAuditLog::MethodRecovery, + IPHelper::getUserIp() + ); + + return $this->login_strategy->postLogin(); + } catch (ValidationException $ex) { + Log::warning($ex); + return $this->error412($ex->getMessages()); + } catch (Exception $ex) { + Log::error($ex); + return $this->error500($ex); + } + } + + /** + * Re-issues a 2FA challenge for the pending login and returns the challenge payload. + * + * @return \Illuminate\Http\JsonResponse|mixed + */ + public function resend2FA() + { + try { + $data = Request::all(); + $validator = Validator::make($data, [ + 'method' => 'required|string|in:' . implode(',', User::ValidMFAMethods), + ]); + + if (!$validator->passes()) { + return $this->error412($validator->getMessageBag()->getMessages()); + } + + $method = $data['method']; + $strategy = MFAChallengeStrategyFactory::create($method); + $pending = $strategy->getPendingState(); + + if (is_null($pending)) { + return $this->mfaSessionExpired(); + } + + $user = $this->auth_service->getUserById((int) $pending['user_id']); + if (is_null($user) || !$user->isTwoFactorMethodEnabled($method)) { + $strategy->clearPendingState(); + return $this->mfaSessionExpired(); + } + + $payload = $this->auth_service->resendMFAChallenge($user, $strategy, $this->resolveClientFromMemento(), (bool) $pending['remember']); + + $this->two_factor_audit_service->log( + $user, + TwoFactorAuditLog::EventChallengeIssued, + $method, + IPHelper::getUserIp() + ); + + return $this->ok($payload); + } catch (ValidationException $ex) { + Log::warning($ex); + return $this->error412($ex->getMessages()); + } catch (Exception $ex) { + Log::error($ex); + return $this->error500($ex); + } + } + + /** + * @return \Illuminate\Http\JsonResponse + */ + private function mfaSessionExpired() + { + return Response::json(['error_code' => 'mfa_session_expired'], HttpResponse::HTTP_UNAUTHORIZED); + } + /** * @return \Illuminate\Http\Response|mixed */ diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index d81fc8df..6e3c84df 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -75,6 +75,7 @@ class Kernel extends HttpKernel 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class, 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequestsWithRedis::class, 'csrf' => \App\Http\Middleware\VerifyCsrfToken::class, + '2fa.rate' => \App\Http\Middleware\TwoFactorRateLimitMiddleware::class, 'oauth2.endpoint' => \App\Http\Middleware\OAuth2BearerAccessTokenRequestValidator::class, 'oauth2.currentuser.serveradmin' => \App\Http\Middleware\CurrentUserIsOAuth2ServerAdmin::class, 'oauth2.currentuser.serveradmin.json' => \App\Http\Middleware\CurrentUserIsOAuth2ServerAdminJson::class, diff --git a/app/Http/Middleware/EncryptCookies.php b/app/Http/Middleware/EncryptCookies.php index a613dc08..c682095f 100644 --- a/app/Http/Middleware/EncryptCookies.php +++ b/app/Http/Middleware/EncryptCookies.php @@ -11,6 +11,7 @@ * See the License for the specific language governing permissions and * limitations under the License. **/ +use Illuminate\Contracts\Encryption\Encrypter; use Illuminate\Cookie\Middleware\EncryptCookies as Middleware; use OAuth2\Services\IPrincipalService; /** @@ -22,10 +23,20 @@ class EncryptCookies extends Middleware /** * The names of the cookies that should not be encrypted. * + * The trusted-device token is a high-entropy random secret only ever compared + * against a server-side SHA-256 hash, so cookie-layer encryption adds no + * meaningful protection - exclude it so the value round-trips verbatim. + * * @var array */ protected $except = [ - IPrincipalService::OP_BROWSER_STATE_COOKIE_NAME + IPrincipalService::OP_BROWSER_STATE_COOKIE_NAME, ]; + public function __construct(Encrypter $encrypter) + { + parent::__construct($encrypter); + $this->except[] = config('two_factor.cookie_name', 'device_trust_token'); + } + } diff --git a/app/Http/Middleware/TwoFactorRateLimitMiddleware.php b/app/Http/Middleware/TwoFactorRateLimitMiddleware.php new file mode 100644 index 00000000..a0bba142 --- /dev/null +++ b/app/Http/Middleware/TwoFactorRateLimitMiddleware.php @@ -0,0 +1,145 @@ +limitsFor($action); + $key = $this->cacheKey($action, $userId); + + if ((int) Cache::get($key, 0) >= $maxAttempts) { + Log::debug(sprintf("TwoFactorRateLimitMiddleware: action %s user %s rate limited", $action, $userId)); + return Response::json( + [ + 'error_code' => 'mfa_rate_limit', + 'error_message' => 'Too many attempts. Please try again later.', + ], + HttpResponse::HTTP_TOO_MANY_REQUESTS + ); + } + + $response = $next($request); + + if ($action === self::ActionResend) { + $this->increment($key, $windowSeconds); + } else if ($this->isFailure($response)) { + $this->increment($key, $windowSeconds); + } + + return $response; + } + + /** + * @param string $action + * @return array{0:int,1:int} [maxAttempts, windowSeconds] + */ + private function limitsFor(string $action): array + { + if ($action === self::ActionResend) { + return [ + (int) Config::get('two_factor.rate_limit.max_otp_requests', 5), + (int) Config::get('two_factor.rate_limit.otp_window_minutes', 15) * 60, + ]; + } + + return [ + (int) Config::get('two_factor.rate_limit.max_attempts', 3), + (int) Config::get('two_factor.rate_limit.window_seconds', 900), + ]; + } + + private function cacheKey(string $action, $userId): string + { + return sprintf('2fa_rate:%s:%s', $action, $userId); + } + + /** + * Increment within a fixed window: add() sets the TTL once (only if the key + * is absent), increment() bumps the value while preserving that TTL, so the + * window starts at the first hit and does not slide. + */ + private function increment(string $key, int $windowSeconds): void + { + Cache::add($key, 0, $windowSeconds); + Cache::increment($key); + } + + /** + * @param mixed $response + * @return bool + */ + private function isFailure($response): bool + { + $content = method_exists($response, 'getContent') ? $response->getContent() : null; + if (empty($content)) { + return false; + } + + $decoded = json_decode($content, true); + if (!is_array($decoded) || !isset($decoded['error_code'])) { + return false; + } + + return in_array($decoded['error_code'], self::FAILURE_CODES, true); + } +} diff --git a/app/Services/Auth/DeviceTrustService.php b/app/Services/Auth/DeviceTrustService.php index e6f317b6..d4242c2f 100644 --- a/app/Services/Auth/DeviceTrustService.php +++ b/app/Services/Auth/DeviceTrustService.php @@ -13,12 +13,15 @@ * limitations under the License. **/ +use App\libs\Auth\Models\TwoFactorAuditLog; use App\libs\Auth\Models\UserTrustedDevice; use Auth\Repositories\IUserTrustedDeviceRepository; use Auth\User; use DateTime; use DateInterval; use DateTimeZone; +use Utils\IPHelper; +use Utils\Db\ITransactionService; /** * Class DeviceTrustService @@ -26,7 +29,11 @@ */ final class DeviceTrustService implements IDeviceTrustService { - public function __construct(private readonly IUserTrustedDeviceRepository $repository) + public function __construct( + private readonly IUserTrustedDeviceRepository $repository, + private readonly ITwoFactorAuditService $audit_service, + private readonly ITransactionService $tx_service + ) { } @@ -55,7 +62,16 @@ public function trustDevice(User $user, string $userAgent, string $ipAddress): s $device->setLastSeenAt(clone $now); $device->setIsRevoked(false); - $this->repository->add($device, true); + $this->tx_service->transaction(function () use ($device) { + $this->repository->add($device, false); + }); + + $this->audit_service->log( + $user, + TwoFactorAuditLog::EventDeviceTrusted, + $user->getTwoFactorMethod(), + $ipAddress + ); return $rawToken; } @@ -74,12 +90,21 @@ public function isDeviceTrusted(User $user, ?string $cookieToken): bool } $device->setLastSeenAt(new DateTime('now', new DateTimeZone('UTC'))); - $this->repository->add($device, true); + $this->tx_service->transaction(function () use ($device) { + $this->repository->add($device, false); + }); return true; } public function removeTrustedDevices(User $user): void { $this->repository->revokeAllForUser($user); + + $this->audit_service->log( + $user, + TwoFactorAuditLog::EventDeviceRevoked, + $user->getTwoFactorMethod(), + IPHelper::getUserIp() + ); } } diff --git a/app/Services/Auth/TwoFactorAuditService.php b/app/Services/Auth/TwoFactorAuditService.php index 9a02586d..a1dfd9f8 100644 --- a/app/Services/Auth/TwoFactorAuditService.php +++ b/app/Services/Auth/TwoFactorAuditService.php @@ -18,6 +18,7 @@ use Auth\Repositories\ITwoFactorAuditLogRepository; use Auth\User; use Illuminate\Support\Facades\Log; +use Utils\Db\ITransactionService; /** * Class TwoFactorAuditService @@ -26,7 +27,8 @@ final class TwoFactorAuditService implements ITwoFactorAuditService { public function __construct( - private readonly ITwoFactorAuditLogRepository $repository + private readonly ITwoFactorAuditLogRepository $repository, + private readonly ITransactionService $tx_service ) { } @@ -53,7 +55,9 @@ public function log(User $user, string $eventType, string $method, string $ipAdd $auditLog->setUserAgent(request()?->userAgent() ?? ''); $auditLog->setMetadata($metadata); - $this->repository->add($auditLog, true); + $this->tx_service->transaction(function () use ($auditLog) { + $this->repository->add($auditLog, false); + }); if (config('opentelemetry.enabled', false)) { EmitAuditLogJob::dispatch('two_factor.audit', [ diff --git a/app/Strategies/MFA/AbstractMFAChallengeStrategy.php b/app/Strategies/MFA/AbstractMFAChallengeStrategy.php index 172ab7e6..ce940857 100644 --- a/app/Strategies/MFA/AbstractMFAChallengeStrategy.php +++ b/app/Strategies/MFA/AbstractMFAChallengeStrategy.php @@ -51,6 +51,7 @@ public function verifyRecoveryCode(User $user, string $code): void foreach ($this->recovery_code_repository->getUnusedByUser($user) as $recoveryCode) { if (Hash::check($code, $recoveryCode->getCodeHash())) { $recoveryCode->markUsed(); + $this->recovery_code_repository->add($recoveryCode, false); return; } } diff --git a/app/Strategies/MFA/EmailOTPMFAChallengeStrategy.php b/app/Strategies/MFA/EmailOTPMFAChallengeStrategy.php index 56a35da2..9c25f2e0 100644 --- a/app/Strategies/MFA/EmailOTPMFAChallengeStrategy.php +++ b/app/Strategies/MFA/EmailOTPMFAChallengeStrategy.php @@ -5,7 +5,6 @@ use Auth\Repositories\IUserRecoveryCodeRepository; use Auth\User; use Models\OAuth2\Client; -use Models\OAuth2\OAuth2OTP; use OAuth2\OAuth2Protocol; use OAuth2\Services\ITokenService; @@ -37,7 +36,14 @@ public function issueChallenge(User $user, ?Client $client, bool $remember): arr public function verifyChallenge(User $user, string $code, ?Client $client = null): void { - $otp = OAuth2OTP::fromParams($user->getEmail(), OAuth2Protocol::OAuth2PasswordlessConnectionEmail, $code); + // Look up the STORED single-use code so the submitted value is actually + // validated against what was issued (a non-matching code resolves to null). + $otp = $this->otp_repository->getByValueConnectionAndUserName( + $code, + OAuth2Protocol::OAuth2PasswordlessConnectionEmail, + $user->getEmail(), + $client + ); if (is_null($otp)) { throw new AuthenticationException("Non existent single-use code."); @@ -54,10 +60,12 @@ public function verifyChallenge(User $user, string $code, ?Client $client = null } $otp->redeem(); + $this->otp_repository->add($otp, false); foreach ($this->otp_repository->getByUserNameNotRedeemed($user->getEmail()) as $otpToRevoke) { if ($otpToRevoke->getValue() !== $otp->getValue()) { $otpToRevoke->redeem(); + $this->otp_repository->add($otpToRevoke, false); } } } diff --git a/app/libs/Auth/AuthService.php b/app/libs/Auth/AuthService.php index ae71663d..7a51d92c 100644 --- a/app/libs/Auth/AuthService.php +++ b/app/libs/Auth/AuthService.php @@ -39,6 +39,7 @@ use OAuth2\Services\ISecurityContextService; use OpenId\Services\IUserService; use Services\IUserActionService; +use Strategies\MFA\IMFAChallengeStrategy; use utils\Base64UrlRepresentation; use Utils\Db\ITransactionService; use Utils\IPHelper; @@ -426,15 +427,10 @@ public function validateCredentials(string $username, string $password): User { Log::debug("AuthService::validateCredentials"); - try { - /** - * @var User|null $user - */ - $user = Auth::getProvider()->retrieveByCredentials(['username' => $username, 'password' => $password]); - } catch (UnverifiedEmailMemberException $ex) { - throw new AuthenticationException($ex->getMessage()); - } - + /** + * @var User|null $user + */ + $user = Auth::getProvider()->retrieveByCredentials(['username' => $username, 'password' => $password]); if (is_null($user) || !$user instanceof User || !$user->canLogin()) { throw new AuthenticationException("We are sorry, your username or password does not match an existing record."); } @@ -778,4 +774,46 @@ public function postLoginUserActions(int $user_id): void }); } + + public function issueMFAChallenge( + User $user, + IMFAChallengeStrategy $strategy, + ?Client $client = null, + bool $remember = false + ): array { + return $this->tx_service->transaction(function () use ($user, $strategy, $client, $remember) { + return $strategy->issueChallenge($user, $client, $remember); + }); + } + + public function verifyMFAChallenge( + User $user, + IMFAChallengeStrategy $strategy, + string $value + ): void { + $this->tx_service->transaction(function () use ($user, $strategy, $value) { + $strategy->verifyChallenge($user, $value); + }); + } + + public function verifyMFARecoveryCode( + User $user, + IMFAChallengeStrategy $strategy, + string $inputCode + ): void { + $this->tx_service->transaction(function () use ($user, $strategy, $inputCode) { + $strategy->verifyRecoveryCode($user, $inputCode); + }); + } + + public function resendMFAChallenge( + User $user, + IMFAChallengeStrategy $strategy, + ?Client $client = null, + bool $remember = false + ): array { + return $this->tx_service->transaction(function () use ($user, $strategy, $client, $remember) { + return $strategy->resendChallenge($user, $client, $remember); + }); + } } \ No newline at end of file diff --git a/app/libs/Utils/Services/IAuthService.php b/app/libs/Utils/Services/IAuthService.php index c3d26e62..1990c2ed 100644 --- a/app/libs/Utils/Services/IAuthService.php +++ b/app/libs/Utils/Services/IAuthService.php @@ -18,6 +18,7 @@ use Models\OAuth2\OAuth2OTP; use OAuth2\Models\IClient; use OpenId\Models\IOpenIdUser; +use Strategies\MFA\IMFAChallengeStrategy; /** * Interface IAuthService */ @@ -66,6 +67,7 @@ public function login(string $username, string $password, bool $remember_me): bo * @param string $password * @return User * @throws AuthenticationException on invalid credentials, missing user, or locked account. + * @throws \Auth\Exceptions\UnverifiedEmailMemberException when the user's email is not verified */ public function validateCredentials(string $username, string $password): User; @@ -193,4 +195,30 @@ public function verifyOTPChallenge( ?Client $client = null ): OAuth2OTP; + public function issueMFAChallenge( + User $user, + IMFAChallengeStrategy $strategy, + ?Client $client = null, + bool $remember = false + ): array; + + public function verifyMFAChallenge( + User $user, + IMFAChallengeStrategy $strategy, + string $value + ): void; + + public function verifyMFARecoveryCode( + User $user, + IMFAChallengeStrategy $strategy, + string $inputCode + ): void; + + public function resendMFAChallenge( + User $user, + IMFAChallengeStrategy $strategy, + ?Client $client = null, + bool $remember = false + ): array; + } \ No newline at end of file diff --git a/config/two_factor.php b/config/two_factor.php index 8a399806..78ac21a0 100644 --- a/config/two_factor.php +++ b/config/two_factor.php @@ -38,4 +38,23 @@ */ 'device_trust_lifetime_days' => env('DEVICE_TRUST_LIFETIME_DAYS', 30), 'cookie_name' => env('DEVICE_TRUST_COOKIE_NAME', 'device_trust_token'), + + /* + |-------------------------------------------------------------------------- + | Rate Limiting + |-------------------------------------------------------------------------- + | + | Counters live in the cache (NOT the session) so they survive session + | cleanup and keep an independent, fixed TTL window. + | + | verify/recovery: max_attempts failed attempts per window_seconds. + | resend: max_otp_requests requests per otp_window_minutes. + | + */ + 'rate_limit' => [ + 'max_attempts' => env('TWO_FACTOR_MAX_ATTEMPTS', 3), + 'window_seconds' => env('TWO_FACTOR_RATE_WINDOW_SECONDS', 900), + 'max_otp_requests' => env('TWO_FACTOR_MAX_OTP_REQUESTS', 5), + 'otp_window_minutes' => env('TWO_FACTOR_OTP_WINDOW_MINUTES', 15), + ], ]; diff --git a/phpunit.xml b/phpunit.xml index 4c4f8d12..5d09a0bb 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -30,6 +30,7 @@ ./tests/Unit/MFA/MFAChallengeStrategyFactoryTest.php ./tests/Unit/TwoFactorAuditServiceTest.php ./tests/Unit/MFAGateServiceTest.php + ./tests/TwoFactorLoginFlowTest.php diff --git a/routes/web.php b/routes/web.php index b49a3547..5c0f0af0 100644 --- a/routes/web.php +++ b/routes/web.php @@ -49,6 +49,11 @@ Route::group(array('prefix' => 'verification'), function () { Route::post('resend', ['middleware' => ['csrf'], 'uses' => 'UserController@resendVerificationEmail']); }); + Route::group(array('prefix' => '2fa'), function () { + Route::post('verify', ['middleware' => ['csrf', '2fa.rate:verify'], 'uses' => 'UserController@verify2FA']); + Route::post('recovery', ['middleware' => ['csrf', '2fa.rate:recovery'], 'uses' => 'UserController@verify2FARecovery']); + Route::post('resend', ['middleware' => ['csrf', '2fa.rate:resend'], 'uses' => 'UserController@resend2FA']); + }); Route::post('', ['middleware' => 'csrf', 'uses' => 'UserController@postLogin']); Route::get('cancel', "UserController@cancelLogin"); Route::group(array('prefix' => '{provider}'), function () { diff --git a/tests/DeviceTrustServiceTest.php b/tests/DeviceTrustServiceTest.php index 5eb2bdfe..4d8ae8fb 100644 --- a/tests/DeviceTrustServiceTest.php +++ b/tests/DeviceTrustServiceTest.php @@ -15,12 +15,14 @@ use App\libs\Auth\Models\UserTrustedDevice; use App\Services\Auth\DeviceTrustService; +use App\Services\Auth\ITwoFactorAuditService; use Auth\Repositories\IUserTrustedDeviceRepository; use Auth\User; use DateTime; use DateInterval; use DateTimeZone; use Mockery; +use Utils\Db\ITransactionService; /** * Class DeviceTrustServiceTest @@ -33,11 +35,21 @@ final class DeviceTrustServiceTest extends BrowserKitTestCase /** @var \Mockery\MockInterface&IUserTrustedDeviceRepository */ private $repo; + /** @var \Mockery\MockInterface&ITwoFactorAuditService */ + private $audit_service; + + /** @var \Mockery\MockInterface&ITransactionService */ + private $tx_service; + public function setUp(): void { parent::setUp(); $this->repo = Mockery::mock(IUserTrustedDeviceRepository::class); - $this->service = new DeviceTrustService($this->repo); + $this->audit_service = Mockery::mock(ITwoFactorAuditService::class); + $this->audit_service->shouldReceive('log')->byDefault(); + $this->tx_service = Mockery::mock(ITransactionService::class); + $this->tx_service->shouldReceive('transaction')->andReturnUsing(fn($cb) => $cb())->byDefault(); + $this->service = new DeviceTrustService($this->repo, $this->audit_service, $this->tx_service); } public function tearDown(): void @@ -53,6 +65,7 @@ public function tearDown(): void public function testIsDeviceTrustedNullCookie(): void { $user = Mockery::mock(User::class); + $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP); $this->repo->shouldNotReceive('getByUserAndDeviceIdentifier'); $this->assertFalse($this->service->isDeviceTrusted($user, null)); @@ -61,6 +74,7 @@ public function testIsDeviceTrustedNullCookie(): void public function testIsDeviceTrustedEmptyCookie(): void { $user = Mockery::mock(User::class); + $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP); $this->repo->shouldNotReceive('getByUserAndDeviceIdentifier'); $this->assertFalse($this->service->isDeviceTrusted($user, '')); @@ -69,6 +83,7 @@ public function testIsDeviceTrustedEmptyCookie(): void public function testIsDeviceTrustedWrongCookie(): void { $user = Mockery::mock(User::class); + $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP); $this->repo ->shouldReceive('getByUserAndDeviceIdentifier') ->once() @@ -80,6 +95,7 @@ public function testIsDeviceTrustedWrongCookie(): void public function testIsDeviceTrustedRevokedDevice(): void { $user = Mockery::mock(User::class); + $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP); $device = $this->makeDevice(expired: false, revoked: true); @@ -94,6 +110,7 @@ public function testIsDeviceTrustedRevokedDevice(): void public function testIsDeviceTrustedExpiredDevice(): void { $user = Mockery::mock(User::class); + $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP); $device = $this->makeDevice(expired: true, revoked: false); @@ -108,6 +125,7 @@ public function testIsDeviceTrustedExpiredDevice(): void public function testIsDeviceTrustedValidDevice(): void { $user = Mockery::mock(User::class); + $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP); $device = $this->makeDevice(expired: false, revoked: false); @@ -123,6 +141,7 @@ public function testIsDeviceTrustedValidDevice(): void public function testIsDeviceTrustedUpdatesLastSeenAt(): void { $user = Mockery::mock(User::class); + $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP); $device = $this->makeDevice(expired: false, revoked: false); // set last_seen_at to a known old value so the update is detectable @@ -148,6 +167,7 @@ public function testIsDeviceTrustedUpdatesLastSeenAt(): void public function testTrustDeviceReturnsToken(): void { $user = Mockery::mock(User::class); + $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP); $this->repo->shouldReceive('add')->once(); @@ -160,6 +180,7 @@ public function testTrustDeviceReturnsToken(): void public function testTrustDeviceStoresHash(): void { $user = Mockery::mock(User::class); + $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP); /** @var UserTrustedDevice|null $persistedDevice */ $persistedDevice = null; @@ -181,6 +202,7 @@ public function testTrustDeviceStoresHash(): void public function testTrustDeviceRawTokenNotStored(): void { $user = Mockery::mock(User::class); + $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP); /** @var UserTrustedDevice|null $persistedDevice */ $persistedDevice = null; @@ -202,15 +224,32 @@ public function testTrustDeviceRawTokenNotStored(): void public function testTrustDeviceCreatesExactlyOneRecord(): void { $user = Mockery::mock(User::class); + $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP); + + $this->repo->shouldReceive('add')->once(); + + $this->service->trustDevice($user, 'Mozilla/5.0', '127.0.0.1'); + } + + public function testTrustDeviceEmitsDeviceTrustedAuditEvent(): void + { + $user = Mockery::mock(User::class); + $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP); $this->repo->shouldReceive('add')->once(); + $this->audit_service + ->shouldReceive('log') + ->once() + ->with($user, \App\libs\Auth\Models\TwoFactorAuditLog::EventDeviceTrusted, User::MFAMethod_OTP, '127.0.0.1'); + $this->service->trustDevice($user, 'Mozilla/5.0', '127.0.0.1'); } public function testTrustDeviceSetsExpiresAtFromConfig(): void { $user = Mockery::mock(User::class); + $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP); /** @var UserTrustedDevice|null $persistedDevice */ $persistedDevice = null; @@ -239,12 +278,18 @@ public function testTrustDeviceSetsExpiresAtFromConfig(): void public function testRemoveTrustedDevicesRevokesAll(): void { $user = Mockery::mock(User::class); + $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP); $this->repo ->shouldReceive('revokeAllForUser') ->once() ->with($user); + $this->audit_service + ->shouldReceive('log') + ->once() + ->with($user, \App\libs\Auth\Models\TwoFactorAuditLog::EventDeviceRevoked, User::MFAMethod_OTP, Mockery::type('string')); + $this->service->removeTrustedDevices($user); } diff --git a/tests/TwoFactorLoginFlowTest.php b/tests/TwoFactorLoginFlowTest.php new file mode 100644 index 00000000..e8d8521e --- /dev/null +++ b/tests/TwoFactorLoginFlowTest.php @@ -0,0 +1,426 @@ +flushRateLimitCounters(); + } + + protected function tearDown(): void + { + $this->flushRateLimitCounters(); + parent::tearDown(); + } + + private function flushRateLimitCounters(): void + { + $admin = EntityManager::getRepository(User::class)->getByEmailOrName(self::ADMIN_EMAIL); + if (!$admin) return; + $userId = $admin->getId(); + foreach (['verify', 'recovery', 'resend'] as $action) { + Cache::forget("2fa_rate:{$action}:{$userId}"); + } + } + + // ------------------------------------------------------------------------- + // postLogin gate + // ------------------------------------------------------------------------- + + public function testAdminLoginTriggersMFAChallenge(): void + { + $response = $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + + $this->assertResponseStatus(200); + $payload = json_decode($response->getContent(), true); + $this->assertSame('mfa_required', $payload['error_code']); + $this->assertFalse(Auth::check(), 'no session must be established when a challenge is required'); + + $admin = $this->user(self::ADMIN_EMAIL); + $this->assertGreaterThan(0, $this->countAudit($admin->getId(), TwoFactorAuditLog::EventChallengeIssued)); + } + + public function testNonAdminWithoutMFALogsInNormally(): void + { + $email = $this->createPlainUser(); + + $response = $this->postLogin($email, self::SEED_PASSWORD); + + $this->assertResponseStatus(302); + $this->assertTrue(Auth::check(), 'a non-MFA user must get an authenticated session'); + } + + // ------------------------------------------------------------------------- + // verify2FA + // ------------------------------------------------------------------------- + + public function testSuccessfulOTPVerificationCompletesLogin(): void + { + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + $code = $this->latestOtpCode(self::ADMIN_EMAIL); + + $response = $this->verify($code); + + $this->assertResponseStatus(302); + $this->assertTrue(Auth::check()); + + $admin = $this->user(self::ADMIN_EMAIL); + $this->assertGreaterThan(0, $this->countAudit($admin->getId(), TwoFactorAuditLog::EventChallengeSucceeded)); + } + + public function testFailedOTPVerificationReturnsErrorAndIncrementsCounter(): void + { + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + $admin = $this->user(self::ADMIN_EMAIL); + $userId = $admin->getId(); + + $response = $this->verify('000000-wrong'); + + $this->assertResponseStatus(401); + $payload = json_decode($response->getContent(), true); + $this->assertSame('mfa_verification_failed', $payload['error_code']); + $this->assertFalse(Auth::check()); + + $this->assertSame(1, (int) Cache::get('2fa_rate:verify:' . $userId, 0), 'verify counter must increment on failure'); + $this->assertGreaterThan(0, $this->countAudit($userId, TwoFactorAuditLog::EventChallengeFailed)); + } + + public function testSuccessfulVerificationDoesNotIncrementCounter(): void + { + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + $userId = $this->user(self::ADMIN_EMAIL)->getId(); + $code = $this->latestOtpCode(self::ADMIN_EMAIL); + + $this->verify($code); + + $this->assertSame(0, (int) Cache::get('2fa_rate:verify:' . $userId, 0), 'success must NOT increment the verify counter'); + } + + public function testOTPVerificationRejectsWrongCode(): void + { + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + // Confirm there is a real OTP issued, then send a wrong value. + $this->latestOtpCode(self::ADMIN_EMAIL); // asserts an OTP exists + $wrongCode = 'WRONG-CODE-THAT-DOES-NOT-EXIST'; + + $response = $this->verify($wrongCode); + + $this->assertResponseStatus(401); + $payload = json_decode($response->getContent(), true); + $this->assertSame('mfa_verification_failed', $payload['error_code'], + 'verifyChallenge must load the stored OTP and reject a non-matching value'); + $this->assertFalse(Auth::check()); + } + + public function testOTPCodeRejectsReuseAfterSuccessfulVerification(): void + { + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + $code = $this->latestOtpCode(self::ADMIN_EMAIL); + + // First use — must succeed. + $this->verify($code); + $this->assertTrue(Auth::check(), 'first OTP use must establish a session'); + + // Second use — OTP must be redeemed (committed by the AuthService tx). + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + $response = $this->verify($code); + + $this->assertResponseStatus(401); + $payload = json_decode($response->getContent(), true); + $this->assertSame('mfa_verification_failed', $payload['error_code'], + 'a reused OTP must be rejected because the redemption was committed by the AuthService transaction'); + } + + public function testRecoveryCodeRejectsReuseAfterTransactionCommit(): void + { + $admin = $this->user(self::ADMIN_EMAIL); + $plain = 'RECOVERY-REUSE-TX-' . uniqid(); + $this->createRecoveryCode($admin, $plain, false); + + // First use — must succeed. + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + $this->recovery($plain); + $this->assertTrue(Auth::check(), 'first recovery-code use must establish a session'); + + // Second use — used_at marking must have been committed by the AuthService tx. + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + $response = $this->recovery($plain); + + $this->assertResponseStatus(401); + $payload = json_decode($response->getContent(), true); + $this->assertSame('mfa_invalid_recovery', $payload['error_code'], + 'recovery code reuse must be rejected because used_at was committed via the AuthService transaction'); + } + + public function testExpiredMFASessionFails(): void + { + // No prior postLogin -> no pending state. + $response = $this->verify('whatever'); + + $this->assertResponseStatus(401); + $payload = json_decode($response->getContent(), true); + $this->assertSame('mfa_session_expired', $payload['error_code']); + } + + // ------------------------------------------------------------------------- + // trusted device + // ------------------------------------------------------------------------- + + public function testTrustDeviceEnrollmentPersistsRecord(): void + { + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + $admin = $this->user(self::ADMIN_EMAIL); + $code = $this->latestOtpCode(self::ADMIN_EMAIL); + + $response = $this->verify($code, true); + $this->assertResponseStatus(302); + + EntityManager::clear(); + $devices = EntityManager::getRepository(UserTrustedDevice::class)->findBy(['user' => $admin->getId()]); + $this->assertNotEmpty($devices, 'a trusted-device record must be persisted'); + $this->assertGreaterThan(0, $this->countAudit($admin->getId(), TwoFactorAuditLog::EventDeviceTrusted)); + } + + public function testTrustedDeviceCookieBypassesMFA(): void + { + $admin = $this->user(self::ADMIN_EMAIL); + + /** @var IDeviceTrustService $deviceTrust */ + $deviceTrust = App::make(IDeviceTrustService::class); + $rawToken = $deviceTrust->trustDevice($admin, 'Mozilla/5.0 (test)', '127.0.0.1'); + + // The device-trust cookie is excluded from encryption, so it is sent verbatim. + $response = $this->postLogin( + self::ADMIN_EMAIL, + self::SEED_PASSWORD, + [Config::get('two_factor.cookie_name') => $rawToken] + ); + + $this->assertResponseStatus(302); + $this->assertTrue(Auth::check(), 'a valid trusted-device cookie must bypass MFA'); + } + + // ------------------------------------------------------------------------- + // recovery codes + // ------------------------------------------------------------------------- + + public function testRecoveryCodeLoginSucceeds(): void + { + $admin = $this->user(self::ADMIN_EMAIL); + $plain = 'RECOVERY-PLAIN-123'; + $codeId = $this->createRecoveryCode($admin, $plain, false); + + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + $response = $this->recovery($plain); + + $this->assertResponseStatus(302); + $this->assertTrue(Auth::check()); + + EntityManager::clear(); + $code = EntityManager::find(UserRecoveryCode::class, $codeId); + $this->assertTrue($code->isUsed(), 'the recovery code must be marked used'); + $this->assertGreaterThan(0, $this->countAudit($admin->getId(), TwoFactorAuditLog::EventRecoveryUsed)); + } + + public function testUsedRecoveryCodeFails(): void + { + $admin = $this->user(self::ADMIN_EMAIL); + $plain = 'RECOVERY-USED-456'; + $this->createRecoveryCode($admin, $plain, true); // already used + + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + $response = $this->recovery($plain); + + $this->assertResponseStatus(401); + $payload = json_decode($response->getContent(), true); + $this->assertSame('mfa_invalid_recovery', $payload['error_code']); + $this->assertFalse(Auth::check()); + } + + // ------------------------------------------------------------------------- + // resend + // ------------------------------------------------------------------------- + + public function testResendEndpointReturnsChallengePayload(): void + { + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + + $response = $this->resend(); + + $this->assertResponseStatus(200); + $payload = json_decode($response->getContent(), true); + $this->assertArrayHasKey('otp_length', $payload); + $this->assertArrayHasKey('otp_lifetime', $payload); + } + + // ------------------------------------------------------------------------- + // rate limiting + // ------------------------------------------------------------------------- + + public function testVerifyRateLimitBlocksAfterThreshold(): void + { + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + + $max = (int) Config::get('two_factor.rate_limit.max_attempts'); + for ($i = 0; $i < $max; $i++) { + $this->verify('bad-code-' . $i); + } + + $response = $this->verify('bad-code-final'); + $this->assertResponseStatus(429); + $payload = json_decode($response->getContent(), true); + $this->assertSame('mfa_rate_limit', $payload['error_code']); + } + + public function testResendRateLimitBlocksAfterThreshold(): void + { + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + + $max = (int) Config::get('two_factor.rate_limit.max_otp_requests'); + for ($i = 0; $i < $max; $i++) { + $this->resend(); + } + + $response = $this->resend(); + $this->assertResponseStatus(429); + $payload = json_decode($response->getContent(), true); + $this->assertSame('mfa_rate_limit', $payload['error_code']); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private function postLogin(string $username, string $password, array $cookies = []) + { + return $this->action('POST', 'UserController@postLogin', [ + 'username' => $username, + 'password' => $password, + 'flow' => 'password', + '_token' => Session::token(), + ], [], $cookies); + } + + private function verify(string $otp, bool $trustDevice = false) + { + return $this->action('POST', 'UserController@verify2FA', [ + 'otp_value' => $otp, + 'method' => User::MFAMethod_OTP, + 'trust_device' => $trustDevice ? '1' : '0', + '_token' => Session::token(), + ]); + } + + private function recovery(string $code) + { + return $this->action('POST', 'UserController@verify2FARecovery', [ + 'recovery_code' => $code, + '_token' => Session::token(), + ]); + } + + private function resend() + { + return $this->action('POST', 'UserController@resend2FA', [ + 'method' => User::MFAMethod_OTP, + '_token' => Session::token(), + ]); + } + + private function user(string $email): User + { + $repo = EntityManager::getRepository(User::class); + $user = $repo->getByEmailOrName($email); + $this->assertInstanceOf(User::class, $user, "user {$email} not found"); + return $user; + } + + private function createPlainUser(): string + { + $email = 'plain.' . uniqid() . '@test.invalid'; + $user = UserFactory::build([ + 'first_name' => 'Plain', + 'last_name' => 'User', + 'email' => $email, + 'password' => self::SEED_PASSWORD, + 'password_enc' => AuthHelper::AlgSHA1_V2_4, + 'active' => true, + 'email_verified' => true, + 'identifier' => 'plain.' . uniqid(), + ]); + EntityManager::persist($user); + EntityManager::flush(); + return $email; + } + + private function createRecoveryCode(User $user, string $plain, bool $used): int + { + $code = new UserRecoveryCode(); + $code->setUser($user); + $code->setCodeHash(Hash::make($plain)); + if ($used) { + $code->markUsed(); + } + EntityManager::persist($code); + EntityManager::flush(); + return $code->getId(); + } + + private function latestOtpCode(string $email): string + { + EntityManager::clear(); + /** @var IOAuth2OTPRepository $repo */ + $repo = App::make(IOAuth2OTPRepository::class); + $otps = $repo->getByUserNameNotRedeemed($email); + $this->assertNotEmpty($otps, "no OTP issued for {$email}"); + return end($otps)->getValue(); + } + + private function countAudit(int $userId, string $eventType): int + { + EntityManager::clear(); + return (int) count( + EntityManager::getRepository(TwoFactorAuditLog::class) + ->findBy(['user' => $userId, 'event_type' => $eventType]) + ); + } +} diff --git a/tests/Unit/MFA/AbstractMFAChallengeStrategyTest.php b/tests/Unit/MFA/AbstractMFAChallengeStrategyTest.php index 743cf6f5..d79d13c6 100644 --- a/tests/Unit/MFA/AbstractMFAChallengeStrategyTest.php +++ b/tests/Unit/MFA/AbstractMFAChallengeStrategyTest.php @@ -90,6 +90,7 @@ public function testVerifyRecoveryCode_withMatchingCode_marksAsUsed(): void $repo = \Mockery::mock(IUserRecoveryCodeRepository::class); $repo->shouldReceive('getUnusedByUser')->with($user)->andReturn([$recoveryCode]); + $repo->shouldReceive('add')->with($recoveryCode, false)->once(); $strategy = new class($repo) extends AbstractMFAChallengeStrategy { public function issueChallenge(User $user, ?Client $client, bool $remember): array { return []; } diff --git a/tests/Unit/MFA/EmailOTPMFAChallengeStrategyTest.php b/tests/Unit/MFA/EmailOTPMFAChallengeStrategyTest.php index f2c56a7b..3d9b7809 100644 --- a/tests/Unit/MFA/EmailOTPMFAChallengeStrategyTest.php +++ b/tests/Unit/MFA/EmailOTPMFAChallengeStrategyTest.php @@ -99,6 +99,19 @@ public function testVerifyChallenge_withValidOtp_redeemsAndRevokesOthers(): void $user = $this->buildUser(1, 'verify@example.com'); $code = '123456'; + $storedOtp = \Mockery::mock(OAuth2OTP::class); + $storedOtp->shouldReceive('getValue')->andReturn($code); + $storedOtp->shouldReceive('logRedeemAttempt')->once(); + $storedOtp->shouldReceive('isAlive')->andReturn(true); + $storedOtp->shouldReceive('isValid')->andReturn(true); + $storedOtp->shouldReceive('redeem')->once(); + + $this->otpRepository + ->shouldReceive('getByValueConnectionAndUserName') + ->once() + ->with($code, 'email', 'verify@example.com', null) + ->andReturn($storedOtp); + $otherOtp = \Mockery::mock(OAuth2OTP::class); $otherOtp->shouldReceive('getValue')->andReturn('654321'); $otherOtp->shouldReceive('redeem')->once(); @@ -107,7 +120,24 @@ public function testVerifyChallenge_withValidOtp_redeemsAndRevokesOthers(): void ->shouldReceive('getByUserNameNotRedeemed') ->andReturn([$otherOtp]); + // The redeemed code and the revoked sibling are both persisted with deferred flush. + $this->otpRepository->shouldReceive('add')->with($storedOtp, false)->once(); + $this->otpRepository->shouldReceive('add')->with($otherOtp, false)->once(); + $this->strategy->verifyChallenge($user, $code); $this->addToAssertionCount(1); } + + public function testVerifyChallenge_withNonMatchingCode_throws(): void + { + $user = $this->buildUser(1, 'verify@example.com'); + + $this->otpRepository + ->shouldReceive('getByValueConnectionAndUserName') + ->once() + ->andReturn(null); + + $this->expectException(\Auth\Exceptions\AuthenticationException::class); + $this->strategy->verifyChallenge($user, 'wrong-code'); + } } diff --git a/tests/Unit/TwoFactorAuditServiceTest.php b/tests/Unit/TwoFactorAuditServiceTest.php index e16c5679..23a5cf3f 100644 --- a/tests/Unit/TwoFactorAuditServiceTest.php +++ b/tests/Unit/TwoFactorAuditServiceTest.php @@ -22,6 +22,7 @@ use Illuminate\Support\Facades\Queue; use Mockery; use Tests\TestCase; +use Utils\Db\ITransactionService; /** * Class TwoFactorAuditServiceTest @@ -35,6 +36,9 @@ final class TwoFactorAuditServiceTest extends TestCase /** @var \Mockery\MockInterface&ITwoFactorAuditLogRepository */ private $repository; + /** @var \Mockery\MockInterface&ITransactionService */ + private $tx_service; + /** @var \Mockery\MockInterface&User */ private $user; @@ -44,7 +48,8 @@ protected function setUp(): void Queue::fake(); $this->repository = Mockery::mock(ITwoFactorAuditLogRepository::class); - $this->service = new TwoFactorAuditService($this->repository); + $this->tx_service = Mockery::mock(ITransactionService::class); + $this->service = new TwoFactorAuditService($this->repository, $this->tx_service); $this->user = Mockery::mock(User::class); $this->user->shouldReceive('getId')->andReturn(42); @@ -70,7 +75,14 @@ public function testLogPersistsTwoFactorAuditLogWithCorrectFields(): void ->once() ->withArgs(function (TwoFactorAuditLog $log, bool $sync) use (&$persisted) { $persisted = $log; - return $sync === true; + return $sync === false; + }); + + $this->tx_service + ->shouldReceive('transaction') + ->once() + ->andReturnUsing(function (callable $callback) { + return $callback(); }); $this->service->log( @@ -99,6 +111,13 @@ public function testLogEmitsOtlpAttributes(): void $this->repository->shouldReceive('add')->once(); + $this->tx_service + ->shouldReceive('transaction') + ->once() + ->andReturnUsing(function (callable $callback) { + return $callback(); + }); + $this->service->log( $this->user, TwoFactorAuditLog::EventChallengeSucceeded, @@ -127,6 +146,13 @@ public function testLogEmitsSuccessFalseForChallengeFailed(): void $this->repository->shouldReceive('add')->once(); + $this->tx_service + ->shouldReceive('transaction') + ->once() + ->andReturnUsing(function (callable $callback) { + return $callback(); + }); + $this->service->log( $this->user, TwoFactorAuditLog::EventChallengeFailed, @@ -150,6 +176,13 @@ public function testLogDoesNotDispatchJobWhenOtlpDisabled(): void $this->repository->shouldReceive('add')->once(); + $this->tx_service + ->shouldReceive('transaction') + ->once() + ->andReturnUsing(function (callable $callback) { + return $callback(); + }); + $this->service->log( $this->user, TwoFactorAuditLog::EventChallengeSucceeded, @@ -177,6 +210,13 @@ public function testLogAcceptsNullMetadata(): void return true; }); + $this->tx_service + ->shouldReceive('transaction') + ->once() + ->andReturnUsing(function (callable $callback) { + return $callback(); + }); + $this->service->log( $this->user, TwoFactorAuditLog::EventChallengeIssued, diff --git a/tests/unit/AuthServiceValidateCredentialsTest.php b/tests/unit/AuthServiceValidateCredentialsTest.php index a504adc4..05d0bc65 100644 --- a/tests/unit/AuthServiceValidateCredentialsTest.php +++ b/tests/unit/AuthServiceValidateCredentialsTest.php @@ -182,10 +182,10 @@ public function testLoginUser_throwsException_whenIsNotActive(): void } /** - * UnverifiedEmailMemberException from the provider must be caught and - * re-thrown as AuthenticationException (contract: @throws AuthenticationException only). + * UnverifiedEmailMemberException from the provider propagates to the caller + * so UserController::postLogin() can handle it with a specific UI message. */ - public function testUnverifiedUser_throwsAuthenticationException(): void + public function testUnverifiedUser_throwsUnverifiedEmailMemberException(): void { $username = 'unverified@example.com'; $password = 'any'; @@ -199,7 +199,7 @@ public function testUnverifiedUser_throwsAuthenticationException(): void $this->auth_mock->shouldReceive('getProvider')->once()->andReturn($provider_mock); $this->auth_mock->shouldNotReceive('login'); - $this->expectException(AuthenticationException::class); + $this->expectException(UnverifiedEmailMemberException::class); $this->expectExceptionMessage('Email not verified.'); $this->service->validateCredentials($username, $password); From 10eb2e08135ef83da802a180000a74566c6904bc Mon Sep 17 00:00:00 2001 From: matiasperrone-exo Date: Fri, 5 Jun 2026 22:11:08 +0000 Subject: [PATCH 22/26] chore: Add PR's requested changed --- .../Controllers/Traits/MFACookieManager.php | 2 + app/Http/Controllers/UserController.php | 61 ++++++++++++++--- .../DoctrineUserRecoveryCodeRepository.php | 6 ++ .../MFA/AbstractMFAChallengeStrategy.php | 18 ++++- .../MFA/EmailOTPMFAChallengeStrategy.php | 21 +++++- app/libs/Auth/AuthService.php | 18 ++++- .../IUserRecoveryCodeRepository.php | 7 ++ app/libs/Utils/Services/IAuthService.php | 3 +- storage/framework/cache/data/.gitignore | 2 - tests/TwoFactorLoginFlowTest.php | 68 +++++++++++++++++++ .../MFA/AbstractMFAChallengeStrategyTest.php | 31 +++++++++ .../MFA/EmailOTPMFAChallengeStrategyTest.php | 50 ++++++++++++-- .../AuthServiceValidateCredentialsTest.php | 56 +++++++++++++-- 13 files changed, 315 insertions(+), 28 deletions(-) delete mode 100755 storage/framework/cache/data/.gitignore diff --git a/app/Http/Controllers/Traits/MFACookieManager.php b/app/Http/Controllers/Traits/MFACookieManager.php index 718b9305..086553d5 100644 --- a/app/Http/Controllers/Traits/MFACookieManager.php +++ b/app/Http/Controllers/Traits/MFACookieManager.php @@ -13,9 +13,11 @@ **/ use Auth\User; +use Exception; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Cookie; use Illuminate\Support\Facades\Request; +use Keepsuit\LaravelOpenTelemetry\Facades\Logger; use Utils\IPHelper; /** diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 9644ca91..6ec54f3a 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -469,7 +469,7 @@ public function postLogin() $connection = $data['connection'] ?? null; try { - if ($flow == "password") { + if ($flow == IAuthService::AuthenticationFlowPassword) { // Validate credentials WITHOUT establishing a session, so the // MFA gate can run before the user is authenticated. $user = $this->auth_service->validateCredentials($username, $password); @@ -501,7 +501,7 @@ public function postLogin() return $this->login_strategy->postLogin(); } - if ($flow == "otp") { + if ($flow == IAuthService::AuthenticationFlowPasswordless) { $client = $this->resolveClientFromMemento(); @@ -631,6 +631,24 @@ private function resolveClientFromMemento(): ?Client return $client; } + /** + * Resolves the OAuth2 client carried in the pending MFA state (the client the + * challenge was issued for), so verification scopes the OTP lookup and + * sibling-revoke to the same client. Returns null when the challenge was not + * issued in a client context. + * + * @param array $pending + * @return Client|null + */ + private function resolveClientFromPendingState(array $pending): ?Client + { + $clientId = $pending['client_id'] ?? null; + if (is_null($clientId)) { + return null; + } + return $this->client_repository->getClientById($clientId); + } + /** * Verifies a 2FA OTP challenge and, on success, establishes the session. * @@ -667,8 +685,19 @@ public function verify2FA() return $this->mfaSessionExpired(); } + // Scope verification to the client the challenge was issued for. + $client = $this->resolveClientFromPendingState($pending); + try { - $this->auth_service->verifyMFAChallenge($user, $strategy, $otp_value); + // Commits the OTP redeem (+ sibling revoke) in its own tx. The + // session, trusted-device enrollment and audit are applied below + // as separate post-verification steps. + $this->auth_service->verifyMFAChallenge( + $user, + $strategy, + $otp_value, + $client + ); } catch (AuthenticationException $ex) { Log::warning($ex); // Re-fetch user: the tx wrapper closed/reset the EM on failure, detaching the entity. @@ -687,17 +716,29 @@ public function verify2FA() $this->auth_service->loginUser($user, (bool) $pending['remember']); if ($trust_device) { - $this->queueDeviceTrustCookie($user); + // Best-effort: the OTP is already redeemed and the session + // established, so a trusted-device enrollment failure must not + // 500 the user (which would lock them out on retry against a + // burned OTP). Log and continue; the device just isn't remembered. + try { + $this->queueDeviceTrustCookie($user); + } catch (Exception $ex) { + Log::warning($ex); + } } $strategy->clearPendingState(); - $this->two_factor_audit_service->log( - $user, - TwoFactorAuditLog::EventChallengeSucceeded, - $method, - IPHelper::getUserIp() - ); + try { + $this->two_factor_audit_service->log( + $user, + TwoFactorAuditLog::EventChallengeSucceeded, + $method, + IPHelper::getUserIp() + ); + } catch (Exception $ex) { + Log::warning($ex); + } return $this->login_strategy->postLogin(); } catch (ValidationException $ex) { diff --git a/app/Repositories/DoctrineUserRecoveryCodeRepository.php b/app/Repositories/DoctrineUserRecoveryCodeRepository.php index b492a0f6..202e48b6 100644 --- a/app/Repositories/DoctrineUserRecoveryCodeRepository.php +++ b/app/Repositories/DoctrineUserRecoveryCodeRepository.php @@ -31,6 +31,12 @@ public function getUnusedByUser(User $user): array ]); } + public function refreshExclusiveLock(UserRecoveryCode $code): void + { + // Single round-trip: SELECT ... FOR UPDATE that also re-hydrates the entity. + $this->getEntityManager()->refresh($code, \Doctrine\DBAL\LockMode::PESSIMISTIC_WRITE); + } + public function deleteAllForUser(User $user): int { $em = $this->getEntityManager(); diff --git a/app/Strategies/MFA/AbstractMFAChallengeStrategy.php b/app/Strategies/MFA/AbstractMFAChallengeStrategy.php index ce940857..3bf3055c 100644 --- a/app/Strategies/MFA/AbstractMFAChallengeStrategy.php +++ b/app/Strategies/MFA/AbstractMFAChallengeStrategy.php @@ -13,6 +13,7 @@ abstract class AbstractMFAChallengeStrategy implements IMFAChallengeStrategy private const KEY_USER_ID = '2fa_pending_user_id'; private const KEY_PENDING_AT = '2fa_pending_at'; private const KEY_REMEMBER = '2fa_remember'; + private const KEY_CLIENT_ID = '2fa_pending_client_id'; private const KEY_RECOVERY_ATTEMPTS = '2fa_recovery_attempts'; public function __construct(protected IUserRecoveryCodeRepository $recovery_code_repository) {} @@ -35,6 +36,7 @@ public function getPendingState(): ?array 'user_id' => $user_id, 'pending_at' => $pending_at, 'remember' => Session::get(self::KEY_REMEMBER, false), + 'client_id' => Session::get(self::KEY_CLIENT_ID), ]; } @@ -43,6 +45,7 @@ public function clearPendingState(): void Session::remove(self::KEY_USER_ID); Session::remove(self::KEY_PENDING_AT); Session::remove(self::KEY_REMEMBER); + Session::remove(self::KEY_CLIENT_ID); Session::remove(self::KEY_RECOVERY_ATTEMPTS); } @@ -50,6 +53,14 @@ public function verifyRecoveryCode(User $user, string $code): void { foreach ($this->recovery_code_repository->getUnusedByUser($user) as $recoveryCode) { if (Hash::check($code, $recoveryCode->getCodeHash())) { + // Concurrency: acquire a PESSIMISTIC_WRITE row lock and re-hydrate + // used_at before mutating. This closes the check->markUsed race + // window: a second concurrent submitter blocks on the lock and, on + // resume, sees the code already used instead of double-spending it. + $this->recovery_code_repository->refreshExclusiveLock($recoveryCode); + if ($recoveryCode->isUsed()) { + throw new AuthenticationException("Invalid recovery code."); + } $recoveryCode->markUsed(); $this->recovery_code_repository->add($recoveryCode, false); return; @@ -58,11 +69,16 @@ public function verifyRecoveryCode(User $user, string $code): void throw new AuthenticationException("Invalid recovery code."); } - protected function storePendingState(int $userId, bool $remember): void + protected function storePendingState(int $userId, bool $remember, ?string $clientId = null): void { Session::put(self::KEY_USER_ID, $userId); Session::put(self::KEY_PENDING_AT, time()); Session::put(self::KEY_REMEMBER, $remember); + if (is_null($clientId)) { + Session::remove(self::KEY_CLIENT_ID); + } else { + Session::put(self::KEY_CLIENT_ID, $clientId); + } } public function verifyChallenge(User $user, string $code, ?Client $client = null): void diff --git a/app/Strategies/MFA/EmailOTPMFAChallengeStrategy.php b/app/Strategies/MFA/EmailOTPMFAChallengeStrategy.php index 9c25f2e0..a07f366b 100644 --- a/app/Strategies/MFA/EmailOTPMFAChallengeStrategy.php +++ b/app/Strategies/MFA/EmailOTPMFAChallengeStrategy.php @@ -20,7 +20,9 @@ public function __construct( public function issueChallenge(User $user, ?Client $client, bool $remember): array { - $this->storePendingState($user->getId(), $remember); + // Carry the issuing client into the pending state so verification scopes + // the OTP lookup and sibling-revoke to the same client (see verifyChallenge). + $this->storePendingState($user->getId(), $remember, $client?->getClientId()); $otp = $this->token_service->createOTPFromPayload([ OAuth2Protocol::OAuth2PasswordlessConnection => OAuth2Protocol::OAuth2PasswordlessConnectionEmail, @@ -38,6 +40,8 @@ public function verifyChallenge(User $user, string $code, ?Client $client = null { // Look up the STORED single-use code so the submitted value is actually // validated against what was issued (a non-matching code resolves to null). + // Scope the lookup to the issuing client so an MFA OTP is only matched + // against the client it was issued for. $otp = $this->otp_repository->getByValueConnectionAndUserName( $code, OAuth2Protocol::OAuth2PasswordlessConnectionEmail, @@ -59,10 +63,23 @@ public function verifyChallenge(User $user, string $code, ?Client $client = null throw new AuthenticationException("Verification code is not valid."); } + // Concurrency: acquire a PESSIMISTIC_WRITE row lock and re-hydrate redemption + // state before redeeming, mirroring AuthService::finalizeRedemption(). This + // closes the validate->redeem race so two concurrent submissions of the same + // valid code cannot both succeed. Runs inside the verifyMFAChallenge tx. + if ($otp->getConnection() !== OAuth2Protocol::OAuth2PasswordlessConnectionInline) { + $this->otp_repository->refreshExclusiveLock($otp); + if ($otp->isRedeemed()) { + throw new AuthenticationException("Verification code is already redeemed."); + } + } + $otp->redeem(); $this->otp_repository->add($otp, false); - foreach ($this->otp_repository->getByUserNameNotRedeemed($user->getEmail()) as $otpToRevoke) { + // Revoke other pending OTPs for this user, scoped to the same client so we + // never burn unrelated OTPs (e.g. passwordless-login codes for other clients). + foreach ($this->otp_repository->getByUserNameNotRedeemed($user->getEmail(), $client) as $otpToRevoke) { if ($otpToRevoke->getValue() !== $otp->getValue()) { $otpToRevoke->redeem(); $this->otp_repository->add($otpToRevoke, false); diff --git a/app/libs/Auth/AuthService.php b/app/libs/Auth/AuthService.php index 7a51d92c..ed1f6510 100644 --- a/app/libs/Auth/AuthService.php +++ b/app/libs/Auth/AuthService.php @@ -447,6 +447,13 @@ public function loginUser(User $user, bool $remember): void Log::debug("AuthService::loginUser"); if (!$user->canLogin()) throw new AuthenticationException("User is not active or cannot login."); + + $this->principal_service->clear(); + $this->principal_service->register + ( + $user->getId(), + time() + ); Auth::login($user, $remember); } @@ -789,10 +796,15 @@ public function issueMFAChallenge( public function verifyMFAChallenge( User $user, IMFAChallengeStrategy $strategy, - string $value + string $value, + ?Client $client = null ): void { - $this->tx_service->transaction(function () use ($user, $strategy, $value) { - $strategy->verifyChallenge($user, $value); + // Commits the OTP redeem (+ sibling revoke) as a single tx. Trusted-device + // enrollment and audit are applied by the caller as best-effort, + // non-blocking side effects after this commits, so a failure in either + // does not block (or roll back) an already-verified second factor. + $this->tx_service->transaction(function () use ($user, $strategy, $value, $client) { + $strategy->verifyChallenge($user, $value, $client); }); } diff --git a/app/libs/Auth/Repositories/IUserRecoveryCodeRepository.php b/app/libs/Auth/Repositories/IUserRecoveryCodeRepository.php index f9733f87..0bfd0335 100644 --- a/app/libs/Auth/Repositories/IUserRecoveryCodeRepository.php +++ b/app/libs/Auth/Repositories/IUserRecoveryCodeRepository.php @@ -22,6 +22,13 @@ interface IUserRecoveryCodeRepository extends IBaseRepository */ public function getUnusedByUser(User $user): array; + /** + * Acquires a PESSIMISTIC_WRITE row lock on the given recovery code and + * re-hydrates its used_at state in the same round-trip. Required before + * redeeming a recovery code to close the check->markUsed double-spend race. + */ + public function refreshExclusiveLock(UserRecoveryCode $code): void; + /** * Delete every recovery code for a user (used when regenerating). */ diff --git a/app/libs/Utils/Services/IAuthService.php b/app/libs/Utils/Services/IAuthService.php index 1990c2ed..b1b6abfc 100644 --- a/app/libs/Utils/Services/IAuthService.php +++ b/app/libs/Utils/Services/IAuthService.php @@ -205,7 +205,8 @@ public function issueMFAChallenge( public function verifyMFAChallenge( User $user, IMFAChallengeStrategy $strategy, - string $value + string $value, + ?Client $client = null ): void; public function verifyMFARecoveryCode( diff --git a/storage/framework/cache/data/.gitignore b/storage/framework/cache/data/.gitignore deleted file mode 100755 index d6b7ef32..00000000 --- a/storage/framework/cache/data/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/tests/TwoFactorLoginFlowTest.php b/tests/TwoFactorLoginFlowTest.php index e8d8521e..0fc20bd6 100644 --- a/tests/TwoFactorLoginFlowTest.php +++ b/tests/TwoFactorLoginFlowTest.php @@ -17,6 +17,7 @@ use App\libs\Auth\Models\UserRecoveryCode; use App\libs\Auth\Models\UserTrustedDevice; use App\Services\Auth\IDeviceTrustService; +use App\Services\Auth\ITwoFactorAuditService; use Auth\AuthHelper; use Auth\User; use Illuminate\Support\Facades\App; @@ -239,6 +240,58 @@ public function testTrustedDeviceCookieBypassesMFA(): void $this->assertTrue(Auth::check(), 'a valid trusted-device cookie must bypass MFA'); } + // ------------------------------------------------------------------------- + // post-verify transaction boundary (Task 5: device-trust atomic, audit best-effort) + // ------------------------------------------------------------------------- + + public function testAuditFailureDoesNotBlockLogin(): void + { + // Audit is best-effort: a failure emitting challenge_succeeded must NOT + // 500 a user whose OTP is already redeemed and session established. + $auditMock = \Mockery::mock(ITwoFactorAuditService::class); + $auditMock->shouldReceive('log') + ->andReturnUsing(function (User $user, string $eventType) { + // Allow challenge_issued (postLogin) so the challenge is created; + // blow up only on the post-success event. + if ($eventType === TwoFactorAuditLog::EventChallengeSucceeded) { + throw new \Exception('audit sink unavailable'); + } + }); + $this->app->instance(ITwoFactorAuditService::class, $auditMock); + + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + $code = $this->latestOtpCode(self::ADMIN_EMAIL); + + $response = $this->verify($code); + + $this->assertEquals(302, $response->getStatusCode(), 'a best-effort audit failure must not fail the login'); + $this->assertTrue(Auth::check(), 'session must be established despite the audit failure'); + } + + public function testDeviceTrustFailureDoesNotBlockLogin(): void + { + // Device-trust enrollment is best-effort: by the time it runs the OTP is + // already redeemed and the session established, so a failure must NOT 500 + // the user (which would lock them out on retry against a now-burned OTP), + // and the pending MFA state must still be cleared. + $deviceTrustMock = \Mockery::mock(IDeviceTrustService::class); + // Gate path: no cookie -> not trusted, so the challenge is still issued. + $deviceTrustMock->shouldReceive('isDeviceTrusted')->andReturn(false); + // Enrollment blows up AFTER the OTP has been redeemed and the session set. + $deviceTrustMock->shouldReceive('trustDevice') + ->andThrow(new \Exception('trusted-device store unavailable')); + $this->app->instance(IDeviceTrustService::class, $deviceTrustMock); + + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + $code = $this->latestOtpCode(self::ADMIN_EMAIL); + + $response = $this->verify($code, true); // trust_device = true + + $this->assertEquals(302, $response->getStatusCode(), 'a best-effort device-trust failure must not fail the login'); + $this->assertTrue(Auth::check(), 'session must be established despite the device-trust failure'); + $this->assertNull(Session::get('2fa_pending_user_id'), 'pending MFA state must be cleared even when device-trust enrollment fails'); + } + // ------------------------------------------------------------------------- // recovery codes // ------------------------------------------------------------------------- @@ -311,6 +364,21 @@ public function testVerifyRateLimitBlocksAfterThreshold(): void $this->assertSame('mfa_rate_limit', $payload['error_code']); } + public function testRecoveryRateLimitBlocksAfterThreshold(): void + { + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + + $max = (int) Config::get('two_factor.rate_limit.max_attempts'); + for ($i = 0; $i < $max; $i++) { + $this->recovery('bad-recovery-' . $i); + } + + $response = $this->recovery('bad-recovery-final'); + $this->assertResponseStatus(429); + $payload = json_decode($response->getContent(), true); + $this->assertSame('mfa_rate_limit', $payload['error_code']); + } + public function testResendRateLimitBlocksAfterThreshold(): void { $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); diff --git a/tests/Unit/MFA/AbstractMFAChallengeStrategyTest.php b/tests/Unit/MFA/AbstractMFAChallengeStrategyTest.php index d79d13c6..cdf2fcfb 100644 --- a/tests/Unit/MFA/AbstractMFAChallengeStrategyTest.php +++ b/tests/Unit/MFA/AbstractMFAChallengeStrategyTest.php @@ -86,10 +86,13 @@ public function testVerifyRecoveryCode_withMatchingCode_marksAsUsed(): void $recoveryCode = \Mockery::mock(\App\libs\Auth\Models\UserRecoveryCode::class); $recoveryCode->shouldReceive('getCodeHash')->andReturn(Hash::make($code)); + $recoveryCode->shouldReceive('isUsed')->andReturn(false); $recoveryCode->shouldReceive('markUsed')->once(); $repo = \Mockery::mock(IUserRecoveryCodeRepository::class); $repo->shouldReceive('getUnusedByUser')->with($user)->andReturn([$recoveryCode]); + // Lock is taken before mutating (regression guard for 3357348455). + $repo->shouldReceive('refreshExclusiveLock')->with($recoveryCode)->once(); $repo->shouldReceive('add')->with($recoveryCode, false)->once(); $strategy = new class($repo) extends AbstractMFAChallengeStrategy { @@ -102,6 +105,34 @@ public function resendChallenge(User $user, ?Client $client, bool $remember): ar $this->addToAssertionCount(1); // markUsed()->once() verified by Mockery in tearDown } + public function testVerifyRecoveryCode_locksAndRejects_whenUsedAfterLock(): void + { + $user = new User(); + $code = 'VALID-CODE'; + + // Hash matches, but a concurrent winner marked it used; the lock+recheck + // must reject without double-spending (regression guard for 3357348455). + $recoveryCode = \Mockery::mock(\App\libs\Auth\Models\UserRecoveryCode::class); + $recoveryCode->shouldReceive('getCodeHash')->andReturn(Hash::make($code)); + $recoveryCode->shouldReceive('isUsed')->andReturn(true); + $recoveryCode->shouldNotReceive('markUsed'); + + $repo = \Mockery::mock(IUserRecoveryCodeRepository::class); + $repo->shouldReceive('getUnusedByUser')->with($user)->andReturn([$recoveryCode]); + $repo->shouldReceive('refreshExclusiveLock')->with($recoveryCode)->once(); + $repo->shouldNotReceive('add'); + + $strategy = new class($repo) extends AbstractMFAChallengeStrategy { + public function issueChallenge(User $user, ?Client $client, bool $remember): array { return []; } + public function verifyChallenge(User $user, string $code, ?Client $client = null): void {} + public function resendChallenge(User $user, ?Client $client, bool $remember): array { return []; } + }; + + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage("Invalid recovery code."); + $strategy->verifyRecoveryCode($user, $code); + } + public function testVerifyRecoveryCode_withNonMatchingCode_throwsException(): void { $user = new User(); diff --git a/tests/Unit/MFA/EmailOTPMFAChallengeStrategyTest.php b/tests/Unit/MFA/EmailOTPMFAChallengeStrategyTest.php index 3d9b7809..7db3b1ba 100644 --- a/tests/Unit/MFA/EmailOTPMFAChallengeStrategyTest.php +++ b/tests/Unit/MFA/EmailOTPMFAChallengeStrategyTest.php @@ -4,6 +4,7 @@ use Auth\Repositories\IUserRecoveryCodeRepository; use Auth\User; use Illuminate\Support\Facades\Session; +use Models\OAuth2\Client; use Models\OAuth2\OAuth2OTP; use OAuth2\Services\ITokenService; use Strategies\MFA\EmailOTPMFAChallengeStrategy; @@ -94,40 +95,79 @@ public function testResendChallenge_delegatesToIssueChallenge(): void // ---------- verifyChallenge ---------- - public function testVerifyChallenge_withValidOtp_redeemsAndRevokesOthers(): void + public function testVerifyChallenge_withValidOtp_redeemsAndRevokesOthers_scopedToClient(): void { - $user = $this->buildUser(1, 'verify@example.com'); - $code = '123456'; + $user = $this->buildUser(1, 'verify@example.com'); + $code = '123456'; + $client = \Mockery::mock(Client::class); $storedOtp = \Mockery::mock(OAuth2OTP::class); $storedOtp->shouldReceive('getValue')->andReturn($code); $storedOtp->shouldReceive('logRedeemAttempt')->once(); $storedOtp->shouldReceive('isAlive')->andReturn(true); $storedOtp->shouldReceive('isValid')->andReturn(true); + $storedOtp->shouldReceive('getConnection')->andReturn('email'); + $storedOtp->shouldReceive('isRedeemed')->andReturn(false); $storedOtp->shouldReceive('redeem')->once(); + // Lookup MUST be scoped to the issuing client (regression guard for r3357348448). $this->otpRepository ->shouldReceive('getByValueConnectionAndUserName') ->once() - ->with($code, 'email', 'verify@example.com', null) + ->with($code, 'email', 'verify@example.com', $client) ->andReturn($storedOtp); + // A pessimistic row lock is taken before redeeming (regression guard for 3357348444). + $this->otpRepository->shouldReceive('refreshExclusiveLock')->with($storedOtp)->once(); + $otherOtp = \Mockery::mock(OAuth2OTP::class); $otherOtp->shouldReceive('getValue')->andReturn('654321'); $otherOtp->shouldReceive('redeem')->once(); + // Sibling revoke MUST be scoped to the SAME client so unrelated OTPs survive. $this->otpRepository ->shouldReceive('getByUserNameNotRedeemed') + ->once() + ->with('verify@example.com', $client) ->andReturn([$otherOtp]); // The redeemed code and the revoked sibling are both persisted with deferred flush. $this->otpRepository->shouldReceive('add')->with($storedOtp, false)->once(); $this->otpRepository->shouldReceive('add')->with($otherOtp, false)->once(); - $this->strategy->verifyChallenge($user, $code); + $this->strategy->verifyChallenge($user, $code, $client); $this->addToAssertionCount(1); } + public function testVerifyChallenge_acquiresLock_andRejectsAlreadyRedeemed(): void + { + $user = $this->buildUser(1, 'verify@example.com'); + $code = '123456'; + + $storedOtp = \Mockery::mock(OAuth2OTP::class); + $storedOtp->shouldReceive('getValue')->andReturn($code); + $storedOtp->shouldReceive('logRedeemAttempt')->once(); + $storedOtp->shouldReceive('isAlive')->andReturn(true); + $storedOtp->shouldReceive('isValid')->andReturn(true); + $storedOtp->shouldReceive('getConnection')->andReturn('email'); + // Concurrent winner already redeemed the row under the lock. + $storedOtp->shouldReceive('isRedeemed')->andReturn(true); + // Must NOT redeem again nor sweep siblings. + $storedOtp->shouldReceive('redeem')->never(); + + $this->otpRepository + ->shouldReceive('getByValueConnectionAndUserName') + ->once() + ->andReturn($storedOtp); + + $this->otpRepository->shouldReceive('refreshExclusiveLock')->with($storedOtp)->once(); + $this->otpRepository->shouldReceive('getByUserNameNotRedeemed')->never(); + $this->otpRepository->shouldReceive('add')->never(); + + $this->expectException(\Auth\Exceptions\AuthenticationException::class); + $this->strategy->verifyChallenge($user, $code); + } + public function testVerifyChallenge_withNonMatchingCode_throws(): void { $user = $this->buildUser(1, 'verify@example.com'); diff --git a/tests/unit/AuthServiceValidateCredentialsTest.php b/tests/unit/AuthServiceValidateCredentialsTest.php index 05d0bc65..678cc4c7 100644 --- a/tests/unit/AuthServiceValidateCredentialsTest.php +++ b/tests/unit/AuthServiceValidateCredentialsTest.php @@ -32,8 +32,9 @@ /** * Class AuthServiceValidateCredentialsTest * Verifies that AuthService::validateCredentials() validates the password - * WITHOUT establishing a session, and that AuthService::loginUser() calls - * Auth::login() for the 2FA completion step. + * WITHOUT establishing a session, and that AuthService::loginUser() registers + * the OIDC principal (clear + register with auth time) and calls Auth::login() + * for the 2FA completion step. */ #[\PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses] #[\PHPUnit\Framework\Attributes\PreserveGlobalState(false)] @@ -44,6 +45,7 @@ final class AuthServiceValidateCredentialsTest extends PHPUnitTestCase private AuthService $service; private $mock_user_repository; + private $mock_principal_service; // Facade aliases private $auth_mock; @@ -55,7 +57,7 @@ protected function setUp(): void $this->mock_user_repository = $this->createMock(IUserRepository::class); $mock_otp_repository = $this->createMock(IOAuth2OTPRepository::class); - $mock_principal_service = $this->createMock(IPrincipalService::class); + $this->mock_principal_service = $this->createMock(IPrincipalService::class); $mock_user_service = $this->createMock(IUserService::class); $mock_user_action_service = $this->createMock(IUserActionService::class); $mock_cache_service = $this->createMock(ICacheService::class); @@ -72,7 +74,7 @@ protected function setUp(): void $this->service = new AuthService( $this->mock_user_repository, $mock_otp_repository, - $mock_principal_service, + $this->mock_principal_service, $mock_user_service, $mock_user_action_service, $mock_cache_service, @@ -140,6 +142,7 @@ public function testLoginUser_callsAuthLogin_withRememberTrue(): void { $user = Mockery::mock('Auth\User'); $user->shouldReceive('canLogin')->andReturn(true); + $user->shouldReceive('getId')->andReturn(123); $this->auth_mock ->shouldReceive('login') @@ -156,6 +159,7 @@ public function testLoginUser_callsAuthLogin_withRememberFalse(): void { $user = Mockery::mock('Auth\User'); $user->shouldReceive('canLogin')->andReturn(true); + $user->shouldReceive('getId')->andReturn(123); $this->auth_mock ->shouldReceive('login') @@ -165,6 +169,50 @@ public function testLoginUser_callsAuthLogin_withRememberFalse(): void $this->service->loginUser($user, false); } + /** + * Regression: loginUser() MUST register the OIDC principal before establishing + * the session, mirroring AuthService::login(). Without this, the principal has + * no auth_time, so the id_token auth_time claim is wrong and + * InteractiveGrantType::shouldForceReLogin() silently skips max_age / prompt=login + * enforcement for every password / 2FA / recovery login. + */ + public function testLoginUser_registersPrincipal_withAuthTime(): void + { + $user = Mockery::mock('Auth\User'); + $user->shouldReceive('canLogin')->andReturn(true); + $user->shouldReceive('getId')->andReturn(123); + + $this->mock_principal_service->expects($this->once())->method('clear'); + $this->mock_principal_service->expects($this->once()) + ->method('register') + ->with(123, $this->greaterThan(0)); + + $this->auth_mock + ->shouldReceive('login') + ->once() + ->with($user, true); + + $this->service->loginUser($user, true); + } + + /** + * Regression: when the user cannot log in, loginUser() must throw BEFORE + * touching the principal — no clear()/register() and no Auth::login(). + */ + public function testLoginUser_doesNotRegisterPrincipal_whenCannotLogin(): void + { + $user = Mockery::mock('Auth\User'); + $user->shouldReceive('canLogin')->andReturn(false); + + $this->mock_principal_service->expects($this->never())->method('clear'); + $this->mock_principal_service->expects($this->never())->method('register'); + $this->auth_mock->shouldNotReceive('login'); + + $this->expectException(AuthenticationException::class); + + $this->service->loginUser($user, true); + } + /** * loginUser(user, [true|false]) and isActive or canLogin false throws an Exception. */ From 5fc47a4a942061b13a60bcef7ccd7e9a7f5b0deb Mon Sep 17 00:00:00 2001 From: matiasperrone-exo Date: Mon, 8 Jun 2026 18:19:59 +0000 Subject: [PATCH 23/26] chore: Add PR's requested changes --- app/Http/Controllers/UserController.php | 20 +------------------ .../MFA/AbstractMFAChallengeStrategy.php | 11 +--------- .../MFA/EmailOTPMFAChallengeStrategy.php | 6 +----- app/libs/Auth/AuthService.php | 6 ------ .../MFA/AbstractMFAChallengeStrategyTest.php | 1 - .../MFA/EmailOTPMFAChallengeStrategyTest.php | 2 -- 6 files changed, 3 insertions(+), 43 deletions(-) diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 6ec54f3a..b37ccdb3 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -631,24 +631,6 @@ private function resolveClientFromMemento(): ?Client return $client; } - /** - * Resolves the OAuth2 client carried in the pending MFA state (the client the - * challenge was issued for), so verification scopes the OTP lookup and - * sibling-revoke to the same client. Returns null when the challenge was not - * issued in a client context. - * - * @param array $pending - * @return Client|null - */ - private function resolveClientFromPendingState(array $pending): ?Client - { - $clientId = $pending['client_id'] ?? null; - if (is_null($clientId)) { - return null; - } - return $this->client_repository->getClientById($clientId); - } - /** * Verifies a 2FA OTP challenge and, on success, establishes the session. * @@ -686,7 +668,7 @@ public function verify2FA() } // Scope verification to the client the challenge was issued for. - $client = $this->resolveClientFromPendingState($pending); + $client = $this->resolveClientFromMemento(); try { // Commits the OTP redeem (+ sibling revoke) in its own tx. The diff --git a/app/Strategies/MFA/AbstractMFAChallengeStrategy.php b/app/Strategies/MFA/AbstractMFAChallengeStrategy.php index 3bf3055c..c2d04fea 100644 --- a/app/Strategies/MFA/AbstractMFAChallengeStrategy.php +++ b/app/Strategies/MFA/AbstractMFAChallengeStrategy.php @@ -13,7 +13,6 @@ abstract class AbstractMFAChallengeStrategy implements IMFAChallengeStrategy private const KEY_USER_ID = '2fa_pending_user_id'; private const KEY_PENDING_AT = '2fa_pending_at'; private const KEY_REMEMBER = '2fa_remember'; - private const KEY_CLIENT_ID = '2fa_pending_client_id'; private const KEY_RECOVERY_ATTEMPTS = '2fa_recovery_attempts'; public function __construct(protected IUserRecoveryCodeRepository $recovery_code_repository) {} @@ -36,7 +35,6 @@ public function getPendingState(): ?array 'user_id' => $user_id, 'pending_at' => $pending_at, 'remember' => Session::get(self::KEY_REMEMBER, false), - 'client_id' => Session::get(self::KEY_CLIENT_ID), ]; } @@ -45,7 +43,6 @@ public function clearPendingState(): void Session::remove(self::KEY_USER_ID); Session::remove(self::KEY_PENDING_AT); Session::remove(self::KEY_REMEMBER); - Session::remove(self::KEY_CLIENT_ID); Session::remove(self::KEY_RECOVERY_ATTEMPTS); } @@ -62,23 +59,17 @@ public function verifyRecoveryCode(User $user, string $code): void throw new AuthenticationException("Invalid recovery code."); } $recoveryCode->markUsed(); - $this->recovery_code_repository->add($recoveryCode, false); return; } } throw new AuthenticationException("Invalid recovery code."); } - protected function storePendingState(int $userId, bool $remember, ?string $clientId = null): void + protected function storePendingState(int $userId, bool $remember): void { Session::put(self::KEY_USER_ID, $userId); Session::put(self::KEY_PENDING_AT, time()); Session::put(self::KEY_REMEMBER, $remember); - if (is_null($clientId)) { - Session::remove(self::KEY_CLIENT_ID); - } else { - Session::put(self::KEY_CLIENT_ID, $clientId); - } } public function verifyChallenge(User $user, string $code, ?Client $client = null): void diff --git a/app/Strategies/MFA/EmailOTPMFAChallengeStrategy.php b/app/Strategies/MFA/EmailOTPMFAChallengeStrategy.php index a07f366b..5a32052f 100644 --- a/app/Strategies/MFA/EmailOTPMFAChallengeStrategy.php +++ b/app/Strategies/MFA/EmailOTPMFAChallengeStrategy.php @@ -20,9 +20,7 @@ public function __construct( public function issueChallenge(User $user, ?Client $client, bool $remember): array { - // Carry the issuing client into the pending state so verification scopes - // the OTP lookup and sibling-revoke to the same client (see verifyChallenge). - $this->storePendingState($user->getId(), $remember, $client?->getClientId()); + $this->storePendingState($user->getId(), $remember); $otp = $this->token_service->createOTPFromPayload([ OAuth2Protocol::OAuth2PasswordlessConnection => OAuth2Protocol::OAuth2PasswordlessConnectionEmail, @@ -75,14 +73,12 @@ public function verifyChallenge(User $user, string $code, ?Client $client = null } $otp->redeem(); - $this->otp_repository->add($otp, false); // Revoke other pending OTPs for this user, scoped to the same client so we // never burn unrelated OTPs (e.g. passwordless-login codes for other clients). foreach ($this->otp_repository->getByUserNameNotRedeemed($user->getEmail(), $client) as $otpToRevoke) { if ($otpToRevoke->getValue() !== $otp->getValue()) { $otpToRevoke->redeem(); - $this->otp_repository->add($otpToRevoke, false); } } } diff --git a/app/libs/Auth/AuthService.php b/app/libs/Auth/AuthService.php index ed1f6510..6d52be81 100644 --- a/app/libs/Auth/AuthService.php +++ b/app/libs/Auth/AuthService.php @@ -19,7 +19,6 @@ use App\Services\Auth\IUserService as IAuthUserService; use Auth\Exceptions\AuthenticationException; use Auth\Exceptions\AuthenticationLockedUserLoginAttempt; -use Auth\Exceptions\UnverifiedEmailMemberException; use Auth\Repositories\IUserRepository; use Exception; use Illuminate\Support\Facades\Auth; @@ -393,7 +392,6 @@ public function login(string $username, string $password, bool $remember_me): bo { Log::debug("AuthService::login"); - $this->last_login_error = ""; if (!Auth::attempt(['username' => $username, 'password' => $password], $remember_me)) { throw new AuthenticationException ( @@ -799,10 +797,6 @@ public function verifyMFAChallenge( string $value, ?Client $client = null ): void { - // Commits the OTP redeem (+ sibling revoke) as a single tx. Trusted-device - // enrollment and audit are applied by the caller as best-effort, - // non-blocking side effects after this commits, so a failure in either - // does not block (or roll back) an already-verified second factor. $this->tx_service->transaction(function () use ($user, $strategy, $value, $client) { $strategy->verifyChallenge($user, $value, $client); }); diff --git a/tests/Unit/MFA/AbstractMFAChallengeStrategyTest.php b/tests/Unit/MFA/AbstractMFAChallengeStrategyTest.php index cdf2fcfb..1ac5d20b 100644 --- a/tests/Unit/MFA/AbstractMFAChallengeStrategyTest.php +++ b/tests/Unit/MFA/AbstractMFAChallengeStrategyTest.php @@ -93,7 +93,6 @@ public function testVerifyRecoveryCode_withMatchingCode_marksAsUsed(): void $repo->shouldReceive('getUnusedByUser')->with($user)->andReturn([$recoveryCode]); // Lock is taken before mutating (regression guard for 3357348455). $repo->shouldReceive('refreshExclusiveLock')->with($recoveryCode)->once(); - $repo->shouldReceive('add')->with($recoveryCode, false)->once(); $strategy = new class($repo) extends AbstractMFAChallengeStrategy { public function issueChallenge(User $user, ?Client $client, bool $remember): array { return []; } diff --git a/tests/Unit/MFA/EmailOTPMFAChallengeStrategyTest.php b/tests/Unit/MFA/EmailOTPMFAChallengeStrategyTest.php index 7db3b1ba..cdf374c0 100644 --- a/tests/Unit/MFA/EmailOTPMFAChallengeStrategyTest.php +++ b/tests/Unit/MFA/EmailOTPMFAChallengeStrategyTest.php @@ -132,8 +132,6 @@ public function testVerifyChallenge_withValidOtp_redeemsAndRevokesOthers_scopedT ->andReturn([$otherOtp]); // The redeemed code and the revoked sibling are both persisted with deferred flush. - $this->otpRepository->shouldReceive('add')->with($storedOtp, false)->once(); - $this->otpRepository->shouldReceive('add')->with($otherOtp, false)->once(); $this->strategy->verifyChallenge($user, $code, $client); $this->addToAssertionCount(1); From e0b851928d6596790e62ee9a3f17769867b261a9 Mon Sep 17 00:00:00 2001 From: matiasperrone-exo Date: Wed, 24 Jun 2026 18:25:08 +0000 Subject: [PATCH 24/26] feat: Add Login UI MFA Flow --- package.json | 4 +- resources/js/base_actions.js | 34 + resources/js/login/actions.js | 39 +- .../login/components/email_error_actions.js | 60 + .../js/login/components/email_input_form.js | 61 + .../components/existing_account_actions.js | 47 + resources/js/login/components/help_links.js | 78 + .../js/login/components/otp_help_links.js | 20 + .../js/login/components/otp_input_form.js | 117 ++ .../login/components/password_input_form.js | 193 ++ .../js/login/components/recovery_code_form.js | 84 + .../third_party_identity_providers.js | 36 + .../js/login/components/two_factor_form.js | 149 ++ resources/js/login/constants.js | 32 + resources/js/login/login.js | 1793 +++++++++-------- resources/js/login/login.module.scss | 29 + resources/js/shared/HTMLRender.jsx | 27 + resources/views/auth/login.blade.php | 17 + yarn.lock | 14 +- 19 files changed, 1966 insertions(+), 868 deletions(-) create mode 100644 resources/js/login/components/email_error_actions.js create mode 100644 resources/js/login/components/email_input_form.js create mode 100644 resources/js/login/components/existing_account_actions.js create mode 100644 resources/js/login/components/help_links.js create mode 100644 resources/js/login/components/otp_help_links.js create mode 100644 resources/js/login/components/otp_input_form.js create mode 100644 resources/js/login/components/password_input_form.js create mode 100644 resources/js/login/components/recovery_code_form.js create mode 100644 resources/js/login/components/third_party_identity_providers.js create mode 100644 resources/js/login/components/two_factor_form.js create mode 100644 resources/js/login/constants.js create mode 100644 resources/js/shared/HTMLRender.jsx diff --git a/package.json b/package.json index ccf4660e..729fca9c 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "clean": "find . -name \"node_modules\" -type d -prune -exec rm -rf '{}' + && yarn", "build-dev": "./node_modules/.bin/webpack --config webpack.dev.js", "build": "./node_modules/.bin/webpack --config webpack.prod.js", - "serve": "webpack-dev-server --open --port=8888 --https --config webpack.dev.js", + "serve": "webpack-dev-server --open --port=8888 --server-type https --config webpack.dev.js", "test": "jest --watch" }, "devDependencies": { @@ -81,6 +81,7 @@ "bootstrap-tagsinput": "^0.7.1", "chosen-js": "^1.8.7", "crypto-js": "^3.1.9-1", + "dompurify": "^3.4.11", "easymde": "^2.18.0", "font-awesome": "^4.7.0", "formik": "^2.2.9", @@ -95,6 +96,7 @@ "moment": "^2.29.4", "moment-timezone": "^0.5.21", "popper.js": "^1.14.3", + "prop-types": "^15.8.1", "pure": "^2.85.0", "pwstrength-bootstrap": "^3.0.10", "react-otp-input": "^3.1.1", diff --git a/resources/js/base_actions.js b/resources/js/base_actions.js index 18cd3b49..236d12aa 100644 --- a/resources/js/base_actions.js +++ b/resources/js/base_actions.js @@ -92,6 +92,40 @@ export const postRawRequest = (endpoint) => (params, headers = {}) => { }) } +// Like postRawRequest, but also surfaces the final URL the browser landed on after the +// XHR transparently followed any 3xx redirects (res.xhr.responseURL) and the HTTP status. +// Used by flows that complete via a server-side redirect (e.g. 2FA verify) so the SPA can +// navigate the top window to the post-login destination. +export const postRawRequestFull = (endpoint) => (params, headers = {}, queryParams = {}) => { + let url = URI(endpoint); + + if (!isObjectEmpty(queryParams)) + url = url.query(queryParams); + + let key = url.toString(); + + cancel(key); + + let req = http.post(url.toString()); + + schedule(key, req); + + return req.set(headers).send(params).timeout({ + response: 60000, + deadline: 60000, + }).then((res) => { + end(key); + return Promise.resolve({ + response: res.body, + status: res.status, + finalUrl: (res.xhr && res.xhr.responseURL) ? res.xhr.responseURL : null, + }); + }).catch((error) => { + end(key); + return Promise.reject(error); + }) +} + export const putRawRequest = (endpoint) => (payload = null, params={}, headers = {}) => { let url = URI(endpoint); diff --git a/resources/js/login/actions.js b/resources/js/login/actions.js index d0d20ad9..aaea626c 100644 --- a/resources/js/login/actions.js +++ b/resources/js/login/actions.js @@ -1,4 +1,4 @@ -import {postRawRequest} from '../base_actions' +import {postRawRequest, postRawRequestFull } from '../base_actions' export const verifyAccount = (email, token) => { @@ -27,3 +27,40 @@ export const resendVerificationEmail = (email, token) => { return postRawRequest(window.RESEND_VERIFICATION_EMAIL_ENDPOINT)(params, {'X-CSRF-TOKEN': token}); } + +// verify / recovery complete login via a server-side redirect, so use the *Full helper to +// recover the final URL for top-window navigation. +export const verify2FA = (otpValue, method, trustDevice, token) => { + const params = { + otp_value: otpValue, + method: method, + trust_device: trustDevice ? 1 : 0 + }; + + return postRawRequestFull(window.VERIFY_2FA_ENDPOINT)(params, {'X-CSRF-TOKEN': token}); +} + +export const resend2FA = (method, token) => { + const params = { + method: method + }; + + return postRawRequestFull(window.RESEND_2FA_ENDPOINT)(params, {'X-CSRF-TOKEN': token}); +} + +export const verifyRecoveryCode = (recoveryCode, token) => { + const params = { + recovery_code: recoveryCode + }; + + return postRawRequestFull(window.RECOVERY_2FA_ENDPOINT)(params, {'X-CSRF-TOKEN': token}); +} + +export const authenticateWithPassword = (formData, token) => { + const params = Object.fromEntries(formData.entries()); + return postRawRequestFull(window.FORM_ACTION_ENDPOINT)(params, {'X-CSRF-TOKEN': token}); +} + +export const cancelLogin = (token) => { + return postRawRequest(window.CANCEL_LOGIN_ENDPOINT)({}, {'X-CSRF-TOKEN': token}); +} diff --git a/resources/js/login/components/email_error_actions.js b/resources/js/login/components/email_error_actions.js new file mode 100644 index 00000000..4e67ce3b --- /dev/null +++ b/resources/js/login/components/email_error_actions.js @@ -0,0 +1,60 @@ +import React from "react"; +import Grid from "@material-ui/core/Grid"; +import Button from "@material-ui/core/Button"; +import styles from "../login.module.scss"; + +const EmailErrorActions = ({ + emitOtpAction, + createAccountAction, + onValidateEmail, + disableInput, +}) => { + return ( + + + + + + + + + + + + + + ); +}; + +export default EmailErrorActions; diff --git a/resources/js/login/components/email_input_form.js b/resources/js/login/components/email_input_form.js new file mode 100644 index 00000000..ab26ff47 --- /dev/null +++ b/resources/js/login/components/email_input_form.js @@ -0,0 +1,61 @@ +import React from "react"; +import Paper from "@material-ui/core/Paper"; +import TextField from "@material-ui/core/TextField"; +import Button from "@material-ui/core/Button"; +import styles from "../login.module.scss"; +import HTMLRender from "../../shared/HTMLRender"; + +const EmailInputForm = ({ + value, + onValidateEmail, + onHandleUserNameChange, + disableInput, + emailError, +}) => { + return ( + <> + + + {emailError == "" && ( + + )} + + {emailError != "" && ( + + {emailError} + + )} + + ); +}; + +export default EmailInputForm; diff --git a/resources/js/login/components/existing_account_actions.js b/resources/js/login/components/existing_account_actions.js new file mode 100644 index 00000000..649d31fd --- /dev/null +++ b/resources/js/login/components/existing_account_actions.js @@ -0,0 +1,47 @@ +import React from "react"; +import Grid from "@material-ui/core/Grid"; +import Button from "@material-ui/core/Button"; +import Link from "@material-ui/core/Link"; +import styles from "../login.module.scss"; + +const ExistingAccountActions = ({ + emitOtpAction, + forgotPasswordAction, + userName, + disableInput, +}) => { + let forgotPasswordActionHref = forgotPasswordAction; + + if (userName) { + forgotPasswordActionHref = `${forgotPasswordAction}?email=${encodeURIComponent(userName)}`; + } + + return ( + + + + + + e.preventDefault() : undefined} + href={forgotPasswordActionHref} + target="_self" + variant="body2" + > + Reset your password + + + + ); +}; + +export default ExistingAccountActions; diff --git a/resources/js/login/components/help_links.js b/resources/js/login/components/help_links.js new file mode 100644 index 00000000..c4668e00 --- /dev/null +++ b/resources/js/login/components/help_links.js @@ -0,0 +1,78 @@ +import React, { useMemo } from "react"; +import Link from "@material-ui/core/Link"; +import styles from "../login.module.scss"; + +const HelpLinks = ({ + userName, + showEmitOtpAction, + forgotPasswordAction, + showForgotPasswordAction, + showVerifyEmailAction, + verifyEmailAction, + showHelpAction, + helpAction, + appName, + emitOtpAction, +}) => { + const actions = useMemo(() => { + let forgotPasswordActionHref = forgotPasswordAction; + if (userName) { + const separator = forgotPasswordAction.includes("?") ? "&" : "?"; + forgotPasswordActionHref = `${forgotPasswordAction}${separator}email=${encodeURIComponent(userName)}`; + } + + return [ + { + show: showEmitOtpAction, + href: "#", + onClick: emitOtpAction, + label: "Get A Single-use Code emailed to you", + }, + { + show: showForgotPasswordAction, + href: forgotPasswordActionHref, + label: "Reset your password", + }, + { + show: showVerifyEmailAction, + href: verifyEmailAction, + label: `Verify ${appName}`, + }, + { + show: showHelpAction, + href: helpAction, + label: "Having trouble?", + }, + ].filter((action) => action.show); + }, [ + showEmitOtpAction, + showForgotPasswordAction, + showVerifyEmailAction, + showHelpAction, + userName, + forgotPasswordAction, + verifyEmailAction, + helpAction, + appName, + emitOtpAction, + ]); + + return ( + <> +
+ {actions.map((action, index) => ( + + {action.label} + + ))} + + ); +}; + +export default HelpLinks; diff --git a/resources/js/login/components/otp_help_links.js b/resources/js/login/components/otp_help_links.js new file mode 100644 index 00000000..2e51b26e --- /dev/null +++ b/resources/js/login/components/otp_help_links.js @@ -0,0 +1,20 @@ +import React from "react"; +import Link from "@material-ui/core/Link"; +import styles from "../login.module.scss"; + +const OTPHelpLinks = ({ emitOtpAction }) => { + return ( + <> +
+

Didn't receive it ?

+

+ Check your spam folder or{" "} + + resend email. + +

+ + ); +}; + +export default OTPHelpLinks; diff --git a/resources/js/login/components/otp_input_form.js b/resources/js/login/components/otp_input_form.js new file mode 100644 index 00000000..77bd83d2 --- /dev/null +++ b/resources/js/login/components/otp_input_form.js @@ -0,0 +1,117 @@ +import React, { useMemo } from "react"; +import { Turnstile } from "@marsidev/react-turnstile"; +import Button from "@material-ui/core/Button"; +import Link from "@material-ui/core/Link"; +import OtpInput from "react-otp-input"; +import styles from "../login.module.scss"; +import HTMLRender from "../../shared/HTMLRender"; + +const OTPInputForm = ({ + disableInput, + formAction, + onAuthenticate, + otpCode, + otpError, + otpLength, + onCodeChange, + userNameValue, + csrfToken, + shouldShowCaptcha, + captchaPublicKey, + onChangeCaptchaProvider, + onExpireCaptchaProvider, + onErrorCaptchaProvider, + onReset, + loginAttempts, +}) => { + const showCaptcha = shouldShowCaptcha(); + const handleSubmit = (ev) => { + if (!onAuthenticate(ev.target)) + { + ev.preventDefault(); + } + } + + return ( +
+
+ Enter the single-use code sent to your email: +
+
+ } + shouldAutoFocus={true} + hasErrored={!!otpError} + errorStyle={{ border: "1px solid #e5424d" }} + data-testid="otp_code" + /> +
+ {otpError && ( + + {otpError} + + )} +
+ +
+
+

+ + Sign in using a different e-mail + +

+
+
After you login you will be e-mailed a link to
+
set a password and complete your account.
+
+
+ + + + + + + {showCaptcha && captchaPublicKey && ( + + )} + + ); +}; + +export default OTPInputForm; diff --git a/resources/js/login/components/password_input_form.js b/resources/js/login/components/password_input_form.js new file mode 100644 index 00000000..d68b4258 --- /dev/null +++ b/resources/js/login/components/password_input_form.js @@ -0,0 +1,193 @@ +import React, { useRef } from "react"; +import { Turnstile } from "@marsidev/react-turnstile"; +import TextField from "@material-ui/core/TextField"; +import Button from "@material-ui/core/Button"; +import Grid from "@material-ui/core/Grid"; +import FormControlLabel from "@material-ui/core/FormControlLabel"; +import Checkbox from "@material-ui/core/Checkbox"; +import Visibility from "@material-ui/icons/Visibility"; +import VisibilityOff from "@material-ui/icons/VisibilityOff"; +import InputAdornment from "@material-ui/core/InputAdornment"; +import IconButton from "@material-ui/core/IconButton"; +import ExistingAccountActions from "./existing_account_actions"; +import styles from "../login.module.scss"; +import HTMLRender from "../../shared/HTMLRender"; + +const PasswordInputForm = ({ + formAction, + onAuthenticate, + disableInput, + showPassword, + passwordValue, + passwordError, + onUserPasswordChange, + handleClickShowPassword, + handleMouseDownPassword, + userNameValue, + csrfToken, + shouldShowCaptcha, + captchaPublicKey, + onChangeCaptchaProvider, + onExpireCaptchaProvider, + onErrorCaptchaProvider, + handleEmitOtpAction, + forgotPasswordAction, + loginAttempts, + maxLoginFailedAttempts, + userIsActive, + helpAction, +}) => { + const formRef = useRef(null); + const handleContinue = () => onAuthenticate(formRef.current); + const onEnterSubmit = (ev) => { + if (ev.key === "Enter") { + ev.preventDefault(); + handleContinue(); + } + } + + const ErrorMessage = () => { + const attempts = parseInt(loginAttempts, 10); + const maxAttempts = parseInt(maxLoginFailedAttempts, 10); + const attemptsLeft = maxAttempts - attempts; + + if (!passwordError) return null; + + if (attempts > 0 && attempts < maxAttempts && userIsActive) { + return ( +

+ Incorrect password. You have {attemptsLeft} more attempt + {attemptsLeft !== 1 ? "s" : ""} before your account is locked. +

+ ); + } + + if (attempts > 0 && attempts === maxAttempts && userIsActive) { + return ( +

+ Incorrect password. You have reached the maximum ({maxAttempts}) + login attempts. Your account will be locked after another failed + login. +

+ ); + } + + if (attempts > 0 && attempts === maxAttempts && !userIsActive) { + return ( +

+ Your account has been locked due to multiple failed login + attempts. Please contact support to + unlock it. +

+ ); + } + + return ( + + {passwordError} + + ); + }; + + return ( +
ev.preventDefault()} + target="_self" + > + + + {showPassword ? : } + + + ), + }} + /> + + + + + + + + } + label="Remember me" + /> + + + + + + + + {shouldShowCaptcha() && captchaPublicKey && ( + + )} + + + ); +}; + +export default PasswordInputForm; diff --git a/resources/js/login/components/recovery_code_form.js b/resources/js/login/components/recovery_code_form.js new file mode 100644 index 00000000..49ace5f6 --- /dev/null +++ b/resources/js/login/components/recovery_code_form.js @@ -0,0 +1,84 @@ +import React from 'react'; +import TextField from '@material-ui/core/TextField'; +import Button from '@material-ui/core/Button'; +import Link from '@material-ui/core/Link'; +import styles from '../login.module.scss'; +import HTMLRender from '../../shared/HTMLRender'; + +const RecoveryCodeForm = ({ + recoveryCode, + recoveryError, + onRecoveryCodeChange, + onVerify, + onBackToOtp, + onCancel, + disableInput + }) => { + + const handleSubmit = (ev) => { + ev.preventDefault(); + onVerify(); + }; + + const handleBack = (ev) => { + ev.preventDefault(); + onBackToOtp(); + }; + + const handleCancel = (ev) => { + ev.preventDefault(); + onCancel(); + }; + + return ( +
+
Enter a recovery code
+

+ Enter one of the recovery codes you saved when you enabled two-step verification. +

+ + {recoveryError && ( + + {recoveryError} + + )} +
+ +
+
+
+
+ + Back to verification code + + {" · "} + + Cancel + +
+
+ + ); +} + +export default RecoveryCodeForm; diff --git a/resources/js/login/components/third_party_identity_providers.js b/resources/js/login/components/third_party_identity_providers.js new file mode 100644 index 00000000..ee37915c --- /dev/null +++ b/resources/js/login/components/third_party_identity_providers.js @@ -0,0 +1,36 @@ +import React from 'react'; +import DividerWithText from '../../components/divider_with_text'; +import Button from '@material-ui/core/Button'; +import {handleThirdPartyProvidersVerbiage} from '../../utils'; +import styles from '../login.module.scss'; +import '../third_party_identity_providers.scss'; + +const ThirdPartyIdentityProviders = ({ thirdPartyProviders, formAction, disableInput, allowNativeAuth }) => { + return ( + <> + {allowNativeAuth && or} + { + thirdPartyProviders.map((provider) => { + const verbiage = `${handleThirdPartyProvidersVerbiage(provider.name)} with ${provider.label}`; + return ( + + ); + }) + } +

If you have a login, you may still choose to use a social login with the same email address to + access your account.

+ + ); +} + +export default ThirdPartyIdentityProviders; diff --git a/resources/js/login/components/two_factor_form.js b/resources/js/login/components/two_factor_form.js new file mode 100644 index 00000000..fe29f3f6 --- /dev/null +++ b/resources/js/login/components/two_factor_form.js @@ -0,0 +1,149 @@ +import React, {useState, useEffect} from 'react'; +import Button from '@material-ui/core/Button'; +import Link from '@material-ui/core/Link'; +import FormControlLabel from '@material-ui/core/FormControlLabel'; +import Checkbox from '@material-ui/core/Checkbox'; +import OtpInput from 'react-otp-input'; +import {formatTime} from '../../utils'; +import styles from '../login.module.scss'; +import HTMLRender from '../../shared/HTMLRender'; + +// Cooldown applied to the resend action to avoid hammering the resend endpoint +// (the backend also rate-limits server-side). +const RESEND_COOLDOWN_SECONDS = 30; + +const TwoFactorForm = ({ + otpCode, + otpError, + otpLength, + otpLifetime, + codeVersion, + onCodeChange, + onVerify, + trustDevice, + onTrustDeviceChange, + onResend, + onUseRecovery, + onCancel, + disableInput + }) => { + + const [secondsLeft, setSecondsLeft] = useState(otpLifetime || 0); + const [cooldown, setCooldown] = useState(0); + + // Reset the expiry countdown whenever a fresh code is issued (initial render or resend). + useEffect(() => { + setSecondsLeft(otpLifetime || 0); + }, [otpLifetime, codeVersion]); + + // Single 1s ticker drives both the code-expiry countdown and the resend cooldown. + useEffect(() => { + const timer = setInterval(() => { + setSecondsLeft(prev => (prev > 0 ? prev - 1 : 0)); + setCooldown(prev => (prev > 0 ? prev - 1 : 0)); + }, 1000); + return () => clearInterval(timer); + }, []); + + const expired = secondsLeft <= 0; + + const handleSubmit = (ev) => { + ev.preventDefault(); + onVerify(); + }; + + const handleResend = (ev) => { + ev.preventDefault(); + if (cooldown > 0 || disableInput) return; + setCooldown(RESEND_COOLDOWN_SECONDS); + const result = onResend(); + if (result && typeof result.then === 'function') { + result.then(() => setSecondsLeft(otpLifetime || 0)).catch(() => {}); + } + }; + + const handleRecovery = (ev) => { + ev.preventDefault(); + onUseRecovery(); + }; + + const handleCancel = (ev) => { + ev.preventDefault(); + onCancel(); + }; + + return ( +
+
Enter the single-use code sent to your email:
+
+ } + shouldAutoFocus={true} + hasErrored={!!otpError} + errorStyle={{border: '1px solid #e5424d'}} + data-testid="two_factor_code" + /> +
+ {otpError && + + {otpError} + + } +

+ {expired + ? 'Your verification code has expired. Please request a new one.' + : `Code expires in ${formatTime(secondsLeft)}.`} +

+
+ + } + label="Trust this device for 30 days" + /> +
+
+ +
+
+

+ Didn't receive it? Check your spam folder or{" "} + 0 || disableInput) ? styles.disabled_link : ''}> + {cooldown > 0 ? `resend code (${cooldown}s)` : 'resend code'} + . +

+ {/* "Use a different method" is intentionally hidden in Phase I (email_otp only). */} +
+
+ + Cancel + + + Use a recovery code instead + +
+
+
+ ); +} + +export default TwoFactorForm; diff --git a/resources/js/login/constants.js b/resources/js/login/constants.js new file mode 100644 index 00000000..7fd2cdf7 --- /dev/null +++ b/resources/js/login/constants.js @@ -0,0 +1,32 @@ +export const HTTP_CODES = { + OK: 200, + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + FORBIDDEN: 403, + NOT_FOUND: 404, + PRECONDITION_FAILED: 412, + TOO_MANY_REQUESTS: 429, + INTERNAL_SERVER_ERROR: 500, +}; + +export const MFA_METHODS = { + EMAIL_OTP: "email_otp", + TOTP: "totp", +}; + +export const FLOW = { + PASSWORD: "password", + MFA: "2fa", + RECOVERY: "recovery", + OTP: "otp", +}; + +export const OTP_LENGTH_DEFAULT = 6; +export const OTP_TTL_DEFAULT = 300; +export const MFA_METHOD_DEFAULT = MFA_METHODS.EMAIL_OTP; +export const CAPTCHA_FIELD = 'cf-turnstile-response'; + +export const MFA_ERROR_CODE = { + MFA_SESSION_EXPIRED: "mfa_session_expired", + MFA_CHALLENGE_REQUIRED: "mfa_required", +}; diff --git a/resources/js/login/login.js b/resources/js/login/login.js index ee061b9a..789898a7 100644 --- a/resources/js/login/login.js +++ b/resources/js/login/login.js @@ -1,937 +1,1000 @@ -import React from 'react'; +import React from "react"; import { Turnstile } from "@marsidev/react-turnstile"; -import ReactDOM from 'react-dom'; -import Avatar from '@material-ui/core/Avatar'; -import Button from '@material-ui/core/Button'; -import CssBaseline from '@material-ui/core/CssBaseline'; -import TextField from '@material-ui/core/TextField'; -import Link from '@material-ui/core/Link'; -import Typography from '@material-ui/core/Typography'; -import Paper from '@material-ui/core/Paper'; -import Container from '@material-ui/core/Container'; -import Chip from '@material-ui/core/Chip'; -import FormControlLabel from '@material-ui/core/FormControlLabel'; -import Checkbox from '@material-ui/core/Checkbox'; -import {verifyAccount, emitOTP, resendVerificationEmail} from './actions'; -import {MuiThemeProvider, createTheme} from '@material-ui/core/styles'; -import DividerWithText from '../components/divider_with_text'; -import Visibility from '@material-ui/icons/Visibility'; -import VisibilityOff from '@material-ui/icons/VisibilityOff'; -import InputAdornment from '@material-ui/core/InputAdornment'; -import IconButton from '@material-ui/core/IconButton'; -import { emailValidator } from '../validator'; -import Grid from '@material-ui/core/Grid'; +import ReactDOM from "react-dom"; +import Avatar from "@material-ui/core/Avatar"; +import Button from "@material-ui/core/Button"; +import CssBaseline from "@material-ui/core/CssBaseline"; +import Typography from "@material-ui/core/Typography"; +import Container from "@material-ui/core/Container"; +import Chip from "@material-ui/core/Chip"; +import { MuiThemeProvider, createTheme } from "@material-ui/core/styles"; +import { + verifyAccount, + emitOTP, + resendVerificationEmail, + verify2FA, + resend2FA, + verifyRecoveryCode, + authenticateWithPassword, + cancelLogin, +} from "./actions"; +import { emailValidator } from "../validator"; import CustomSnackbar from "../components/custom_snackbar"; -import Banner from '../components/banner/banner'; -import OtpInput from 'react-otp-input'; -import {handleErrorResponse, handleThirdPartyProvidersVerbiage} from '../utils'; - -import styles from './login.module.scss' +import Banner from "../components/banner/banner"; +import { handleErrorResponse } from "../utils"; + +import EmailInputForm from "./components/email_input_form"; +import PasswordInputForm from "./components/password_input_form"; +import OTPInputForm from "./components/otp_input_form"; +import HelpLinks from "./components/help_links"; +import OTPHelpLinks from "./components/otp_help_links"; +import EmailErrorActions from "./components/email_error_actions"; +import ThirdPartyIdentityProviders from "./components/third_party_identity_providers"; +import TwoFactorForm from "./components/two_factor_form"; +import RecoveryCodeForm from "./components/recovery_code_form"; + +import styles from "./login.module.scss"; import "./third_party_identity_providers.scss"; +import { + FLOW, + HTTP_CODES, + MFA_ERROR_CODE, + OTP_LENGTH_DEFAULT, + OTP_TTL_DEFAULT, + MFA_METHOD_DEFAULT, +} from "./constants"; -const EmailInputForm = ({ value, onValidateEmail, onHandleUserNameChange, disableInput, emailError }) => { - - return ( - <> - - - {emailError == "" && - - } - - { emailError != "" && -

- } - - ); -} - -const PasswordInputForm = ({ - formAction, - onAuthenticate, - disableInput, - showPassword, - passwordValue, - passwordError, - onUserPasswordChange, - handleClickShowPassword, - handleMouseDownPassword, - userNameValue, - csrfToken, - shouldShowCaptcha, - captchaPublicKey, - onChangeCaptchaProvider, - onExpireCaptchaProvider, - onErrorCaptchaProvider, - handleEmitOtpAction, - forgotPasswordAction, - loginAttempts, - maxLoginFailedAttempts, - userIsActive, - helpAction - }) => { - return ( -
- - - {showPassword ? : } - - - ) - }} - /> - {(() => { - const attempts = parseInt(loginAttempts, 10); - const maxAttempts = parseInt(maxLoginFailedAttempts, 10); - const attemptsLeft = maxAttempts - attempts; - - if (!passwordError) return null; - - if (attempts > 0 && attempts < maxAttempts && userIsActive) { - return ( - <> -

- Incorrect password. You have {attemptsLeft} more attempt{attemptsLeft !== 1 ? 's' : ''} before your account is locked. -

- - ); - } - - if (attempts > 0 && attempts === maxAttempts && userIsActive) { - return ( - <> -

- Incorrect password. You have reached the maximum ({maxAttempts}) login attempts. Your account will be locked after another failed login. -

- - ); - } - - if (attempts > 0 && attempts === maxAttempts && !userIsActive) { - return ( - <> -

- Your account has been locked due to multiple failed login attempts. Please contact support to unlock it. -

- - ); - } - - return

; - })()} - - - - - - - } - label="Remember me" - /> - - - - - - - - {shouldShowCaptcha() && captchaPublicKey && - - } - - - ); -} - -const OTPInputForm = ({ - disableInput, - formAction, - onAuthenticate, - otpCode, - otpError, - otpLength, - onCodeChange, - userNameValue, - csrfToken, - shouldShowCaptcha, - captchaPublicKey, - onChangeCaptchaProvider, - onExpireCaptchaProvider, - onErrorCaptchaProvider, - onReset, - loginAttempts - }) => { - return ( - <> -
-
Enter the single-use code sent to your email:
-
- } - shouldAutoFocus={true} - hasErrored={!otpError} - errorStyle={{border: '1px solid #e5424d'}} - data-testid="otp_code" - /> -
- {otpError && -

- } -
- -
-
-

- - Sign in using a different e-mail - -

-
-
After you login you will be e-mailed a link to
-
set a password and complete your account.
-
-
- - - - - - - {shouldShowCaptcha() && captchaPublicKey && - - } - - - ); -} +class LoginPage extends React.Component { + constructor(props) { + super(props); + this.state = { + user_name: props.userName, + user_password: "", + otpCode: "", + user_pic: props.user_pic ?? null, + user_fullname: props.user_fullname ?? null, + user_verified: props.user_verified ?? false, + user_active: props.user_active ?? null, + email_verified: props.email_verified ?? null, + errors: { + email: "", + otp: props.authError ?? "", + password: props.authError ?? "", + twofactor: "", + recovery: "", + }, + notification: { + message: null, + severity: "info", + }, + captcha_value: "", + showPassword: false, + disableInput: false, + authFlow: props.flow, + allowNativeAuth: props.allowNativeAuth, + showInfoBanner: props.showInfoBanner, + infoBannerContent: props.infoBannerContent, + // Two-factor state (populated from the flash redirect when a challenge is required). + otpLength: props.otpLength ?? OTP_LENGTH_DEFAULT, + otpLifetime: props.otpLifetime ?? OTP_TTL_DEFAULT, + mfaMethod: props.mfaMethod ?? MFA_METHOD_DEFAULT, + trustDevice: false, + twoFactorCode: "", + recoveryCode: "", + codeVersion: 0, + }; -const HelpLinks = ({ - userName, - showEmitOtpAction, - forgotPasswordAction, - showForgotPasswordAction, - showVerifyEmailAction, - verifyEmailAction, - showHelpAction, - helpAction, - appName, - emitOtpAction - }) => { - if (userName) { - forgotPasswordAction = `${forgotPasswordAction}?email=${encodeURIComponent(userName)}`; + if (props.authError != "" && !this.state.user_fullname) { + this.state.user_fullname = props.userName; } - return ( - <> -
- { - showEmitOtpAction && - - Get A Single-use Code emailed to you - - } - { - showForgotPasswordAction && - - Reset your password - - } - { - showVerifyEmailAction && - - Verify {appName} - - } - {showHelpAction && - - Having trouble? - - } - - ); -} - -const OTPHelpLinks = ({ emitOtpAction }) => { - return ( - <> -
-

Didn't receive it ?

-

Check your spam folder or resend email. -

- - ); -} - -const EmailErrorActions = ({ emitOtpAction, createAccountAction, onValidateEmail, disableInput }) => { - return ( - - - - - - - - - - - - - - ); -} - -const ExistingAccountActions = ({emitOtpAction, forgotPasswordAction, userName, disableInput}) => { - if (userName) { - forgotPasswordAction = `${forgotPasswordAction}?email=${encodeURIComponent(userName)}`; + if ( + this.state.errors.password && + this.state.errors.password.includes("is not yet verified") + ) { + this.state.errors.password = + this.state.errors.password + + `Or have another verification email sent to you.`; } - return ( - - - - - - - Reset your password - - - + this.onHandleUserNameChange = this.onHandleUserNameChange.bind(this); + this.onValidateEmail = this.onValidateEmail.bind(this); + this.handleDelete = this.handleDelete.bind(this); + this.onAuthenticate = this.onAuthenticate.bind(this); + this.onChangeCaptchaProvider = this.onChangeCaptchaProvider.bind(this); + this.onExpireCaptchaProvider = this.onExpireCaptchaProvider.bind(this); + this.onErrorCaptchaProvider = this.onErrorCaptchaProvider.bind(this); + this.onUserPasswordChange = this.onUserPasswordChange.bind(this); + this.onOTPCodeChange = this.onOTPCodeChange.bind(this); + this.shouldShowCaptcha = this.shouldShowCaptcha.bind(this); + this.handleClickShowPassword = this.handleClickShowPassword.bind(this); + this.handleMouseDownPassword = this.handleMouseDownPassword.bind(this); + this.handleEmitOtpAction = this.handleEmitOtpAction.bind(this); + this.resendVerificationEmail = this.resendVerificationEmail.bind(this); + this.handleSnackbarClose = this.handleSnackbarClose.bind(this); + this.showAlert = this.showAlert.bind(this); + this.onTwoFactorCodeChange = this.onTwoFactorCodeChange.bind(this); + this.onRecoveryCodeChange = this.onRecoveryCodeChange.bind(this); + this.onTrustDeviceChange = this.onTrustDeviceChange.bind(this); + this.onVerify2FA = this.onVerify2FA.bind(this); + this.onResend2FA = this.onResend2FA.bind(this); + this.onVerifyRecovery = this.onVerifyRecovery.bind(this); + this.onUseRecovery = this.onUseRecovery.bind(this); + this.onBackToOtp = this.onBackToOtp.bind(this); + this.resetToPasswordFlow = this.resetToPasswordFlow.bind(this); + } + + showAlert(message, severity) { + this.setState({ + ...this.state, + notification: { + message: message, + severity: severity, + }, + }); + } + + emitOtpAction() { + let user_fullname = this.state.user_fullname + ? this.state.user_fullname + : this.state.user_name; + + emitOTP(this.state.user_name, this.props.token).then( + (payload) => { + let { response } = payload; + this.setState({ + ...this.state, + authFlow: FLOW.OTP, + errors: { + email: "", + otp: "", + password: "", + }, + user_verified: true, + user_fullname: user_fullname, + }); + }, + (error) => { + let { response, status, message } = error; + if (status == 412) { + const { message, errors } = response.body; + this.showAlert(errors[0], "error"); + return; + } + this.showAlert("Oops... Something went wrong!", "error"); + }, ); -} + return false; + } -const ThirdPartyIdentityProviders = ({ thirdPartyProviders, formAction, disableInput, allowNativeAuth }) => { + handleEmitOtpAction(ev) { + ev.preventDefault(); + return this.emitOtpAction(); + } + + shouldShowCaptcha() { return ( - <> - {allowNativeAuth && or} - { - thirdPartyProviders.map((provider) => { - const verbiage = `${handleThirdPartyProvidersVerbiage(provider.name)} with ${provider.label}`; - return ( - - ); - }) - } -

If you have a login, you may still choose to use a social login with the same email address to - access your account.

- + typeof this.props.maxLoginAttempts2ShowCaptcha !== "undefined" && + typeof this.props.loginAttempts !== "undefined" && + this.props.loginAttempts >= this.props.maxLoginAttempts2ShowCaptcha ); -} + } -const otp_flow = 'otp'; -const password_flow = 'password'; - -class LoginPage extends React.Component { + handleAuthenticateValidation() { - constructor(props) { - super(props); - this.state = { - user_name: props.userName, - user_password: '', - otpCode: '', - user_pic: props.hasOwnProperty('user_pic') ? props.user_pic : null, - user_fullname: props.hasOwnProperty('user_fullname') ? props.user_fullname : null, - user_verified: props.hasOwnProperty('user_verified') ? props.user_verified : false, - user_active: props.hasOwnProperty('user_active') ? props.user_active : null, - email_verified: props.hasOwnProperty('email_verified') ? props.email_verified : null, - errors: { - email: '', - otp: props.authError != '' ? props.authError : '', - password: props.authError != '' ? props.authError : '', - }, - notification: { - message: null, - severity: 'info' - }, - captcha_value: '', - showPassword: false, + switch (this.state.authFlow) { + case FLOW.OTP: + if (this.state.otpCode == "") { + this.setState({ + ...this.state, disableInput: false, - authFlow: props.flow, - allowNativeAuth: props.allowNativeAuth, - showInfoBanner: props.showInfoBanner, - infoBannerContent: props.infoBannerContent, - } - - if (props.authError != '' && !this.state.user_fullname) { - this.state.user_fullname = props.userName; + errors: { ...this.state.errors, otp: "Single-use code is empty" }, + }); + return false; } - - if (this.state.errors.password && this.state.errors.password.includes("is not yet verified")) { - this.state.errors.password = this.state.errors.password + `Or have another verification email sent to you.`; + break; + default: + if (this.state.user_password == "") { + this.setState({ + ...this.state, + disableInput: false, + errors: { ...this.state.errors, password: "Password is empty" }, + }); + return false; } - this.onHandleUserNameChange = this.onHandleUserNameChange.bind(this); - this.onValidateEmail = this.onValidateEmail.bind(this); - this.handleDelete = this.handleDelete.bind(this); - this.onAuthenticate = this.onAuthenticate.bind(this); - this.onChangeCaptchaProvider = this.onChangeCaptchaProvider.bind(this); - this.onExpireCaptchaProvider = this.onExpireCaptchaProvider.bind(this); - this.onErrorCaptchaProvider = this.onErrorCaptchaProvider.bind(this); - this.onUserPasswordChange = this.onUserPasswordChange.bind(this); - this.onOTPCodeChange = this.onOTPCodeChange.bind(this); - this.shouldShowCaptcha = this.shouldShowCaptcha.bind(this); - this.handleClickShowPassword = this.handleClickShowPassword.bind(this); - this.handleMouseDownPassword = this.handleMouseDownPassword.bind(this); - this.handleEmitOtpAction = this.handleEmitOtpAction.bind(this); - this.resendVerificationEmail = this.resendVerificationEmail.bind(this); - this.handleSnackbarClose = this.handleSnackbarClose.bind(this); - this.showAlert = this.showAlert.bind(this); - } - - showAlert(message, severity) { - this.setState({ + if (this.state.captcha_value == "" && this.shouldShowCaptcha()) { + this.setState({ ...this.state, - notification: { - message: message, - severity: severity - } - }); + disableInput: false, + errors: { ...this.state.errors, password: "you must check CAPTCHA" }, + }); + return false; + } } - emitOtpAction() { - let user_fullname = this.state.user_fullname ? this.state.user_fullname : this.state.user_name; - - emitOTP(this.state.user_name, this.props.token).then((payload) => { - let {response} = payload; - this.setState({ - ...this.state, - authFlow: otp_flow, - errors: { - email: '', - otp: '', - password: '' - }, - user_verified: true, - user_fullname: user_fullname, - }); - }, (error) => { - let {response, status, message} = error; - if(status == 412){ - const {message, errors} = response.body; - this.showAlert(errors[0], 'error'); - return; - } - this.showAlert('Oops... Something went wrong!', 'error'); - }); - return false; + return true; + } + + handleAuthenticatePasswordOk(payload) { + const { response, status, finalUrl } = payload || {}; + const { error_code, otp_length, otp_lifetime } = response || {}; + + switch (error_code) { + case MFA_ERROR_CODE.MFA_CHALLENGE_REQUIRED: + this.setState((prevState) => ({ + ...prevState, + authFlow: FLOW.MFA, + disableInput: false, + otpLength: otp_length ?? prevState.otpLength, + otpLifetime: otp_lifetime ?? prevState.otpLifetime, + })); + break; + default: + { + const redirect = finalUrl && status === HTTP_CODES.OK; + if (redirect) { + window.location.href = finalUrl; + } else { + this.showAlert("Oops... Something went wrong!", "error"); + this.setState((prevState) => ({ + ...prevState, + disableInput: false, + })); + } + } } - - handleEmitOtpAction(ev) { - ev.preventDefault(); - return this.emitOtpAction(); + } + + handleAuthenticatePasswordError(error) { + let { response, status, message } = error; + this.setState((prevState) => ({ ...prevState, disableInput: false })); + if (status === HTTP_CODES.UNAUTHORIZED) { + this.showAlert("Invalid username or password.", "error"); + } else { + this.showAlert("Oops... Something went wrong!", "error"); } + } - shouldShowCaptcha() { - return ( - this.props.hasOwnProperty('maxLoginAttempts2ShowCaptcha') && - this.props.hasOwnProperty('loginAttempts') && - this.props.loginAttempts >= this.props.maxLoginAttempts2ShowCaptcha - ) - } + handleAuthenticatePasswordFlow(form) { + const formData = new FormData(form); - onAuthenticate(ev) { - if (this.state.authFlow === otp_flow) { - if (this.state.otpCode == '') { - this.setState({...this.state, disableInput: false, errors: {...this.state.errors, otp: 'Single-use code is empty'}}); - ev.preventDefault(); - return false; - } - } else if (this.state.user_password == '') { - this.setState({...this.state, disableInput: false, errors: {...this.state.errors, password: 'Password is empty'}}); - ev.preventDefault(); - return false; - } + authenticateWithPassword(formData, this.props.token) + .then( + (payload) => { + this.handleAuthenticatePasswordOk(payload); + }, + (error) => this.handleAuthenticatePasswordError(error) + ); + } - if (this.state.captcha_value == '' && this.shouldShowCaptcha()) { - this.setState({...this.state, disableInput: false, errors: {...this.state.errors, password: 'you must check CAPTCHA'}}); - ev.preventDefault(); - return false; - } - this.setState({ ...this.state, disableInput: true }); - return true; + onAuthenticate(form) { + + if (!this.handleAuthenticateValidation()) { + return false; } - onChangeCaptchaProvider(value) { - this.setState({ ...this.state, captcha_value: value }); + this.setState({ ...this.state, disableInput: true }); + + + if (this.state.authFlow === FLOW.PASSWORD) { + this.handleAuthenticatePasswordFlow(form); + return false; } - onExpireCaptchaProvider() { - this.setState({ ...this.state, captcha_value: '' }); + return true; + } + + onChangeCaptchaProvider(value) { + this.setState({ ...this.state, captcha_value: value }); + } + + onExpireCaptchaProvider() { + this.setState({ ...this.state, captcha_value: "" }); + } + + onErrorCaptchaProvider() { + this.setState({ ...this.state, captcha_value: "" }); + } + + onHandleUserNameChange(ev) { + let { value, id } = ev.target; + this.setState({ ...this.state, user_name: value }); + } + + onUserPasswordChange(ev) { + let { errors } = this.state; + let { value, id } = ev.target; + if (value == "") + // clean error + errors[id] = ""; + this.setState({ + ...this.state, + user_password: value, + errors: { ...errors }, + }); + } + + onOTPCodeChange(value) { + this.setState({ ...this.state, otpCode: value }); + } + + onTwoFactorCodeChange(value) { + this.setState({ + ...this.state, + twoFactorCode: value, + errors: { ...this.state.errors, twofactor: "" }, + }); + } + + onRecoveryCodeChange(ev) { + let { value } = ev.target; + this.setState({ + ...this.state, + recoveryCode: value, + errors: { ...this.state.errors, recovery: "" }, + }); + } + + onTrustDeviceChange(ev) { + this.setState({ ...this.state, trustDevice: ev.target.checked }); + } + + /** + * Resets client-side MFA state and returns the user to the password screen. + */ + resetToPasswordFlow() { + this.setState({ + ...this.state, + authFlow: FLOW.PASSWORD, + disableInput: false, + twoFactorCode: "", + user_name: "", + user_password: "", + user_pic: "", + user_fullname: "", + user_verified: false, + recoveryCode: "", + trustDevice: false, + errors: { + ...this.state.errors, + twofactor: "", + recovery: "", + email: "", + otp: "", + password: "", + }, + }); + cancelLogin(this.props.token); + } + + /** + * Shared error handling for the 2FA verify / recovery AJAX calls. + * @param {*} error superagent error + * @param {string} field 'twofactor' | 'recovery' + */ + handleMfaError(error, field) { + const status = error ? error.status : undefined; + const body = error && error.response ? error.response.body : null; + const code = body ? body.error_code : null; + + if ( + status === HTTP_CODES.UNAUTHORIZED && + code === MFA_ERROR_CODE.MFA_SESSION_EXPIRED + ) { + this.resetToPasswordFlow(); + this.showAlert( + "Your verification session has expired. Please sign in again.", + "warning", + ); + return; } - onErrorCaptchaProvider() { - this.setState({ ...this.state, captcha_value: '' }); + if (status === HTTP_CODES.TOO_MANY_REQUESTS) { + const msg = + body && body.error_message + ? body.error_message + : "Too many attempts. Please try again later."; + this.setState({ + ...this.state, + disableInput: false, + errors: { ...this.state.errors, [field]: msg }, + }); + return; } - onHandleUserNameChange(ev) { - let { value, id } = ev.target; - this.setState({ ...this.state, user_name: value }); + if (status === HTTP_CODES.UNAUTHORIZED) { + const msg = + field === "recovery" + ? "Invalid recovery code. Please try again." + : "Invalid or expired verification code. Please try again."; + this.setState({ + ...this.state, + disableInput: false, + errors: { ...this.state.errors, [field]: msg }, + }); + return; } - onUserPasswordChange(ev) { - let {errors} = this.state; - let {value, id} = ev.target; - if (value == "") // clean error - errors[id] = ''; - this.setState({...this.state, user_password: value, errors: {...errors}}); + if (status === HTTP_CODES.PRECONDITION_FAILED) { + this.setState({ + ...this.state, + disableInput: false, + errors: { ...this.state.errors, [field]: "Please enter a valid code." }, + }); + return; } - onOTPCodeChange(value) { - this.setState({...this.state, otpCode: value}); + /** + * No HTTP status: the XHR likely followed a (possibly cross-origin) success redirect + * it could not read. The IDP session may already be established, so reload and let + * the server route us to the right place; a genuine network error just re-shows login. + */ + if (typeof status === "undefined" || status === 0) { + window.location.reload(); + return; } - onValidateEmail(ev) { + this.setState({ ...this.state, disableInput: false }); + this.showAlert("Oops... Something went wrong!", "error"); + } + + onVerify2FA() { + if (this.state.disableInput) return; + const { twoFactorCode, trustDevice, mfaMethod } = this.state; + if (twoFactorCode === "") { + this.setState({ + ...this.state, + errors: { + ...this.state.errors, + twofactor: "Verification code is empty", + }, + }); + return; + } + this.setState({ + ...this.state, + disableInput: true, + errors: { ...this.state.errors, twofactor: "" }, + }); + + verify2FA(twoFactorCode, mfaMethod, trustDevice, this.props.token).then( + (payload) => { + // Success: the backend redirected (302) and the XHR followed it; navigate the top + // window to the final destination to resume the normal redirect / OIDC flow. + window.location.href = payload.finalUrl || window.location.href; + }, + (error) => { + this.handleMfaError(error, "twofactor"); + }, + ); + } - ev.preventDefault(); - let {user_name} = this.state; - user_name = user_name?.trim(); + onResend2FA() { + const promise = resend2FA(this.state.mfaMethod, this.props.token); - if (user_name == '') { - return false; + promise.then( + (payload) => { + const { response } = payload; + this.setState({ + ...this.state, + otpLength: + response && response.otp_length + ? response.otp_length + : this.state.otpLength, + otpLifetime: + response && response.otp_lifetime + ? response.otp_lifetime + : this.state.otpLifetime, + codeVersion: this.state.codeVersion + 1, + errors: { ...this.state.errors, twofactor: "" }, + }); + this.showAlert( + "A new verification code has been sent to your email.", + "success", + ); + }, + (error) => { + const status = error ? error.status : undefined; + const body = error && error.response ? error.response.body : null; + const code = body ? body.error_code : null; + + if ( + status === HTTP_CODES.UNAUTHORIZED && + code === MFA_ERROR_CODE.MFA_SESSION_EXPIRED + ) { + this.resetToPasswordFlow(); + this.showAlert( + "Your verification session has expired. Please sign in again.", + "warning", + ); + return; } - if (!emailValidator(user_name)) { - return false; + if (status === HTTP_CODES.TOO_MANY_REQUESTS) { + const msg = + body && body.error_message + ? body.error_message + : "Too many attempts. Please try again later."; + this.showAlert(msg, "warning"); + return; } - this.setState({ ...this.state, disableInput: true }); - - verifyAccount(user_name, this.props.token).then((payload) => { - let { response } = payload; - - let error = ''; - if (response.is_active === false) { - error = `Your user account is currently locked. Please contact support for further assistance.`; - } else if (response.is_active === true && response.is_verified === false) { - error = 'Your email has not been verified. Please check your inbox or resend the verification email.'; - } - - this.setState({ - ...this.state, - user_pic: response.pic, - user_fullname: response.full_name, - user_verified: true, - user_active: response.is_active, - email_verified: response.is_verified, - authFlow: response.has_password_set ? password_flow : otp_flow, - errors: { - email: error, - otp: '', - password: '' - }, - disableInput: false - }, function () { - //Once the state is updated, it's now possible to trigger emitOtpAction. - //No need to wait for the component to update. - if (!response.has_password_set && response.is_verified !== false) { - this.emitOtpAction(); - } - }); - }, (error) => { - - let { response, status, message } = error; - - let newErrors = {}; - - newErrors['password'] = ''; - newErrors['email'] = " "; - - if (status == 429) { - newErrors['email'] = "Too many requests. Try it later."; - } + this.showAlert( + "Oops... Something went wrong while resending the code.", + "error", + ); + }, + ); - this.setState({ - ...this.state, - user_pic: null, - user_fullname: null, - user_verified: false, - errors: newErrors, - disableInput: false - }); - }); - return true; + // Returned so the form can reset its expiry countdown once the resend resolves. + return promise; + } + + onVerifyRecovery() { + if (this.state.disableInput) return; + const { recoveryCode } = this.state; + if (recoveryCode === "") { + this.setState({ + ...this.state, + errors: { ...this.state.errors, recovery: "Recovery code is empty" }, + }); + return; } - - resendVerificationEmail(ev) { - ev.preventDefault(); - let {user_name} = this.state; - user_name = user_name?.trim(); - - if (!user_name) { - this.showAlert( - 'Something went wrong while trying to resend the verification email. Please try again later.', - 'error'); - return; - } - - resendVerificationEmail(user_name, this.props.token).then((payload) => { - this.showAlert( - 'We\'ve sent you a verification email. Please check your inbox and click the link to verify your account.', - 'success'); - }, (error) => { - handleErrorResponse(error, (title, messageLines, type) => { - const message = (messageLines ?? []).join(', ') - this.showAlert(`${title}: ${message}`, type); - }); - }); + this.setState({ + ...this.state, + disableInput: true, + errors: { ...this.state.errors, recovery: "" }, + }); + + verifyRecoveryCode(recoveryCode, this.props.token).then( + (payload) => { + window.location.href = payload.finalUrl || window.location.href; + }, + (error) => { + this.handleMfaError(error, "recovery"); + }, + ); + } + + onUseRecovery() { + this.setState({ + ...this.state, + authFlow: FLOW.RECOVERY, + errors: { ...this.state.errors, recovery: "" }, + }); + } + + onBackToOtp() { + this.setState({ + ...this.state, + authFlow: FLOW.MFA, + errors: { ...this.state.errors, twofactor: "" }, + }); + } + + onValidateEmail(ev) { + ev.preventDefault(); + let { user_name } = this.state; + user_name = user_name?.trim(); + + if (user_name == "") { + return false; + } + if (!emailValidator(user_name)) { + return false; } + this.setState({ ...this.state, disableInput: true }); + + verifyAccount(user_name, this.props.token).then( + (payload) => { + let { response } = payload; + + let error = ""; + if (response.is_active === false) { + error = `Your user account is currently locked. Please contact support for further assistance.`; + } else if ( + response.is_active === true && + response.is_verified === false + ) { + error = + "Your email has not been verified. Please check your inbox or resend the verification email."; + } - handleDelete(ev) { - ev.preventDefault(); - this.setState({ + this.setState( + { ...this.state, - user_name: null, - user_pic: null, - user_fullname: null, - user_verified: false, - user_active: null, - email_verified: null, - authFlow: "password", + user_pic: response.pic, + user_fullname: response.full_name, + user_verified: true, + user_active: response.is_active, + email_verified: response.is_verified, + authFlow: response.has_password_set ? FLOW.PASSWORD : FLOW.OTP, errors: { - email: '', - otp: '', - password: '' + email: error, + otp: "", + password: "", + }, + disableInput: false, + }, + function () { + //Once the state is updated, it's now possible to trigger emitOtpAction. + //No need to wait for the component to update. + if (!response.has_password_set && response.is_verified !== false) { + this.emitOtpAction(); } - }); - return false; - } - - handleClickShowPassword(ev) { - ev.preventDefault(); - this.setState({ ...this.state, showPassword: !this.state.showPassword }) - } + }, + ); + }, + (error) => { + let { response, status, message } = error; - handleMouseDownPassword(ev) { - ev.preventDefault(); - } + let newErrors = {}; - existingUserCanContinue() { - const { user_active, email_verified } = this.state; - return user_active !== false && email_verified !== false; - } + newErrors["password"] = ""; + newErrors["email"] = " "; - getSignUpSignInTitle() { - const { errors, user_active } = this.state; + if (status == HTTP_CODES.TOO_MANY_REQUESTS) { + newErrors["email"] = "Too many requests. Try it later."; + } - if (errors.email && this.existingUserCanContinue()) { - return 'Create an account for:'; - } - return 'Sign in'; + this.setState({ + ...this.state, + user_pic: null, + user_fullname: null, + user_verified: false, + errors: newErrors, + disableInput: false, + }); + }, + ); + return true; + } + + resendVerificationEmail(ev) { + ev.preventDefault(); + let { user_name } = this.state; + user_name = user_name?.trim(); + + if (!user_name) { + this.showAlert( + "Something went wrong while trying to resend the verification email. Please try again later.", + "error", + ); + return; } - handleSnackbarClose() { - this.setState({ - ...this.state, - notification: { - message: null, - severity: 'info' - } + resendVerificationEmail(user_name, this.props.token).then( + (payload) => { + this.showAlert( + "We've sent you a verification email. Please check your inbox and click the link to verify your account.", + "success", + ); + }, + (error) => { + handleErrorResponse(error, (title, messageLines, type) => { + const message = (messageLines ?? []).join(", "); + this.showAlert(`${title}: ${message}`, type); }); - }; + }, + ); + } + + handleDelete(ev) { + ev.preventDefault(); + this.setState({ + ...this.state, + user_name: null, + user_pic: null, + user_fullname: null, + user_verified: false, + user_active: null, + email_verified: null, + authFlow: "password", + errors: { + email: "", + otp: "", + password: "", + }, + }); + return false; + } + + handleClickShowPassword(ev) { + ev.preventDefault(); + this.setState({ ...this.state, showPassword: !this.state.showPassword }); + } + + handleMouseDownPassword(ev) { + ev.preventDefault(); + } + + existingUserCanContinue() { + const { user_active, email_verified } = this.state; + return user_active !== false && email_verified !== false; + } + + isMfaFlow() { + return ( + this.state.authFlow === FLOW.MFA || this.state.authFlow === FLOW.RECOVERY + ); + } - componentDidUpdate(prevProps, prevState) { - if (this.state.user_verified && this.existingUserCanContinue() && prevState.authFlow !== this.state.authFlow) { - this.setState({ - ...this.state, - captcha_value: '', - }); - } + getSignUpSignInTitle() { + const { errors, user_active } = this.state; + + if (errors.email && this.existingUserCanContinue()) { + return "Create an account for:"; + } + return "Sign in"; + } + + handleSnackbarClose() { + this.setState({ + ...this.state, + notification: { + message: null, + severity: "info", + }, + }); + } + + componentDidUpdate(prevProps, prevState) { + if ( + this.state.user_verified && + this.existingUserCanContinue() && + prevState.authFlow !== this.state.authFlow + ) { + this.setState({ + ...this.state, + captcha_value: "", + }); } + } + + render() { + const showTwoFactorForm = this.state.authFlow === FLOW.MFA; + const showRecoveryForm = this.state.authFlow === FLOW.RECOVERY; + const isPasswordFlow = + !showTwoFactorForm && + !showRecoveryForm && + !this.isMfaFlow() && + this.state.user_verified && + this.existingUserCanContinue() && + this.state.authFlow === FLOW.PASSWORD; + const isOtpFlow = + !showTwoFactorForm && + !showRecoveryForm && + !this.isMfaFlow() && + this.state.user_verified && + this.existingUserCanContinue() && + this.state.authFlow === FLOW.OTP; + const showDefaultFlow = !showTwoFactorForm && !showRecoveryForm && !isPasswordFlow && !isOtpFlow; + const createAccountAction = this.props.createAccountAction + + (this.state.user_name ? `?email=${encodeURIComponent(this.state.user_name)}` : ""); - render() { - return ( - - - {this.state.showInfoBanner && } - -
- - {this.props.appName} - - - {this.getSignUpSignInTitle()} - {this.state.user_fullname && - } - variant="outlined" - className={styles.valid_user_name_chip} - label={this.state.user_name} - onDelete={this.handleDelete}/> - } - - {(!this.state.user_verified || !this.existingUserCanContinue()) && - <> - {this.state.allowNativeAuth && - - } - {this.state.errors.email === '' && - this.props.thirdPartyProviders.length > 0 && - - } - { - // we already had an interaction and got an user error... - this.state.errors.email !== '' && - <> - {this.existingUserCanContinue() && - - } - { - this.state.user_active === true && this.state.email_verified === false && - - } - - - } - - } - {this.state.user_verified && this.existingUserCanContinue() && this.state.authFlow === password_flow && - // proceed to ask for password ( 2nd step ) - <> - - - - } - {this.state.user_verified && this.existingUserCanContinue() && this.state.authFlow === otp_flow && - // proceed to ask for password ( 2nd step ) - <> - - - - } - + + {this.state.showInfoBanner && ( + + )} + +
+ + + {this.props.appName} + + + + {this.getSignUpSignInTitle()} + {this.state.user_fullname && ( + + } + variant="outlined" + className={styles.valid_user_name_chip} + label={this.state.user_name} + onDelete={this.handleDelete} + /> + )} + + {showTwoFactorForm && ( + + )} + {showRecoveryForm && ( + + )} + {isPasswordFlow && ( + // proceed to ask for password ( 2nd step ) + <> + + + + )} + {isOtpFlow && ( + // proceed to ask for password ( 2nd step ) + <> + + + + )} + {showDefaultFlow && ( + <> + {this.state.allowNativeAuth && ( + + )} + {this.state.errors.email === "" && + this.props.thirdPartyProviders.length > 0 && ( + + )} + { + // we already had an interaction and got an user error... + this.state.errors.email !== "" && ( + <> + {this.existingUserCanContinue() && ( + -
-
- - ); - } + )} + {this.state.user_active === true && + this.state.email_verified === false && ( + + )} + + + ) + } + + )} + +
+
+
+ ); + } } // Or Create your Own theme: const theme = createTheme({ - palette: { - primary: { - main: '#3fa2f7' - }, + palette: { + primary: { + main: "#3fa2f7", }, - overrides: { - MuiButton: { - containedPrimary: { - color: 'white', - textTransform: 'none' - } - } - } + }, + overrides: { + MuiButton: { + containedPrimary: { + color: "white", + textTransform: "none", + }, + }, + }, }); ReactDOM.render( - - - , - document.querySelector('#root') + + + , + document.querySelector("#root"), ); diff --git a/resources/js/login/login.module.scss b/resources/js/login/login.module.scss index fb0257d1..cbd0b254 100644 --- a/resources/js/login/login.module.scss +++ b/resources/js/login/login.module.scss @@ -88,6 +88,28 @@ p > a { margin-top: 20px; } } + + .info_message { + margin-top: 8px; + color: $text-color-dark; + } + + .countdown { + margin-top: 10px; + font-size: 0.85rem; + color: $hint-text-color; + } + + .trust_device_row { + margin-top: 10px; + margin-bottom: 10px; + text-align: left; + } + + .disabled_link { + pointer-events: none; + opacity: 0.5; + } } } @@ -133,4 +155,11 @@ p > a { .otp_p { margin: 0; padding: 0; +} + +.box { + display: flex; + justify-content: space-between; + margin-bottom: 10px; + flex-direction: row; } \ No newline at end of file diff --git a/resources/js/shared/HTMLRender.jsx b/resources/js/shared/HTMLRender.jsx new file mode 100644 index 00000000..98062c02 --- /dev/null +++ b/resources/js/shared/HTMLRender.jsx @@ -0,0 +1,27 @@ +/* eslint-disable react/no-danger */ +import PropTypes from "prop-types"; +import DOMPurify from "dompurify"; + +const HTMLRender = ({ children, className, style, component = "div" }) => { + const html = DOMPurify.sanitize(children || ""); + const Component = component; + + return ( + + ); +}; + +HTMLRender.propTypes = { + children: PropTypes.string, + className: PropTypes.string, + style: PropTypes.shape({ + [PropTypes.string]: PropTypes.string + }), + component: PropTypes.elementType +}; + +export default HTMLRender; diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index d2ca52ee..4c8e617d 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -34,6 +34,11 @@ accountVerifyAction : '{{URL::action("UserController@getAccount")}}', emitOtpAction : '{{URL::action("UserController@emitOTP")}}', resendVerificationEmailAction: '{{ URL::action("UserController@resendVerificationEmail") }}', + verify2faAction: '{{ URL::action("UserController@verify2FA") }}', + resend2faAction: '{{ URL::action("UserController@resend2FA") }}', + cancelLogin: '{{ URL::action("UserController@cancelLogin") }}', + recovery2faAction: '{{ URL::action("UserController@verify2FARecovery") }}', + mfaMethod: '{{ Session::has("mfa_method") ? Session::get("mfa_method") : "email_otp" }}', authError: authError, captchaPublicKey: '{{ Config::get("services.turnstile.key") }}', flow: 'password', @@ -84,9 +89,21 @@ config.flow = '{{Session::get('flow')}}'; @endif + @if(Session::has('otp_length')) + config.otpLength = {{Session::get("otp_length")}}; + @endif + @if(Session::has('otp_lifetime')) + config.otpLifetime = {{Session::get("otp_lifetime")}}; + @endif + window.VERIFY_ACCOUNT_ENDPOINT = config.accountVerifyAction; window.EMIT_OTP_ENDPOINT = config.emitOtpAction; window.RESEND_VERIFICATION_EMAIL_ENDPOINT = config.resendVerificationEmailAction; + window.VERIFY_2FA_ENDPOINT = config.verify2faAction; + window.RESEND_2FA_ENDPOINT = config.resend2faAction; + window.CANCEL_LOGIN_ENDPOINT = config.cancelLogin; + window.RECOVERY_2FA_ENDPOINT = config.recovery2faAction; + window.FORM_ACTION_ENDPOINT = config.formAction; {!! script_to('assets/login.js') !!} @append \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 542300dc..e3e0ae0f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2047,6 +2047,11 @@ dependencies: "@types/estree" "*" +"@types/trusted-types@^2.0.7": + version "2.0.7" + resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11" + integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw== + "@types/ws@^8.5.5": version "8.18.1" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.18.1.tgz#48464e4bf2ddfd17db13d845467f6070ffea4aa9" @@ -3645,6 +3650,13 @@ domexception@^2.0.1: dependencies: webidl-conversions "^5.0.0" +dompurify@^3.4.11: + version "3.4.11" + resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.4.11.tgz#29c8ba496475f279ef4015784068452fb14a0680" + integrity sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw== + optionalDependencies: + "@types/trusted-types" "^2.0.7" + dotenv-defaults@^1.0.2: version "1.1.1" resolved "https://registry.yarnpkg.com/dotenv-defaults/-/dotenv-defaults-1.1.1.tgz#032c024f4b5906d9990eb06d722dc74cc60ec1bd" @@ -6558,7 +6570,7 @@ prompts@^2.0.1: kleur "^3.0.3" sisteransi "^1.0.5" -prop-types@^15.5.6, prop-types@^15.5.7, prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2: +prop-types@^15.5.6, prop-types@^15.5.7, prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== From f7b8a9101b0012349cad3bc70e4ace5e74266d20 Mon Sep 17 00:00:00 2001 From: romanetar Date: Fri, 26 Jun 2026 16:45:22 +0200 Subject: [PATCH 25/26] feat: first tests Signed-off-by: romanetar --- .gitignore | 9 +- package.json | 12 +- tests/e2e/fixtures/index.ts | 31 + tests/e2e/pages/LoginPage.ts | 46 ++ tests/e2e/pages/RegisterPage.ts | 49 ++ tests/e2e/tests/auth/login.spec.ts | 32 + tests/e2e/tests/auth/register.spec.ts | 26 + tests/e2e/tests/oauth2/auth-code-flow.spec.ts | 32 + tests/e2e/tsconfig.json | 17 + tests/js/__mocks__/fileMock.js | 1 + tests/js/components/Banner.test.js | 23 + tests/js/components/CustomSnackbar.test.js | 28 + tests/js/components/DividerWithText.test.js | 15 + tests/js/setup.js | 1 + tests/js/validator/validator.test.js | 43 + yarn.lock | 739 +++++++++++++++++- 16 files changed, 1092 insertions(+), 12 deletions(-) create mode 100644 tests/e2e/fixtures/index.ts create mode 100644 tests/e2e/pages/LoginPage.ts create mode 100644 tests/e2e/pages/RegisterPage.ts create mode 100644 tests/e2e/tests/auth/login.spec.ts create mode 100644 tests/e2e/tests/auth/register.spec.ts create mode 100644 tests/e2e/tests/oauth2/auth-code-flow.spec.ts create mode 100644 tests/e2e/tsconfig.json create mode 100644 tests/js/__mocks__/fileMock.js create mode 100644 tests/js/components/Banner.test.js create mode 100644 tests/js/components/CustomSnackbar.test.js create mode 100644 tests/js/components/DividerWithText.test.js create mode 100644 tests/js/setup.js create mode 100644 tests/js/validator/validator.test.js diff --git a/.gitignore b/.gitignore index 2b975b7c..28fd799b 100644 --- a/.gitignore +++ b/.gitignore @@ -52,4 +52,11 @@ model.sql /.phpunit.cache/ docker-compose/mysql/model/*.sql public/assets/*.map -public/assets/css/*.map \ No newline at end of file +public/assets/css/*.map +# Playwright +/tests/e2e/report/ + +# Jest +/tests/js/coverage/ +/playwright-report/ +/.playwright-out/ diff --git a/package.json b/package.json index 729fca9c..9ce0634a 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,13 @@ "build-dev": "./node_modules/.bin/webpack --config webpack.dev.js", "build": "./node_modules/.bin/webpack --config webpack.prod.js", "serve": "webpack-dev-server --open --port=8888 --server-type https --config webpack.dev.js", - "test": "jest --watch" + "test": "jest --watch", + "test:unit": "jest --testPathPattern=tests/js", + "test:unit:ci": "jest --testPathPattern=tests/js --ci --coverage", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:debug": "playwright test --debug", + "test:e2e:report": "playwright show-report tests/e2e/report" }, "devDependencies": { "@babel/core": "^7.17.8", @@ -21,6 +27,10 @@ "@babel/preset-flow": "^7.7.4", "@babel/preset-react": "^7.7.4", "@babel/runtime": "^7.20.7", + "@playwright/test": "^1.61.1", + "@testing-library/jest-dom": "^5", + "@testing-library/react": "^12", + "@testing-library/user-event": "^13", "babel-cli": "^6.26.0", "babel-jest": "^26.6.3", "babel-loader": "^8.2.4", diff --git a/tests/e2e/fixtures/index.ts b/tests/e2e/fixtures/index.ts new file mode 100644 index 00000000..af156189 --- /dev/null +++ b/tests/e2e/fixtures/index.ts @@ -0,0 +1,31 @@ +import { test as base } from '@playwright/test'; +import { LoginPage } from '../pages/LoginPage'; +import { RegisterPage } from '../pages/RegisterPage'; + +type E2EFixtures = { + loginPage: LoginPage; + registerPage: RegisterPage; + authenticatedPage: LoginPage; +}; + +export const test = base.extend({ + loginPage: async ({ page }, use) => { + await use(new LoginPage(page)); + }, + + registerPage: async ({ page }, use) => { + await use(new RegisterPage(page)); + }, + + // Pre-authenticated session using the default seeded admin account + authenticatedPage: async ({ page }, use) => { + const loginPage = new LoginPage(page); + await loginPage.login( + process.env.TEST_USER_EMAIL || 'test@test.com', + process.env.TEST_USER_PASSWORD || '1Qaz2wsx!' + ); + await use(loginPage); + }, +}); + +export { expect } from '@playwright/test'; diff --git a/tests/e2e/pages/LoginPage.ts b/tests/e2e/pages/LoginPage.ts new file mode 100644 index 00000000..09457670 --- /dev/null +++ b/tests/e2e/pages/LoginPage.ts @@ -0,0 +1,46 @@ +import { type Page, type Locator } from '@playwright/test'; + +export class LoginPage { + readonly page: Page; + readonly emailInput: Locator; + readonly passwordInput: Locator; + readonly submitButton: Locator; + readonly rememberMeCheckbox: Locator; + readonly errorLabel: Locator; + readonly otpInput: Locator; + + constructor(page: Page) { + this.page = page; + this.emailInput = page.locator('#email'); + this.passwordInput = page.locator('#password'); + this.submitButton = page.locator('button[type="submit"]'); + this.rememberMeCheckbox = page.locator('#remember'); + this.errorLabel = page.locator('[class*="error_label"]'); + this.otpInput = page.locator('[data-testid="otp_code"]'); + } + + async goto() { + await this.page.goto('/auth/login'); + } + + async fillEmail(email: string) { + await this.emailInput.fill(email); + await this.submitButton.click(); + } + + async fillPassword(password: string) { + await this.passwordInput.fill(password); + await this.submitButton.click(); + } + + async login(email: string, password: string) { + await this.goto(); + await this.fillEmail(email); + await this.fillPassword(password); + } + + async fillOtp(code: string) { + await this.otpInput.fill(code); + await this.submitButton.click(); + } +} diff --git a/tests/e2e/pages/RegisterPage.ts b/tests/e2e/pages/RegisterPage.ts new file mode 100644 index 00000000..b2121d85 --- /dev/null +++ b/tests/e2e/pages/RegisterPage.ts @@ -0,0 +1,49 @@ +import { type Page, type Locator } from '@playwright/test'; + +export class RegisterPage { + readonly page: Page; + readonly firstNameInput: Locator; + readonly lastNameInput: Locator; + readonly emailInput: Locator; + readonly passwordInput: Locator; + readonly passwordConfirmInput: Locator; + readonly countrySelect: Locator; + readonly codeOfConductCheckbox: Locator; + readonly submitButton: Locator; + readonly errorContainer: Locator; + + constructor(page: Page) { + this.page = page; + this.firstNameInput = page.locator('[name="first_name"]'); + this.lastNameInput = page.locator('[name="last_name"]'); + this.emailInput = page.locator('[name="email"]'); + this.passwordInput = page.locator('[name="password"]'); + this.passwordConfirmInput = page.locator('[name="password_confirmation"]'); + this.countrySelect = page.locator('[name="country_iso_code"]'); + this.codeOfConductCheckbox = page.locator('[name="agree_code_of_conduct"]'); + this.submitButton = page.locator('button[type="submit"]'); + this.errorContainer = page.locator('[class*="error"]'); + } + + async goto() { + await this.page.goto('/auth/register'); + } + + async register(data: { + firstName: string; + lastName: string; + email: string; + password: string; + country: string; + }) { + await this.goto(); + await this.firstNameInput.fill(data.firstName); + await this.lastNameInput.fill(data.lastName); + await this.emailInput.fill(data.email); + await this.passwordInput.fill(data.password); + await this.passwordConfirmInput.fill(data.password); + await this.countrySelect.selectOption(data.country); + await this.codeOfConductCheckbox.check(); + await this.submitButton.click(); + } +} diff --git a/tests/e2e/tests/auth/login.spec.ts b/tests/e2e/tests/auth/login.spec.ts new file mode 100644 index 00000000..2fad6580 --- /dev/null +++ b/tests/e2e/tests/auth/login.spec.ts @@ -0,0 +1,32 @@ +import { test, expect } from '../../fixtures'; + +test.describe('Login flow', () => { + test('shows login page', async ({ loginPage }) => { + await loginPage.goto(); + await expect(loginPage.emailInput).toBeVisible(); + }); + + test('advances to password step after valid email', async ({ loginPage }) => { + await loginPage.goto(); + await loginPage.fillEmail('test@test.com'); + await expect(loginPage.passwordInput).toBeVisible(); + }); + + test('shows error on invalid credentials', async ({ loginPage }) => { + await loginPage.goto(); + await loginPage.fillEmail('test@test.com'); + await loginPage.fillPassword('wrongpassword'); + await expect(loginPage.errorLabel).toBeVisible(); + }); + + test('redirects to home after successful login', async ({ loginPage, page }) => { + await loginPage.login('test@test.com', '1Qaz2wsx!'); + await expect(page).not.toHaveURL(/\/auth\/login/); + }); + + test('email step rejects unknown email', async ({ loginPage }) => { + await loginPage.goto(); + await loginPage.fillEmail('nonexistent@example.com'); + await expect(loginPage.errorLabel).toBeVisible(); + }); +}); diff --git a/tests/e2e/tests/auth/register.spec.ts b/tests/e2e/tests/auth/register.spec.ts new file mode 100644 index 00000000..93f25d90 --- /dev/null +++ b/tests/e2e/tests/auth/register.spec.ts @@ -0,0 +1,26 @@ +import { test, expect } from '../../fixtures'; + +test.describe('Registration flow', () => { + test('shows registration form', async ({ registerPage }) => { + await registerPage.goto(); + await expect(registerPage.firstNameInput).toBeVisible(); + await expect(registerPage.emailInput).toBeVisible(); + }); + + test('shows validation errors on empty submit', async ({ registerPage, page }) => { + await registerPage.goto(); + await registerPage.submitButton.click(); + await expect(registerPage.errorContainer).toBeVisible(); + }); + + test('shows error on duplicate email', async ({ registerPage }) => { + await registerPage.register({ + firstName: 'Test', + lastName: 'User', + email: 'test@test.com', // already seeded + password: 'TestPass123!', + country: 'US', + }); + await expect(registerPage.errorContainer).toBeVisible(); + }); +}); diff --git a/tests/e2e/tests/oauth2/auth-code-flow.spec.ts b/tests/e2e/tests/oauth2/auth-code-flow.spec.ts new file mode 100644 index 00000000..b042d3e1 --- /dev/null +++ b/tests/e2e/tests/oauth2/auth-code-flow.spec.ts @@ -0,0 +1,32 @@ +import { test, expect } from '../../fixtures'; + +// These values match the seeded test client from DatabaseSeeder +const SEEDED_CLIENT_ID = 'Jiz87D8/Vcvr6fvQbH4HyNgwTlfSyQ3x.openstack.client'; +const REDIRECT_URI = 'https://www.test.com/oauth2'; + +test.describe('OAuth2 Authorization Code Flow', () => { + test('unauthenticated request redirects to login', async ({ page }) => { + const params = new URLSearchParams({ + client_id: SEEDED_CLIENT_ID, + redirect_uri: REDIRECT_URI, + response_type: 'code', + scope: 'profile', + }); + + await page.goto(`/oauth2/auth?${params}`); + await expect(page).toHaveURL(/\/auth\/login/); + }); + + test('authenticated user sees consent screen', async ({ authenticatedPage, page }) => { + const params = new URLSearchParams({ + client_id: SEEDED_CLIENT_ID, + redirect_uri: REDIRECT_URI, + response_type: 'code', + scope: 'profile', + }); + + await page.goto(`/oauth2/auth?${params}`); + // Should show consent page, not redirect back to login + await expect(page).not.toHaveURL(/\/auth\/login/); + }); +}); diff --git a/tests/e2e/tsconfig.json b/tests/e2e/tsconfig.json new file mode 100644 index 00000000..536a49f5 --- /dev/null +++ b/tests/e2e/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "CommonJS", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "outDir": "../.playwright-out", + "baseUrl": ".", + "paths": { + "@pages/*": ["pages/*"], + "@fixtures/*": ["fixtures/*"] + } + }, + "include": ["**/*.ts"], + "exclude": ["../node_modules"] +} diff --git a/tests/js/__mocks__/fileMock.js b/tests/js/__mocks__/fileMock.js new file mode 100644 index 00000000..86059f36 --- /dev/null +++ b/tests/js/__mocks__/fileMock.js @@ -0,0 +1 @@ +module.exports = 'test-file-stub'; diff --git a/tests/js/components/Banner.test.js b/tests/js/components/Banner.test.js new file mode 100644 index 00000000..1537842e --- /dev/null +++ b/tests/js/components/Banner.test.js @@ -0,0 +1,23 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import Banner from '../../../resources/js/components/banner/banner'; + +describe('Banner', () => { + it('renders plain text content', () => { + render(); + expect(screen.getByText('Scheduled maintenance tonight')).toBeInTheDocument(); + }); + + it('renders HTML content via dangerouslySetInnerHTML', () => { + const { container } = render( + + ); + expect(container.querySelector('strong')).toBeInTheDocument(); + expect(container.querySelector('strong').textContent).toBe('Important'); + }); + + it('renders empty content without crashing', () => { + const { container } = render(); + expect(container).toBeInTheDocument(); + }); +}); diff --git a/tests/js/components/CustomSnackbar.test.js b/tests/js/components/CustomSnackbar.test.js new file mode 100644 index 00000000..40eb78a8 --- /dev/null +++ b/tests/js/components/CustomSnackbar.test.js @@ -0,0 +1,28 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import CustomSnackbar from '../../../resources/js/components/custom_snackbar'; + +describe('CustomSnackbar', () => { + it('renders message when open', () => { + render( {}} />); + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + }); + + it('is not visible when message is null', () => { + render( {}} />); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('calls onClose when close button is clicked', async () => { + const onClose = jest.fn(); + render(); + await userEvent.click(screen.getByRole('button')); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('defaults severity to info', () => { + render( {}} />); + expect(screen.getByText('Hello')).toBeInTheDocument(); + }); +}); diff --git a/tests/js/components/DividerWithText.test.js b/tests/js/components/DividerWithText.test.js new file mode 100644 index 00000000..3f55fb2d --- /dev/null +++ b/tests/js/components/DividerWithText.test.js @@ -0,0 +1,15 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import DividerWithText from '../../../resources/js/components/divider_with_text'; + +describe('DividerWithText', () => { + it('renders children text', () => { + render(or); + expect(screen.getByText('or')).toBeInTheDocument(); + }); + + it('renders any string children', () => { + render(Sign in with); + expect(screen.getByText('Sign in with')).toBeInTheDocument(); + }); +}); diff --git a/tests/js/setup.js b/tests/js/setup.js new file mode 100644 index 00000000..7b0828bf --- /dev/null +++ b/tests/js/setup.js @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/tests/js/validator/validator.test.js b/tests/js/validator/validator.test.js new file mode 100644 index 00000000..5646f679 --- /dev/null +++ b/tests/js/validator/validator.test.js @@ -0,0 +1,43 @@ +import { emailValidator, buildPasswordValidationSchema } from '../../../resources/js/validator'; + +describe('emailValidator', () => { + it('accepts valid emails', () => { + expect(emailValidator('user@example.com')).toBe(true); + expect(emailValidator('test@test.com')).toBe(true); + expect(emailValidator('user+tag@sub.domain.org')).toBe(true); + }); + + it('rejects invalid emails', () => { + expect(emailValidator('notanemail')).toBe(false); + expect(emailValidator('@nodomain')).toBe(false); + expect(emailValidator('spaces in@email.com')).toBe(false); + expect(emailValidator('')).toBe(false); + }); +}); + +describe('buildPasswordValidationSchema', () => { + const policy = { + min_length: 8, + max_length: 64, + shape_pattern: '^(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$]).+$', + allowed_special_characters: '[a-zA-Z0-9!@#$]', + shape_warning: 'Password must have uppercase, number and special character', + }; + + it('returns schema with password and password_confirmation fields', () => { + const schema = buildPasswordValidationSchema(policy); + expect(schema).toHaveProperty('password'); + expect(schema).toHaveProperty('password_confirmation'); + }); + + it('password schema rejects values shorter than min_length', async () => { + const schema = buildPasswordValidationSchema(policy, true); + await expect(schema.password.validate('Ab1!')).rejects.toThrow(); + }); + + it('password schema rejects values longer than max_length', async () => { + const longPass = 'A'.repeat(65) + '1!'; + const schema = buildPasswordValidationSchema(policy); + await expect(schema.password.validate(longPass)).rejects.toThrow(); + }); +}); diff --git a/yarn.lock b/yarn.lock index e3e0ae0f..5c8cb8e6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,6 +2,11 @@ # yarn lockfile v1 +"@adobe/css-tools@^4.0.1": + version "4.5.0" + resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.5.0.tgz#b5b71a25a4d16afa2482592ddfa62fccc60bc7d1" + integrity sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q== + "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.28.6", "@babel/code-frame@^7.29.0": version "7.29.0" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.0.tgz#7cd7a59f15b3cc0dcd803038f7792712a7d0b15c" @@ -11,6 +16,15 @@ js-tokens "^4.0.0" picocolors "^1.1.1" +"@babel/code-frame@^7.10.4", "@babel/code-frame@^7.27.1": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.7.tgz#f2fbbfea87c44a21590ec515b778b2c26d8866e7" + integrity sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw== + dependencies: + "@babel/helper-validator-identifier" "^7.29.7" + js-tokens "^4.0.0" + picocolors "^1.1.1" + "@babel/compat-data@^7.20.5", "@babel/compat-data@^7.28.6", "@babel/compat-data@^7.29.0": version "7.29.0" resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.29.0.tgz#00d03e8c0ac24dd9be942c5370990cbe1f17d88d" @@ -177,6 +191,11 @@ resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz#010b6938fab7cb7df74aa2bbc06aa503b8fe5fb4" integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q== +"@babel/helper-validator-identifier@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz#bd87084ced0c796ec46bda492de6e83d29e89fc2" + integrity sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg== + "@babel/helper-validator-option@^7.27.1": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz#fa52f5b1e7db1ab049445b421c4471303897702f" @@ -973,6 +992,11 @@ resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.29.2.tgz#9a6e2d05f4b6692e1801cd4fb176ad823930ed5e" integrity sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g== +"@babel/runtime@^7.12.5": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.29.7.tgz#12022450c45a4da6d8d8287b18a4ff2ddb23f768" + integrity sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw== + "@babel/template@^7.28.6", "@babel/template@^7.3.3": version "7.28.6" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.28.6.tgz#0e7e56ecedb78aeef66ce7972b082fce76a23e57" @@ -1257,6 +1281,11 @@ slash "^3.0.0" strip-ansi "^6.0.0" +"@jest/diff-sequences@30.4.0": + version "30.4.0" + resolved "https://registry.yarnpkg.com/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz#8be2d260e6241d6cddddd102c304fe13b4fc8e3e" + integrity sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g== + "@jest/environment@^26.6.2": version "26.6.2" resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-26.6.2.tgz#ba364cc72e221e79cc8f0a99555bf5d7577cf92c" @@ -1267,6 +1296,13 @@ "@types/node" "*" jest-mock "^26.6.2" +"@jest/expect-utils@30.4.1": + version "30.4.1" + resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-30.4.1.tgz#e0c7436d52b08610de9027841912dc3734ae80b2" + integrity sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ== + dependencies: + "@jest/get-type" "30.1.0" + "@jest/fake-timers@^26.6.2": version "26.6.2" resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-26.6.2.tgz#459c329bcf70cee4af4d7e3f3e67848123535aad" @@ -1279,6 +1315,11 @@ jest-mock "^26.6.2" jest-util "^26.6.2" +"@jest/get-type@30.1.0": + version "30.1.0" + resolved "https://registry.yarnpkg.com/@jest/get-type/-/get-type-30.1.0.tgz#4fcb4dc2ebcf0811be1c04fd1cb79c2dba431cbc" + integrity sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA== + "@jest/globals@^26.6.2": version "26.6.2" resolved "https://registry.yarnpkg.com/@jest/globals/-/globals-26.6.2.tgz#5b613b78a1aa2655ae908eba638cc96a20df720a" @@ -1288,6 +1329,14 @@ "@jest/types" "^26.6.2" expect "^26.6.2" +"@jest/pattern@30.4.0": + version "30.4.0" + resolved "https://registry.yarnpkg.com/@jest/pattern/-/pattern-30.4.0.tgz#fcb519eeacc25caa3768f787595a27afa15302ae" + integrity sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg== + dependencies: + "@types/node" "*" + jest-regex-util "30.4.0" + "@jest/reporters@^26.6.2": version "26.6.2" resolved "https://registry.yarnpkg.com/@jest/reporters/-/reporters-26.6.2.tgz#1f518b99637a5f18307bd3ecf9275f6882a667f6" @@ -1320,6 +1369,13 @@ optionalDependencies: node-notifier "^8.0.0" +"@jest/schemas@30.4.1": + version "30.4.1" + resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-30.4.1.tgz#c3703fdd71357e2c83aa59bd38469e60a11529c6" + integrity sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q== + dependencies: + "@sinclair/typebox" "^0.34.0" + "@jest/source-map@^26.6.2": version "26.6.2" resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-26.6.2.tgz#29af5e1e2e324cafccc936f218309f54ab69d535" @@ -1371,6 +1427,19 @@ source-map "^0.6.1" write-file-atomic "^3.0.0" +"@jest/types@30.4.1": + version "30.4.1" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-30.4.1.tgz#f79b647a85cb2ff4a90cc55984b31dae820db1f7" + integrity sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ== + dependencies: + "@jest/pattern" "30.4.0" + "@jest/schemas" "30.4.1" + "@types/istanbul-lib-coverage" "^2.0.6" + "@types/istanbul-reports" "^3.0.4" + "@types/node" "*" + "@types/yargs" "^17.0.33" + chalk "^4.1.2" + "@jest/types@^26.6.2": version "26.6.2" resolved "https://registry.yarnpkg.com/@jest/types/-/types-26.6.2.tgz#bef5a532030e1d88a2f5a6d933f84e97226ed48e" @@ -1683,6 +1752,18 @@ "@parcel/watcher-win32-ia32" "2.5.6" "@parcel/watcher-win32-x64" "2.5.6" +"@playwright/test@^1.61.1": + version "1.61.1" + resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.61.1.tgz#48568dc22af7819e55fa5e8e3bc79b7e6a3e6675" + integrity sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig== + dependencies: + playwright "1.61.1" + +"@sinclair/typebox@^0.34.0": + version "0.34.49" + resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.34.49.tgz#4f1369234f2ecf693866476c3b2e1b54d2a9d68e" + integrity sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A== + "@sinonjs/commons@^1.7.0": version "1.8.6" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.6.tgz#80c516a4dc264c2a69115e7578d62581ff455ed9" @@ -1697,11 +1778,61 @@ dependencies: "@sinonjs/commons" "^1.7.0" +"@testing-library/dom@^8.0.0": + version "8.20.1" + resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.20.1.tgz#2e52a32e46fc88369eef7eef634ac2a192decd9f" + integrity sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g== + dependencies: + "@babel/code-frame" "^7.10.4" + "@babel/runtime" "^7.12.5" + "@types/aria-query" "^5.0.1" + aria-query "5.1.3" + chalk "^4.1.0" + dom-accessibility-api "^0.5.9" + lz-string "^1.5.0" + pretty-format "^27.0.2" + +"@testing-library/jest-dom@^5": + version "5.17.0" + resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-5.17.0.tgz#5e97c8f9a15ccf4656da00fecab505728de81e0c" + integrity sha512-ynmNeT7asXyH3aSVv4vvX4Rb+0qjOhdNHnO/3vuZNqPmhDpV/+rCSGwQ7bLcmU2cJ4dvoheIO85LQj0IbJHEtg== + dependencies: + "@adobe/css-tools" "^4.0.1" + "@babel/runtime" "^7.9.2" + "@types/testing-library__jest-dom" "^5.9.1" + aria-query "^5.0.0" + chalk "^3.0.0" + css.escape "^1.5.1" + dom-accessibility-api "^0.5.6" + lodash "^4.17.15" + redent "^3.0.0" + +"@testing-library/react@^12": + version "12.1.5" + resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-12.1.5.tgz#bb248f72f02a5ac9d949dea07279095fa577963b" + integrity sha512-OfTXCJUFgjd/digLUuPxa0+/3ZxsQmE7ub9kcbW/wi96Bh3o/p5vrETcBGfP17NWPGqeYYl5LTRpwyGoMC4ysg== + dependencies: + "@babel/runtime" "^7.12.5" + "@testing-library/dom" "^8.0.0" + "@types/react-dom" "<18.0.0" + +"@testing-library/user-event@^13": + version "13.5.0" + resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-13.5.0.tgz#69d77007f1e124d55314a2b73fd204b333b13295" + integrity sha512-5Kwtbo3Y/NowpkbRuSepbyMFkZmHgD+vPzYB/RJ4oxt5Gj/avFFBYjhw27cqSVPVw/3a67NK1PbiIr9k4Gwmdg== + dependencies: + "@babel/runtime" "^7.12.5" + "@tootallnate/once@1": version "1.1.2" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" integrity sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw== +"@types/aria-query@^5.0.1": + version "5.0.4" + resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-5.0.4.tgz#1a31c3d378850d2778dabb6374d036dcba4ba708" + integrity sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw== + "@types/babel__core@^7.0.0", "@types/babel__core@^7.1.7": version "7.20.5" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017" @@ -1858,7 +1989,7 @@ dependencies: "@types/node" "*" -"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": +"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1", "@types/istanbul-lib-coverage@^2.0.6": version "2.0.6" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz#7739c232a1fee9b4d3ce8985f314c0c6d33549d7" integrity sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w== @@ -1870,13 +2001,21 @@ dependencies: "@types/istanbul-lib-coverage" "*" -"@types/istanbul-reports@^3.0.0": +"@types/istanbul-reports@^3.0.0", "@types/istanbul-reports@^3.0.4": version "3.0.4" resolved "https://registry.yarnpkg.com/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz#0f03e3d2f670fbdac586e34b433783070cc16f54" integrity sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ== dependencies: "@types/istanbul-lib-report" "*" +"@types/jest@*": + version "30.0.0" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-30.0.0.tgz#5e85ae568006712e4ad66f25433e9bdac8801f1d" + integrity sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA== + dependencies: + expect "^30.0.0" + pretty-format "^30.0.0" + "@types/json-schema@*", "@types/json-schema@^7.0.15", "@types/json-schema@^7.0.5", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9": version "7.0.15" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" @@ -1941,6 +2080,11 @@ resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb" integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ== +"@types/react-dom@<18.0.0": + version "17.0.26" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.26.tgz#fa7891ba70fd39ddbaa7e85b6ff9175bb546bc1b" + integrity sha512-Z+2VcYXJwOqQ79HreLU/1fyQ88eXSSFh6I3JdrEHQIfYSI0kCQpTGvOrbE6jFGGYXKsHuwY9tBa/w5Uo6KzrEg== + "@types/react-is@^16.7.1 || ^17.0.0": version "17.0.7" resolved "https://registry.yarnpkg.com/@types/react-is/-/react-is-17.0.7.tgz#1402c8f14e8533eaeeac128c0bfa11478202ae37" @@ -2035,7 +2179,7 @@ dependencies: "@types/node" "*" -"@types/stack-utils@^2.0.0": +"@types/stack-utils@^2.0.0", "@types/stack-utils@^2.0.3": version "2.0.3" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8" integrity sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw== @@ -2047,6 +2191,13 @@ dependencies: "@types/estree" "*" +"@types/testing-library__jest-dom@^5.9.1": + version "5.14.9" + resolved "https://registry.yarnpkg.com/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.9.tgz#0fb1e6a0278d87b6737db55af5967570b67cb466" + integrity sha512-FSYhIjFlfOpGSRyVoMBMuS3ws5ehFQODymf3vlI7U1K8c7PHwWwFY7VREfmsuzHSOnoKs/9/Y983ayOs7eRzqw== + dependencies: + "@types/jest" "*" + "@types/trusted-types@^2.0.7": version "2.0.7" resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11" @@ -2071,6 +2222,13 @@ dependencies: "@types/yargs-parser" "*" +"@types/yargs@^17.0.33": + version "17.0.35" + resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.35.tgz#07013e46aa4d7d7d50a49e15604c1c5340d4eb24" + integrity sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg== + dependencies: + "@types/yargs-parser" "*" + "@webassemblyjs/ast@1.14.1", "@webassemblyjs/ast@^1.14.1": version "1.14.1" resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.14.1.tgz#a9f6a07f2b03c95c8d38c4536a1fdfb521ff55b6" @@ -2355,6 +2513,11 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0: dependencies: color-convert "^2.0.1" +ansi-styles@^5.0.0, ansi-styles@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" + integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== + anymatch@^1.3.0: version "1.3.2" resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-1.3.2.tgz#553dcb8f91e3c889845dfdba34c77721b90b9d7a" @@ -2386,6 +2549,18 @@ argparse@^1.0.7: dependencies: sprintf-js "~1.0.2" +aria-query@5.1.3: + version "5.1.3" + resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.1.3.tgz#19db27cd101152773631396f7a95a3b58c22c35e" + integrity sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ== + dependencies: + deep-equal "^2.0.5" + +aria-query@^5.0.0: + version "5.3.2" + resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.3.2.tgz#93f81a43480e33a338f19163a3d10a50c01dcd59" + integrity sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw== + arr-diff@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-2.0.0.tgz#8f3b827f955a8bd669697e4a4256ac3ceae356cf" @@ -2408,6 +2583,14 @@ arr-union@^3.1.0: resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4" integrity sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q== +array-buffer-byte-length@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz#384d12a37295aec3769ab022ad323a18a51ccf8b" + integrity sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw== + dependencies: + call-bound "^1.0.3" + is-array-buffer "^3.0.5" + array-flatten@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" @@ -2448,6 +2631,13 @@ atob@^2.1.2: resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== +available-typed-arrays@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz#a5cc375d6a03c2efc87a553f3e0b1522def14846" + integrity sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ== + dependencies: + possible-typed-array-names "^1.0.0" + babel-cli@^6.26.0: version "6.26.0" resolved "https://registry.yarnpkg.com/babel-cli/-/babel-cli-6.26.0.tgz#502ab54874d7db88ad00b887a06383ce03d002f1" @@ -2996,7 +3186,17 @@ call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: es-errors "^1.3.0" function-bind "^1.1.2" -call-bound@^1.0.2: +call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.7, call-bind@^1.0.8, call-bind@^1.0.9: + version "1.0.9" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.9.tgz#39a644700c80bc7d0ca9102fc6d1d43b2fd7eee7" + integrity sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ== + dependencies: + call-bind-apply-helpers "^1.0.2" + es-define-property "^1.0.1" + get-intrinsic "^1.3.0" + set-function-length "^1.2.2" + +call-bound@^1.0.2, call-bound@^1.0.3, call-bound@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a" integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== @@ -3047,7 +3247,15 @@ chalk@^1.1.3: strip-ansi "^3.0.0" supports-color "^2.0.0" -chalk@^4.0.0: +chalk@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-3.0.0.tgz#3f73c2bf526591f574cc492c51e2456349f844e4" + integrity sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== @@ -3113,6 +3321,11 @@ ci-info@^2.0.0: resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ== +ci-info@^4.2.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-4.4.0.tgz#7d54eff9f54b45b62401c26032696eb59c8bd18c" + integrity sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg== + cjs-module-lexer@^0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-0.6.0.tgz#4186fcca0eae175970aee870b9fe2d6cf8d5655f" @@ -3433,6 +3646,11 @@ css-vendor@^2.0.8: "@babel/runtime" "^7.8.3" is-in-browser "^1.0.2" +css.escape@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb" + integrity sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg== + cssesc@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" @@ -3510,6 +3728,30 @@ decode-uri-component@^0.2.0: resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.2.tgz#e69dbe25d37941171dd540e024c444cd5188e1e9" integrity sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ== +deep-equal@^2.0.5: + version "2.2.3" + resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-2.2.3.tgz#af89dafb23a396c7da3e862abc0be27cf51d56e1" + integrity sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA== + dependencies: + array-buffer-byte-length "^1.0.0" + call-bind "^1.0.5" + es-get-iterator "^1.1.3" + get-intrinsic "^1.2.2" + is-arguments "^1.1.1" + is-array-buffer "^3.0.2" + is-date-object "^1.0.5" + is-regex "^1.1.4" + is-shared-array-buffer "^1.0.2" + isarray "^2.0.5" + object-is "^1.1.5" + object-keys "^1.1.1" + object.assign "^4.1.4" + regexp.prototype.flags "^1.5.1" + side-channel "^1.0.4" + which-boxed-primitive "^1.0.2" + which-collection "^1.0.1" + which-typed-array "^1.1.13" + deepmerge@^2.1.1: version "2.2.1" resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-2.2.1.tgz#5d3ff22a01c00f645405a2fbc17d0778a1801170" @@ -3527,11 +3769,29 @@ default-gateway@^6.0.3: dependencies: execa "^5.0.0" +define-data-property@^1.0.1, define-data-property@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" + integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + gopd "^1.0.1" + define-lazy-prop@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f" integrity sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og== +define-properties@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.2.1.tgz#10781cc616eb951a80a034bafcaa7377f6af2b6c" + integrity sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg== + dependencies: + define-data-property "^1.0.1" + has-property-descriptors "^1.0.0" + object-keys "^1.1.1" + define-property@^0.2.5: version "0.2.5" resolved "https://registry.yarnpkg.com/define-property/-/define-property-0.2.5.tgz#c35b1ef918ec3c990f9a5bc57be04aacec5c8116" @@ -3628,6 +3888,11 @@ dns-packet@^5.2.2: dependencies: "@leichtgewicht/ip-codec" "^2.0.1" +dom-accessibility-api@^0.5.6, dom-accessibility-api@^0.5.9: + version "0.5.16" + resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz#5a7429e6066eb3664d911e33fb0e45de8eb08453" + integrity sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg== + dom-helpers@^3.2.0: version "3.4.0" resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.4.0.tgz#e9b369700f959f62ecde5a6babde4bccd9169af8" @@ -3760,7 +4025,7 @@ error-ex@^1.3.1: dependencies: is-arrayish "^0.2.1" -es-define-property@^1.0.1: +es-define-property@^1.0.0, es-define-property@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== @@ -3770,6 +4035,21 @@ es-errors@^1.3.0: resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== +es-get-iterator@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/es-get-iterator/-/es-get-iterator-1.1.3.tgz#3ef87523c5d464d41084b2c3c9c214f1199763d6" + integrity sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw== + dependencies: + call-bind "^1.0.2" + get-intrinsic "^1.1.3" + has-symbols "^1.0.3" + is-arguments "^1.1.1" + is-map "^2.0.2" + is-set "^2.0.2" + is-string "^1.0.7" + isarray "^2.0.5" + stop-iteration-iterator "^1.0.0" + es-module-lexer@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-2.0.0.tgz#f657cd7a9448dcdda9c070a3cb75e5dc1e85f5b1" @@ -3970,6 +4250,18 @@ expect@^26.6.2: jest-message-util "^26.6.2" jest-regex-util "^26.0.0" +expect@^30.0.0: + version "30.4.1" + resolved "https://registry.yarnpkg.com/expect/-/expect-30.4.1.tgz#897e0390a0b6c333dbcf3a24dee3ad49553577e0" + integrity sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA== + dependencies: + "@jest/expect-utils" "30.4.1" + "@jest/get-type" "30.1.0" + jest-matcher-utils "30.4.1" + jest-message-util "30.4.1" + jest-mock "30.4.1" + jest-util "30.4.1" + express@^4.17.3: version "4.22.1" resolved "https://registry.yarnpkg.com/express/-/express-4.22.1.tgz#1de23a09745a4fffdb39247b344bb5eaff382069" @@ -4201,6 +4493,13 @@ font-awesome@^4.7.0: resolved "https://registry.yarnpkg.com/font-awesome/-/font-awesome-4.7.0.tgz#8fa8cf0411a1a31afd07b06d2902bb9fc815a133" integrity sha512-U6kGnykA/6bFmg1M/oT9EkFeIYv7JlX3bozwQJWiiLz6L0w3F5vBVPxHlwyX/vtNq1ckcpRKOB9f2Qal/VtFpg== +for-each@^0.3.5: + version "0.3.5" + resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.5.tgz#d650688027826920feeb0af747ee7b9421a41d47" + integrity sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg== + dependencies: + is-callable "^1.2.7" + for-in@^1.0.1, for-in@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" @@ -4308,6 +4607,11 @@ fs.realpath@^1.0.0: resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== +fsevents@2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + fsevents@^1.0.0: version "1.2.13" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.13.tgz#f325cb0455592428bcf11b383370ef70e3bfcc38" @@ -4326,6 +4630,11 @@ function-bind@^1.1.2: resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== +functions-have-names@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" + integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== + gensync@^1.0.0-beta.2: version "1.0.0-beta.2" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" @@ -4336,7 +4645,7 @@ get-caller-file@^2.0.1: resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== -get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.3.0: +get-intrinsic@^1.1.3, get-intrinsic@^1.2.2, get-intrinsic@^1.2.4, get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== @@ -4458,7 +4767,7 @@ globby@^13.1.1: merge2 "^1.4.1" slash "^4.0.0" -gopd@^1.2.0: +gopd@^1.0.1, gopd@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== @@ -4490,11 +4799,23 @@ has-ansi@^2.0.0: dependencies: ansi-regex "^2.0.0" +has-bigints@^1.0.2: + version "1.1.0" + resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.1.0.tgz#28607e965ac967e03cd2a2c70a2636a1edad49fe" + integrity sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg== + has-flag@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== +has-property-descriptors@^1.0.0, has-property-descriptors@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854" + integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== + dependencies: + es-define-property "^1.0.0" + has-symbols@^1.0.3, has-symbols@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" @@ -4762,6 +5083,11 @@ imurmurhash@^0.1.4: resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== +indent-string@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251" + integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg== + inflight@^1.0.4: version "1.0.6" resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" @@ -4775,6 +5101,15 @@ inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.3, i resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== +internal-slot@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.1.0.tgz#1eac91762947d2f7056bc838d93e13b2e9604961" + integrity sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw== + dependencies: + es-errors "^1.3.0" + hasown "^2.0.2" + side-channel "^1.1.0" + interpret@^3.1.1: version "3.1.1" resolved "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz#5be0ceed67ca79c6c4bc5cf0d7ee843dcea110c4" @@ -4804,11 +5139,35 @@ is-accessor-descriptor@^1.0.1: dependencies: hasown "^2.0.0" +is-arguments@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.2.0.tgz#ad58c6aecf563b78ef2bf04df540da8f5d7d8e1b" + integrity sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA== + dependencies: + call-bound "^1.0.2" + has-tostringtag "^1.0.2" + +is-array-buffer@^3.0.2, is-array-buffer@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.5.tgz#65742e1e687bd2cc666253068fd8707fe4d44280" + integrity sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.3" + get-intrinsic "^1.2.6" + is-arrayish@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== +is-bigint@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.1.0.tgz#dda7a3445df57a42583db4228682eba7c4170672" + integrity sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ== + dependencies: + has-bigints "^1.0.2" + is-binary-path@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-1.0.1.tgz#75f16642b480f187a711c814161fd3a4a7655898" @@ -4823,11 +5182,24 @@ is-binary-path@~2.1.0: dependencies: binary-extensions "^2.0.0" +is-boolean-object@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.2.2.tgz#7067f47709809a393c71ff5bb3e135d8a9215d9e" + integrity sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A== + dependencies: + call-bound "^1.0.3" + has-tostringtag "^1.0.2" + is-buffer@^1.1.5: version "1.1.6" resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== +is-callable@^1.2.7: + version "1.2.7" + resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" + integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== + is-ci@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c" @@ -4849,6 +5221,14 @@ is-data-descriptor@^1.0.1: dependencies: hasown "^2.0.0" +is-date-object@^1.0.5: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.1.0.tgz#ad85541996fc7aa8b2729701d27b7319f95d82f7" + integrity sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg== + dependencies: + call-bound "^1.0.2" + has-tostringtag "^1.0.2" + is-descriptor@^0.1.0: version "0.1.7" resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-0.1.7.tgz#2727eb61fd789dcd5bdf0ed4569f551d2fe3be33" @@ -4938,6 +5318,19 @@ is-in-browser@^1.0.2, is-in-browser@^1.1.3: resolved "https://registry.yarnpkg.com/is-in-browser/-/is-in-browser-1.1.3.tgz#56ff4db683a078c6082eb95dad7dc62e1d04f835" integrity sha512-FeXIBgG/CPGd/WUxuEyvgGTEfwiG9Z4EKGxjNMRqviiIIfsmgrpnHLffEDdwUHqNva1VEW91o3xBT/m8Elgl9g== +is-map@^2.0.2, is-map@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.3.tgz#ede96b7fe1e270b3c4465e3a465658764926d62e" + integrity sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw== + +is-number-object@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.1.1.tgz#144b21e95a1bc148205dcc2814a9134ec41b2541" + integrity sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw== + dependencies: + call-bound "^1.0.3" + has-tostringtag "^1.0.2" + is-number@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/is-number/-/is-number-2.1.0.tgz#01fcbbb393463a548f2f466cce16dece49db908f" @@ -4989,6 +5382,28 @@ is-primitive@^2.0.0: resolved "https://registry.yarnpkg.com/is-primitive/-/is-primitive-2.0.0.tgz#207bab91638499c07b2adf240a41a87210034575" integrity sha512-N3w1tFaRfk3UrPfqeRyD+GYDASU3W5VinKhlORy8EWVf/sIdDL9GAcew85XmktCfH+ngG7SRXEVDoO18WMdB/Q== +is-regex@^1.1.4, is-regex@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.2.1.tgz#76d70a3ed10ef9be48eb577887d74205bf0cad22" + integrity sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g== + dependencies: + call-bound "^1.0.2" + gopd "^1.2.0" + has-tostringtag "^1.0.2" + hasown "^2.0.2" + +is-set@^2.0.2, is-set@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-set/-/is-set-2.0.3.tgz#8ab209ea424608141372ded6e0cb200ef1d9d01d" + integrity sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg== + +is-shared-array-buffer@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz#9b67844bd9b7f246ba0708c3a93e34269c774f6f" + integrity sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A== + dependencies: + call-bound "^1.0.3" + is-stream@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" @@ -4999,11 +5414,41 @@ is-stream@^2.0.0: resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== +is-string@^1.0.7, is-string@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.1.1.tgz#92ea3f3d5c5b6e039ca8677e5ac8d07ea773cbb9" + integrity sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA== + dependencies: + call-bound "^1.0.3" + has-tostringtag "^1.0.2" + +is-symbol@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.1.1.tgz#f47761279f532e2b05a7024a7506dbbedacd0634" + integrity sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w== + dependencies: + call-bound "^1.0.2" + has-symbols "^1.1.0" + safe-regex-test "^1.1.0" + is-typedarray@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" integrity sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA== +is-weakmap@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/is-weakmap/-/is-weakmap-2.0.2.tgz#bf72615d649dfe5f699079c54b83e47d1ae19cfd" + integrity sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w== + +is-weakset@^2.0.3: + version "2.0.4" + resolved "https://registry.yarnpkg.com/is-weakset/-/is-weakset-2.0.4.tgz#c9f5deb0bc1906c6d6f1027f284ddf459249daca" + integrity sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ== + dependencies: + call-bound "^1.0.3" + get-intrinsic "^1.2.6" + is-what@^4.1.8: version "4.1.16" resolved "https://registry.yarnpkg.com/is-what/-/is-what-4.1.16.tgz#1ad860a19da8b4895ad5495da3182ce2acdd7a6f" @@ -5026,6 +5471,11 @@ isarray@1.0.0, isarray@~1.0.0: resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ== +isarray@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723" + integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw== + isexe@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" @@ -5147,6 +5597,16 @@ jest-config@^26.6.3: micromatch "^4.0.2" pretty-format "^26.6.2" +jest-diff@30.4.1: + version "30.4.1" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-30.4.1.tgz#26691c73975768409af4a66b2754cea3182aa2dc" + integrity sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA== + dependencies: + "@jest/diff-sequences" "30.4.0" + "@jest/get-type" "30.1.0" + chalk "^4.1.2" + pretty-format "30.4.1" + jest-diff@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-26.6.2.tgz#1aa7468b52c3a68d7d5c5fdcdfcd5e49bd164394" @@ -5258,6 +5718,16 @@ jest-leak-detector@^26.6.2: jest-get-type "^26.3.0" pretty-format "^26.6.2" +jest-matcher-utils@30.4.1: + version "30.4.1" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-30.4.1.tgz#3fee8c89dbd8fc6e60eb590def9897e18f110ec4" + integrity sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A== + dependencies: + "@jest/get-type" "30.1.0" + chalk "^4.1.2" + jest-diff "30.4.1" + pretty-format "30.4.1" + jest-matcher-utils@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-26.6.2.tgz#8e6fd6e863c8b2d31ac6472eeb237bc595e53e7a" @@ -5268,6 +5738,22 @@ jest-matcher-utils@^26.6.2: jest-get-type "^26.3.0" pretty-format "^26.6.2" +jest-message-util@30.4.1: + version "30.4.1" + resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-30.4.1.tgz#40f6bfa5f564363edcba7ce0ca64277fd2ad6af7" + integrity sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ== + dependencies: + "@babel/code-frame" "^7.27.1" + "@jest/types" "30.4.1" + "@types/stack-utils" "^2.0.3" + chalk "^4.1.2" + graceful-fs "^4.2.11" + jest-util "30.4.1" + picomatch "^4.0.3" + pretty-format "30.4.1" + slash "^3.0.0" + stack-utils "^2.0.6" + jest-message-util@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-26.6.2.tgz#58173744ad6fc0506b5d21150b9be56ef001ca07" @@ -5283,6 +5769,15 @@ jest-message-util@^26.6.2: slash "^3.0.0" stack-utils "^2.0.2" +jest-mock@30.4.1: + version "30.4.1" + resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-30.4.1.tgz#5e11a05d7719a1e3c7bba6348b70ff4e1bc5ea68" + integrity sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw== + dependencies: + "@jest/types" "30.4.1" + "@types/node" "*" + jest-util "30.4.1" + jest-mock@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-26.6.2.tgz#d6cb712b041ed47fe0d9b6fc3474bc6543feb302" @@ -5296,6 +5791,11 @@ jest-pnp-resolver@^1.2.2: resolved "https://registry.yarnpkg.com/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz#930b1546164d4ad5937d5540e711d4d38d4cad2e" integrity sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w== +jest-regex-util@30.4.0: + version "30.4.0" + resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-30.4.0.tgz#f75ccc43857633df2563a03588b5cb45c7c2941b" + integrity sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg== + jest-regex-util@^26.0.0: version "26.0.0" resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-26.0.0.tgz#d25e7184b36e39fd466c3bc41be0971e821fee28" @@ -5413,6 +5913,18 @@ jest-snapshot@^26.6.2: pretty-format "^26.6.2" semver "^7.3.2" +jest-util@30.4.1: + version "30.4.1" + resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-30.4.1.tgz#979c9d014fdd12bb95d3dcde0192e1a9e0bc93d6" + integrity sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw== + dependencies: + "@jest/types" "30.4.1" + "@types/node" "*" + chalk "^4.1.2" + ci-info "^4.2.0" + graceful-fs "^4.2.11" + picomatch "^4.0.3" + jest-util@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-26.6.2.tgz#907535dbe4d5a6cb4c47ac9b926f6af29576cbc1" @@ -5779,6 +6291,11 @@ lodash.isplainobject@^4.0.6: resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" integrity sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA== +lodash@^4.17.15: + version "4.18.1" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.18.1.tgz#ff2b66c1f6326d59513de2407bf881439812771c" + integrity sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q== + lodash@^4.17.21, lodash@^4.17.4, lodash@^4.2.1, lodash@^4.7.0: version "4.17.23" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.23.tgz#f113b0378386103be4f6893388c73d0bde7f2c5a" @@ -5798,6 +6315,11 @@ lru-cache@^5.1.1: dependencies: yallist "^3.0.2" +lz-string@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941" + integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ== + make-dir@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-2.1.0.tgz#5f0310e18b8be898cc07009295a30ae41e91e6f5" @@ -5994,6 +6516,11 @@ mimic-fn@^3.1.0: resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-3.1.0.tgz#65755145bbf3e36954b949c16450427451d5ca74" integrity sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ== +min-indent@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869" + integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg== + mini-css-extract-plugin@^2.6.0: version "2.10.1" resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-2.10.1.tgz#a7f0bb890f4e1ce6dfc124bd1e6d6fcd3b359844" @@ -6221,11 +6748,24 @@ object-copy@^0.1.0: define-property "^0.2.5" kind-of "^3.0.3" -object-inspect@^1.13.3: +object-inspect@^1.13.3, object-inspect@^1.13.4: version "1.13.4" resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== +object-is@^1.1.5: + version "1.1.6" + resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.6.tgz#1a6a53aed2dd8f7e6775ff870bea58545956ab07" + integrity sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + +object-keys@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" + integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== + object-visit@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb" @@ -6233,6 +6773,18 @@ object-visit@^1.0.0: dependencies: isobject "^3.0.0" +object.assign@^4.1.4: + version "4.1.7" + resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.7.tgz#8c14ca1a424c6a561b0bb2a22f66f5049a945d3d" + integrity sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.3" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + has-symbols "^1.1.0" + object-keys "^1.1.1" + object.omit@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/object.omit/-/object.omit-2.0.1.tgz#1a9c744829f39dbb858c76ca3579ae2a54ebd1fa" @@ -6463,6 +7015,20 @@ pkg-dir@^4.1.0, pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" +playwright-core@1.61.1: + version "1.61.1" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.61.1.tgz#3c99841307efbbabc9d724c41a88c914705d15fc" + integrity sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg== + +playwright@1.61.1: + version "1.61.1" + resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.61.1.tgz#d8c0c06eb93c28981afc747bace453bdbd5018bc" + integrity sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ== + dependencies: + playwright-core "1.61.1" + optionalDependencies: + fsevents "2.3.2" + popper.js@1.16.1-lts: version "1.16.1-lts" resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.16.1-lts.tgz#cf6847b807da3799d80ee3d6d2f90df8a3f50b05" @@ -6478,6 +7044,11 @@ posix-character-classes@^0.1.0: resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" integrity sha512-xTgYBc3fuo7Yt7JbiuFxSYGToMoz8fLoE6TC9Wx1P/u+LfeThMOAqmuyECnlBaaJb+u1m9hHiXUEtwW4OzfUJg== +possible-typed-array-names@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz#93e3582bc0e5426586d9d07b79ee40fc841de4ae" + integrity sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg== + postcss-loader@^6.2.1: version "6.2.1" resolved "https://registry.yarnpkg.com/postcss-loader/-/postcss-loader-6.2.1.tgz#0895f7346b1702103d30fdc66e4d494a93c008ef" @@ -6542,6 +7113,16 @@ preserve@^0.2.0: resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b" integrity sha512-s/46sYeylUfHNjI+sA/78FAHlmIuKqI9wNnzEOGehAlUUYeObv5C2mOinXBjyUyWmJ2SfcS2/ydApH4hTF4WXQ== +pretty-format@30.4.1, pretty-format@^30.0.0: + version "30.4.1" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-30.4.1.tgz#0911652e92e1e91f475e3e6a16e628e50649ea69" + integrity sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw== + dependencies: + "@jest/schemas" "30.4.1" + ansi-styles "^5.2.0" + react-is-18 "npm:react-is@^18.3.1" + react-is-19 "npm:react-is@^19.2.5" + pretty-format@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-26.6.2.tgz#e35c2705f14cb7fe2fe94fa078345b444120fc93" @@ -6552,6 +7133,15 @@ pretty-format@^26.6.2: ansi-styles "^4.0.0" react-is "^17.0.1" +pretty-format@^27.0.2: + version "27.5.1" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.5.1.tgz#2181879fdea51a7a5851fb39d920faa63f01d88e" + integrity sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ== + dependencies: + ansi-regex "^5.0.1" + ansi-styles "^5.0.0" + react-is "^17.0.1" + private@^0.1.8: version "0.1.8" resolved "https://registry.yarnpkg.com/private/-/private-0.1.8.tgz#2381edb3689f7a53d653190060fcf822d2f368ff" @@ -6740,6 +7330,16 @@ react-fit@^1.7.0: prop-types "^15.6.0" tiny-warning "^1.0.0" +"react-is-18@npm:react-is@^18.3.1": + version "18.3.1" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" + integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== + +"react-is-19@npm:react-is@^19.2.5": + version "19.2.7" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-19.2.7.tgz#57668ee86a78574a542b0a539455212b2c086df2" + integrity sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A== + "react-is@^16.12.0 || ^17.0.0 || ^18.0.0": version "18.3.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" @@ -6907,6 +7507,14 @@ rechoir@^0.8.0: dependencies: resolve "^1.20.0" +redent@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f" + integrity sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg== + dependencies: + indent-string "^4.0.0" + strip-indent "^3.0.0" + redux-mock-store@^1.5.4: version "1.5.5" resolved "https://registry.yarnpkg.com/redux-mock-store/-/redux-mock-store-1.5.5.tgz#ec3676663c081c4ca5a6a14f1ac193b56c3220eb" @@ -6983,6 +7591,18 @@ regex-not@^1.0.0, regex-not@^1.0.2: extend-shallow "^3.0.2" safe-regex "^1.1.0" +regexp.prototype.flags@^1.5.1: + version "1.5.4" + resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz#1ad6c62d44a259007e55b3970e00f746efbcaa19" + integrity sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA== + dependencies: + call-bind "^1.0.8" + define-properties "^1.2.1" + es-errors "^1.3.0" + get-proto "^1.0.1" + gopd "^1.2.0" + set-function-name "^2.0.2" + regexpu-core@^6.3.1: version "6.4.0" resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-6.4.0.tgz#3580ce0c4faedef599eccb146612436b62a176e5" @@ -7144,6 +7764,15 @@ safe-buffer@~5.1.0, safe-buffer@~5.1.1: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== +safe-regex-test@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.1.0.tgz#7f87dfb67a3150782eaaf18583ff5d1711ac10c1" + integrity sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + is-regex "^1.2.1" + safe-regex@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/safe-regex/-/safe-regex-1.1.0.tgz#40a3669f3b077d1e943d44629e157dd48023bf2e" @@ -7320,6 +7949,28 @@ set-blocking@^2.0.0: resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw== +set-function-length@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" + integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + gopd "^1.0.1" + has-property-descriptors "^1.0.2" + +set-function-name@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/set-function-name/-/set-function-name-2.0.2.tgz#16a705c5a0dc2f5e638ca96d8a8cd4e1c2b90985" + integrity sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + functions-have-names "^1.2.3" + has-property-descriptors "^1.0.2" + set-value@^2.0.0, set-value@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.1.tgz#a18d40530e6f07de4228c7defe4227af8cad005b" @@ -7384,6 +8035,14 @@ side-channel-list@^1.0.0: es-errors "^1.3.0" object-inspect "^1.13.3" +side-channel-list@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.1.tgz#c2e0b5a14a540aebee3bbc6c3f8666cc9b509127" + integrity sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.4" + side-channel-map@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42" @@ -7405,6 +8064,17 @@ side-channel-weakmap@^1.0.2: object-inspect "^1.13.3" side-channel-map "^1.0.1" +side-channel@^1.0.4: + version "1.1.1" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.1.tgz#ea02c62e05dc4bea67d4442f0fb71ee192f8e0ab" + integrity sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.4" + side-channel-list "^1.0.1" + side-channel-map "^1.0.1" + side-channel-weakmap "^1.0.2" + side-channel@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.0.tgz#c3fcff9c4da932784873335ec9765fa94ff66bc9" @@ -7601,7 +8271,7 @@ sprintf-js@~1.0.2: resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== -stack-utils@^2.0.2: +stack-utils@^2.0.2, stack-utils@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-2.0.6.tgz#aaf0748169c02fc33c8232abccf933f54a1cc34f" integrity sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ== @@ -7626,6 +8296,14 @@ statuses@~2.0.1, statuses@~2.0.2: resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382" integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== +stop-iteration-iterator@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz#f481ff70a548f6124d0312c3aa14cbfa7aa542ad" + integrity sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ== + dependencies: + es-errors "^1.3.0" + internal-slot "^1.1.0" + storage2@^0.1.0: version "0.1.2" resolved "https://registry.yarnpkg.com/storage2/-/storage2-0.1.2.tgz#3c433adf88b1bf39b61530e361b359fa78c9f244" @@ -7691,6 +8369,13 @@ strip-final-newline@^2.0.0: resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== +strip-indent@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-3.0.0.tgz#c32e1cee940b6b3432c771bc2c54bcce73cd3001" + integrity sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ== + dependencies: + min-indent "^1.0.0" + style-loader@^3.3.1: version "3.3.4" resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-3.3.4.tgz#f30f786c36db03a45cbd55b6a70d930c479090e7" @@ -8347,11 +9032,45 @@ whatwg-url@^8.0.0, whatwg-url@^8.5.0: tr46 "^2.1.0" webidl-conversions "^6.1.0" +which-boxed-primitive@^1.0.2: + version "1.1.1" + resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz#d76ec27df7fa165f18d5808374a5fe23c29b176e" + integrity sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA== + dependencies: + is-bigint "^1.1.0" + is-boolean-object "^1.2.1" + is-number-object "^1.1.1" + is-string "^1.1.1" + is-symbol "^1.1.1" + +which-collection@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/which-collection/-/which-collection-1.0.2.tgz#627ef76243920a107e7ce8e96191debe4b16c2a0" + integrity sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw== + dependencies: + is-map "^2.0.3" + is-set "^2.0.3" + is-weakmap "^2.0.2" + is-weakset "^2.0.3" + which-module@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.1.tgz#776b1fe35d90aebe99e8ac15eb24093389a4a409" integrity sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ== +which-typed-array@^1.1.13: + version "1.1.22" + resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.22.tgz#8f3cc78aefb40b437346dd40a1dbfa5d1da43fe9" + integrity sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw== + dependencies: + available-typed-arrays "^1.0.7" + call-bind "^1.0.9" + call-bound "^1.0.4" + for-each "^0.3.5" + get-proto "^1.0.1" + gopd "^1.2.0" + has-tostringtag "^1.0.2" + which@^1.2.9: version "1.3.1" resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" From 7acd03db7cee8d52d7e4bc3b0d1455f69a3cd82d Mon Sep 17 00:00:00 2001 From: romanetar Date: Tue, 30 Jun 2026 18:14:54 +0200 Subject: [PATCH 26/26] feat: add testing infrastructure for login MFA flow and E2E suite Signed-off-by: romanetar --- .../workflows/pull_request_frontend_tests.yml | 132 +++++++++++ app/Console/Commands/CreateRawUser.php | 57 +++++ app/Console/Kernel.php | 1 + .../Controllers/Auth/RegisterController.php | 17 +- babel.config.js | 17 +- config/session.php | 4 +- doc/mfa-test-gap-report.md | 143 ++++++++++++ docker-compose.yml | 17 ++ jest.config.js | 18 ++ package.json | 1 - playwright.config.ts | 22 ++ readme.md | 39 +++- .../js/login/components/email_input_form.js | 2 +- .../login/components/password_input_form.js | 8 +- .../js/login/components/recovery_code_form.js | 9 +- .../js/login/components/two_factor_form.js | 14 +- resources/js/login/login.js | 21 +- resources/js/shared/HTMLRender.jsx | 4 +- resources/js/signup/signup.js | 10 +- start_local_server.sh | 30 ++- tests/e2e/fixtures/index.ts | 60 ++++- tests/e2e/pages/LoginPage.ts | 33 ++- tests/e2e/pages/RegisterPage.ts | 16 +- tests/e2e/tests/auth/login-mfa-flow.spec.ts | 206 ++++++++++++++++++ tests/e2e/tests/auth/login.spec.ts | 16 +- tests/e2e/tests/auth/register.spec.ts | 3 +- tests/e2e/tests/oauth2/auth-code-flow.spec.ts | 32 --- .../login/components/two-factor-form.test.js | 56 +++++ tests/js/login/login.mfa.test.js | 179 +++++++++++++++ tests/unit/DisqusSSOProfileMappingTest.php | 32 ++- .../MFA/AbstractMFAChallengeStrategyTest.php | 15 +- .../MFA/EmailOTPMFAChallengeStrategyTest.php | 15 +- .../MFA/MFAChallengeStrategyFactoryTest.php | 15 +- tests/{Unit => unit}/MFAGateServiceTest.php | 8 +- tests/unit/OAuth2LoginStrategyTest.php | 93 ++++++++ .../TwoFactorAuditServiceTest.php | 4 +- webpack.common.js | 1 + 37 files changed, 1243 insertions(+), 107 deletions(-) create mode 100644 .github/workflows/pull_request_frontend_tests.yml create mode 100644 app/Console/Commands/CreateRawUser.php create mode 100644 doc/mfa-test-gap-report.md create mode 100644 jest.config.js create mode 100644 playwright.config.ts create mode 100644 tests/e2e/tests/auth/login-mfa-flow.spec.ts delete mode 100644 tests/e2e/tests/oauth2/auth-code-flow.spec.ts create mode 100644 tests/js/login/components/two-factor-form.test.js create mode 100644 tests/js/login/login.mfa.test.js rename tests/{Unit => unit}/MFA/AbstractMFAChallengeStrategyTest.php (92%) rename tests/{Unit => unit}/MFA/EmailOTPMFAChallengeStrategyTest.php (91%) rename tests/{Unit => unit}/MFA/MFAChallengeStrategyFactoryTest.php (53%) rename tests/{Unit => unit}/MFAGateServiceTest.php (98%) rename tests/{Unit => unit}/TwoFactorAuditServiceTest.php (99%) diff --git a/.github/workflows/pull_request_frontend_tests.yml b/.github/workflows/pull_request_frontend_tests.yml new file mode 100644 index 00000000..1968d028 --- /dev/null +++ b/.github/workflows/pull_request_frontend_tests.yml @@ -0,0 +1,132 @@ +name: Front End Tests On Pull Request + +on: + pull_request: + types: [opened, reopened, edited, synchronize] + branches: ["main"] + +jobs: + + js-unit-tests: + runs-on: ubuntu-latest + steps: + - name: Check out repository code + uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'yarn' + - name: Install JS dependencies + run: yarn install --frozen-lockfile + - name: Run Jest unit tests + run: yarn test:unit:ci + - name: Upload Jest coverage + uses: actions/upload-artifact@v4 + if: always() + with: + name: jest-coverage + path: tests/js/coverage + retention-days: 5 + + e2e-tests: + runs-on: ubuntu-latest + env: + APP_ENV: testing + APP_DEBUG: true + APP_KEY: base64:4vh0op/S1dAsXKQ2bbdCfWRyCI9r8NNIdPXyZWt9PX4= + APP_URL: http://localhost:8001 + DEV_EMAIL_TO: smarcet@gmail.com + DB_CONNECTION: mysql + DB_HOST: 127.0.0.1 + DB_PORT: 3306 + DB_DATABASE: idp_test + DB_USERNAME: root + DB_PASSWORD: 1qaz2wsx + REDIS_HOST: 127.0.0.1 + REDIS_PORT: 6379 + REDIS_DB: 0 + REDIS_PASSWORD: 1qaz2wsx + REDIS_DATABASES: 16 + SSL_ENABLED: false + SESSION_DRIVER: redis + SESSION_COOKIE_SECURE: false + PHP_VERSION: 8.3 + OTEL_SDK_DISABLED: true + OTEL_SERVICE_ENABLED: false + TURNSTILE_SITE_KEY: '' + TURNSTILE_SECRET_KEY: '' + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: 1qaz2wsx + MYSQL_DATABASE: idp_test + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping" + --health-interval=10s + --health-timeout=5s + --health-retries=3 + steps: + - name: Create Redis + uses: supercharge/redis-github-action@1.8.1 + with: + redis-port: 6379 + redis-password: 1qaz2wsx + - name: Check out repository code + uses: actions/checkout@v4 + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ env.PHP_VERSION }} + extensions: pdo_mysql, mbstring, exif, pcntl, bcmath, sockets, gettext, apcu + - name: Install PHP dependencies + uses: ramsey/composer-install@v3 + env: + COMPOSER_AUTH: '{"github-oauth": {"github.com": "${{ secrets.PAT }}"} }' + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'yarn' + - name: Install JS dependencies + run: yarn install --frozen-lockfile + - name: Build frontend assets + run: yarn build + - name: Prepare application + run: | + cp .env.example .env + ./update_doctrine.sh + php artisan doctrine:migrations:migrate --no-interaction + php artisan db:seed --force + php artisan idp:create-super-admin test@test.com '1Qaz2wsx!' + php artisan idp:create-raw-user e2e@test.com '1Qaz2wsx!' + - name: Install Playwright Chromium + run: npx playwright install --with-deps chromium + - name: Start web server + run: php artisan serve --host=127.0.0.1 --port=8001 & + - name: Wait for server to be ready + run: | + for i in $(seq 1 20); do + curl -sf http://localhost:8001 > /dev/null 2>&1 && echo "Server ready" && exit 0 + sleep 2 + done + echo "Server did not start in time" && exit 1 + - name: Run E2E tests + run: yarn test:e2e --reporter=list + - name: Upload Playwright report + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: tests/e2e/report + retention-days: 7 + - name: Upload Playwright traces + uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-traces + path: test-results/ + retention-days: 7 diff --git a/app/Console/Commands/CreateRawUser.php b/app/Console/Commands/CreateRawUser.php new file mode 100644 index 00000000..b25450b8 --- /dev/null +++ b/app/Console/Commands/CreateRawUser.php @@ -0,0 +1,57 @@ +argument('email')); + $password = trim($this->argument('password')); + + $user = EntityManager::getRepository(User::class)->findOneBy(['email' => $email]); + if (is_null($user)) { + $user = new User(); + $user->setEmail($email); + $user->verifyEmail(); + $user->setPassword($password); + $user->setFirstName($email); + $user->setLastName($email); + $user->setIdentifier($email); + EntityManager::persist($user); + EntityManager::flush(); + $this->info("Created user: {$email}"); + } else { + $this->info("User already exists: {$email}"); + } + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 89bf376a..309c0b72 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -29,6 +29,7 @@ class Kernel extends ConsoleKernel Commands\CleanOAuth2StaleData::class, Commands\CleanOpenIdStaleData::class, Commands\CreateSuperAdmin::class, + Commands\CreateRawUser::class, Commands\SpammerProcess\RebuildUserSpammerEstimator::class, Commands\SpammerProcess\UserSpammerProcessor::class, ]; diff --git a/app/Http/Controllers/Auth/RegisterController.php b/app/Http/Controllers/Auth/RegisterController.php index 4ec12ec0..f1bf24aa 100644 --- a/app/Http/Controllers/Auth/RegisterController.php +++ b/app/Http/Controllers/Auth/RegisterController.php @@ -173,15 +173,18 @@ public function showRegistrationForm(LaravelRequest $request) protected function validator(array $data) { $rules = [ - 'first_name' => 'required|string|max:100', - 'last_name' => 'required|string|max:100', - 'country_iso_code' => 'required|string|country_iso_alpha2_code', - 'email' => 'required|string|email|max:255', - 'password' => 'required|string|confirmed|password_policy', - 'cf-turnstile-response' => ['required', new Turnstile()], + 'first_name' => 'required|string|max:100', + 'last_name' => 'required|string|max:100', + 'country_iso_code' => 'required|string|country_iso_alpha2_code', + 'email' => 'required|string|email|max:255', + 'password' => 'required|string|confirmed|password_policy', ]; - if(!empty(Config::get("app.code_of_conduct_link", null))){ + if (!empty(Config::get("services.turnstile.secret", null))) { + $rules['cf-turnstile-response'] = ['required', new Turnstile()]; + } + + if (!empty(Config::get("app.code_of_conduct_link", null))) { $rules['agree_code_of_conduct'] = 'required|string|in:true'; } diff --git a/babel.config.js b/babel.config.js index bc853ec6..e5afd041 100644 --- a/babel.config.js +++ b/babel.config.js @@ -21,6 +21,21 @@ module.exports = { plugins: [ "@babel/plugin-proposal-object-rest-spread", "@babel/plugin-proposal-class-properties" - ] + ], + env: { + test: { + presets: [ + [ + "@babel/preset-env", + { + "targets": { "node": "current" }, + "useBuiltIns": false + } + ], + "@babel/preset-react", + "@babel/preset-flow" + ] + } + } }; diff --git a/config/session.php b/config/session.php index 39306e12..6e18cbbe 100644 --- a/config/session.php +++ b/config/session.php @@ -148,7 +148,7 @@ | */ - 'secure' => true, + 'secure' => env('SESSION_SECURE_COOKIE', false), /* |-------------------------------------------------------------------------- @@ -176,6 +176,6 @@ | */ - 'same_site' => 'none', + 'same_site' => env('SESSION_COOKIE_SAME_SITE', 'lax'), ]; diff --git a/doc/mfa-test-gap-report.md b/doc/mfa-test-gap-report.md new file mode 100644 index 00000000..e1e5ce7b --- /dev/null +++ b/doc/mfa-test-gap-report.md @@ -0,0 +1,143 @@ +# MFA Test Gap Report — PR 142 + +**Branch:** `feat/mfa---login-ui-flow` +**Date:** 2026-06-30 +**Scope:** All files changed across the MFA feature branch (backend + frontend) + +--- + +## Summary + +PR 142 adds full MFA authentication support: 65 files changed, +6,900 lines. The PHP backend layer has strong coverage — 11 dedicated test files were added as part of the PR. The entire frontend refactor (15 JavaScript/JSX files, ~2,220 lines) has **zero test coverage**, and four specific PHP areas were identified as gaps in isolation-level coverage even though they are exercised indirectly by the integration suite. + +| Layer | Files Changed | Files with Tests | Coverage | +|---|---|---|---| +| PHP — services, strategies, repositories | 30 | 30 | ✅ Direct | +| PHP — HTTP / controller layer | 6 | 0 (integration only) | ⚠️ Partial | +| JavaScript — login UI | 15 | 0 | ❌ None | + +--- + +## What IS Covered — PHP Test Files Added in PR 142 + +The following 11 test files were added or substantially extended as part of this PR. They form the baseline any reviewer can rely on. + +### Integration / Feature Tests + +| File | Tests | What it covers | +|---|---|---| +| `tests/TwoFactorLoginFlowTest.php` | 19 | Full end-to-end MFA login flow via HTTP: admin/non-admin routing, OTP verify/fail/reuse, recovery codes, device trust cookie enrollment, trusted-device bypass, audit failure resilience, rate-limit enforcement on verify/recovery/resend endpoints | +| `tests/AuthServiceValidateCredentialsIntegrationTest.php` | 2 | `AuthService::validateCredentials` integration path including the MFA gate check | + +### Unit Tests + +| File | Tests | What it covers | +|---|---|---| +| `tests/unit/AuthServiceValidateCredentialsTest.php` | 9 | Password validation, account state guards, `validateCredentials` under MFA gate (unit) | +| `tests/unit/UserTwoFactorTest.php` | 14 | User entity 2FA flag logic, enforcement rules, group-based enforcement, method availability | +| `tests/Unit/MFAGateServiceTest.php` | 5 | `MFAGateService::requiresChallenge` decision tree for all trust/enforce/cookie combinations | +| `tests/Unit/TwoFactorAuditServiceTest.php` | 7 | Audit event recording: challenge issued, verified, failed, device trusted | +| `tests/DeviceTrustServiceTest.php` | 15 | Full `DeviceTrustService` contract: trust/revoke/expire/validate, SHA-256 storage, audit wiring | +| `tests/Unit/MFA/AbstractMFAChallengeStrategyTest.php` | 8 | Base strategy: OTP generation, expiry, session binding, reuse prevention | +| `tests/Unit/MFA/EmailOTPMFAChallengeStrategyTest.php` | 5 | Email OTP dispatch, already-redeemed race, numeric-only validation | +| `tests/Unit/MFA/MFAChallengeStrategyFactoryTest.php` | 2 | Factory resolves correct strategy for each `MFA_METHODS` value | + +### Repository / Model Tests + +| File | Tests | What it covers | +|---|---|---| +| `tests/TwoFactorRepositoriesTest.php` | 11 | Doctrine round-trips for `UserTrustedDevice`, `TwoFactorAuditLog`, `UserRecoveryCode`: persistence, expiry/revocation queries, uniqueness constraints, `setCodeHash` guards against plaintext | + +--- + +## Gaps — PHP Backend + +These four items lack isolated test coverage. They are exercised indirectly by `TwoFactorLoginFlowTest` but would be invisible to a unit test runner. + +### 1. `cancelLogin` Controller Endpoint (Critical) + +**File:** `app/Http/Controllers/UserController.php` — `cancelLogin()` action +**What it does:** Tears down the pending-MFA session state when the user cancels mid-challenge. If this is broken, users can get stuck in an unrecoverable MFA state or, worse, a session may retain stale auth context. +**Gap:** No unit or dedicated integration test for the `POST /auth/cancel-login` route. The flow test exercises the happy-path continuation but not cancellation edge cases (double-cancel, cancel with no pending session, cancel with concurrent session). + +### 2. `TwoFactorRateLimitMiddleware` Isolation (High) + +**File:** `app/Http/Middleware/TwoFactorRateLimitMiddleware.php` +**What it does:** Cache-backed, fixed-window rate limiting for verify/recovery/resend. Counters survive session cleanup. Verify/recovery increment only on failure; resend increments always. +**Gap:** The middleware is tested indirectly through `TwoFactorLoginFlowTest` (`testVerifyRateLimitBlocksAfterThreshold` etc.), but there are no isolated middleware unit tests covering: window expiry after TTL, per-action counter separation, resend counting regardless of response status, and behavior when no pending session key exists. + +### 3. `MFACookieManager` Trait Isolation (Medium) + +**File:** `app/Http/Controllers/Traits/MFACookieManager.php` +**What it does:** Reads the raw device-trust cookie from the request and queues the `Set-Cookie` header. Cookie name, lifetime, and security flags (Secure, HttpOnly, SameSite=lax) are configuration-driven. +**Gap:** No unit test verifies that `queueDeviceTrustCookie` passes the correct flags to `Cookie::queue`, that the lifetime calculation (`days × 24 × 60`) is right, or that `getCookieToken` returns `null` when no cookie is present. A misconfigured `$secure = true` hardcode already exists in the code and warrants explicit assertion. + +### 4. `EncryptCookies` Exclusion (Medium) + +**File:** `app/Http/Middleware/EncryptCookies.php` +**What it does:** Excludes the device-trust token from Laravel's cookie encryption layer so the raw token survives the round-trip. +**Gap:** No test asserts that `config('two_factor.cookie_name')` is in `$except`, so a future refactor that drops the constructor injection would silently encrypt the cookie and break device trust comparison in `DeviceTrustService` with no test failure. + +--- + +## Gaps — JavaScript Frontend + +All 15 frontend files introduced or substantially modified by this PR have no test coverage of any kind. + +### File Coverage Table + +| File | Lines | Category | Risk | Notes | +|---|---|---|---|---| +| `resources/js/login/login.js` | 1,000 | State machine / orchestrator | **Critical** | Core MFA flow controller: `handleAuthenticatePasswordOk` dispatches to `FLOW.MFA`; `handleMfaError` maps 401/412/429/0 to UI states; `resetToPasswordFlow`; `onVerify2FA`; `onVerifyRecovery`; `onResend2FA` | +| `resources/js/login/components/two_factor_form.js` | 149 | UI Component | **Critical** | Countdown timer with dual `useEffect` (expiry + cooldown), resend cooldown guard, expired-code state, trust-device checkbox | +| `resources/js/login/components/otp_input_form.js` | 117 | UI Component | **High** | OTP entry for email-verification flow; error display, submit guard | +| `resources/js/login/components/password_input_form.js` | 193 | UI Component | **High** | Password entry + show/hide; attempt-count error states; `data-testid` error label | +| `resources/js/login/components/recovery_code_form.js` | 84 | UI Component | **High** | Recovery code entry, empty-submit guard | +| `resources/js/login/actions.js` | 66 | API Layer | **High** | `verify2FA`, `resend2FA`, `verifyRecoveryCode`, `cancelLogin`, `authenticateWithPassword` — all XHR wrappers; URL sourced from `window.*_ENDPOINT` | +| `resources/js/base_actions.js` | 248 | API Layer | **High** | `postRawRequest` / `postRawRequestFull` — XHR transport, redirect-following, `responseURL` extraction; used by every action | +| `resources/js/login/components/email_input_form.js` | 61 | UI Component | **Medium** | Email entry step; `data-testid="error-label"` | +| `resources/js/login/components/email_error_actions.js` | 60 | UI Component | **Medium** | Unknown-email CTA display | +| `resources/js/login/components/existing_account_actions.js` | 47 | UI Component | **Medium** | Account-exists action set | +| `resources/js/login/components/help_links.js` | 78 | UI Component | **Medium** | Context-sensitive help links | +| `resources/js/login/constants.js` | 32 | Constants | **Low** | `FLOW`, `HTTP_CODES`, `MFA_ERROR_CODE` enum values | +| `resources/js/login/components/otp_help_links.js` | 20 | UI Component | **Low** | OTP-specific help link | +| `resources/js/login/components/third_party_identity_providers.js` | 36 | UI Component | **Low** | SSO provider list display | +| `resources/js/shared/HTMLRender.jsx` | 29 | Shared Utility | **Low** | DOMPurify wrapper; `...rest` prop forwarding | + +--- + +## Priority Recommendations + +| Priority | Item | Rationale | +|---|---|---| +| **Critical** | Unit tests for `login.js` state machine | `handleAuthenticatePasswordOk`, `handleMfaError`, `handleAuthenticateValidation`, and `resetToPasswordFlow` are pure state logic that can be tested without a DOM. These are the highest-value, lowest-effort tests — each branch covers a real user failure mode. | +| **Critical** | Jest component tests for `TwoFactorForm` | The countdown + cooldown dual-timer is the most complex UI logic in the PR. Timer behavior, expired-code state, and resend-button disabling are invisible in E2E tests but trivially verifiable with `@testing-library/react` + `jest.useFakeTimers`. | +| **Critical** | Dedicated integration test for `cancelLogin` | Covers the session-cleanup contract that is otherwise only exercised by the happy path. | +| **High** | Jest tests for `actions.js` and `base_actions.js` | Mock `window.*_ENDPOINT` and `superagent`; assert that `postRawRequestFull` extracts `responseURL` as `finalUrl`. These are the only XHR-level contracts between React and the PHP backend. | +| **High** | Playwright E2E: full MFA flow | `goes to 2FA step after password → enters code → logs in` and the expired-session regression. The scaffold (`tests/e2e/`) already exists. | +| **High** | `TwoFactorRateLimitMiddleware` unit tests | Isolated cache-mock tests for window expiry and per-action counter separation. | +| **Medium** | `MFACookieManager` unit tests | Assert cookie flag values. | +| **Medium** | Jest component tests: `RecoveryCodeForm`, `PasswordInputForm`, `OTPInputForm` | Error-display and empty-submit guard branches. | +| **Medium** | `EncryptCookies` exclusion assertion | One-line test: `assertContains(config('two_factor.cookie_name'), (new EncryptCookies(...))->getExcept())`. | +| **Low** | `constants.js` smoke test | Not worth dedicated tests; covered by any consumer test that imports the file. | + +--- + +## How to Run What Exists Today + +```bash +# PHP — all suites +./vendor/bin/phpunit + +# PHP — MFA suite only +./vendor/bin/phpunit --testsuite "Two Factor Authentication Test Suite" + +# PHP — integration suite only +./vendor/bin/phpunit tests/TwoFactorLoginFlowTest.php + +# JS — unit tests (Jest) +yarn test:unit:ci + +# E2E (requires Docker stack) +docker compose --profile e2e run --rm playwright npx playwright test tests/e2e/tests/auth/ +``` diff --git a/docker-compose.yml b/docker-compose.yml index a185686e..329e2c8b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -57,6 +57,22 @@ services: networks: - idp-local-net env_file: ./.env + playwright: + image: mcr.microsoft.com/playwright:v1.61.1-jammy + container_name: idp-playwright + working_dir: /var/www + volumes: + - ./:/var/www + - playwright_cache:/root/.cache/ms-playwright + networks: + - idp-local-net + depends_on: + - nginx + profiles: + - e2e + environment: + - APP_URL=http://nginx + nginx: image: nginx:alpine container_name: nginx-idp @@ -119,3 +135,4 @@ networks: volumes: mysql_idp: elasticsearch_data: + playwright_cache: diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 00000000..2cb8907a --- /dev/null +++ b/jest.config.js @@ -0,0 +1,18 @@ +module.exports = { + testEnvironment: 'jsdom', + testMatch: ['/tests/js/**/*.test.js'], + // @marsidev/react-turnstile ships as pure ESM; allow Babel to transform it. + transformIgnorePatterns: ['/node_modules/(?!@marsidev/react-turnstile)'], + moduleNameMapper: { + '\\.(css|scss|sass|less)$': 'identity-obj-proxy', + '\\.(jpg|jpeg|png|gif|svg|ttf|woff|woff2|eot|otf|webp)$': + '/tests/js/__mocks__/fileMock.js', + }, + setupFilesAfterEnv: ['/tests/js/setup.js'], + transform: { + '^.+\\.[jt]sx?$': 'babel-jest', + }, + moduleDirectories: ['node_modules', 'resources/js'], + collectCoverageFrom: ['resources/js/**/*.{js,jsx}', '!resources/js/index.js'], + coverageDirectory: 'tests/js/coverage', +}; diff --git a/package.json b/package.json index 9ce0634a..0992db7c 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,6 @@ "test:unit:ci": "jest --testPathPattern=tests/js --ci --coverage", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", - "test:e2e:debug": "playwright test --debug", "test:e2e:report": "playwright show-report tests/e2e/report" }, "devDependencies": { diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..2175d399 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,22 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/e2e/tests', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: [['html', { outputFolder: 'tests/e2e/report' }]], + use: { + baseURL: process.env.APP_URL || 'http://localhost:8001', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/readme.md b/readme.md index 26b145d9..8df45030 100644 --- a/readme.md +++ b/readme.md @@ -79,10 +79,47 @@ nvm use # Tests +## Backend (PHPUnit) + +```bash php artisan view:clear php artisan cache:clear - ./vendor/bin/phpunit +``` + +## Frontend — Unit/Component (Jest) + +Run from inside the `idp-app` container: + +```bash +yarn test:unit # watch mode +yarn test:unit:ci # single run with coverage +``` + +## Frontend — E2E (Playwright) + +Run from the **host** (outside any container). The full stack must be running (`./start_local_server.sh`). + +```bash +# Run all E2E tests +docker compose --profile e2e run --rm playwright npx playwright test + +# Run a specific file +docker compose --profile e2e run --rm playwright npx playwright test tests/e2e/tests/auth/login.spec.ts +``` + +> E2E tests cannot be run from inside the `idp-app` container — it has no browser. +> The `playwright` service (`mcr.microsoft.com/playwright:v1.61.1-jammy`) includes Chromium and all required system dependencies. + +### Viewing the HTML report + +The report is written to `tests/e2e/report/` on the host. Serve it from the host (not from inside any container) so the browser can reach it: + +```bash +nvm use 22.2.0 +yarn test:e2e:report +# Open http://localhost:9323 +``` # install docker compose diff --git a/resources/js/login/components/email_input_form.js b/resources/js/login/components/email_input_form.js index ab26ff47..d074bb23 100644 --- a/resources/js/login/components/email_input_form.js +++ b/resources/js/login/components/email_input_form.js @@ -50,7 +50,7 @@ const EmailInputForm = ({ )} {emailError != "" && ( - + {emailError} )} diff --git a/resources/js/login/components/password_input_form.js b/resources/js/login/components/password_input_form.js index d68b4258..bc4be234 100644 --- a/resources/js/login/components/password_input_form.js +++ b/resources/js/login/components/password_input_form.js @@ -55,7 +55,7 @@ const PasswordInputForm = ({ if (attempts > 0 && attempts < maxAttempts && userIsActive) { return ( -

+

Incorrect password. You have {attemptsLeft} more attempt {attemptsLeft !== 1 ? "s" : ""} before your account is locked.

@@ -64,7 +64,7 @@ const PasswordInputForm = ({ if (attempts > 0 && attempts === maxAttempts && userIsActive) { return ( -

+

Incorrect password. You have reached the maximum ({maxAttempts}) login attempts. Your account will be locked after another failed login. @@ -74,7 +74,7 @@ const PasswordInputForm = ({ if (attempts > 0 && attempts === maxAttempts && !userIsActive) { return ( -

+

Your account has been locked due to multiple failed login attempts. Please contact support to unlock it. @@ -83,7 +83,7 @@ const PasswordInputForm = ({ } return ( - + {passwordError} ); diff --git a/resources/js/login/components/recovery_code_form.js b/resources/js/login/components/recovery_code_form.js index 49ace5f6..50830e04 100644 --- a/resources/js/login/components/recovery_code_form.js +++ b/resources/js/login/components/recovery_code_form.js @@ -31,7 +31,7 @@ const RecoveryCodeForm = ({ }; return ( -

+
Enter a recovery code

Enter one of the recovery codes you saved when you enabled two-step verification. @@ -52,7 +52,7 @@ const RecoveryCodeForm = ({ error={!!recoveryError} /> {recoveryError && ( - + {recoveryError} )} @@ -61,7 +61,8 @@ const RecoveryCodeForm = ({ disabled={disableInput || recoveryCode === ''} color="primary" type="submit" - target="_self"> + target="_self" + data-testid="verify-button"> VERIFY @@ -72,7 +73,7 @@ const RecoveryCodeForm = ({ Back to verification code {" · "} - + Cancel diff --git a/resources/js/login/components/two_factor_form.js b/resources/js/login/components/two_factor_form.js index fe29f3f6..c32c468d 100644 --- a/resources/js/login/components/two_factor_form.js +++ b/resources/js/login/components/two_factor_form.js @@ -73,7 +73,7 @@ const TwoFactorForm = ({ }; return ( - +

Enter the single-use code sent to your email:
{otpError && - + {otpError} } @@ -119,7 +119,8 @@ const TwoFactorForm = ({ disabled={disableInput || otpCode === ''} color="primary" type="submit" - target="_self"> + target="_self" + data-testid="verify-button"> VERIFY @@ -127,17 +128,18 @@ const TwoFactorForm = ({

Didn't receive it? Check your spam folder or{" "} 0 || disableInput) ? styles.disabled_link : ''}> + className={(cooldown > 0 || disableInput) ? styles.disabled_link : ''} + data-testid="resend-link"> {cooldown > 0 ? `resend code (${cooldown}s)` : 'resend code'} .

{/* "Use a different method" is intentionally hidden in Phase I (email_otp only). */}
- + Cancel - + Use a recovery code instead
diff --git a/resources/js/login/login.js b/resources/js/login/login.js index 789898a7..ccc552d9 100644 --- a/resources/js/login/login.js +++ b/resources/js/login/login.js @@ -839,7 +839,7 @@ class LoginPage extends React.Component { )} {isPasswordFlow && ( // proceed to ask for password ( 2nd step ) - <> +
- +
)} {isOtpFlow && ( // proceed to ask for password ( 2nd step ) @@ -992,9 +992,14 @@ const theme = createTheme({ }, }); -ReactDOM.render( - - - , - document.querySelector("#root"), -); +export { LoginPage }; + +const root = document.querySelector("#root"); +if (root) { + ReactDOM.render( + + + , + root, + ); +} diff --git a/resources/js/shared/HTMLRender.jsx b/resources/js/shared/HTMLRender.jsx index 98062c02..d8e52fda 100644 --- a/resources/js/shared/HTMLRender.jsx +++ b/resources/js/shared/HTMLRender.jsx @@ -1,8 +1,9 @@ /* eslint-disable react/no-danger */ +import React from "react"; import PropTypes from "prop-types"; import DOMPurify from "dompurify"; -const HTMLRender = ({ children, className, style, component = "div" }) => { +const HTMLRender = ({ children, className, style, component = "div", ...rest }) => { const html = DOMPurify.sanitize(children || ""); const Component = component; @@ -11,6 +12,7 @@ const HTMLRender = ({ children, className, style, component = "div" }) => { style={style} className={className} dangerouslySetInnerHTML={{ __html: html }} + {...rest} /> ); }; diff --git a/resources/js/signup/signup.js b/resources/js/signup/signup.js index 1d595e2a..93a98b2f 100644 --- a/resources/js/signup/signup.js +++ b/resources/js/signup/signup.js @@ -84,10 +84,12 @@ const SignUpPage = ({ return errors; }, onSubmit: (values) => { - const turnstileResponse = captcha.current?.getResponse(); - if (!turnstileResponse) { - setCaptchaConfirmation("Remember to check the captcha"); - return; + if (captchaPublicKey) { + const turnstileResponse = captcha.current?.getResponse(); + if (!turnstileResponse) { + setCaptchaConfirmation("Remember to check the captcha"); + return; + } } doHtmlFormPost(); }, diff --git a/start_local_server.sh b/start_local_server.sh index 0535f4b8..62d66591 100755 --- a/start_local_server.sh +++ b/start_local_server.sh @@ -2,11 +2,31 @@ set -e export DOCKER_SCAN_SUGGEST=false -docker compose run --rm app composer install -docker compose run --rm app php artisan doctrine:migrations:migrate --no-interaction -docker compose run --rm app php artisan db:seed --force -docker compose run --rm app php artisan idp:create-super-admin test@test.com 1Qaz2wsx! +# Install PHP deps without running post-autoload scripts (package:discover +# boots Laravel which triggers the OTEL exporter flush — hangs if the +# collector isn't up yet). +docker compose run --rm app composer install --no-scripts + +# JS deps and build don't involve artisan, safe to run before the full stack. docker compose run --rm app yarn install docker compose run --rm app yarn build + +# Bring up the full stack so the OTEL collector is reachable before any +# artisan command runs. docker compose up -d -docker compose exec app /bin/bash \ No newline at end of file + +echo "Waiting for app container to be ready..." +until docker compose exec app true 2>/dev/null; do sleep 1; done + +# Now run artisan commands with every service available. +docker compose exec app php artisan package:discover --ansi +docker compose exec app php artisan doctrine:migrations:migrate --no-interaction +docker compose exec app php artisan db:seed --force +docker compose exec app php artisan idp:create-super-admin test@test.com 1Qaz2wsx! +docker compose exec app php artisan idp:create-raw-user e2e@test.com 1Qaz2wsx! + +# Install Playwright Chromium into the named volume (skipped automatically if +# already cached from a previous run). +docker compose --profile e2e run --rm playwright npx playwright install chromium + +docker compose exec app /bin/bash diff --git a/tests/e2e/fixtures/index.ts b/tests/e2e/fixtures/index.ts index af156189..f6d80a7c 100644 --- a/tests/e2e/fixtures/index.ts +++ b/tests/e2e/fixtures/index.ts @@ -9,6 +9,60 @@ type E2EFixtures = { }; export const test = base.extend({ + page: async ({ page }, use) => { + // When running inside the Docker playwright container, APP_URL=http://nginx is + // injected by docker-compose. The PHP app bakes http://localhost:8001 into the + // page HTML (asset src attributes and window.*_ENDPOINT globals). Two problems: + // 1. Assets at http://localhost:8001/assets/** → unreachable from the container. + // 2. XHR to http://localhost:8001/** is cross-origin from http://nginx, so the + // browser omits session cookies → server returns 419 CSRF error. + // + // Fix A: route.continue rewrite for assets (scripts, images, CSS). + // Fix B: addInitScript intercepts window.*_ENDPOINT assignments before they are + // read by React, rewriting them to http://nginx so XHR is same-origin. + // + // From the host (APP_URL unset or http://localhost:*), no interception is needed. + const internalUrl = process.env.APP_URL; + if (internalUrl && !internalUrl.includes('localhost')) { + // Fix A: rewrite asset URLs. + await page.route(/^http:\/\/localhost(:\d+)?\//, (route) => { + const rewritten = route.request().url() + .replace(/^http:\/\/localhost(:\d+)?/, internalUrl); + route.continue({ url: rewritten }); + }); + + // Fix B: intercept window.*_ENDPOINT property assignments so every XHR + // made by React targets http://nginx (same origin), ensuring the session + // cookie is automatically included and CSRF validation succeeds. + const endpoints = [ + 'VERIFY_ACCOUNT_ENDPOINT', + 'EMIT_OTP_ENDPOINT', + 'RESEND_VERIFICATION_EMAIL_ENDPOINT', + 'VERIFY_2FA_ENDPOINT', + 'RESEND_2FA_ENDPOINT', + 'CANCEL_LOGIN_ENDPOINT', + 'RECOVERY_2FA_ENDPOINT', + 'FORM_ACTION_ENDPOINT', + ]; + await page.addInitScript(({ endpoints, internalUrl }) => { + for (const key of endpoints) { + let _val; + Object.defineProperty(window, key, { + configurable: true, + enumerable: true, + set(v) { + _val = typeof v === 'string' + ? v.replace(/http:\/\/localhost(:\d+)?/, internalUrl) + : v; + }, + get() { return _val; }, + }); + } + }, { endpoints, internalUrl }); + } + await use(page); + }, + loginPage: async ({ page }, use) => { await use(new LoginPage(page)); }, @@ -17,11 +71,13 @@ export const test = base.extend({ await use(new RegisterPage(page)); }, - // Pre-authenticated session using the default seeded admin account + // Pre-authenticated session using the raw E2E user (no group memberships, + // so MFA is never enforced and the login completes without a 2FA challenge). + // Override via TEST_USER_EMAIL / TEST_USER_PASSWORD env vars if needed. authenticatedPage: async ({ page }, use) => { const loginPage = new LoginPage(page); await loginPage.login( - process.env.TEST_USER_EMAIL || 'test@test.com', + process.env.TEST_USER_EMAIL || 'e2e@test.com', process.env.TEST_USER_PASSWORD || '1Qaz2wsx!' ); await use(loginPage); diff --git a/tests/e2e/pages/LoginPage.ts b/tests/e2e/pages/LoginPage.ts index 09457670..d05b4be0 100644 --- a/tests/e2e/pages/LoginPage.ts +++ b/tests/e2e/pages/LoginPage.ts @@ -4,19 +4,40 @@ export class LoginPage { readonly page: Page; readonly emailInput: Locator; readonly passwordInput: Locator; - readonly submitButton: Locator; + // Email step: button has title="Continue" (text is ">") + readonly emailSubmitButton: Locator; + // Password step: button text is "Continue", type="button" (no title) + readonly passwordSubmitButton: Locator; readonly rememberMeCheckbox: Locator; readonly errorLabel: Locator; readonly otpInput: Locator; + // Password step container + readonly passwordForm: Locator; + // Two-factor (MFA) step + readonly twoFactorForm: Locator; + readonly verifyButton: Locator; + readonly resendLink: Locator; + readonly cancelLink: Locator; + readonly useRecoveryLink: Locator; + // Recovery code step + readonly recoveryForm: Locator; constructor(page: Page) { this.page = page; this.emailInput = page.locator('#email'); this.passwordInput = page.locator('#password'); - this.submitButton = page.locator('button[type="submit"]'); + this.emailSubmitButton = page.locator('button[title="Continue"]'); + this.passwordSubmitButton = page.getByRole('button', { name: 'Continue' }); this.rememberMeCheckbox = page.locator('#remember'); - this.errorLabel = page.locator('[class*="error_label"]'); + this.errorLabel = page.locator('[data-testid="error-label"]'); this.otpInput = page.locator('[data-testid="otp_code"]'); + this.passwordForm = page.locator('[data-testid="password-form"]'); + this.twoFactorForm = page.locator('[data-testid="two-factor-form"]'); + this.verifyButton = page.locator('[data-testid="verify-button"]'); + this.resendLink = page.locator('[data-testid="resend-link"]'); + this.cancelLink = page.locator('[data-testid="cancel-link"]'); + this.useRecoveryLink = page.locator('[data-testid="use-recovery-link"]'); + this.recoveryForm = page.locator('[data-testid="recovery-form"]'); } async goto() { @@ -25,12 +46,12 @@ export class LoginPage { async fillEmail(email: string) { await this.emailInput.fill(email); - await this.submitButton.click(); + await this.emailSubmitButton.click(); } async fillPassword(password: string) { await this.passwordInput.fill(password); - await this.submitButton.click(); + await this.passwordSubmitButton.click(); } async login(email: string, password: string) { @@ -41,6 +62,6 @@ export class LoginPage { async fillOtp(code: string) { await this.otpInput.fill(code); - await this.submitButton.click(); + await this.passwordSubmitButton.click(); } } diff --git a/tests/e2e/pages/RegisterPage.ts b/tests/e2e/pages/RegisterPage.ts index b2121d85..1c03f16e 100644 --- a/tests/e2e/pages/RegisterPage.ts +++ b/tests/e2e/pages/RegisterPage.ts @@ -7,10 +7,12 @@ export class RegisterPage { readonly emailInput: Locator; readonly passwordInput: Locator; readonly passwordConfirmInput: Locator; - readonly countrySelect: Locator; readonly codeOfConductCheckbox: Locator; readonly submitButton: Locator; + // MUI FormHelperText error messages (not CSS-module classes, so not hashed) readonly errorContainer: Locator; + // SweetAlert2 popup shown for server-side errors (e.g. duplicate email) + readonly swalPopup: Locator; constructor(page: Page) { this.page = page; @@ -19,16 +21,22 @@ export class RegisterPage { this.emailInput = page.locator('[name="email"]'); this.passwordInput = page.locator('[name="password"]'); this.passwordConfirmInput = page.locator('[name="password_confirmation"]'); - this.countrySelect = page.locator('[name="country_iso_code"]'); this.codeOfConductCheckbox = page.locator('[name="agree_code_of_conduct"]'); this.submitButton = page.locator('button[type="submit"]'); - this.errorContainer = page.locator('[class*="error"]'); + this.errorContainer = page.locator('p.MuiFormHelperText-root.Mui-error').first(); + this.swalPopup = page.locator('.swal2-popup'); } async goto() { await this.page.goto('/auth/register'); } + // MUI Select does not render a native