From 700c01f7c65abda757243639c8d9a8e2fd9de1bb Mon Sep 17 00:00:00 2001 From: matiasperrone-exo Date: Thu, 16 Apr 2026 20:05:52 +0000 Subject: [PATCH 01/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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 68a6c8dc8c20c0bd06ef8fa946b92d78ec3db00d Mon Sep 17 00:00:00 2001 From: Matias Perrone Date: Tue, 11 Aug 2026 13:48:21 -0300 Subject: [PATCH 10/11] Feature | Implement Two-Factor Authentication (2FA) support for users (#126) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Add MultiFactor Authentication * Feature | Add AuthService validateCredentials method (#127) * 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() * chore: lint file app/libs/Auth/AuthService.php * chore: Add PR's requested changes * chore: Add PR's requested changes Add tests changes with suggestion * chore: Fix issues created on rebase * Feature | MFA Challenge Strategy Pattern (Interface, Abstract, Factory, EmailOTP) (#129) * feat: Implement Multi-Factor Authentication challenge strategies and tests * chore: Add PR's requested changes * Feature | Add Device Trust Service (#133) * feat: Add Device Trust Service * Feature | Two-Factor Audit Service (#134) * feat: Two-Factor Audit Service * Feature | MFAGateService (Two-Factor Gate Decision Service) (#135) * feat: MFAGateService (Two-Factor Gate Decision Service) * Feature | UserController MFA Integration, Device Trust Cookie Management, Audit Wiring, and 2FA Rate Limiting (#136) * feat: UserController MFA Integration, Device Trust Cookie Management, Audit Wiring, and 2FA Rate Limiting * chore: Add PR's requested changed * chore: Add PR's requested changes * Add TWO_FACTOR_ENABLED global kill-switch to MFA gate MFAGateService::requiresChallenge() had no master on/off switch, contradicting the SDS idp-mfa.md §10.1 rollout plan, which requires being able to instantly revert to password-only login without a code rollback if something goes wrong post-deploy. config/two_factor.php gains an 'enabled' key (env TWO_FACTOR_ENABLED, default true) checked first in requiresChallenge(), short-circuiting before any per-user or device-trust evaluation. * Route MFA challenge responses through login_strategy, not hardcoded JSON postLogin()'s mfa_required response, and the display-strategy contract it depends on, bypassed $this->login_strategy entirely: every MFA response was Response::json(...) built by hand in the controller, ignoring OAuth2 display-strategy polymorphism (native vs page/popup/touch). Native OAuth2 clients (display=native) got JSON+200 with an ad hoc shape instead of the 412 + required_params/url/method contract every other login error already returns for that display mode. - ILoginStrategy::challengeRequired() / IDisplayResponseStrategy:: getChallengeRequiredResponse(): new methods, distinct from errorLogin() since a pending MFA challenge isn't a failed attempt. - DefaultLoginStrategy: identical bytes to before (200 + JSON) - zero behavior change for the plain IdP flow. - OAuth2LoginStrategy: rebuilds the auth_request from the memento (same pattern as errorLogin()) and delegates to DisplayResponseStrategyFactory. - DisplayResponseJsonStrategy (native): 412, matching its sibling getConsentResponse/getLoginResponse/getLoginErrorResponse methods. - DisplayResponseUserAgentStrategy (page/popup/touch): 200 JSON, same live in-SPA transition as the plain flow, since both render the same login.js. - ILoginStrategy::MFA_REQUIRED constant replaces the 'mfa_required' literal duplicated across three classes. Also closes a refresh-resilience gap PR #142's frontend already expected but the backend never delivered (its login.js constructor comment reads "Two-factor state (populated from the flash redirect...)"): postLogin() now flashes flow/mfa_method/otp_length/otp_lifetime to session on mfa_required so a page refresh mid-challenge restores the 2FA screen instead of dropping back to the password form. Cleared on successful verification/recovery and on session expiry; refreshed on resend2FA() (including method switches). New: OAuth2NativeMFALoginFlowTest exercises the real /oauth2/auth -> memento -> postLogin() path for display=native and asserts 412+mfa_required. TwoFactorLoginFlowTest gains coverage for the session-flash/clear behavior. * Clear pending MFA challenge and UI-restoration state on cancelLogin() None of the three login strategies' cancelLogin() cleared any 2FA session state - not the pre-existing 2fa_pending_user_id/2fa_pending_at/2fa_remember keys, nor the flow/mfa_method/otp_length/otp_lifetime keys added for refresh-resilience. PR #142's Cancel button resets the client's React state immediately and fires cancelLogin() as a best-effort background call, so the broken UX was masked within the same tab - but a subsequent full page load within the challenge's 300s TTL (back button, reopened tab, direct /login navigation) would restore the 2FA screen for a challenge the user explicitly abandoned, and the stale OTP could still complete it. UserController::cancelLogin() now resolves the pending strategy via the mfa_method session key (when present) and clears its pending state before delegating to the login strategy, plus clears the UI-restoration keys via the existing clearMFAUISessionState() helper. New test proves the strongest form of the property: an OTP valid before cancel returns mfa_session_expired afterward, not just that some session keys are gone. * Block passwordless MFA bypass; make challengeRequired self-contained Two related fixes to the MFA login flow: 1. Passwordless (flow=otp) login never checked shouldRequire2FA(), so an enforced-2FA user could bypass MFA entirely via emitOTP() + postLogin with flow=otp instead of flow=password (SDS idp-mfa.md §7.4 / Open Question #3 explicitly treats passwordless as single-factor). Now throws AuthenticationException before loginWithOTP(), reusing the existing errorLogin() redirect+flash path - the OTP form still submits as a native form POST, so this needed no new response contract. 2. challengeRequired()'s redirect-based implementations (DefaultLoginStrategy, DisplayResponseUserAgentStrategy) previously ignored the $params they received, silently depending on the caller having already flashed otp_length/otp_lifetime to session - an implicit contract that would silently break for any other caller. Both now flash their own $params (persistent, not one-shot, so it survives repeated refreshes) and set error_code, mirroring what DisplayResponseJsonStrategy already sends native clients in JSON. clearMFAUISessionState() now clears error_code too. The '2fa' flow value moves from a new ILoginStrategy constant to IAuthService::AuthenticationFlowMFA, alongside its siblings AuthenticationFlowPassword/AuthenticationFlowPasswordless - all three are the same session 'flow' enum (already flashed together in the AuthenticationException catch block), so splitting the third value into a different interface would have been inconsistent. New test: OAuth2NativeMFALoginFlowTest gains a non-native (page/popup/ touch) case proving the 302+session-flash contract, alongside the existing native 412+JSON case. TwoFactorLoginFlowTest covers the passwordless-bypass rejection (including that it still reuses errorLogin(), not a new JSON contract) and the error_code flash/clear. * Rate-limit the initial MFA challenge issuance in postLogin() The '2fa.rate' middleware could never gate postLogin()'s initial OTP issuance: its before-phase reads 2fa_pending_user_id from session to know which user to throttle, but that key is only written by issueChallenge() - inside the very request that would need throttling. A user with valid credentials could repeatedly POST to the plain login route and trigger unlimited email-OTP sends, bypassing the 5-per-15-minute resend cap entirely (SDS idp-mfa.md §4.12 explicitly requires the initial issuance to share the same 2fa_rate:resend:{user_id} window as resend()). Extracted the cache-key/window logic that lived only in TwoFactorRateLimitMiddleware into ITwoFactorRateLimitService / TwoFactorRateLimitService (same pattern as DeviceTrustService / TwoFactorAuditService / MFAGateService, registered in TwoFactorServiceProvider), so both the middleware (verify/recovery/resend routes) and UserController::postLogin() (initial issuance, now knows the user id post-validateCredentials()) share one source of truth instead of duplicating cache-key construction. postLogin() checks isRateLimited() before issuing a challenge and calls increment() after a successful issue. The rejection throws AuthenticationException, reusing the existing catch block's errorLogin() redirect+flash path - consistent with challengeRequired() already being redirect-based, since the password form still submits as a native form POST. resend2FA()/verify2FA()/verifyRecoveryCode() stay JSON+429 via the middleware, unaffected, since those are AJAX-only endpoints. New test proves postLogin() and resend() share the same window: after max_otp_requests postLogin() calls, the next one is rejected. * Fix op_browser_state ordering bug in AuthService::loginUser() Investigated the "session fixation" finding from the PR review (SDS idp-mfa.md §9.3 asks for a test proving 2fa_pending_user_id cannot be injected). Traced actual runtime behavior via debug instrumentation before writing a fix, since pattern-matching "no explicit Session::regenerate() call" as a vulnerability turned out to be wrong. Laravel's SessionGuard::login() (invoked via Auth::login(), already called unconditionally at the end of loginUser()) already calls $session->migrate(true) internally - the session-fixation window was already closed by the framework, with no code change needed for that property specifically. An added test asserting this (comparing session ID before/after login) passed identically with or without any fix, proving it was a false positive caused by this test harness resetting the session ID between $this->action() calls regardless of production behavior - that test was written and then discarded rather than kept for false confidence. What IS real, found via the same investigation: PrincipalService::register() (called by loginUser() before this fix) hashes the CURRENT session ID into op_browser_state, used for OIDC Session Management (check-session iframe). Since register() ran BEFORE Auth::login(), its hash was computed from a session ID that Auth::login()'s own migrate(true) was about to invalidate moments later - any relying party polling the check-session iframe would see a value that no longer matched what the server would recompute, incorrectly signaling a session change. Fix: call Auth::login() first, then principal_service->clear()/register() after, so the hash uses the final, stable post-login session ID. No new Session::regenerate() call needed - Auth::login() already provides one. New tests: - AuthServiceLoginUserTest (unit, Mockery-alias facades, same pattern as AuthServiceLogoutTest): asserts the call order directly. - TwoFactorLoginFlowTest::testCompletedMFALoginKeepsOPBrowserStateConsistentWithSessionId (integration): proves op_browser_state matches a freshly-computed hash of the post-login session ID end-to-end through the real MFA verify flow. Confirmed failing against the pre-fix ordering, passing after. * Add test proving OTP redeem rolls back on mid-transaction failure Ticket CU-86ba2zc6p's TESTS list requires: "OTP redeem is persisted only on commit; a failure inside the verify transaction rolls back the redeem." No such test existed anywhere in this branch or PR #142/#146 - the two closest existing tests (testOTPCodeRejectsReuseAfterSuccessfulVerification, testRecoveryCodeRejectsReuseAfterTransactionCommit) only prove the COMMIT path (a successful verification's redeem persists and blocks reuse), not that a FAILED verification's partial redeem rolls back. Pure test-coverage gap, no production fix needed - AuthService::verifyMFAChallenge() already wraps strategy->verifyChallenge() in tx_service->transaction(), and DoctrineTransactionService already rolls back and re-throws on failure. Confirmed the test has teeth: temporarily bypassing the transaction wrapper broke the pessimistic-lock acquisition inside verifyChallenge() (which requires an open transaction), proving the test environment genuinely depends on transactional context, not just coincidentally passing. testOTPRedeemRollsBackOnMidTransactionFailure wraps the real EmailOTPMFAChallengeStrategy in a test double that lets the genuine redeem happen, then throws immediately after - inside the same transaction. Asserts the OTP is refetched from the DB (post-rollback) still unredeemed. * Make verify2FARecovery audit logging best-effort EventRecoveryUsed was logged unguarded after loginUser() and clearPendingState(), so an audit-sink failure at that point propagated to the outer catch(Exception) and returned a 500 to a user who was already authenticated with an already-burned recovery code — the account's last-resort login path. Mirrors the same best-effort try/catch already applied to verify2FA()'s EventChallengeSucceeded audit call. Adds testRecoveryAuditFailureDoesNotBlockLogin, the recovery-path analogue of testAuditFailureDoesNotBlockLogin, reproducing the 500 before the fix and asserting a 302 + established session after it. * Add real concurrent-connection tests for OTP/recovery-code row locks testOTPCodeRejectsReuseAfterSuccessfulVerification and testRecoveryCodeRejectsReuseAfterTransactionCommit only prove sequential reuse is rejected after a transaction commits. Neither exercises the actual property refreshExclusiveLock() exists for: blocking a second, concurrent request from redeeming the same unredeemed OTP or recovery code while the first request's transaction still holds the row. Adds two tests that open a genuinely independent physical DB connection (verified via differing MySQL CONNECTION_ID()) and prove FOR UPDATE from that connection is blocked (lock wait timeout) while EmailOTPMFAChallengeStrategy/AbstractMFAChallengeStrategy's production refreshExclusiveLock() call holds the row. Verified the assertion is non-vacuous by temporarily disabling the lock call and confirming the test fails as expected, then restoring it. * Guard all MFA audit-log calls against Throwable, not just Exception Best-effort audit logging around the MFA flows only caught Exception, which misses Error subtypes (TypeError, ArgumentCountError, etc.). An Error escaping any of these would still turn a clean response into an uncaught 500 or, worse for the two failure-path calls, drop the error_code the rate-limit middleware keys its failure counter on (TwoFactorRateLimitMiddleware::isFailure() only sees the JSON body of whatever response actually gets returned). Applies the codebase's existing convention for this exact situation (see app/Audit/AuditLoggerFactory.php, TrackRequestMiddleware.php) to all 7 best-effort audit/device-trust sites in this controller: - postLogin(): initial challenge issuance audit log (was unguarded) - verify2FA(): failure-path audit log (was unguarded) - verify2FA(): queueDeviceTrustCookie() call (was catch(Exception)) - verify2FA(): success-path audit log (was catch(Exception)) - verify2FARecovery(): failure-path audit log (was unguarded) - verify2FARecovery(): success-path audit log (was catch(Exception)) - resend2FA(): challenge-reissue audit log (was unguarded) Verified: full Two Factor Authentication Test Suite (83 tests, 241 assertions) passes unchanged. Co-Authored-By: Claude * Honor the global 2FA kill-switch in User::shouldRequire2FA() The passwordless-login guard called shouldRequire2FA() directly, which ignored config('two_factor.enabled'), so an enforced admin stayed blocked from passwordless login even with the kill-switch off (SDS idp-mfa.md rollout, section 10.1). Move the enabled check into shouldRequire2FA() as the single source of truth shared by both the MFA gate and the passwordless guard, and drop the now-redundant check in MFAGateService. * Feature | Add Login UI MFA Flow (#142) * feat: Add Login UI MFA Flow * fix: rename HTMLRender.jsx to .js so webpack can resolve it webpack.common.js has no .jsx resolve extension configured, so the bare '../../shared/HTMLRender' import used by every login form component failed to resolve, breaking the build for this whole tree. * fix: revert password submit to native form POST The backend login strategies (DefaultLoginStrategy, DisplayResponseUserAgentStrategy) answer wrong-password and mfa_required with a 302 redirect plus flashed/persisted session state, meant to be consumed by a native top-level form submit - the same mechanism already used by the OTP and MFA screens. Converting the password step to AJAX (postRawRequestFull) broke that contract: the hidden XHR redirect-follow GET consumed the one-shot flash before the SPA could show it, silently dropping the wrong-password message, resetting login_attempts (disabling the server-side captcha escalation), and losing native password-manager save/update prompts. Reverts PasswordInputForm to the same native-submit adapter OTPInputForm already uses, and removes the now-dead AJAX path: handleAuthenticatePasswordFlow/Ok/Error, authenticateWithPassword, window.FORM_ACTION_ENDPOINT, and the MFA_CHALLENGE_REQUIRED constant (confirmed unused end-to-end - the server never emits mfa_required as JSON to browser clients either, only via session state under the 'flow' key). Also removes disabled={disableInput} from the password TextField and the 'remember' FormControlLabel. Under native submission, React's synchronous setState(disableInput: true) inside the same onSubmit handler commits the disabled attribute to the DOM before the browser constructs the form's data set - and the HTML spec excludes disabled controls from that set. The result was a silently dropped password field ('The password field is required.', confirmed live against the backend). OTPInputForm was never affected because it only disables its submit Button, never the field carrying the actual submitted value - the fix here matches that pattern. * fix: add missing React import in HTMLRender to prevent ReferenceError crash HTMLRender uses JSX () but never imported React. The project's babel-preset-react runs in classic mode (webpack.common.js), which compiles JSX to React.createElement(...) calls requiring React in scope per-module - importing it in a sibling file doesn't help, since webpack wraps each module in its own function scope. Every other component in this PR imports React; this one was missed. It went unnoticed while the file's own path (HTMLRender.jsx) failed to resolve at all; once that resolution bug was fixed, the runtime ReferenceError surfaced and crashed the whole login page on any render path that hits this component (confirmed live: 'ReferenceError: React is not defined', white-screen crash after password submit). * fix: cancel login now invalidates the pending MFA challenge server-side The 'cancel' route was GET-only (pre-dates this feature, never had a JS caller before). This PR's new cancelLogin() action POSTs to it, which 405'd silently (no .catch on the fire-and-forget call) - so UserController::cancelLogin()'s MFA cleanup (clearPendingState() + clearMFAUISessionState()) never ran. An OTP issued before Cancel stayed valid server-side despite the UI resetting to the password screen. Registers 'cancel' as POST + csrf, matching the sibling verify/recovery/resend routes (GET would work too - Laravel's CSRF middleware only checks unsafe verbs - but modeling a state-mutating action as GET risks a prefetcher/link-scanner silently cancelling a real pending session). Adds error handling to the previously fire-and-forget JS call, and updates TwoFactorLoginFlowTest's cancelLogin() test helper to POST with a CSRF token (it called the old GET route directly and would 405 otherwise). Verified live: POST /auth/login/cancel -> 200, and tests/TwoFactorLoginFlowTest.php passes in full (31 tests, 116 assertions). * fix: stop the 2FA verify XHR from following cross-origin OAuth2 redirects Root cause: verify2FA()/verify2FARecovery() returned login_strategy->postLogin()'s raw RedirectResponse directly to the XHR that called them. postLogin() always redirects to a same-origin URL (e.g. /oauth2/auth), but when the OAuth2 client already has consent on file, that endpoint's own consent-bypass branch (InteractiveGrantType::handle(), the has_former_consent + auto_approval case) issues the authorization code and redirects straight to the client's cross-origin redirect_uri - a hop the XHR was transparently trying to follow. No browser XHR/fetch can read a cross-origin redirect's response (confirmed against superagent's own source: lib/client.js, the browser build this project ships, has zero redirect-handling logic - only lib/node/index.js implements the .redirects(n) option, so that setting is a silent no-op in the browser). Worse, that same consent-bypass branch calls memento_service->forget() right after building the response, since the server considers the authorization complete - so the silently-failed XHR follow-through burns a real, delivered authorization code with no way for the frontend to recover it. handleMfaError()'s fallback (window.location.reload()) then finds the OAuth2 memento gone and lands the user on their own profile instead of resuming the flow - confirmed live end-to-end against a real oauth2_test_app client with a pre-existing consent record. Fix: verify2FA()/verify2FARecovery() now capture postLogin()'s redirect target and return it as JSON data (redirect_url) instead of a raw redirect. The frontend does a real window.location.href navigation to that same-origin URL - top-level navigations are never subject to CORS, so the browser completes any further hop (including the cross-origin one) natively, exactly as the original pre-MFA native-form-submit login flow always did. Cleanup: postRawRequestFull's finalUrl/status become unused by all three remaining callers (verify2FA, resend2FA, verifyRecoveryCode) once this lands, making it functionally identical to postRawRequest - removed and callers switched over. Also replaces the three remaining raw Response::json calls (HTTP_UNAUTHORIZED) in UserController with JsonResponses::unauthorized(), completing the same trait-based convention already used for the other status codes in this controller; the now-unused Symfony Response import (HttpResponse) is removed. Verified live against a real OAuth2 authorization_code flow (oauth2_test_app, consent already on file): the post-2FA redirect now correctly lands on the client's registered redirect_uri instead of the user's own profile page. tests/TwoFactorLoginFlowTest.php passes in full (31 tests, 120 assertions), updated to match the new 200+redirect_url contract on verify2FA/recovery success (six assertions across five tests); the three assertions covering postLogin()'s own native-submit paths (mfa_required, OTP-flow rejection, rate-limited retry) are untouched since postLogin() itself still redirects directly for those callers. * fix: cancel and session-expiry now correctly return to the password screen Root cause was two-layered: 1. resetToPasswordFlow() reset authFlow to FLOW.PASSWORD but also cleared user_name/user_pic/user_fullname/user_verified in the same setState call. isPasswordFlow's render condition requires user_verified === true, so wiping it forced the render logic to showDefaultFlow (the email screen) regardless of authFlow being correct. 2. That alone wasn't sufficient: since the password step now submits as a native form POST (see the earlier native-submit fix), the mfa_required transition is a full page reload, not a client-side setState - the React app remounts from scratch and only recovers state the backend flashed to session. issueChallenge() (EmailOTPMFAChallengeStrategy/AbstractMFA ChallengeStrategy) only returns otp_length/otp_lifetime, so challengeRequired()'s session flash never carried username/user_fullname/ user_pic/user_verified in the first place - user_verified was already false the moment the 2FA screen first rendered, before Cancel was ever clicked. Fix #1 alone had nothing to preserve. Fix: postLogin()'s mfa_required branch now merges the same identity fields into the challengeRequired() payload that the AuthenticationException errorLogin() branch already flashes (same fields, same getters: username, user_fullname, user_pic, user_verified, user_is_active) - restoring the identity chip on the 2FA screen and giving resetToPasswordFlow() correct state to preserve. resetToPasswordFlow() no longer clears user_name/user_pic/ user_fullname/user_verified. Verified live: 2FA screen now shows the identity chip from first render: Cancel from the 2FA screen now returns directly to the password screen with the same user still identified, instead of resetting to the email-entry screen. tests/TwoFactorLoginFlowTest.php passes in full (31 tests, 120 assertions) - unaffected, since no test asserted the previously-missing identity fields. * fix: clear identity fields from session on MFA cancel/verify-success clearMFAUISessionState() only forgot flow/mfa_method/otp_length/otp_lifetime/ error_code. postLogin()'s challengeRequired() payload also persists username, user_fullname, user_pic, user_verified and user_is_active (needed to hydrate the React app on the initial post-redirect GET /login after an MFA challenge is issued), but those were never cleared - so they survived cancel, a successful verify, or a session-expiry indefinitely. On a shared browser session, the next visitor to hit /login would inherit the previous attempt's identity chip and skip straight to the password screen. Extend clearMFAUISessionState() to forget the same 5 keys, and extend testSuccessfulVerificationClearsUIState / testCancelClearsUIStateAndPendingChallenge to assert they're gone, matching the existing coverage for the other UI-state keys. * fix: invalidate pending MFA challenge when identity chip is cleared handleDelete() (the login page's identity chip "x") reset client-side state but never called cancelLogin(), unlike the explicit "Cancel" link (resetToPasswordFlow()). During the 2fa/recovery screens this left the pending 2fa_pending_user_id session state and the issued OTP alive server-side until the session TTL, instead of being invalidated immediately like Cancel does. PR #142 review finding #1. * fix: block submitting an expired MFA code TwoFactorForm computed `expired` to show the countdown message but never used it to gate submission. A submit after expiry always fails server-side with mfa_verification_failed, which counts against the 2fa.rate:verify middleware's 3-attempt window - letting a user burn that budget on guaranteed-fail submits and get 429-locked out of the login flow entirely. Disable the VERIFY button and short-circuit handleSubmit (Enter-key defense in depth) once expired is true. PR #142 review finding #4. * fix: seed the MFA countdown with the remaining OTP lifetime after refresh The session stored otp_lifetime as a static duration, so any mid-challenge GET /login re-seeded the countdown with the FULL TTL - a user refreshing 4 minutes into a 5-minute challenge saw a fresh 5:00 countdown for a code the server would reject much sooner, letting them burn the 2fa.rate:verify attempt window (3 failures / 15 min lockout) on a code the UI claimed was still valid. issueChallenge() now also returns otp_issued_at taken from the OTP entity's created_at - the same source isAlive()/getRemainingLifetime() use server-side, so the countdown can never drift from the actual expiry check (a controller-side time() stamp would land after issuance and overstate the remaining window). The timestamp rides the same challengeRequired()/session mechanism as otp_length/otp_lifetime, is kept in sync on resend, cleared with the rest of the MFA UI state, and the blade seeds config.otpLifetime with max(0, lifetime - elapsed). RED verified before the fix: the new reproducer rendered the full TTL (600) instead of the remaining ~500. Full TwoFactorLoginFlowTest suite green: 32 tests, 140 assertions. PR #142 review finding LOW #1. * feat: add expiry countdown to the passwordless OTP form, dedup shared code-entry UI The passwordless OTP expires exactly like the MFA one (same createOTPFromPayload infra) but its form gave no expiry feedback at all - the user only found out the code was dead after a full form POST the server rejected. emitOTP already returned otp_lifetime; the client just ignored it. Extract the duplicated code-entry cluster shared by TwoFactorForm and OTPInputForm into two reusable pieces: - use_otp_countdown.js: the 1s expiry ticker (reset via otpLifetime / codeVersion), lifted verbatim from TwoFactorForm. - otp_code_input.js: subtitle + OTP boxes + error + optional countdown (the ~25-line block both forms duplicated). OTPInputForm now shows the countdown and blocks submitting an expired code (same gating pattern as the MFA form). The countdown only renders when a fresh emitOTP happened in this page view (passwordlessLifetime state, null on restored views) - after a failed-submit reload the issuance time is unknown and showing a fresh full countdown would overstate the code's validity. Verified: babel parse on all five files + full yarn build (prod webpack) green. Net -14 lines including the new feature. * fix: surface cancelLogin failures instead of swallowing them in console Both cancel paths (resetToPasswordFlow and handleDelete) reset the UI optimistically and fired cancelLogin() without handling failure beyond a console.error - on a network failure the server-side pending challenge silently survived until its 300s TTL while the UI told the user it was cancelled. Extract the duplicated call into a single cancelPendingLogin() helper that warns the user via the existing snackbar when the server-side invalidation fails, so they know the pending verification will only die by its own TTL. The optimistic reset is kept - the user asked to cancel, so returning control immediately stays correct. PR #142 review finding LOW #2. * feat: add 30s resend cooldown to the passwordless OTP screen The 'resend email.' link in OTPHelpLinks had no throttle at all - each click fired a fresh emitOTP request immediately, unlike the MFA screen's 'resend code' link (TwoFactorForm), which already cools down for 30s. Mirror that exact pattern: OTPHelpLinks gains its own cooldown timer (useState/setInterval, same shape as TwoFactorForm's) and is also disabled while disableInput is true, matching the disableInput-gating convention already enforced elsewhere in this login flow. Promoted RESEND_COOLDOWN_SECONDS from a local constant in two_factor_form.js to constants.js so both forms share one value. Verified live in-browser (not just build): resend fires exactly one emitOTP request, the link disables and counts down (30s -> 1s), a click mid-cooldown fires zero additional requests, and the link re-enables with the countdown reset after expiry. Plan: docs/plans/2026-07-23-passwordless-otp-resend-cooldown.md, Task 1. * feat: rate-limit the passwordless OTP issuance endpoint server-side POST /auth/login/otp (emitOTP) had zero server-side throttle, unlike the MFA resend endpoint's 2fa.rate:resend. Reuses TwoFactorRateLimitMiddleware /TwoFactorRateLimitService via a new 'otp' action instead of duplicating a parallel middleware - the counting logic (cache-backed fixed window, 429 JSON shape) was already subject-agnostic; only the subject-resolution step needed a branch, since emitOTP() never writes any session state to key on (verified: zero Session::put calls in that method) unlike the session-keyed MFA actions. isRateLimited()/increment()/cacheKey() widen from int to string|int - source-compatible with both existing call sites (TwoFactorRateLimitMiddleware, UserController::postLogin()), which already pass an int. The otp subject is the submitted email, lowercased and trimmed - not just trimmed like postLogin()'s username normalization, which is safe only because it feeds a case-insensitive DB lookup before ever reaching a rate limiter. otp has no such lookup; the raw string IS the cache key, so trim-only normalization would let an attacker reset the budget every request by cycling the target email's casing (verified live: users.email collation is utf8mb3_unicode_ci). Caught and fixed via spec-review before implementation - see the case-insensitivity test below. New config keys max_otp_email_requests/otp_email_window_minutes (both default 5/15min, same as the MFA resend budget) are kept independent so ops can tune the anonymous endpoint separately. Client: emitOtpAction's error handler now shows a specific 'Too many attempts' message on 429 instead of the generic fallback. Two new PHPUnit tests: threshold + per-email isolation, and the case-insensitivity fix specifically. Both verified RED before implementation. flushRateLimitCounters() extended to also clear the new email-keyed cache entries between tests - a real cross-test contamination bug surfaced when running the full suite (an early test failed because the new tests' counters leaked into it), not merely anticipated. Verified: full TwoFactorLoginFlowTest suite green (34 tests, 145 assertions, includes regression coverage for the existing MFA rate limits). Live end-to-end in-browser: a real 429 with the specific snackbar message, confirmed against localhost with the limit temporarily lowered to 1. Also found and fixed, as a side effect of that live check, a pre-existing storage/framework/cache permission issue unrelated to this change's code (files owned by root from prior root-run test sessions blocked www-data's cache writes) - not part of this commit's diff. Plan: docs/plans/2026-07-23-passwordless-otp-resend-cooldown.md, Task 2. * fix: mock EmailOTPMFAChallengeStrategy's new getCreatedAt() call in unit tests CI broke on push: issueChallenge()/resendChallenge() gained a call to $otp->getCreatedAt() in an earlier commit this session (0330c3be, seeding the MFA countdown with the OTP's actual issuance time), but the strict Mockery mocks in EmailOTPMFAChallengeStrategyTest never declared that expectation - BadMethodCallException on every call, in both testIssueChallenge_storesPendingStateAndReturnsOtpInfo and testResendChallenge_delegatesToIssueChallenge. Only ran tests/TwoFactorLoginFlowTest.php locally in that earlier commit (the file the plan named), not the full suite - this unit test file was never exercised until CI's own full run caught it. Mock getCreatedAt() with a fixed DateTime and extend both tests' assertSame() to include the new otp_issued_at key in the expected result array, matching the real return shape. Verified in isolation: 5 tests, 8 assertions, green. * feat: passwordless OTP screen survives browser refresh Mirrors the MFA challenge flow's existing refresh-resilience pattern: emitOTP() now persists flow/username/user_verified/otp_length/otp_lifetime/ otp_issued_at and identity fields (when the user already exists) via Session::put(), the same keys login.blade.php already rehydrates generically for the MFA screen. user_verified is set unconditionally since loginWithOTP() auto-registers brand-new emails at redemption time. State is cleared via the existing clearMFAUISessionState() on a successful passwordless login and on cancel (login.js's handleDelete() now also invokes cancelPendingLogin() for the passwordless flow via a new isPasswordlessFlow() predicate, not just MFA). Also fixes a gap found during live browser verification: OTPInputForm read a separate, never-seeded state.passwordlessLifetime field instead of the session-restored otpLifetime prop, so the countdown disappeared on refresh even though the screen itself restored correctly. 4 new tests in TwoFactorLoginFlowTest.php cover: session persistence on emit, persistence for not-yet-registered emails, clearing on successful login, and clearing on cancel. Full suite: 38 tests, 184 assertions. * fix: show success snackbar when passwordless OTP code is (re)sent Root cause: emitOtpAction() (shared by the initial automatic passwordless send and the explicit "resend email" click) never called this.showAlert(...), unlike its sibling onResend2FA() which confirms a successful MFA resend. Adds the same showAlert(..., "success") call to emitOtpAction()'s success branch, mirroring onResend2FA() verbatim. Extracts the message into a new shared constant CODE_RESENT_MESSAGE so the two flows can't diverge in wording. Note: the snackbar now also fires on the initial code-send, not just an explicit resend, since both paths share emitOtpAction() - confirmed via live browser verification, a deliberate trade-off over adding a new isResend flag. * fix: set Retry-After/X-RateLimit-* headers on 2FA rate-limit 429s Root cause: TwoFactorRateLimitMiddleware.php:70-79 returned a 429 with no headers because ITwoFactorRateLimitService only exposed isRateLimited()/ increment() - no way to learn the configured limit or window reset time. Switches TwoFactorRateLimitService's internals from hand-rolled Cache::get/add/increment calls to Laravel's own Illuminate\Support\Facades\ RateLimiter (already used elsewhere in this codebase, already installed, implements the same fixed-window counter+timer pattern, and is driver-agnostic - this deployment's actual cache driver is 'file', so a Redis-specific TTL query would have silently misbehaved). Adds getLimit() and getRetryAfterSeconds() to the interface, backed by it. TwoFactorRateLimitMiddleware now attaches Retry-After, X-RateLimit-Limit, and X-RateLimit-Remaining to its 429 JSON response using these two methods. Same cache-key format preserved, so UserController::postLogin()'s direct isRateLimited()/increment() calls (the MFA-shares-resend-window rule) are unaffected. flushRateLimitCounters() test helper updated to also clear the new ":timer" companion key RateLimiter::hit() writes. Verified live: triggering a real 429 via curl against the running instance shows Retry-After: 899, X-RateLimit-Limit: 5, X-RateLimit-Remaining: 0. * fix: persist identity chip fallback for new passwordless-OTP users Root cause: UserController.php:410-414 (emitOTP()) gated Session::put('user_fullname', ...) behind an existing-user check, so a not-yet-registered email never got a persisted display name - but login.js:165-167 (emitOtpAction()) already falls back to the submitted email as the chip's display name in live client state. This asymmetry made the identity chip visible right after opting into OTP, then vanish entirely on a page refresh. Moves the user_fullname Session::put() outside the existing-user conditional, using the same email fallback the client already applies. user_pic/user_is_active remain conditional - confirmed login.js has no equivalent avatar fallback, so no client/server asymmetry existed there. Inverts the existing (bug-encoding) assertion in testEmitOtpForNewUserStillPersistsRefreshState rather than adding a new test - it covers the exact same code path. * Refactor 2FA rate limiting to use RateLimiter::for() named limiters Subject resolution and the 429 response shape for the MFA verify/ recovery/resend/otp actions now live in named RateLimiter::for() limiters registered in TwoFactorServiceProvider, instead of being hand-rolled in TwoFactorRateLimitMiddleware. The middleware keeps only what the stock throttle pipeline can't express: deciding *when* a hit counts (failure-only for verify/recovery per SDS idp-mfa.md §4.12, every-request for resend/otp). - ITwoFactorRateLimitService: add PENDING_USER_SESSION_KEY and RATE_LIMITER_NAME_PREFIX constants, and a getWindowSeconds() accessor so the named limiters carry the real max/window instead of placeholder defaults. - TwoFactorRateLimitService: implement getWindowSeconds(). - TwoFactorServiceProvider: register the verify/recovery/resend/otp named limiters (subject via Limit::by(), response via Limit::response()). - TwoFactorRateLimitMiddleware: drop resolveSessionSubject()/ resolveOtpSubject() and the hand-built 429 response; resolve both from the named limiter instead. - RouteServiceProvider: remove the RateLimiter::for('otp', ...) registration - dead since the throttle:otp route middleware was removed in 1167374c (Dec 2021) and never reattached. Its name collided with the new 2fa-rate 'otp' action before the RATE_LIMITER_NAME_PREFIX namespacing was added. Verified: TwoFactorLoginFlowTest (38 tests, 197 assertions) green before and after, inside the idp-app container. * Feat/fe testing infrastructure (#144) * feat: first tests Signed-off-by: romanetar * feat: add testing infrastructure for login MFA flow and E2E suite Signed-off-by: romanetar * feat: add testing infrastructure for login MFA flow and E2E suite Signed-off-by: romanetar * test: isolate login.spec.ts in CI, fix MFA mock route ordering Comment out login-mfa-flow.spec.ts and register.spec.ts so CI runs login.spec.ts alone to verify it now passes without account lockout interference. Also fix the MFA beforeEach mock: fulfill() must run before unroute(), otherwise Playwright auto-resolves the in-flight route on unroute and the later fulfill() throws "Route is already handled" - which was letting the real POST through with a wrong password and locking out test@test.com. * test: re-enable MFA and registration e2e suites login.spec.ts verified green in isolation; re-enable the MFA flow suite (route-ordering fix already applied) and the registration suite now that the account-lockout cascade is gone. Signed-off-by: romanetar * fix: align MFA e2e/JS tests with PR #142's native-form-POST mechanism PR #142 reverted the password login step from AJAX back to a native form POST + server redirect/session flow (commit 0eca371c), removing handleAuthenticatePasswordFlow/Ok/Error, authenticateWithPassword, and the MFA_CHALLENGE_REQUIRED constant. The tests added by this branch were written against the old AJAX contract and needed to be realigned. - tests/js/login/login.mfa.test.js: remove the handleAuthenticatePasswordOk describe block - it tested a client-side AJAX handler that no longer exists in login.js. - tests/e2e/tests/auth/login-mfa-flow.spec.ts: - beforeEach no longer mocks the password POST as JSON; it performs a real native login against a real MFA-enforced account, matching how postLogin() actually issues a challenge (redirect + session state). - Each TS-* test now uses its own seeded MFA user (mfa-ts-NNN@test.com) instead of sharing one fixed account - a real challenge issuance counts against two_factor.rate_limit.max_otp_requests, so 8 tests sharing one account exhausted the limit before the suite finished. - Fixed VERIFY_URL/RESEND_URL/RECOVERY_URL/CANCEL_URL glob patterns to end with '**': postRawRequest() appends every param as a query string in addition to the body, so the exact-suffix glob never matched and silently left every route mock inert (requests were hitting the real backend instead). - TS-004/TS-007: resetToPasswordFlow() keeps the verified identity and returns to the password step (authFlow: FLOW.PASSWORD) - it does not clear user_name/user_verified. Both tests asserted the email step was shown instead, contradicting their own titles and the function's name. - TS-002: widened the post-verify assertion timeout - onVerify2FA() always assigns window.location.href on success, so even a same-URL mock response occasionally triggers a real navigation that raced the original 1s timeout. - .github/workflows/{pull_request,push}_frontend_tests.yml: seed the 8 mfa-ts-NNN@test.com accounts alongside the existing test@test.com / e2e@test.com fixtures. - .gitignore: add /test-results/ (Playwright's screenshot/video/trace output directory) - only /tests/e2e/report/ was previously ignored. Verified: 40/40 PHP (TwoFactorLoginFlowTest), 23/23 Jest, 13/13 Playwright e2e, stable across repeated runs via `docker compose --profile e2e run --rm playwright npx playwright test`. * feat: add e2e coverage for the OAuth2 authorization code flow Adds tests/e2e/tests/oauth2/auth-code-flow.spec.ts, exercising the full authorization code grant end to end - including the memento (pending OAuth2 request) surviving a real MFA detour, consent-bypass for a returning user, and MFA-skip for a trusted device: - unauthenticated /oauth2/auth redirects to login (memento serialized). - full flow: real login -> real MFA challenge -> real OTP -> consent screen for the correct client -> Accept -> authorization code -> code exchanged at the token endpoint for a real access_token. - returning user with prior consent: a second /oauth2/auth for the same client+scope skips the consent screen entirely and redirects straight to redirect_uri (InteractiveGrantType::handle()'s has_former_consent + auto_approval branch). - trusted device: checking "Trust this device" during MFA sets the Secure device_trust_token cookie; logging out and logging back in then skips the MFA challenge entirely. Infrastructure needed to drive this for real (no mocks): - app/Console/Commands/GetLatestOtp.php (idp:get-latest-otp {email}): prints the newest not-yet-redeemed OTP for a user, since the mailer queues via Redis and there is no catchable local mailbox to read the code from. Registered in app/Console/Kernel.php. - tests/e2e/utils/otp.ts: reads that OTP from the test runner - directly via `php artisan` when reachable in-process (CI, host dev), or via `docker exec idp-app php artisan ...` when running against the dockerized stack (APP_URL points at nginx). - docker-compose/playwright/Dockerfile + docker-compose.yml: the playwright service now builds this image (adds the Docker CLI on top of the stock Playwright image) and mounts /var/run/docker.sock so the above `docker exec` path works from inside that container. Scoped to the e2e profile only. - The suite works around two config('app.url')-vs-actual-origin mismatches (e.g. app.url=http://localhost but this suite runs against http://nginx in the docker-compose e2e profile - cookies are domain-scoped, so following the server's literal absolute redirect/ form-action URLs client-side would drop the session): verify2FA's redirect_url, the consent form's action, and the password step's postLogin() redirect are all replayed via page.request (shares the page's cookies) instead of trusting the browser/client-side JS to follow them unassisted. - .github/workflows/{pull_request,push}_frontend_tests.yml: seed mfa-oauth2-consent@test.com and mfa-oauth2-trust@test.com alongside the existing mfa-oauth2@test.com fixture. Known environment limitation (not a bug): the trusted-device assertion requires a "potentially trustworthy origin" for the Secure cookie to persist - true for http://localhost (host dev, and CI, which already uses APP_URL=http://localhost:8001) but not for the docker-compose e2e profile's http://nginx, where browsers silently drop the cookie. Verified: 16/17 e2e via `docker compose --profile e2e run --rm playwright npx playwright test` (the trusted-device test is the one expected miss, per the above), 4/4 in tests/e2e/tests/oauth2/ via host (`npx playwright test`), 40/40 PHP (TwoFactorLoginFlowTest), 23/23 Jest. * fix: seed the e2e OAuth2 test client without depending on TestSeeder CI was red: tests/e2e/tests/oauth2/auth-code-flow.spec.ts authorizes against a client_id that only exists as a side effect of database/seeds/TestSeeder.php, which is wired ONLY into PHPUnit's BrowserKitTestCase ($this->seed('TestSeeder')) - never into `php artisan db:seed`, which is all the CI workflow runs. On a genuinely fresh database the client_id never resolves, so InteractiveGrantType::handle() throws InvalidClientException before ever reaching the "redirect to login" branch, and the very first oauth2 test ("unauthenticated request redirects to login") gets a 400 error page instead of a redirect - exactly what the failing CI run showed. Local testing never caught this because the long-lived docker-compose dev database already had TestSeeder's fixtures from past PHPUnit runs. TestSeeder itself is not a safe fix for CI: its run() truncates users/groups/oauth2_client (and otp/consent/session-adjacent tables) before reseeding its own fixed set - correct for PHPUnit's isolated test lifecycle, destructive against the same shared database this workflow also seeds idp:create-super-admin/idp:create-raw-user users into. - app/Console/Commands/CreateOAuth2TestClient.php (idp:create-oauth2-test-client): idempotent, additive-only - creates just the one confidential client (same client_id/secret/redirect_uri the e2e suite already uses) plus a dedicated owner user (the consent screen's getDeveloperEmail() dereferences the owner unconditionally - an ownerless client 500s as soon as a real login reaches /accounts/user/consent) and grants it the 'profile' scope. Registered in app/Console/Kernel.php. - .github/workflows/{pull_request,push}_frontend_tests.yml: run the new command alongside the existing user fixtures. Verified: 16/17 e2e via `docker compose --profile e2e run --rm playwright npx playwright test` (the 17th, trusted-device, is the pre-existing environment-only miss - Secure cookies don't persist over http://nginx), 40/40 PHP, 23/23 Jest. * feat: recovery code management (#146) * feat: recovery code management Signed-off-by: romanetar * fix: add missing postRawRequestFull to base_actions.js profile/actions.js imports postRawRequestFull for the new enableTwoFactor and regenerateRecoveryCodes flows, but it was never exported, causing a runtime TypeError on both actions. Falling back to postRawRequest is unsafe here since it copies params into the URL query string, which would leak current_password into access logs. * fix: reject enableTwoFactor when 2FA is already enabled enable2FA() had no already-enrolled guard, so a second POST to /2fa/enable for an enrolled user silently regenerated recovery codes with no password confirmation, bypassing the password-gated rotation flow required by CU-86ba2zp66 and sds/idp-mfa.md §4.10.3. * refactor: move 2FA enrollment orchestration into RecoveryCodeService The transaction plus enable2FA + repository->add + code generation lived in UserApiController, breaking the thin-controllers/fat-services convention and diverging from the regenerateRecoveryCodes path, which already delegates to the service. UserApiController::enableTwoFactor now only validates input and calls RecoveryCodeService::enableTwoFactorAndGenerateCodes. * fix: normalize recovery code server-side before hash check Hash::check() compared the raw submitted code against the dash-less uppercase hash, so the "strip separators + uppercase" contract was only enforced by the login.js client. Any other consumer submitting a code exactly as displayed (XXXX-XXXX) would fail verification on this lockout-critical path. Apply the same normalization in AbstractMFAChallengeStrategy::verifyRecoveryCode() before Hash::check. * feat: warn on low recovery codes after MFA recovery login CU-86ba2zp66 / sds/idp-mfa.md §4.10.3, §4.11 step 5 require a dismissable low-code warning after a successful MFA login, but it was only wired into the profile page - a user who burns codes at login never saw it unless they happened to visit their profile. verify2FARecovery now returns recovery_codes_remaining and the configured low threshold; login.js holds the post-login redirect and shows a dismissable banner when the count is low, before navigating away. The sessionStorage dismissal key is shared with the profile page's RecoveryCodesPanel via a new shared module so dismissing in either place suppresses it everywhere for the rest of the session. * test: cover recovery-code redemption, re-enrollment, and the real request layer Three gaps mapped to the riskiest parts of this PR were unpinned: 1. Nothing proved a code returned as XXXX-XXXX actually redeems through AbstractMFAChallengeStrategy::verifyRecoveryCode() - the hash is of the dash-less string, so the generate->display->redeem contract (including the dash normalization) was untested. 2. enableTwoFactor()'s already-enrolled guard (412) had no regression test. 3. Every JS test mocked profile/actions, so nothing exercised the real request layer - exactly where the missing postRawRequestFull export lived. tests/js/profile/actions.test.js only stubs the transport (superagent) and calls the real enableTwoFactor/regenerateRecoveryCodes; verified it reproduces the original "postRawRequestFull is not a function" TypeError when that export is removed. * fix: fix CI failures from the recovery-code round-trip test and dash normalization 1. testDisplayedRecoveryCodeRedeemsThroughVerifyRecoveryCode called AbstractMFAChallengeStrategy::verifyRecoveryCode() directly, but it takes a PESSIMISTIC_WRITE row lock that requires an open transaction (Doctrine\ORM\TransactionRequiredException in CI). Route it through IAuthService::verifyMFARecoveryCode(), like the real login flow, which wraps the call in a transaction. 2. Several pre-existing test fixtures hashed a "plain" recovery code with a literal "-" baked in (e.g. 'RECOVERY-REUSE-TX-' . uniqid()) and then submitted that same string for verification. The dash normalization added earlier in this PR strips separators from the submitted code before Hash::check(), so a hash made from a dash-containing string can never match its own normalized submission - a real generated code never contains a dash in its raw/hashed form, only in its display formatting. Fixed the 7 affected fixtures across TwoFactorLoginFlowTest and AbstractMFAChallengeStrategyTest to drop the literal dash. * fix: remove nested transaction in enableTwoFactorAndGenerateCodes enableTwoFactorAndGenerateCodes() wrapped enable2FA()/user persist in one transaction() call while also calling generateRecoveryCodes(), which opens its own. DoctrineTransactionService::transaction() closes the entity manager and connection on failure, so an inner failure could tear down the EM out from under the still-running outer transaction. Extracted the shared code-generation logic into a transaction-free regenerateCodesForUser(), so each public method now opens exactly one transaction. * fix: make recovery-code audit logging best-effort Both generateRecoveryCodes() and enableTwoFactorAndGenerateCodes() logged audit events after the codes were already committed and about to be returned to the client. An audit-logging failure there would 500 a response whose side effects already succeeded, and a client retry on that 500 would regenerate and invalidate the codes it was never shown. Wrap both in try/catch + Log::warning, matching the best-effort pattern already used for audit logging in UserController. * fix: use the configured app name in the downloaded recovery-codes file recovery_code_display.js hardcoded "FNTECH" in both the file header and the downloaded filename, which would misbrand any non-FNTECH deployment. Threaded the existing appName prop (already exposed by profile.blade.php as config.appName, sourced from Config::get('app.app_name')) down through ProfilePage -> TwoFactorSection -> RecoveryCodesPanel -> RecoveryCodeModal -> RecoveryCodeDisplay, with an OpenStackID fallback matching the config default. * fix: uppercase uniqid() in recovery-code test fixtures verifyRecoveryCode() uppercases the submitted code (in addition to stripping separators) before Hash::check() - real generated codes are always uppercase alphanumeric. Three fixtures built their "plain" code with a raw uniqid() suffix, which is lowercase hex, so the hash (made from the original mixed-case string) could never match its own normalized submission. Verified standalone with password_hash/ password_verify that the old fixture reproduces the exact CI failure and the fixed one passes. --------- Signed-off-by: romanetar --------- Signed-off-by: romanetar Co-authored-by: smarcet --------- Signed-off-by: romanetar Co-authored-by: smarcet Co-authored-by: Román Gutierrez --------- Signed-off-by: romanetar Co-authored-by: smarcet Co-authored-by: Claude Co-authored-by: Román Gutierrez --------- Signed-off-by: romanetar Co-authored-by: smarcet Co-authored-by: Claude Co-authored-by: Román Gutierrez --------- Signed-off-by: romanetar Co-authored-by: smarcet Co-authored-by: Claude Co-authored-by: Román Gutierrez --------- Signed-off-by: romanetar Co-authored-by: smarcet Co-authored-by: Claude Co-authored-by: Román Gutierrez --------- Signed-off-by: romanetar Co-authored-by: smarcet Co-authored-by: Claude Co-authored-by: Román Gutierrez --------- Signed-off-by: romanetar Co-authored-by: smarcet Co-authored-by: Claude Co-authored-by: Román Gutierrez --------- Signed-off-by: romanetar Co-authored-by: smarcet Co-authored-by: Claude Co-authored-by: Román Gutierrez --- .../workflows/pull_request_frontend_tests.yml | 138 ++ .github/workflows/pull_request_unit_tests.yml | 2 + .github/workflows/push.yml | 2 + .github/workflows/push_frontend_tests.yml | 139 ++ .gitignore | 13 +- .../Commands/CreateOAuth2TestClient.php | 106 + app/Console/Commands/CreateRawUser.php | 57 + app/Console/Commands/GetLatestOtp.php | 52 + app/Console/Kernel.php | 3 + .../Controllers/Api/UserApiController.php | 74 +- .../Controllers/Auth/RegisterController.php | 17 +- app/Http/Controllers/Traits/JsonResponses.php | 22 +- .../Controllers/Traits/MFACookieManager.php | 88 + app/Http/Controllers/UserController.php | 556 ++++- app/Http/Kernel.php | 1 + app/Http/Middleware/EncryptCookies.php | 13 +- .../TwoFactorRateLimitMiddleware.php | 113 + app/Providers/AppServiceProvider.php | 2 +- app/Providers/RouteServiceProvider.php | 4 - .../DoctrineUserRecoveryCodeRepository.php | 6 + .../DoctrineUserTrustedDeviceRepository.php | 24 + app/Services/Auth/DeviceTrustService.php | 110 + app/Services/Auth/IDeviceTrustService.php | 45 + app/Services/Auth/IRecoveryCodeService.php | 64 + app/Services/Auth/ITwoFactorAuditService.php | 35 + app/Services/Auth/ITwoFactorGateService.php | 33 + .../Auth/ITwoFactorRateLimitService.php | 103 + app/Services/Auth/MFAGateService.php | 42 + app/Services/Auth/RecoveryCodeService.php | 159 ++ app/Services/Auth/TwoFactorAuditService.php | 84 + .../Auth/TwoFactorRateLimitService.php | 99 + .../Auth/TwoFactorServiceProvider.php | 117 ++ app/Strategies/DefaultLoginStrategy.php | 18 + .../DisplayResponseJsonStrategy.php | 9 + .../DisplayResponseUserAgentStrategy.php | 22 + app/Strategies/IDisplayResponseStrategy.php | 9 + app/Strategies/ILoginStrategy.php | 18 +- .../MFA/AbstractMFAChallengeStrategy.php | 88 + .../MFA/EmailOTPMFAChallengeStrategy.php | 93 + app/Strategies/MFA/IMFAChallengeStrategy.php | 14 + .../MFA/MFAChallengeStrategyFactory.php | 12 + app/Strategies/OAuth2LoginStrategy.php | 17 + app/libs/Auth/AuthService.php | 149 +- app/libs/Auth/Models/TwoFactorAuditLog.php | 2 + app/libs/Auth/Models/User.php | 175 ++ app/libs/Auth/Models/UserTrustedDevice.php | 9 +- .../IUserRecoveryCodeRepository.php | 7 + .../IUserTrustedDeviceRepository.php | 12 +- app/libs/Utils/Services/IAuthService.php | 52 + babel.config.js | 17 +- config/app.php | 1 + config/auth.php | 6 + config/session.php | 4 +- config/two_factor.php | 79 + ...tion-not-triggered-by-group-enforcement.md | 91 + doc/mfa-test-gap-report.md | 143 ++ docker-compose.yml | 23 + docker-compose/playwright/Dockerfile | 12 + jest.config.js | 18 + package.json | 15 +- phpunit.xml | 10 + playwright.config.ts | 22 + readme.md | 39 +- resources/js/base_actions.js | 24 + .../js/components/recovery_code_display.js | 76 + .../js/components/recovery_code_modal.js | 64 + .../js/components/recovery_codes.module.scss | 43 + .../js/components/recovery_codes_panel.js | 144 ++ resources/js/components/two_factor_section.js | 68 + resources/js/login/actions.js | 30 + .../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_code_input.js | 56 + .../js/login/components/otp_help_links.js | 43 + .../js/login/components/otp_input_form.js | 115 ++ .../login/components/password_input_form.js | 189 ++ .../js/login/components/recovery_code_form.js | 85 + .../third_party_identity_providers.js | 36 + .../js/login/components/two_factor_form.js | 127 ++ .../js/login/components/use_otp_countdown.js | 25 + resources/js/login/constants.js | 40 + resources/js/login/login.js | 1829 +++++++++-------- resources/js/login/login.module.scss | 29 + resources/js/profile/actions.js | 12 +- resources/js/profile/profile.js | 35 +- resources/js/profile/profile.module.scss | 10 + resources/js/shared/HTMLRender.js | 29 + resources/js/shared/recovery_codes.js | 5 + resources/js/signup/signup.js | 10 +- resources/js/utils.js | 12 + resources/views/auth/login.blade.php | 19 + resources/views/profile.blade.php | 8 +- routes/web.php | 11 +- start_local_server.sh | 30 +- storage/framework/cache/data/.gitignore | 2 - tests/AuthServiceLoginUserTest.php | 120 ++ ...viceValidateCredentialsIntegrationTest.php | 106 + tests/DeviceTrustServiceTest.php | 335 +++ tests/OAuth2NativeMFALoginFlowTest.php | 96 + tests/RecoveryCodeRegenerationTest.php | 237 +++ tests/TurnstileProtectedControllersTest.php | 13 +- tests/TwoFactorLoginFlowTest.php | 1144 +++++++++++ tests/e2e/fixtures/index.ts | 87 + tests/e2e/pages/LoginPage.ts | 67 + tests/e2e/pages/RegisterPage.ts | 57 + tests/e2e/tests/auth/login-mfa-flow.spec.ts | 213 ++ tests/e2e/tests/auth/login.spec.ts | 40 + tests/e2e/tests/oauth2/auth-code-flow.spec.ts | 259 +++ tests/e2e/tsconfig.json | 17 + tests/e2e/utils/otp.ts | 20 + 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 + .../components/recovery_code_display.test.js | 53 + .../js/components/recovery_code_modal.test.js | 60 + .../components/recovery_codes_panel.test.js | 105 + .../js/components/two_factor_section.test.js | 48 + .../login/components/two-factor-form.test.js | 56 + tests/js/login/login.mfa.test.js | 141 ++ tests/js/profile/actions.test.js | 57 + tests/js/setup.js | 12 + tests/js/validator/validator.test.js | 43 + .../AuthServiceValidateCredentialsTest.php | 282 +++ tests/unit/DisqusSSOProfileMappingTest.php | 32 +- .../MFA/AbstractMFAChallengeStrategyTest.php | 193 ++ .../MFA/EmailOTPMFAChallengeStrategyTest.php | 206 ++ .../MFA/MFAChallengeStrategyFactoryTest.php | 36 + tests/unit/MFAGateServiceTest.php | 122 ++ tests/unit/OAuth2LoginStrategyTest.php | 93 + tests/unit/TwoFactorAuditServiceTest.php | 267 +++ tests/unit/UserTwoFactorTest.php | 262 +++ webpack.common.js | 1 + yarn.lock | 753 ++++++- 136 files changed, 11683 insertions(+), 978 deletions(-) create mode 100644 .github/workflows/pull_request_frontend_tests.yml create mode 100644 .github/workflows/push_frontend_tests.yml create mode 100644 app/Console/Commands/CreateOAuth2TestClient.php create mode 100644 app/Console/Commands/CreateRawUser.php create mode 100644 app/Console/Commands/GetLatestOtp.php create mode 100644 app/Http/Controllers/Traits/MFACookieManager.php create mode 100644 app/Http/Middleware/TwoFactorRateLimitMiddleware.php create mode 100644 app/Services/Auth/DeviceTrustService.php create mode 100644 app/Services/Auth/IDeviceTrustService.php create mode 100644 app/Services/Auth/IRecoveryCodeService.php create mode 100644 app/Services/Auth/ITwoFactorAuditService.php create mode 100644 app/Services/Auth/ITwoFactorGateService.php create mode 100644 app/Services/Auth/ITwoFactorRateLimitService.php create mode 100644 app/Services/Auth/MFAGateService.php create mode 100644 app/Services/Auth/RecoveryCodeService.php create mode 100644 app/Services/Auth/TwoFactorAuditService.php create mode 100644 app/Services/Auth/TwoFactorRateLimitService.php create mode 100644 app/Services/Auth/TwoFactorServiceProvider.php 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 config/two_factor.php create mode 100644 doc/adrs/0001-recovery-code-generation-not-triggered-by-group-enforcement.md create mode 100644 doc/mfa-test-gap-report.md create mode 100644 docker-compose/playwright/Dockerfile create mode 100644 jest.config.js create mode 100644 playwright.config.ts create mode 100644 resources/js/components/recovery_code_display.js create mode 100644 resources/js/components/recovery_code_modal.js create mode 100644 resources/js/components/recovery_codes.module.scss create mode 100644 resources/js/components/recovery_codes_panel.js create mode 100644 resources/js/components/two_factor_section.js 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_code_input.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/components/use_otp_countdown.js create mode 100644 resources/js/login/constants.js create mode 100644 resources/js/shared/HTMLRender.js create mode 100644 resources/js/shared/recovery_codes.js delete mode 100755 storage/framework/cache/data/.gitignore create mode 100644 tests/AuthServiceLoginUserTest.php create mode 100644 tests/AuthServiceValidateCredentialsIntegrationTest.php create mode 100644 tests/DeviceTrustServiceTest.php create mode 100644 tests/OAuth2NativeMFALoginFlowTest.php create mode 100644 tests/RecoveryCodeRegenerationTest.php create mode 100644 tests/TwoFactorLoginFlowTest.php 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-mfa-flow.spec.ts create mode 100644 tests/e2e/tests/auth/login.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/e2e/utils/otp.ts 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/components/recovery_code_display.test.js create mode 100644 tests/js/components/recovery_code_modal.test.js create mode 100644 tests/js/components/recovery_codes_panel.test.js create mode 100644 tests/js/components/two_factor_section.test.js create mode 100644 tests/js/login/components/two-factor-form.test.js create mode 100644 tests/js/login/login.mfa.test.js create mode 100644 tests/js/profile/actions.test.js create mode 100644 tests/js/setup.js create mode 100644 tests/js/validator/validator.test.js create mode 100644 tests/unit/AuthServiceValidateCredentialsTest.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 create mode 100644 tests/unit/MFAGateServiceTest.php create mode 100644 tests/unit/TwoFactorAuditServiceTest.php create mode 100644 tests/unit/UserTwoFactorTest.php diff --git a/.github/workflows/pull_request_frontend_tests.yml b/.github/workflows/pull_request_frontend_tests.yml new file mode 100644 index 00000000..f2f8df58 --- /dev/null +++ b/.github/workflows/pull_request_frontend_tests.yml @@ -0,0 +1,138 @@ +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: ${{ secrets.TURNSTILE_SITE_KEY }} + TURNSTILE_SECRET_KEY: ${{ secrets.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: | + ./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!' + for i in 001 002 003 004 005 006 007 008; do + php artisan idp:create-super-admin "mfa-ts-$i@test.com" '1Qaz2wsx!' + done + php artisan idp:create-super-admin mfa-oauth2@test.com '1Qaz2wsx!' + php artisan idp:create-super-admin mfa-oauth2-consent@test.com '1Qaz2wsx!' + php artisan idp:create-super-admin mfa-oauth2-trust@test.com '1Qaz2wsx!' + php artisan idp:create-oauth2-test-client + - 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/.github/workflows/pull_request_unit_tests.yml b/.github/workflows/pull_request_unit_tests.yml index 462317c3..45df32f3 100644 --- a/.github/workflows/pull_request_unit_tests.yml +++ b/.github/workflows/pull_request_unit_tests.yml @@ -37,6 +37,8 @@ jobs: PHP_VERSION: 8.3 OTEL_SDK_DISABLED: true OTEL_SERVICE_ENABLED: false + TURNSTILE_SITE_KEY: ${{ secrets.TURNSTILE_SITE_KEY }} + TURNSTILE_SECRET_KEY: ${{ secrets.TURNSTILE_SECRET_KEY }} services: mysql: image: mysql:8.0 diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index ad2ede65..ec993b27 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -33,6 +33,8 @@ jobs: PHP_VERSION: 8.3 OTEL_SDK_DISABLED: true OTEL_SERVICE_ENABLED: false + TURNSTILE_SITE_KEY: ${{ secrets.TURNSTILE_SITE_KEY }} + TURNSTILE_SECRET_KEY: ${{ secrets.TURNSTILE_SECRET_KEY }} services: mysql: image: mysql:8.0 diff --git a/.github/workflows/push_frontend_tests.yml b/.github/workflows/push_frontend_tests.yml new file mode 100644 index 00000000..5376f6f0 --- /dev/null +++ b/.github/workflows/push_frontend_tests.yml @@ -0,0 +1,139 @@ +name: Front End Tests On Push + +on: push + +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: ${{ secrets.TURNSTILE_SITE_KEY }} + TURNSTILE_SECRET_KEY: ${{ secrets.TURNSTILE_SECRET_KEY }} + # `php artisan serve` (below) is PHP's built-in single-threaded dev server — + # it can only handle one request at a time, so Playwright workers must stay + # at 1 here or concurrent page loads queue up and blow the 30s test timeout. + PLAYWRIGHT_WORKERS: 1 + 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: | + ./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!' + for i in 001 002 003 004 005 006 007 008; do + php artisan idp:create-super-admin "mfa-ts-$i@test.com" '1Qaz2wsx!' + done + php artisan idp:create-super-admin mfa-oauth2@test.com '1Qaz2wsx!' + php artisan idp:create-super-admin mfa-oauth2-consent@test.com '1Qaz2wsx!' + php artisan idp:create-super-admin mfa-oauth2-trust@test.com '1Qaz2wsx!' + php artisan idp:create-oauth2-test-client + - 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/.gitignore b/.gitignore index 2b975b7c..74d9df00 100644 --- a/.gitignore +++ b/.gitignore @@ -52,4 +52,15 @@ 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 +.codegraph +docs/plans + +# Playwright +/tests/e2e/report/ +/test-results/ + +# Jest +/tests/js/coverage/ +/playwright-report/ +/.playwright-out/ diff --git a/app/Console/Commands/CreateOAuth2TestClient.php b/app/Console/Commands/CreateOAuth2TestClient.php new file mode 100644 index 00000000..8fdaa402 --- /dev/null +++ b/app/Console/Commands/CreateOAuth2TestClient.php @@ -0,0 +1,106 @@ +findOneBy(['client_id' => self::CLIENT_ID]); + + if (is_null($client)) { + // The consent screen's getDeveloperEmail() dereferences the + // client's owner unconditionally - a client without one 500s + // as soon as a real login reaches /accounts/user/consent. + $owner = EntityManager::getRepository(User::class)->findOneBy(['email' => self::OWNER_EMAIL]); + if (is_null($owner)) { + $owner = new User(); + $owner->setEmail(self::OWNER_EMAIL); + $owner->verifyEmail(); + $owner->setPassword('1Qaz2wsx!'); + $owner->setFirstName(self::OWNER_EMAIL); + $owner->setLastName(self::OWNER_EMAIL); + $owner->setIdentifier(self::OWNER_EMAIL); + EntityManager::persist($owner); + EntityManager::flush(); + } + + $client = ClientFactory::build([ + 'app_name' => 'oauth2_test_app', + 'app_description' => 'oauth2_test_app', + 'client_id' => self::CLIENT_ID, + 'client_secret' => self::CLIENT_SECRET, + 'client_type' => IClient::ClientType_Confidential, + 'application_type' => IClient::ApplicationType_Web_App, + 'token_endpoint_auth_method' => OAuth2Protocol::TokenEndpoint_AuthMethod_ClientSecretBasic, + 'owner' => $owner, + 'rotate_refresh_token' => true, + 'use_refresh_token' => true, + 'redirect_uris' => self::REDIRECT_URI, + ]); + EntityManager::persist($client); + EntityManager::flush(); + $this->info('Created client: ' . self::CLIENT_ID); + } else { + $this->info('Client already exists: ' . self::CLIENT_ID); + } + + $scope = EntityManager::getRepository(ApiScope::class)->findOneBy(['name' => 'profile']); + if (is_null($scope)) { + $this->error("api scope 'profile' not found - run php artisan db:seed first"); + return 1; + } + + $client->addScope($scope); + EntityManager::persist($client); + EntityManager::flush(); + + return 0; + } +} 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/Commands/GetLatestOtp.php b/app/Console/Commands/GetLatestOtp.php new file mode 100644 index 00000000..c51a6ff4 --- /dev/null +++ b/app/Console/Commands/GetLatestOtp.php @@ -0,0 +1,52 @@ +argument('email')); + + // DoctrineOAuth2OTPRepository::getByUserNameNotRedeemed() orders by + // id DESC, so the newest not-yet-redeemed OTP is the FIRST result, + // not the last - an account with more than one pending OTP (e.g. a + // prior attempt that was never redeemed) would otherwise return a + // stale code. + $otps = $repository->getByUserNameNotRedeemed($email); + if (empty($otps)) { + $this->error("no pending otp for {$email}"); + return 1; + } + + $this->line(reset($otps)->getValue()); + return 0; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 89bf376a..03857599 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -29,6 +29,9 @@ class Kernel extends ConsoleKernel Commands\CleanOAuth2StaleData::class, Commands\CleanOpenIdStaleData::class, Commands\CreateSuperAdmin::class, + Commands\CreateRawUser::class, + Commands\CreateOAuth2TestClient::class, + Commands\GetLatestOtp::class, Commands\SpammerProcess\RebuildUserSpammerEstimator::class, Commands\SpammerProcess\UserSpammerProcessor::class, ]; diff --git a/app/Http/Controllers/Api/UserApiController.php b/app/Http/Controllers/Api/UserApiController.php index b32c7307..b334959b 100644 --- a/app/Http/Controllers/Api/UserApiController.php +++ b/app/Http/Controllers/Api/UserApiController.php @@ -16,6 +16,7 @@ use App\Http\Controllers\Traits\RequestProcessor; use App\Http\Controllers\UserValidationRulesFactory; use App\ModelSerializers\SerializerRegistry; +use App\Services\Auth\IRecoveryCodeService; use Auth\Repositories\IUserRepository; use Auth\User; use Exception; @@ -23,6 +24,7 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Request; +use Illuminate\Support\Facades\Validator; use models\exceptions\EntityNotFoundException; use models\exceptions\ValidationException; use OAuth2\Services\ITokenService; @@ -43,23 +45,31 @@ final class UserApiController extends APICRUDController */ private $token_service; + /** + * @var IRecoveryCodeService + */ + private $recovery_code_service; + /** * UserApiController constructor. * @param IUserRepository $user_repository * @param ILogService $log_service * @param IUserService $user_service * @param ITokenService $token_service + * @param IRecoveryCodeService $recovery_code_service */ public function __construct ( IUserRepository $user_repository, ILogService $log_service, IUserService $user_service, - ITokenService $token_service + ITokenService $token_service, + IRecoveryCodeService $recovery_code_service ) { parent::__construct($user_repository, $user_service, $log_service); $this->token_service = $token_service; + $this->recovery_code_service = $recovery_code_service; } /** @@ -247,6 +257,68 @@ public function updateMe() return $this->update(Auth::user()->getId()); } + /** + * Enables a 2FA method for the current user and generates the first batch of + * recovery codes for them. Plaintext codes are returned once in the response + * and never persisted. + * + * @return \Illuminate\Http\JsonResponse|mixed + */ + public function enableTwoFactor() + { + if (!Auth::check()) + return $this->error403(); + + return $this->processRequest(function () { + $data = Request::all(); + $validator = Validator::make($data, [ + 'method' => 'required|string|in:' . implode(',', User::ValidMFAMethods), + ]); + + if (!$validator->passes()) { + return $this->error412($validator->getMessageBag()->getMessages()); + } + + $user = Auth::user(); + $method = $data['method']; + + if ($user->isTwoFactorEnabled()) { + return $this->error412(['method' => ['Two-factor authentication is already enabled. Use the regenerate recovery codes endpoint to rotate your codes.']]); + } + + $codes = $this->recovery_code_service->enableTwoFactorAndGenerateCodes($user, $method); + + return $this->ok(['recovery_codes' => $codes]); + }); + } + + /** + * Invalidates the current user's recovery codes and generates a fresh batch. + * Plaintext codes are returned once in the response and never persisted. + * + * @return \Illuminate\Http\JsonResponse|mixed + */ + public function regenerateRecoveryCodes() + { + if (!Auth::check()) + return $this->error403(); + + return $this->processRequest(function () { + $data = Request::all(); + $validator = Validator::make($data, [ + 'current_password' => 'required|string', + ]); + + if (!$validator->passes()) { + return $this->error412($validator->getMessageBag()->getMessages()); + } + + $codes = $this->recovery_code_service->regenerateRecoveryCodes(Auth::user(), $data['current_password']); + + return $this->ok(['recovery_codes' => $codes]); + }); + } + public function revokeAllMyTokens() { if (!Auth::check()) 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/app/Http/Controllers/Traits/JsonResponses.php b/app/Http/Controllers/Traits/JsonResponses.php index e71726d1..b0d9fff7 100644 --- a/app/Http/Controllers/Traits/JsonResponses.php +++ b/app/Http/Controllers/Traits/JsonResponses.php @@ -15,6 +15,7 @@ use Illuminate\Support\Facades\Request; use Illuminate\Support\Facades\Response; use Exception; +use Symfony\Component\HttpFoundation\Response as HttpResponse; /** * Trait JsonResponses * @package App\Http\Controllers\Traits @@ -23,11 +24,11 @@ trait JsonResponses { protected function error500(Exception $ex){ Log::error($ex); - return Response::json(array( 'error' => 'server error'), 500); + return Response::json(array( 'error' => 'server error'), HttpResponse::HTTP_INTERNAL_SERVER_ERROR); } protected function created($data='ok'){ - $res = Response::json($data, 201); + $res = Response::json($data, HttpResponse::HTTP_CREATED ); //jsonp if(Request::has('callback')) $res->setCallback(Request::input('callback')); @@ -36,7 +37,7 @@ protected function created($data='ok'){ protected function updated($data = 'ok', $has_content = true) { - $res = Response::json($data, $has_content ? 201 : 204); + $res = Response::json($data, $has_content ? HttpResponse::HTTP_CREATED : HttpResponse::HTTP_NO_CONTENT); //jsonp if (Request::has('callback')) { $res->setCallback(Request::input('callback')); @@ -45,7 +46,7 @@ protected function updated($data = 'ok', $has_content = true) } protected function deleted($data='ok'){ - $res = Response::json($data, 204); + $res = Response::json($data, HttpResponse::HTTP_NO_CONTENT); //jsonp if(Request::has('callback')) $res->setCallback(Request::input('callback')); @@ -61,19 +62,24 @@ protected function ok($data = 'ok'){ } protected function error400($data = ['message' => 'Bad Request']){ - return Response::json($data, 400); + return Response::json($data, HttpResponse::HTTP_BAD_REQUEST); } protected function error404($data = array('message' => 'Entity Not Found')){ if(!is_array($data)){ $data = ['message' => $data]; } - return Response::json($data, 404); + return Response::json($data, HttpResponse::HTTP_NOT_FOUND); } protected function error403($data = array('message' => 'Forbidden')) { - return Response::json($data, 403); + return Response::json($data, HttpResponse::HTTP_FORBIDDEN); + } + + protected function unauthorized($data = array('message' => 'UnAuthorized')) + { + return Response::json($data, HttpResponse::HTTP_UNAUTHORIZED); } /** @@ -94,6 +100,6 @@ protected function error412($messages){ if(!is_array($messages)){ $messages = [$messages]; } - return Response::json(array('message' => 'Validation Failed', 'errors' => $messages), 412); + return Response::json(array('message' => 'Validation Failed', 'errors' => $messages), HttpResponse::HTTP_PRECONDITION_FAILED); } } \ No newline at end of file diff --git a/app/Http/Controllers/Traits/MFACookieManager.php b/app/Http/Controllers/Traits/MFACookieManager.php new file mode 100644 index 00000000..086553d5 --- /dev/null +++ b/app/Http/Controllers/Traits/MFACookieManager.php @@ -0,0 +1,88 @@ +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..97a54674 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -13,49 +13,58 @@ **/ use App\Http\Controllers\OpenId\DiscoveryController; -use RyanChandler\LaravelCloudflareTurnstile\Rules\Turnstile; -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\libs\OAuth2\Strategies\LoginHintProcessStrategy; use App\ModelSerializers\SerializerRegistry; +use App\Services\Auth\IDeviceTrustService; +use App\Services\Auth\IRecoveryCodeService; +use App\Services\Auth\ITwoFactorAuditService; +use App\Services\Auth\ITwoFactorGateService; +use App\Services\Auth\ITwoFactorRateLimitService; +use App\Services\Auth\IUserService as AuthUserService; use Auth\Exceptions\AuthenticationException; use Auth\Exceptions\UnverifiedEmailMemberException; -use App\Services\Auth\IUserService as AuthUserService; +use Auth\User; use Exception; use Illuminate\Http\Request as LaravelRequest; -use Illuminate\Support\Facades\Config; -use Illuminate\Support\Facades\Request; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Redirect; +use Illuminate\Support\Facades\Request; use Illuminate\Support\Facades\Response; use Illuminate\Support\Facades\Session; use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\View; use models\exceptions\EntityNotFoundException; use models\exceptions\ValidationException; +use Models\OAuth2\Client; use Models\OAuth2\OAuth2OTP; use OAuth2\Factories\OAuth2AuthorizationRequestFactory; use OAuth2\OAuth2Message; use OAuth2\OAuth2Protocol; use OAuth2\Repositories\IApiScopeRepository; use OAuth2\Repositories\IClientRepository; -use OpenId\Services\IUserService; use OAuth2\Services\IMementoOAuth2SerializerService; use OAuth2\Services\IResourceServerService; use OAuth2\Services\ISecurityContextService; use OAuth2\Services\ITokenService; use OpenId\Services\IMementoOpenIdSerializerService; use OpenId\Services\ITrustedSitesService; +use OpenId\Services\IUserService; +use RyanChandler\LaravelCloudflareTurnstile\Rules\Turnstile; use Services\IUserActionService; use Sokil\IsoCodes\IsoCodesFactory; use Strategies\DefaultLoginStrategy; use Strategies\IConsentStrategy; +use Strategies\MFA\MFAChallengeStrategyFactory; use Strategies\OAuth2ConsentStrategy; use Strategies\OAuth2LoginStrategy; use Strategies\OpenIdConsentStrategy; use Strategies\OpenIdLoginStrategy; +use Utils\IPHelper; use Utils\Services\IAuthService; use Utils\Services\IServerConfigurationService; use Utils\Services\IServerConfigurationService as IUtilsServerConfigurationService; @@ -132,6 +141,31 @@ 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; + + /** + * @var ITwoFactorRateLimitService + */ + private $two_factor_rate_limit_service; + + /** + * @var IRecoveryCodeService + */ + private $recovery_code_service; + /** * @param IMementoOpenIdSerializerService $openid_memento_service * @param IMementoOAuth2SerializerService $oauth2_memento_service @@ -167,7 +201,12 @@ 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, + ITwoFactorRateLimitService $two_factor_rate_limit_service, + IRecoveryCodeService $recovery_code_service, ) { $this->openid_memento_service = $openid_memento_service; @@ -185,6 +224,11 @@ 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->two_factor_rate_limit_service = $two_factor_rate_limit_service; + $this->recovery_code_service = $recovery_code_service; $this->middleware(function ($request, $next) use($login_hint_process_strategy){ @@ -249,11 +293,22 @@ public function getLogin() public function cancelLogin() { + // A cancelled login must invalidate any pending MFA challenge server-side, + // not just reset the client's view of things - otherwise an OTP issued + // before cancel can still complete a login the user explicitly abandoned. + $method = Session::get('mfa_method'); + if (!is_null($method)) { + MFAChallengeStrategyFactory::create($method)->clearPendingState(); + } + $this->clearMFAUISessionState(); + return $this->login_strategy->cancelLogin(); } use JsonResponses; + use MFACookieManager; + /** * @return \Illuminate\Http\JsonResponse|mixed */ @@ -345,6 +400,32 @@ public function emitOTP() OAuth2Protocol::OAuth2PasswordlessPhoneNumber => ($connection == OAuth2Protocol::OAuth2PasswordlessConnectionSMS) ? $username : null ], $client); + // Restore-on-refresh: a subsequent GET /login can rehydrate the OTP + // screen from session instead of dropping back to the email form - + // same mechanism postLogin()'s MFA challengeRequired() branch already + // uses. user_verified is set unconditionally (not inside the + // existing-user lookup below) because loginWithOTP() auto-registers + // brand-new emails at redemption time; gating it on an existing user + // would silently break refresh-restoration for first-time passwordless + // users. + $existing_user = $this->auth_service->getUserByUsername($username); + Session::put('flow', IAuthService::AuthenticationFlowPasswordless); + Session::put('username', $username); + Session::put('user_verified', true); + // Mirrors login.js's emitOtpAction(), which falls back to the + // submitted email as the chip's display name when there's no real + // full name yet - persisting the same fallback here keeps the + // identity chip (visible right after opting into OTP) from + // vanishing on a refresh for a not-yet-registered email. + Session::put('user_fullname', !is_null($existing_user) ? $existing_user->getFullName() : $username); + Session::put('otp_length', $otp->getLength()); + Session::put('otp_lifetime', $otp->getLifetime()); + Session::put('otp_issued_at', $otp->getCreatedAt()?->getTimestamp() ?? time()); + if (!is_null($existing_user)) { + Session::put('user_pic', $existing_user->getPic()); + Session::put('user_is_active', $existing_user->isActive() ? 1 : 0); + } + return $this->created([ 'otp_length' => $otp->getLength(), 'otp_lifetime' => $otp->getLifetime(), @@ -436,38 +517,97 @@ 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 == 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); + + $cookieToken = $this->getCookieToken(); + + if ($this->mfa_gate_service->requiresChallenge($user, $cookieToken)) { + // Initial issuance shares the resend rate-limit window + // (SDS idp-mfa.md §4.12) - without this, this route + // would be an unthrottled way to mail-bomb the account + // owner with OTP codes. + if ($this->two_factor_rate_limit_service->isRateLimited( + ITwoFactorRateLimitService::ActionResend, + $user->getId() + )) { + throw new AuthenticationException(ITwoFactorRateLimitService::RATE_LIMIT_MESSAGE); + } - if ($flow == "otp") { + // 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_rate_limit_service->increment(ITwoFactorRateLimitService::ActionResend, $user->getId()); + + // Best-effort: the challenge was already issued and the OTP + // sent, so an audit-logging failure must not 500 the user + // out of the mfa_required response they need to proceed. + try { + $this->two_factor_audit_service->log( + $user, + TwoFactorAuditLog::EventChallengeIssued, + $method, + IPHelper::getUserIp() + ); + } catch (\Throwable $ex) { + Log::warning($ex); + } - $client = null; + // Restore-on-refresh: a subsequent GET /login can rehydrate + // the 2FA screen from session instead of dropping back to the + // password form. otp_length/otp_lifetime (part of $payload) + // are flashed by challengeRequired() itself; flow/mfa_method + // aren't part of the challenge payload, so they're set here. + Session::put('flow', IAuthService::AuthenticationFlowMFA); + Session::put('mfa_method', $method); + + // The password step now submits as a native form POST, so this + // response is a fresh page load, not a client-side transition - + // without these, the React app remounts with no identity state + // at all (no chip, and Cancel/session-expiry can't return to the + // password screen because it looks like the user was never + // verified). Same fields/getters as the AuthenticationException + // errorLogin() branch below. + $payload = array_merge($payload, [ + 'username' => $username, + 'user_fullname' => $user->getFullName(), + 'user_pic' => $user->getPic(), + 'user_verified' => true, + 'user_is_active' => $user->isActive() ? 1 : 0, + ]); + + return $this->login_strategy->challengeRequired($payload); + } - // check if we have a former oauth2 request - if ($this->oauth2_memento_service->exists()) { + // No challenge required: establish the session and continue. + $this->auth_service->loginUser($user, $remember); + return $this->login_strategy->postLogin(); + } - Log::debug("UserController::postLogin exist a oauth auth request on session"); + if ($flow == IAuthService::AuthenticationFlowPasswordless) { - $oauth_auth_request = OAuth2AuthorizationRequestFactory::getInstance()->build - ( - OAuth2Message::buildFromMemento($this->oauth2_memento_service->load()) + // Passwordless login is single-factor (email access only) and + // must not be usable to satisfy MFA enforcement (SDS idp-mfa.md + // §7.4 / Open Question #3). + $existing_user = $this->auth_service->getUserByUsername($username); + if (!is_null($existing_user) && $existing_user->shouldRequire2FA()) { + throw new AuthenticationException( + "This account requires password and two-factor authentication. Please use the password login option." ); - - if ($oauth_auth_request->isValid()) { - - $client_id = $oauth_auth_request->getClientId(); - - $client = $this->client_repository->getClientById($client_id); - if (is_null($client)) - throw new ValidationException("client does not exists"); - - $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); + // A completed login must not leave the OTP screen restorable + // on a later refresh - same identity-leakage concern already + // fixed for the MFA flow's verify2FA()/verify2FARecovery(). + $this->clearMFAUISessionState(); return $this->login_strategy->postLogin(); } } catch (AuthenticationException $ex) { @@ -558,6 +698,360 @@ 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(); + } + + // Scope verification to the client the challenge was issued for. + $client = $this->resolveClientFromMemento(); + + try { + // 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. + $userId = (int) $pending['user_id']; + $user = $this->auth_service->getUserById($userId) ?? $user; + // Best-effort: an audit-logging failure here must not turn a + // clean 401 into a 500 (which would also drop the error_code + // the rate-limit middleware keys its failure count on). + try { + $this->two_factor_audit_service->log( + $user, + TwoFactorAuditLog::EventChallengeFailed, + $method, + IPHelper::getUserIp() + ); + } catch (\Throwable $auditEx) { + Log::warning($auditEx); + } + return $this->unauthorized(['error_code' => 'mfa_verification_failed']); + } + + // Second factor verified: establish the session. + $this->auth_service->loginUser($user, (bool) $pending['remember']); + + if ($trust_device) { + // 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 (\Throwable $ex) { + Log::warning($ex); + } + } + + $strategy->clearPendingState(); + $this->clearMFAUISessionState(); + + try { + $this->two_factor_audit_service->log( + $user, + TwoFactorAuditLog::EventChallengeSucceeded, + $method, + IPHelper::getUserIp() + ); + } catch (\Throwable $ex) { + Log::warning($ex); + } + + // Return the same-origin post-login destination as data instead of a raw + // redirect for this XHR to follow: postLogin() can chain into a cross-origin + // hop (authorization code delivery to an already-consented OAuth2 client), + // which no XHR/fetch can read past - and per InteractiveGrantType::handle()'s + // consent-bypass branch, that hop also consumes the OAuth2 memento as a side + // effect, so a silently-failed XHR follow-through burns the authorization + // code with no way to recover it client-side. A real top-level navigation to + // this URL lets the browser complete that chain natively instead - CORS never + // applies to page navigations, only to XHR/fetch. + $redirect = $this->login_strategy->postLogin(); + return $this->ok(['redirect_url' => $redirect->getTargetUrl()]); + } 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; + // Best-effort: see verify2FA() for rationale. + try { + $this->two_factor_audit_service->log( + $user, + TwoFactorAuditLog::EventChallengeFailed, + TwoFactorAuditLog::MethodRecovery, + IPHelper::getUserIp() + ); + } catch (\Throwable $auditEx) { + Log::warning($auditEx); + } + return $this->unauthorized(['error_code' => 'mfa_invalid_recovery']); + } + + $this->auth_service->loginUser($user, (bool) $pending['remember']); + $strategy->clearPendingState(); + $this->clearMFAUISessionState(); + + // Best-effort: the recovery code is already redeemed and the session + // established, so an audit-logging failure must not 500 the user + // (which would strand them after burning their last-resort code). + try { + $this->two_factor_audit_service->log( + $user, + TwoFactorAuditLog::EventRecoveryUsed, + TwoFactorAuditLog::MethodRecovery, + IPHelper::getUserIp() + ); + } catch (\Throwable $ex) { + Log::warning($ex); + } + + // See verify2FA() for rationale: return the destination as data so a real + // top-level navigation (not this XHR) performs any cross-origin hop. + $redirect = $this->login_strategy->postLogin(); + return $this->ok([ + 'redirect_url' => $redirect->getTargetUrl(), + // CU-86ba2zp66 / sds/idp-mfa.md §4.10.3, §4.11 step 5: the login page + // must be able to warn the user when they've just burned into their + // last few recovery codes, since it may be their only way back in. + 'recovery_codes_remaining' => $this->recovery_code_service->countUnusedRecoveryCodes($user), + 'recovery_codes_low_threshold' => (int) config('auth.recovery_codes.low_threshold', 3), + ]); + } 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']); + + // Keep the refresh-restorable session state in sync with the + // fresh challenge (e.g. otp_lifetime countdown resets on resend, + // mfa_method changes if this resend is actually a method switch). + Session::put('mfa_method', $method); + if (isset($payload['otp_length'])) { + Session::put('otp_length', $payload['otp_length']); + } + if (isset($payload['otp_lifetime'])) { + Session::put('otp_lifetime', $payload['otp_lifetime']); + } + if (isset($payload['otp_issued_at'])) { + Session::put('otp_issued_at', $payload['otp_issued_at']); + } + + // Best-effort: the challenge was already re-issued and the OTP + // sent, so an audit-logging failure must not 500 the user out of + // the payload they need to complete verification. + try { + $this->two_factor_audit_service->log( + $user, + TwoFactorAuditLog::EventChallengeIssued, + $method, + IPHelper::getUserIp() + ); + } catch (\Throwable $ex) { + Log::warning($ex); + } + + 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() + { + $this->clearMFAUISessionState(); + return $this->unauthorized(['error_code' => 'mfa_session_expired']); + } + + /** + * Clears the UI-restoration session keys written when a challenge is + * issued (see postLogin()'s mfa_required branch). Companion to + * IMFAChallengeStrategy::clearPendingState(), which only owns the + * 2fa_* pending-state keys. + * + * @return void + */ + private function clearMFAUISessionState(): void + { + Session::forget('flow'); + Session::forget('mfa_method'); + Session::forget('otp_length'); + Session::forget('otp_lifetime'); + Session::forget('otp_issued_at'); + Session::forget('error_code'); + // Identity/display fields written by postLogin()'s challengeRequired() + // payload (needed only to hydrate the React app on the initial + // post-redirect GET /login mount) - must not survive cancel/verify + // success/session-expiry, or a later visitor on the same browser + // session inherits the previous attempt's identity. + Session::forget('username'); + Session::forget('user_fullname'); + Session::forget('user_pic'); + Session::forget('user_verified'); + Session::forget('user_is_active'); + } + /** * @return \Illuminate\Http\Response|mixed */ @@ -705,6 +1199,10 @@ public function getProfile() 'actions' => $actions, 'countries' => CountryList::getCountries(), 'languages' => $lang2Code, + 'two_factor_enabled' => $user->shouldRequire2FA(), + 'recovery_codes_remaining' => $this->recovery_code_service->countUnusedRecoveryCodes($user), + 'recovery_codes_total' => (int)config('auth.recovery_codes.count', 10), + 'recovery_codes_low_threshold' => (int)config('auth.recovery_codes.low_threshold', 3), ]); } 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..e197735b --- /dev/null +++ b/app/Http/Middleware/TwoFactorRateLimitMiddleware.php @@ -0,0 +1,113 @@ +key; + + if ($this->rate_limit_service->isRateLimited($action, $subject)) { + Log::debug(sprintf("TwoFactorRateLimitMiddleware: action %s subject %s rate limited", $action, $subject)); + + $responseCallback = $limit->responseCallback; + return $responseCallback($request, [ + 'Retry-After' => $this->rate_limit_service->getRetryAfterSeconds($action, $subject), + 'X-RateLimit-Limit' => $this->rate_limit_service->getLimit($action), + 'X-RateLimit-Remaining' => 0, + ]); + } + + $response = $next($request); + + if ($action === ITwoFactorRateLimitService::ActionResend || $action === ITwoFactorRateLimitService::ActionOtp) { + $this->rate_limit_service->increment($action, $subject); + } else if ($this->isFailure($response)) { + $this->rate_limit_service->increment($action, $subject); + } + + return $response; + } + + /** + * @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/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 09ed90d4..e25e83f7 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -40,7 +40,7 @@ class AppServiceProvider extends ServiceProvider */ public function boot() { - if (!App::isLocal()) + if (Config::get('server.ssl_enabled', false)) URL::forceScheme('https'); $logger = Log::getLogger(); diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index cc31990c..7d9b56f3 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -57,10 +57,6 @@ protected function configureRateLimiting() return Limit::perMinute(5)->by(optional($request->user())->id ?: $request->ip()); }); - RateLimiter::for('otp', function (Request $request) { - return Limit::perMinute(10)->by(optional($request->user())->id ?: $request->ip()); - }); - RateLimiter::for('oauth2', function (Request $request) { $maxAttempts = App::environment() == "testing" ? PHP_INT_MAX : 50; return Limit::perMinute($maxAttempts)->by(optional($request->user())->id ?: $request->ip()); 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/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..d4242c2f --- /dev/null +++ b/app/Services/Auth/DeviceTrustService.php @@ -0,0 +1,110 @@ +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->tx_service->transaction(function () use ($device) { + $this->repository->add($device, false); + }); + + $this->audit_service->log( + $user, + TwoFactorAuditLog::EventDeviceTrusted, + $user->getTwoFactorMethod(), + $ipAddress + ); + + 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->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/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 @@ +shouldRequire2FA()) { + return false; + } + return !$this->deviceTrustService->isDeviceTrusted($user, $cookieToken); + } +} diff --git a/app/Services/Auth/RecoveryCodeService.php b/app/Services/Auth/RecoveryCodeService.php new file mode 100644 index 00000000..1abaeb9c --- /dev/null +++ b/app/Services/Auth/RecoveryCodeService.php @@ -0,0 +1,159 @@ +checkPassword(trim($currentPassword))) { + throw new ValidationException('current_password is not correct.'); + } + + return $this->generateRecoveryCodes($user); + } + + /** + * @inheritDoc + */ + public function generateRecoveryCodes(User $user): array + { + $codes = $this->tx_service->transaction(fn() => $this->regenerateCodesForUser($user)); + + // Best-effort: the codes are already committed and about to be shown to + // the user, so an audit-logging failure must not 500 this response - a + // client retry on a 500 would regenerate and invalidate the codes it + // just received. + try { + $this->audit_service->log( + $user, + TwoFactorAuditLog::EventRecoveryCodesGenerated, + TwoFactorAuditLog::MethodRecovery, + IPHelper::getUserIp() + ); + } catch (\Throwable $ex) { + Log::warning($ex); + } + + return $codes; + } + + /** + * @inheritDoc + */ + public function enableTwoFactorAndGenerateCodes(User $user, string $method): array + { + // Everything must live in a single transaction() call: it opens/commits + // its own connection-level transaction and closes the entity manager on + // failure (see DoctrineTransactionService::transaction()), so nesting a + // second call inside it (e.g. by calling generateRecoveryCodes() here) + // would let an inner failure tear down the EM out from under this + // still-running outer transaction. + $codes = $this->tx_service->transaction(function () use ($user, $method) { + $user->enable2FA($method); + $this->user_repository->add($user, false); + + return $this->regenerateCodesForUser($user); + }); + + // Best-effort: 2FA is already enabled and the codes are already + // committed, so an audit-logging failure must not 500 this response - a + // client retry on a 500 would regenerate and invalidate the codes it + // just received. + try { + $this->audit_service->log( + $user, + TwoFactorAuditLog::EventRecoveryCodesGenerated, + TwoFactorAuditLog::MethodRecovery, + IPHelper::getUserIp() + ); + + $this->audit_service->log( + $user, + TwoFactorAuditLog::EventEnrollmentChanged, + $method, + IPHelper::getUserIp() + ); + } catch (\Throwable $ex) { + Log::warning($ex); + } + + return $codes; + } + + /** + * Invalidates every existing recovery code for the user and generates a + * fresh batch, within the caller's already-open transaction. + * + * @return string[] plaintext codes formatted as XXXX-XXXX + */ + private function regenerateCodesForUser(User $user): array + { + $count = (int)config('auth.recovery_codes.count', 10); + $length = (int)config('auth.recovery_codes.length', 8); + + $plaintext_codes = []; + + $this->repository->deleteAllForUser($user); + + for ($i = 0; $i < $count; $i++) { + $plain = Rand::getString($length, self::CODE_CHARSET, true); + $plaintext_codes[] = $plain; + + $code = new UserRecoveryCode(); + $code->setUser($user); + $code->setCodeHash(Hash::make($plain)); + $this->repository->add($code, false); + } + + return array_map(static fn(string $code) => implode('-', str_split($code, 4)), $plaintext_codes); + } + + /** + * @inheritDoc + */ + public function countUnusedRecoveryCodes(User $user): int + { + return count($this->repository->getUnusedByUser($user)); + } +} diff --git a/app/Services/Auth/TwoFactorAuditService.php b/app/Services/Auth/TwoFactorAuditService.php new file mode 100644 index 00000000..a1dfd9f8 --- /dev/null +++ b/app/Services/Auth/TwoFactorAuditService.php @@ -0,0 +1,84 @@ + $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->tx_service->transaction(function () use ($auditLog) { + $this->repository->add($auditLog, false); + }); + + 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/TwoFactorRateLimitService.php b/app/Services/Auth/TwoFactorRateLimitService.php new file mode 100644 index 00000000..13967462 --- /dev/null +++ b/app/Services/Auth/TwoFactorRateLimitService.php @@ -0,0 +1,99 @@ +limitsFor($action); + return RateLimiter::tooManyAttempts($this->cacheKey($action, $subject), $maxAttempts); + } + + public function increment(string $action, string|int $subject): void + { + [, $windowSeconds] = $this->limitsFor($action); + + // Fixed window: RateLimiter::hit() sets a companion "resets at" timer + // key once (only if absent) and bumps the counter while preserving + // that timer, so the window starts at the first hit and does not + // slide - same semantics the previous hand-rolled Cache::add()+ + // increment() had, but this also gives us availableIn() for + // Retry-After without a driver-specific TTL query. + RateLimiter::hit($this->cacheKey($action, $subject), $windowSeconds); + } + + public function getLimit(string $action): int + { + [$maxAttempts, ] = $this->limitsFor($action); + return $maxAttempts; + } + + public function getWindowSeconds(string $action): int + { + [, $windowSeconds] = $this->limitsFor($action); + return $windowSeconds; + } + + public function getRetryAfterSeconds(string $action, string|int $subject): int + { + return RateLimiter::availableIn($this->cacheKey($action, $subject)); + } + + /** + * @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, + ]; + } + + if ($action === self::ActionOtp) { + return [ + (int) Config::get('two_factor.rate_limit.max_otp_email_requests', 5), + (int) Config::get('two_factor.rate_limit.otp_email_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), + ]; + } + + /** + * @param string $action + * @param string|int $subject a user id for session-keyed actions, or a raw + * (already-canonicalized) subject string for ActionOtp + * @return string + */ + private function cacheKey(string $action, string|int $subject): string + { + return sprintf('2fa_rate:%s:%s', $action, $subject); + } +} diff --git a/app/Services/Auth/TwoFactorServiceProvider.php b/app/Services/Auth/TwoFactorServiceProvider.php new file mode 100644 index 00000000..b82bdd10 --- /dev/null +++ b/app/Services/Auth/TwoFactorServiceProvider.php @@ -0,0 +1,117 @@ +registerRateLimiters(); + } + + public function register(): void + { + $this->app->singleton(IDeviceTrustService::class, DeviceTrustService::class); + $this->app->singleton(ITwoFactorAuditService::class, TwoFactorAuditService::class); + $this->app->singleton(ITwoFactorGateService::class, MFAGateService::class); + $this->app->singleton(ITwoFactorRateLimitService::class, TwoFactorRateLimitService::class); + $this->app->singleton(IRecoveryCodeService::class, RecoveryCodeService::class); + } + + /** + * Named RateLimiter::for() limiters for the 2FA actions - own the two + * things that vanilla ->middleware('throttle:...') can declare: the + * throttled subject (Limit::by()) and the 429 response shape + * (Limit::response()). TwoFactorRateLimitMiddleware still enforces the + * limit itself and decides *when* to count a hit, because the stock + * throttle pipeline always increments before the request reaches the + * controller and has no hook for "only count on failure" - required for + * verify/recovery per SDS idp-mfa.md §4.12. Returning Limit::none() + * signals "no resolvable subject yet" so the middleware lets the request + * through and the controller resolves the (missing) state itself. + */ + private function registerRateLimiters(): void + { + $rateLimitService = $this->app->make(ITwoFactorRateLimitService::class); + + $respondRateLimited = fn ($request, array $headers) => Response::json( + [ + 'error_code' => ITwoFactorRateLimitService::RATE_LIMIT_ERROR_CODE, + 'error_message' => ITwoFactorRateLimitService::RATE_LIMIT_MESSAGE, + ], + HttpResponse::HTTP_TOO_MANY_REQUESTS + )->withHeaders($headers); + + $bySessionPendingUser = function (string $action) use ($rateLimitService, $respondRateLimited) { + $userId = Session::get(ITwoFactorRateLimitService::PENDING_USER_SESSION_KEY); + + if (is_null($userId)) { + return Limit::none(); + } + + return (new Limit( + (int) $userId, + $rateLimitService->getLimit($action), + $rateLimitService->getWindowSeconds($action) + ))->response($respondRateLimited); + }; + + $limiterName = fn (string $action) => ITwoFactorRateLimitService::RATE_LIMITER_NAME_PREFIX . $action; + + RateLimiter::for($limiterName(ITwoFactorRateLimitService::ActionVerify), fn () => $bySessionPendingUser(ITwoFactorRateLimitService::ActionVerify)); + RateLimiter::for($limiterName(ITwoFactorRateLimitService::ActionRecovery), fn () => $bySessionPendingUser(ITwoFactorRateLimitService::ActionRecovery)); + RateLimiter::for($limiterName(ITwoFactorRateLimitService::ActionResend), fn () => $bySessionPendingUser(ITwoFactorRateLimitService::ActionResend)); + + RateLimiter::for($limiterName(ITwoFactorRateLimitService::ActionOtp), function (Request $request) use ($rateLimitService, $respondRateLimited) { + $username = strtolower(trim($request->input('username', ''))); + + if ($username === '') { + return Limit::none(); + } + + return (new Limit( + $username, + $rateLimitService->getLimit(ITwoFactorRateLimitService::ActionOtp), + $rateLimitService->getWindowSeconds(ITwoFactorRateLimitService::ActionOtp) + ))->response($respondRateLimited); + }); + } + + public function provides(): array + { + return [ + IDeviceTrustService::class, + ITwoFactorAuditService::class, + ITwoFactorGateService::class, + ITwoFactorRateLimitService::class, + IRecoveryCodeService::class, + ]; + } +} diff --git a/app/Strategies/DefaultLoginStrategy.php b/app/Strategies/DefaultLoginStrategy.php index 1a90c770..693ee417 100644 --- a/app/Strategies/DefaultLoginStrategy.php +++ b/app/Strategies/DefaultLoginStrategy.php @@ -113,4 +113,22 @@ public function errorLogin(array $params) $response = $response->with($key, $val); return $response; } + + /** + * @param array $params + * @return mixed + */ + public function challengeRequired(array $params) + { + // The login form submits as a native form POST, so this must redirect + // like every other outcome (errorLogin()) rather than return JSON. + // Persistent (not one-shot flash) so the state survives repeated + // refreshes while the challenge is still pending. error_code mirrors + // what DisplayResponseJsonStrategy sends native clients in JSON. + Session::put('error_code', ILoginStrategy::MFA_REQUIRED); + foreach ($params as $key => $val) { + Session::put($key, $val); + } + return Redirect::action('UserController@getLogin'); + } } \ No newline at end of file diff --git a/app/Strategies/DisplayResponseJsonStrategy.php b/app/Strategies/DisplayResponseJsonStrategy.php index 3f30a325..150e1596 100644 --- a/app/Strategies/DisplayResponseJsonStrategy.php +++ b/app/Strategies/DisplayResponseJsonStrategy.php @@ -96,4 +96,13 @@ public function getLoginErrorResponse(array $data = []) } return Response::json($data, 412); } + + /** + * @param array $data + * @return SymfonyResponse + */ + public function getChallengeRequiredResponse(array $data = []) + { + return Response::json(array_merge(['error_code' => ILoginStrategy::MFA_REQUIRED], $data), 412); + } } \ No newline at end of file diff --git a/app/Strategies/DisplayResponseUserAgentStrategy.php b/app/Strategies/DisplayResponseUserAgentStrategy.php index 832d5bcb..ea8358ba 100644 --- a/app/Strategies/DisplayResponseUserAgentStrategy.php +++ b/app/Strategies/DisplayResponseUserAgentStrategy.php @@ -17,6 +17,7 @@ use Symfony\Component\HttpFoundation\Response as SymfonyResponse; use Illuminate\Support\Facades\Response; use Illuminate\Support\Facades\Redirect; +use Illuminate\Support\Facades\Session; /** * Class DisplayResponseUserAgentStrategy @@ -72,4 +73,25 @@ public function getLoginErrorResponse(array $data = []) return $response; } + + /** + * Same redirect+session-flash contract as getLoginErrorResponse(): OAuth2 + * page/popup/touch flows render the same login.js SPA via a native form + * POST, so the MFA challenge is delivered the same way every other login + * outcome is. Persistent (not one-shot flash) so the state survives + * repeated refreshes while the challenge is still pending. + * + * @param array $data + * @return SymfonyResponse + */ + public function getChallengeRequiredResponse(array $data = []) + { + // error_code mirrors what DisplayResponseJsonStrategy sends native + // clients in JSON. + Session::put('error_code', ILoginStrategy::MFA_REQUIRED); + foreach ($data as $key => $val) { + Session::put($key, $val); + } + return Redirect::action('UserController@getLogin'); + } } \ No newline at end of file diff --git a/app/Strategies/IDisplayResponseStrategy.php b/app/Strategies/IDisplayResponseStrategy.php index 019615a8..21e8a8a8 100644 --- a/app/Strategies/IDisplayResponseStrategy.php +++ b/app/Strategies/IDisplayResponseStrategy.php @@ -34,4 +34,13 @@ public function getLoginResponse(array $data = []); * @return SymfonyResponse */ public function getLoginErrorResponse(array $data = []); + + /** + * Factor 1 (password) passed but a 2FA challenge must be completed before + * a session is established. + * + * @param array $data + * @return SymfonyResponse + */ + public function getChallengeRequiredResponse(array $data = []); } \ No newline at end of file diff --git a/app/Strategies/ILoginStrategy.php b/app/Strategies/ILoginStrategy.php index 73120130..5894bc4d 100644 --- a/app/Strategies/ILoginStrategy.php +++ b/app/Strategies/ILoginStrategy.php @@ -5,6 +5,12 @@ */ interface ILoginStrategy { + /** + * error_code returned by challengeRequired() when factor 1 passed but a + * 2FA challenge is pending. + */ + const MFA_REQUIRED = 'mfa_required'; + /** * @return mixed */ @@ -26,4 +32,14 @@ public function cancelLogin(); * @return mixed */ public function errorLogin(array $params); -} \ No newline at end of file + + /** + * Factor 1 (password) passed but a 2FA challenge must be completed before + * a session is established. Distinct from errorLogin(): this is a pending + * mid-flow state, not a failed attempt. + * + * @param array $params + * @return mixed + */ + public function challengeRequired(array $params); +} \ No newline at end of file diff --git a/app/Strategies/MFA/AbstractMFAChallengeStrategy.php b/app/Strategies/MFA/AbstractMFAChallengeStrategy.php new file mode 100644 index 00000000..95da987e --- /dev/null +++ b/app/Strategies/MFA/AbstractMFAChallengeStrategy.php @@ -0,0 +1,88 @@ + 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 + { + // Recovery codes are hashed without the "-" separator; it is added only + // for on-screen readability (XXXX-XXXX). Normalize here so a code typed + // or pasted exactly as displayed still matches the stored hash. + $code = strtoupper(preg_replace('/[^A-Za-z0-9]/', '', $code)); + + 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(); + 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); + } + + 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 new file mode 100644 index 00000000..d5d24f8f --- /dev/null +++ b/app/Strategies/MFA/EmailOTPMFAChallengeStrategy.php @@ -0,0 +1,93 @@ +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(), + // Same source isAlive()/getRemainingLifetime() use server-side, so a + // UI countdown seeded from it can never drift from the actual expiry check. + 'otp_issued_at' => $otp->getCreatedAt()?->getTimestamp() ?? time(), + ]; + } + + public function verifyChallenge(User $user, string $code, ?Client $client = null): void + { + // 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, + $user->getEmail(), + $client + ); + + 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."); + } + + // 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(); + + // 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(); + } + } + } + + 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..c395551d --- /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/app/Strategies/OAuth2LoginStrategy.php b/app/Strategies/OAuth2LoginStrategy.php index bcbd5123..8161062c 100644 --- a/app/Strategies/OAuth2LoginStrategy.php +++ b/app/Strategies/OAuth2LoginStrategy.php @@ -127,4 +127,21 @@ public function errorLogin(array $params) return $response_strategy->getLoginErrorResponse($params); } + + /** + * @param array $params + * @return mixed + */ + public function challengeRequired(array $params) + { + $auth_request = OAuth2AuthorizationRequestFactory::getInstance()->build( + OAuth2Message::buildFromMemento( + $this->memento_service->load() + ) + ); + + $response_strategy = DisplayResponseStrategyFactory::build($auth_request->getDisplay()); + + return $response_strategy->getChallengeRequiredResponse($params); + } } \ No newline at end of file diff --git a/app/libs/Auth/AuthService.php b/app/libs/Auth/AuthService.php index 928c5af8..665956d7 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 +131,14 @@ public function isUserLogged() return Auth::check(); } + /** + * @return User|null + */ + public function getCurrentUser(): ?User + { + return Auth::user(); + } + /** * Finds the OTP by value/connection/username, logs the redeem attempt (TX-A), * then validates lifecycle / value / scope / audience (TX-B). @@ -140,13 +148,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 +196,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 +328,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,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 ( @@ -409,11 +416,50 @@ public function login(string $username, string $password, bool $remember_me): bo } /** + * @param string $username + * @param string $password * @return User|null + * @throws AuthenticationException */ - public function getCurrentUser(): ?User + public function validateCredentials(string $username, string $password): User { - return Auth::user(); + Log::debug("AuthService::validateCredentials"); + + /** + * @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."); + } + return $user; + } + + /** + * @param User $user + * @param bool $remember + * @return void + */ + 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() first: Laravel's SessionGuard::login() already + // regenerates the session ID internally (session->migrate(true)), + // closing the pre-auth session-fixation window. Principal bookkeeping + // runs AFTER so register()'s op_browser_state hash (used for OIDC + // Session Management) is derived from the FINAL id, not one that + // Auth::login() is about to invalidate. + Auth::login($user, $remember); + + $this->principal_service->clear(); + $this->principal_service->register + ( + $user->getId(), + time() + ); } /** @@ -618,7 +664,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 +767,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 +786,47 @@ 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, + ?Client $client = null + ): void { + $this->tx_service->transaction(function () use ($user, $strategy, $value, $client) { + $strategy->verifyChallenge($user, $value, $client); + }); + } + + 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/Auth/Models/TwoFactorAuditLog.php b/app/libs/Auth/Models/TwoFactorAuditLog.php index 4938345a..5258978a 100644 --- a/app/libs/Auth/Models/TwoFactorAuditLog.php +++ b/app/libs/Auth/Models/TwoFactorAuditLog.php @@ -29,6 +29,7 @@ class TwoFactorAuditLog extends BaseEntity public const EventDeviceRevoked = 'device_revoked'; public const EventRecoveryUsed = 'recovery_used'; public const EventSettingsChanged = 'settings_changed'; + public const EventRecoveryCodesGenerated = 'recovery_codes_generated'; public const MethodEmailOtp = 'email_otp'; public const MethodSmsOtp = 'sms_otp'; @@ -46,6 +47,7 @@ class TwoFactorAuditLog extends BaseEntity self::EventDeviceRevoked, self::EventRecoveryUsed, self::EventSettingsChanged, + self::EventRecoveryCodesGenerated, ]; private const ALLOWED_METHODS = [ diff --git a/app/libs/Auth/Models/User.php b/app/libs/Auth/Models/User.php index c1d70d96..33f4ba50 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,148 @@ 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. + * + * The global kill-switch is honored first: when config('two_factor.enabled') + * is false the whole 2FA gate is inactive (SDS idp-mfa.md §10.1), so no user + * is required regardless of role or preference. Otherwise a user is required + * when they belong to any of the groups listed in + * config('two_factor.enforced_groups'); failing that, the stored flag applies. + */ + public function shouldRequire2FA(): bool + { + if (!config('two_factor.enabled', true)) { + return false; + } + $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/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/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/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/app/libs/Utils/Services/IAuthService.php b/app/libs/Utils/Services/IAuthService.php index a8fc3dda..ce68d06c 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 */ @@ -38,6 +39,7 @@ interface IAuthService const AuthenticationFlowPassword = "password"; const AuthenticationFlowPasswordless = "otp"; + const AuthenticationFlowMFA = "2fa"; /** * @return bool */ @@ -57,6 +59,29 @@ 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. + * @throws \Auth\Exceptions\UnverifiedEmailMemberException when the user's email is not verified + */ + 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 @@ -171,4 +196,31 @@ 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, + ?Client $client = null + ): 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/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/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/auth.php b/config/auth.php index 5da1b184..69282ef7 100644 --- a/config/auth.php +++ b/config/auth.php @@ -107,6 +107,12 @@ 'password_shape_warning' => env('AUTH_PASSWORD_SHAPE_WARNING', 'Password must include at least one uppercase letter, one lowercase letter, one number, and one special character (#?!@$%^&*+-).'), 'verification_email_lifetime' => env("AUTH_VERIFICATION_EMAIL_LIFETIME", 600), 'allows_native_auth' => env('AUTH_ALLOWS_NATIVE_AUTH', 1), + + 'recovery_codes' => [ + 'count' => env('MFA_RECOVERY_CODES_COUNT', 10), + 'length' => env('MFA_RECOVERY_CODE_LENGTH', 8), + 'low_threshold' => env('MFA_RECOVERY_CODES_LOW_THRESHOLD', 3), + ], 'allows_native_on_config' => env('AUTH_ALLOWS_NATIVE_AUTH_CONFIG', 1), 'allows_opt_auth' => env('AUTH_ALLOWS_OTP_AUTH', 1), ]; 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/config/two_factor.php b/config/two_factor.php new file mode 100644 index 00000000..48491d80 --- /dev/null +++ b/config/two_factor.php @@ -0,0 +1,79 @@ + env('TWO_FACTOR_ENABLED', true), + + /* + |-------------------------------------------------------------------------- + | Enforced Groups + |-------------------------------------------------------------------------- + | + | Users that belong to any of these groups are required to complete 2FA + | regardless of the value of their `two_factor_enabled` flag. + | + */ + 'enforced_groups' => [ + IGroupSlugs::SuperAdminGroup, + IGroupSlugs::AdminGroup, + 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'), + + /* + |-------------------------------------------------------------------------- + | 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), + + // Passwordless OTP issuance (POST /auth/login/otp) - anonymous, pre-auth + // endpoint, keyed by the submitted email rather than a session user id. + // Kept independent from the MFA resend keys above so ops can tune this + // budget separately. + 'max_otp_email_requests' => env('TWO_FACTOR_MAX_OTP_EMAIL_REQUESTS', 5), + 'otp_email_window_minutes' => env('TWO_FACTOR_OTP_EMAIL_WINDOW_MINUTES', 15), + ], +]; diff --git a/doc/adrs/0001-recovery-code-generation-not-triggered-by-group-enforcement.md b/doc/adrs/0001-recovery-code-generation-not-triggered-by-group-enforcement.md new file mode 100644 index 00000000..31ed8bbd --- /dev/null +++ b/doc/adrs/0001-recovery-code-generation-not-triggered-by-group-enforcement.md @@ -0,0 +1,91 @@ +# 0001. Recovery code generation is not automatically triggered by group-based 2FA enforcement + +## Status + +Accepted — 2026-07-08 + +## Context + +Ticket [CU-86ba2zp66](https://app.clickup.com/t/86ba2zp66) ("Recovery Code Management UI and Endpoints") requires: + +> Create endpoint to generate recovery codes **when 2FA is enabled or admin enforcement requires code creation**. + +Two-factor authentication in this project can become mandatory for a user in two distinct ways: + +1. **Explicit self-enrollment** — the user calls the new `UserApiController::enableTwoFactor()` endpoint + (`app/Http/Controllers/Api/UserApiController.php`), which invokes `User::enable2FA($method)` and, in the + same transaction, calls `RecoveryCodeService::generateRecoveryCodes()` to mint the user's first batch of + recovery codes. +2. **Group-based enforcement** — `User::shouldRequire2FA()` (`app/libs/Auth/Models/User.php`) returns `true` + for any user belonging to one of the groups listed in `config('two_factor.enforced_groups')` + (`config/two_factor.php`: SuperAdmin, Admin, OAuth2ServerAdmin, OpenIdServerAdmins), **independently** of + whether that user ever called `enableTwoFactor()` or has the `two_factor_enabled` column set. + +Only path (1) generates recovery codes automatically. Path (2) has no corresponding hook: there is no +listener on group-membership assignment (e.g. when a user is added to the `Admin` group via +`GroupApiController` or any other admin-management flow) that generates a recovery-code batch for that user. + +Practical consequence: a user who is force-enrolled into MFA purely by being added to an enforced group — +and who never separately visits their profile to enable 2FA or regenerate codes — has **zero recovery codes** +until they proactively open their profile's "Two-Factor Authentication" section and click "Regenerate Codes" +(`resources/js/components/recovery_codes_panel.js`). That manual path works correctly and is not gated on +prior enrollment, but it is not automatic, so the literal wording of the ticket ("or admin enforcement +requires code creation") is not satisfied for this path. + +Implementing the automatic path would require identifying every place group membership can be granted +(direct admin action, bulk import, programmatic group assignment, etc.) and wiring a listener/hook into each +one to call `RecoveryCodeService::generateRecoveryCodes()` exactly once per user, without duplicating codes +on repeated grants or interfering with a user who already regenerated codes themselves. No single +well-defined integration point for "group membership changed" was identified during implementation without +a dedicated exploration pass, and building one was judged to be a meaningfully larger change than the rest +of this ticket. + +## Decision + +We accept the gap as a documented scope limitation for this ticket. Recovery code generation for +group-enforced users remains **on-demand**: the user (or an admin acting on their behalf, e.g. via a support +flow) must visit the profile's Two-Factor Authentication section and use "Regenerate Codes" at least once +after being enrolled through group enforcement. + +No code changes accompany this decision; it documents the trade-off already present in the shipped +implementation (`feat/recovery-codes-management` branch). + +## Consequences + +**Positive** + +- No new event/listener infrastructure needed for group-membership changes, keeping the change surface of + this ticket limited to the profile self-service flow it was originally scoped around. +- The manual path is simple, already implemented, and requires no additional user-facing concept: a + group-enforced user sees the same "Two-Factor Authentication" section and the same "Regenerate Codes" + action as anyone who self-enrolled. +- Avoids the risk of generating recovery codes an admin never asked for or expects during unrelated + group-management operations (e.g. bulk group imports). + +**Negative** + +- A user who is force-enrolled by group membership and is challenged for MFA (e.g. at their next login) + before ever visiting their profile has **no recovery codes available** if they lose access to their normal + 2FA method (email, for Phase I) at that point. Their only recourse is out-of-band administrative + intervention (e.g. a server admin resetting their 2FA state directly), not a self-service recovery path. +- The literal acceptance criterion "generate recovery codes ... when admin enforcement requires code + creation" is not met for the group-enforcement path — only for explicit self-enrollment. + +**Follow-up (not scheduled)** + +If this gap needs to be closed later, the natural integration point is wherever group membership is granted +(`GroupApiController` and any other code path that adds a user to `Group`) — call +`RecoveryCodeService::generateRecoveryCodes($user)` immediately after granting membership to a group in +`config('two_factor.enforced_groups')`, guarded so it only fires when the user currently has zero unused +codes (to avoid clobbering codes on every re-grant). + +## Alternatives considered + +- **Hook into group-assignment code paths now.** Rejected for this ticket: requires auditing every place + group membership can change (there is more than one — see `GroupApiController` and related admin flows) + to guarantee the hook fires exactly once and doesn't silently invalidate codes a user already saved. Judged + to be new scope beyond "Recovery Code Management UI and Endpoints," better handled as its own ticket if + the org decides to close this gap. +- **Generate codes lazily on first MFA challenge instead of on group grant.** Rejected: the MFA challenge + screen itself has no natural place to show a one-time "here are your recovery codes" modal without + interrupting the login flow the ticket explicitly requires to remain "unaffected." diff --git a/doc/mfa-test-gap-report.md b/doc/mfa-test-gap-report.md new file mode 100644 index 00000000..5c6fd602 --- /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..644780e1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -57,6 +57,28 @@ services: networks: - idp-local-net env_file: ./.env + playwright: + build: + context: ./docker-compose/playwright + container_name: idp-playwright + working_dir: /var/www + volumes: + - ./:/var/www + - playwright_cache:/root/.cache/ms-playwright + # Lets the e2e suite `docker exec idp-app php artisan idp:get-latest-otp + # ` to read a real OTP value (see tests/e2e/utils/otp.ts) - + # grants this container full control of the host's Docker daemon, not + # just idp-app, so keep this service dev/e2e-only (profiles: [e2e]). + - /var/run/docker.sock:/var/run/docker.sock + networks: + - idp-local-net + depends_on: + - nginx + profiles: + - e2e + environment: + - APP_URL=http://nginx + nginx: image: nginx:alpine container_name: nginx-idp @@ -119,3 +141,4 @@ networks: volumes: mysql_idp: elasticsearch_data: + playwright_cache: diff --git a/docker-compose/playwright/Dockerfile b/docker-compose/playwright/Dockerfile new file mode 100644 index 00000000..14394765 --- /dev/null +++ b/docker-compose/playwright/Dockerfile @@ -0,0 +1,12 @@ +FROM mcr.microsoft.com/playwright:v1.61.1-jammy + +# Docker CLI only (no daemon) - lets the e2e suite shell out to +# `docker exec idp-app php artisan idp:get-latest-otp ` to read a +# real OTP value without adding DB/mail dependencies to the test runner. +# Requires /var/run/docker.sock to be mounted at runtime (docker-compose.yml). +ARG DOCKER_CLI_VERSION=27.3.1 +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && curl -fsSL "https://download.docker.com/linux/static/stable/x86_64/docker-${DOCKER_CLI_VERSION}.tgz" \ + | tar -xz --strip-components=1 -C /usr/local/bin docker/docker \ + && rm -rf /var/lib/apt/lists/* 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 ccf4660e..96e436f3 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,13 @@ "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", - "test": "jest --watch" + "serve": "webpack-dev-server --open --port=8888 --server-type https --config webpack.dev.js", + "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:report": "playwright show-report tests/e2e/report" }, "devDependencies": { "@babel/core": "^7.17.8", @@ -21,6 +26,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/user-event": "^13", + "@testing-library/jest-dom": "^5.16.5", + "@testing-library/react": "^12.1.5", "babel-cli": "^6.26.0", "babel-jest": "^26.6.3", "babel-loader": "^8.2.4", @@ -81,6 +90,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 +105,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/phpunit.xml b/phpunit.xml index 7515f39f..1f73569a 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -22,6 +22,16 @@ ./tests/OpenTelemetry/Formatters/ + + ./tests/TwoFactorRepositoriesTest.php + ./tests/unit/UserTwoFactorTest.php + ./tests/unit/MFA/AbstractMFAChallengeStrategyTest.php + ./tests/unit/MFA/EmailOTPMFAChallengeStrategyTest.php + ./tests/unit/MFA/MFAChallengeStrategyFactoryTest.php + ./tests/unit/TwoFactorAuditServiceTest.php + ./tests/unit/MFAGateServiceTest.php + ./tests/TwoFactorLoginFlowTest.php + diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..0df15725 --- /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 ? parseInt(process.env.PLAYWRIGHT_WORKERS ?? '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/base_actions.js b/resources/js/base_actions.js index 18cd3b49..e6c1ea06 100644 --- a/resources/js/base_actions.js +++ b/resources/js/base_actions.js @@ -92,6 +92,30 @@ export const postRawRequest = (endpoint) => (params, headers = {}) => { }) } +export const postRawRequestFull = (endpoint) => (params, headers = {}) => { + let url = URI(endpoint); + + 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) => { + let json = res.body; + end(key); + return Promise.resolve({response: json}); + }).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/components/recovery_code_display.js b/resources/js/components/recovery_code_display.js new file mode 100644 index 00000000..08f5192a --- /dev/null +++ b/resources/js/components/recovery_code_display.js @@ -0,0 +1,76 @@ +import React, {useState} from "react"; +import Box from "@material-ui/core/Box"; +import Button from "@material-ui/core/Button"; +import Grid from "@material-ui/core/Grid"; +import Typography from "@material-ui/core/Typography"; +import AssignmentIcon from "@material-ui/icons/Assignment"; +import CheckCircleIcon from "@material-ui/icons/CheckCircle"; +import {downloadTextFile} from "../utils"; + +import styles from "./recovery_codes.module.scss"; + +const DEFAULT_APP_NAME = "OpenStackID"; + +const buildFileContent = (codes, email, appName) => { + const date = new Date().toISOString().slice(0, 10); + return [ + `${appName} Recovery Codes`, + `Generated: ${date}`, + `Account: ${email}`, + "", + "Keep these codes somewhere safe. Each code can only be used once to sign in, and they will not be shown again.", + "", + ...codes, + ].join("\n"); +}; + +const RecoveryCodeDisplay = ({codes, email, appName = DEFAULT_APP_NAME}) => { + const [copied, setCopied] = useState(false); + + const handleCopy = () => { + navigator.clipboard.writeText(codes.join("\n")).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }; + + const handleDownload = () => { + const date = new Date().toISOString().slice(0, 10); + const appSlug = appName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); + downloadTextFile(`${appSlug}-recovery-codes-${date}.txt`, buildFileContent(codes, email, appName)); + }; + + return ( + + + {codes.map((code, idx) => ( + + {code} + + ))} + + + +   + + + + ); +}; + +export default RecoveryCodeDisplay; diff --git a/resources/js/components/recovery_code_modal.js b/resources/js/components/recovery_code_modal.js new file mode 100644 index 00000000..f0a09c01 --- /dev/null +++ b/resources/js/components/recovery_code_modal.js @@ -0,0 +1,64 @@ +import React, {useEffect, useState} from "react"; +import Box from "@material-ui/core/Box"; +import Button from "@material-ui/core/Button"; +import Dialog from "@material-ui/core/Dialog"; +import DialogActions from "@material-ui/core/DialogActions"; +import DialogContent from "@material-ui/core/DialogContent"; +import DialogTitle from "@material-ui/core/DialogTitle"; +import Typography from "@material-ui/core/Typography"; +import WarningRoundedIcon from "@material-ui/icons/WarningRounded"; +import RecoveryCodeDisplay from "./recovery_code_display"; + +import styles from "./recovery_codes.module.scss"; + +const ACK_DELAY_SECONDS = 5; + +const RecoveryCodeModal = ({open, codes, email, appName, onAcknowledge}) => { + const [secondsLeft, setSecondsLeft] = useState(ACK_DELAY_SECONDS); + + useEffect(() => { + if (!open) return undefined; + + setSecondsLeft(ACK_DELAY_SECONDS); + const interval = setInterval(() => { + setSecondsLeft((prev) => (prev > 0 ? prev - 1 : 0)); + }, 1000); + + return () => clearInterval(interval); + }, [open]); + + return ( + + Save Your Recovery Codes + + + + + These codes will not be shown again. Copy or download them now and store them somewhere safe. + + + {codes && } + + + + + + ); +}; + +export default RecoveryCodeModal; diff --git a/resources/js/components/recovery_codes.module.scss b/resources/js/components/recovery_codes.module.scss new file mode 100644 index 00000000..c4437a1e --- /dev/null +++ b/resources/js/components/recovery_codes.module.scss @@ -0,0 +1,43 @@ +.recovery_codes_panel { + margin-top: 15px; +} + +.codes_grid { + margin: 8px 0; + padding: 12px; + background-color: #f5f5f5; + border-radius: 4px; +} + +.code { + font-family: monospace; + font-size: 1rem; + letter-spacing: 1px; +} + +.warning_banner { + display: flex; + align-items: flex-start; + margin-bottom: 16px; + padding: 10px 14px; + background-color: #fdecea; + border-left: 4px solid #f44336; + border-radius: 4px; +} + +.warning_icon { + color: #f44336; + margin-right: 10px; + margin-top: 1px; + flex-shrink: 0; +} + +.low_code_warning { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 8px; + padding: 8px 12px; + background-color: #fff3e0; + border-radius: 4px; +} diff --git a/resources/js/components/recovery_codes_panel.js b/resources/js/components/recovery_codes_panel.js new file mode 100644 index 00000000..986c70f7 --- /dev/null +++ b/resources/js/components/recovery_codes_panel.js @@ -0,0 +1,144 @@ +import React, {useState} from "react"; +import Box from "@material-ui/core/Box"; +import Button from "@material-ui/core/Button"; +import Grid from "@material-ui/core/Grid"; +import IconButton from "@material-ui/core/IconButton"; +import Link from "@material-ui/core/Link"; +import TextField from "@material-ui/core/TextField"; +import Typography from "@material-ui/core/Typography"; +import CloseIcon from "@material-ui/icons/Close"; +import {regenerateRecoveryCodes} from "../profile/actions"; +import {handleErrorResponse} from "../utils"; +import RecoveryCodeModal from "./recovery_code_modal"; +import { + RECOVERY_CODES_LOW_WARNING_DISMISSED_KEY, + DEFAULT_RECOVERY_CODES_LOW_THRESHOLD, +} from "../shared/recovery_codes"; + +import styles from "./recovery_codes.module.scss"; + +const RecoveryCodesPanel = ({ + recoveryCodesRemaining, + recoveryCodesTotal, + lowCodeThreshold = DEFAULT_RECOVERY_CODES_LOW_THRESHOLD, + email, + appName, + initialCodes = null + }) => { + const [regenerating, setRegenerating] = useState(false); + const [currentPassword, setCurrentPassword] = useState(""); + const [loading, setLoading] = useState(false); + const [remaining, setRemaining] = useState(recoveryCodesRemaining); + const [total, setTotal] = useState(recoveryCodesTotal); + const [codes, setCodes] = useState(initialCodes); + const [warningDismissed, setWarningDismissed] = useState( + sessionStorage.getItem(RECOVERY_CODES_LOW_WARNING_DISMISSED_KEY) === "1" + ); + + const handleRegenerate = () => { + setLoading(true); + regenerateRecoveryCodes(currentPassword).then(({response}) => { + setLoading(false); + setRegenerating(false); + setCurrentPassword(""); + setCodes(response.recovery_codes); + setRemaining(response.recovery_codes.length); + setTotal(response.recovery_codes.length); + }).catch((err) => { + setLoading(false); + handleErrorResponse(err); + }); + }; + + const handleAcknowledge = () => { + setCodes(null); + }; + + const dismissLowCodeWarning = () => { + sessionStorage.setItem(RECOVERY_CODES_LOW_WARNING_DISMISSED_KEY, "1"); + setWarningDismissed(true); + }; + + const showLowCodeWarning = !warningDismissed && remaining < lowCodeThreshold; + + return ( + <> + + + Recovery Codes: {remaining} of {total} remaining + + { + showLowCodeWarning && + + + You're running low on recovery codes. Regenerate them to avoid getting locked out. + + + + + + } + { + !regenerating && + { + e.preventDefault(); + setRegenerating(true); + }}> + Regenerate Codes + + } + { + regenerating && + + setCurrentPassword(e.target.value)} + onKeyDown={(e) => { + // This panel lives inside the profile page's own
; + // it must not let Enter bubble up and submit that form too. + if (e.key === "Enter") { + e.preventDefault(); + if (currentPassword && !loading) handleRegenerate(); + } + }} + // Detaches this input from the ancestor (the profile page + // wraps everything in one big form) so the browser's native + // "Enter submits the enclosing form" / password-manager-driven + // auto-submit can never fire a GET on it, regardless of the + // onKeyDown handler above. + inputProps={{form: "recovery-codes-detached-form", autoComplete: "off"}} + data-testid="recovery-codes-current-password" + /> +   + +   + { + e.preventDefault(); + setRegenerating(false); + setCurrentPassword(""); + }}> + Cancel + + + } + + + + ); +}; + +export default RecoveryCodesPanel; diff --git a/resources/js/components/two_factor_section.js b/resources/js/components/two_factor_section.js new file mode 100644 index 00000000..ece17eff --- /dev/null +++ b/resources/js/components/two_factor_section.js @@ -0,0 +1,68 @@ +import React, {useState} from "react"; +import Button from "@material-ui/core/Button"; +import Typography from "@material-ui/core/Typography"; +import {enableTwoFactor} from "../profile/actions"; +import {handleErrorResponse} from "../utils"; +import RecoveryCodesPanel from "./recovery_codes_panel"; + +const DEFAULT_METHOD = "email_otp"; + +const TwoFactorSection = ({ + twoFactorEnabled, + recoveryCodesRemaining, + recoveryCodesTotal, + recoveryCodesLowThreshold, + email, + appName + }) => { + const [enabled, setEnabled] = useState(twoFactorEnabled); + const [loading, setLoading] = useState(false); + const [remaining, setRemaining] = useState(recoveryCodesRemaining); + const [total, setTotal] = useState(recoveryCodesTotal); + const [enrollmentCodes, setEnrollmentCodes] = useState(null); + + const handleEnable = () => { + setLoading(true); + enableTwoFactor(DEFAULT_METHOD).then(({response}) => { + setLoading(false); + setRemaining(response.recovery_codes.length); + setTotal(response.recovery_codes.length); + setEnrollmentCodes(response.recovery_codes); + setEnabled(true); + }).catch((err) => { + setLoading(false); + handleErrorResponse(err); + }); + }; + + if (!enabled) { + return ( + <> + + Two-factor authentication is not enabled for your account. + + + + ); + } + + return ( + + ); +}; + +export default TwoFactorSection; diff --git a/resources/js/login/actions.js b/resources/js/login/actions.js index d0d20ad9..ebda8a6a 100644 --- a/resources/js/login/actions.js +++ b/resources/js/login/actions.js @@ -27,3 +27,33 @@ export const resendVerificationEmail = (email, token) => { return postRawRequest(window.RESEND_VERIFICATION_EMAIL_ENDPOINT)(params, {'X-CSRF-TOKEN': token}); } + +export const verify2FA = (otpValue, method, trustDevice, token) => { + const params = { + otp_value: otpValue, + method: method, + trust_device: trustDevice ? 1 : 0 + }; + + return postRawRequest(window.VERIFY_2FA_ENDPOINT)(params, {'X-CSRF-TOKEN': token}); +} + +export const resend2FA = (method, token) => { + const params = { + method: method + }; + + return postRawRequest(window.RESEND_2FA_ENDPOINT)(params, {'X-CSRF-TOKEN': token}); +} + +export const verifyRecoveryCode = (recoveryCode, token) => { + const params = { + recovery_code: recoveryCode + }; + + return postRawRequest(window.RECOVERY_2FA_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..d074bb23 --- /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_code_input.js b/resources/js/login/components/otp_code_input.js new file mode 100644 index 00000000..cb5cbfc3 --- /dev/null +++ b/resources/js/login/components/otp_code_input.js @@ -0,0 +1,56 @@ +import React from 'react'; +import OtpInput from 'react-otp-input'; +import {formatTime} from '../../utils'; +import styles from '../login.module.scss'; +import HTMLRender from '../../shared/HTMLRender'; + +/** + * Shared single-use-code entry block: subtitle, code boxes, error message and + * optional expiry countdown. Used by both the passwordless OTP form and the + * MFA verification form; the owning form keeps the submit mechanics. + */ +const OtpCodeInput = ({ + id, + otpCode, + otpError, + otpLength, + onCodeChange, + countdownActive, + secondsLeft, + expired, + subtitle = 'Enter the single-use code sent to your email:' + }) => { + return ( + <> +
{subtitle}
+
+ } + shouldAutoFocus={true} + hasErrored={!!otpError} + errorStyle={{border: '1px solid #e5424d'}} + data-testid={id} + /> +
+ {otpError && + + {otpError} + + } + {countdownActive && +

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

+ } + + ); +}; + +export default OtpCodeInput; 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..8d85dea6 --- /dev/null +++ b/resources/js/login/components/otp_help_links.js @@ -0,0 +1,43 @@ +import React, {useState, useEffect} from "react"; +import Link from "@material-ui/core/Link"; +import styles from "../login.module.scss"; +import {RESEND_COOLDOWN_SECONDS} from "../constants"; + +const OTPHelpLinks = ({ emitOtpAction, disableInput }) => { + const [cooldown, setCooldown] = useState(0); + + useEffect(() => { + const timer = setInterval(() => { + setCooldown((prev) => (prev > 0 ? prev - 1 : 0)); + }, 1000); + return () => clearInterval(timer); + }, []); + + const handleResend = (ev) => { + ev.preventDefault(); + if (cooldown > 0 || disableInput) return; + setCooldown(RESEND_COOLDOWN_SECONDS); + emitOtpAction(ev); + }; + + return ( + <> +
+

Didn't receive it ?

+

+ Check your spam folder or{" "} + 0 || disableInput) ? styles.disabled_link : ''} + > + {cooldown > 0 ? `resend email (${cooldown}s)` : '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..1634f815 --- /dev/null +++ b/resources/js/login/components/otp_input_form.js @@ -0,0 +1,115 @@ +import React from "react"; +import { Turnstile } from "@marsidev/react-turnstile"; +import Button from "@material-ui/core/Button"; +import Link from "@material-ui/core/Link"; +import OtpCodeInput from "./otp_code_input"; +import useOtpCountdown from "./use_otp_countdown"; +import styles from "../login.module.scss"; + +const OTPInputForm = ({ + disableInput, + formAction, + onAuthenticate, + otpCode, + otpError, + otpLength, + otpLifetime, + codeVersion, + onCodeChange, + userNameValue, + csrfToken, + shouldShowCaptcha, + captchaPublicKey, + onChangeCaptchaProvider, + onExpireCaptchaProvider, + onErrorCaptchaProvider, + onReset, + loginAttempts, +}) => { + const showCaptcha = shouldShowCaptcha(); + const { secondsLeft, expired } = useOtpCountdown(otpLifetime ?? 0, codeVersion); + // The countdown only renders when this page view knows when the code was + // issued (a fresh emitOTP in this session). After a failed-submit page + // reload the issuance time is unknown - showing a fresh full countdown + // would overstate the code's validity, so none is shown. + const countdownActive = otpLifetime != null && otpLifetime > 0; + const blockExpired = countdownActive && expired; + + const handleSubmit = (ev) => { + if (blockExpired || !onAuthenticate(ev.target)) + { + ev.preventDefault(); + } + } + + return ( + + +
+ +
+
+

+ + 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..96e5a1cc --- /dev/null +++ b/resources/js/login/components/password_input_form.js @@ -0,0 +1,189 @@ +import React 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, +}) => { + // Native form submission (same adapter as OTPInputForm): password managers key + // their save/update prompt off the browser's real submit event, and the backend + // login strategies respond with a redirect + flashed session state that only a + // top-level navigation consumes correctly. + const handleSubmit = (ev) => { + if (!onAuthenticate(ev.target)) { + ev.preventDefault(); + } + }; + + 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 ( +
+ + + {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..50830e04 --- /dev/null +++ b/resources/js/login/components/recovery_code_form.js @@ -0,0 +1,85 @@ +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..c06ff08e --- /dev/null +++ b/resources/js/login/components/two_factor_form.js @@ -0,0 +1,127 @@ +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 OtpCodeInput from './otp_code_input'; +import useOtpCountdown from './use_otp_countdown'; +import styles from '../login.module.scss'; +import {RESEND_COOLDOWN_SECONDS} from '../constants'; + +const TwoFactorForm = ({ + otpCode, + otpError, + otpLength, + otpLifetime, + codeVersion, + onCodeChange, + onVerify, + trustDevice, + onTrustDeviceChange, + onResend, + onUseRecovery, + onCancel, + disableInput + }) => { + + const {secondsLeft, expired} = useOtpCountdown(otpLifetime, codeVersion); + const [cooldown, setCooldown] = useState(0); + + // 1s ticker for the resend cooldown (the expiry countdown lives in the hook). + useEffect(() => { + const timer = setInterval(() => { + setCooldown(prev => (prev > 0 ? prev - 1 : 0)); + }, 1000); + return () => clearInterval(timer); + }, []); + + const handleSubmit = (ev) => { + ev.preventDefault(); + if (expired) return; + onVerify(); + }; + + const handleResend = (ev) => { + ev.preventDefault(); + if (cooldown > 0 || disableInput) return; + setCooldown(RESEND_COOLDOWN_SECONDS); + // A successful resend resets the expiry countdown through the parent's + // codeVersion bump; failures are surfaced by the parent as well. + const result = onResend(); + if (result && typeof result.catch === 'function') { + result.catch(() => {}); + } + }; + + const handleRecovery = (ev) => { + ev.preventDefault(); + onUseRecovery(); + }; + + const handleCancel = (ev) => { + ev.preventDefault(); + onCancel(); + }; + + return ( +
+ +
+ + } + label="Trust this device for 30 days" + /> +
+
+ +
+
+

+ Didn't receive it? Check your spam folder or{" "} + 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 + +
+
+ + ); +} + +export default TwoFactorForm; diff --git a/resources/js/login/components/use_otp_countdown.js b/resources/js/login/components/use_otp_countdown.js new file mode 100644 index 00000000..350d04ed --- /dev/null +++ b/resources/js/login/components/use_otp_countdown.js @@ -0,0 +1,25 @@ +import {useState, useEffect} from 'react'; + +/** + * Drives a 1-second expiry countdown for a single-use code. + * Resets whenever a fresh code is issued (otpLifetime change or codeVersion bump). + */ +const useOtpCountdown = (otpLifetime, codeVersion) => { + const [secondsLeft, setSecondsLeft] = useState(otpLifetime || 0); + + useEffect(() => { + setSecondsLeft(otpLifetime || 0); + }, [otpLifetime, codeVersion]); + + useEffect(() => { + const timer = setInterval( + () => setSecondsLeft(prev => (prev > 0 ? prev - 1 : 0)), + 1000 + ); + return () => clearInterval(timer); + }, []); + + return {secondsLeft, expired: secondsLeft <= 0}; +}; + +export default useOtpCountdown; diff --git a/resources/js/login/constants.js b/resources/js/login/constants.js new file mode 100644 index 00000000..183b3784 --- /dev/null +++ b/resources/js/login/constants.js @@ -0,0 +1,40 @@ +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'; + +// Cooldown applied to any "resend code" action (MFA and passwordless OTP) to +// avoid hammering the resend endpoint (the backend also rate-limits server-side). +export const RESEND_COOLDOWN_SECONDS = 30; + +// Success confirmation shown after a code is (re)sent - shared by MFA's +// onResend2FA() and passwordless's emitOtpAction() so the two flows can't +// silently diverge in wording. +export const CODE_RESENT_MESSAGE = "A new verification code has been sent to your email."; + +export const MFA_ERROR_CODE = { + MFA_SESSION_EXPIRED: "mfa_session_expired", +}; diff --git a/resources/js/login/login.js b/resources/js/login/login.js index ee061b9a..3aa2100c 100644 --- a/resources/js/login/login.js +++ b/resources/js/login/login.js @@ -1,937 +1,1070 @@ -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, + 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 Banner from "../components/banner/banner"; +import { handleErrorResponse } from "../utils"; -import styles from './login.module.scss' +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 { + RECOVERY_CODES_LOW_WARNING_DISMISSED_KEY, + DEFAULT_RECOVERY_CODES_LOW_THRESHOLD, +} from "../shared/recovery_codes"; + +import styles from "./login.module.scss"; +import recoveryCodesStyles from "../components/recovery_codes.module.scss"; import "./third_party_identity_providers.scss"; +import { + FLOW, + HTTP_CODES, + MFA_ERROR_CODE, + OTP_LENGTH_DEFAULT, + OTP_TTL_DEFAULT, + MFA_METHOD_DEFAULT, + CODE_RESENT_MESSAGE, +} from "./constants"; -const EmailInputForm = ({ value, onValidateEmail, onHandleUserNameChange, disableInput, emailError }) => { +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, + // Lifetime of the pending passwordless OTP. Seeded from props.otpLifetime + // on mount so a refresh restores the REMAINING countdown (props.otpLifetime + // is already the session-derived remaining time - see login.blade.php's + // otp_issued_at math, same mechanism the MFA challenge screen uses). + // emitOtpAction() overwrites this with the live response on a fresh + // send/resend. Unlike otpLifetime's OTP_TTL_DEFAULT fallback, null is the + // correct fallback here (not a stand-in default): passwordlessLifetime + // only has meaning while authFlow === FLOW.OTP, and Task 1's backend + // change writes otp_lifetime atomically with flow, so props.otpLifetime + // is only ever missing when there's no pending OTP to show a countdown for. + passwordlessLifetime: props.otpLifetime ?? null, + // Set once a recovery-code login succeeds with a low remaining count, so + // the redirect can be held until the user acknowledges the warning - + // this is the only point where the SPA still controls the page (see + // onVerifyRecovery()/onContinueAfterLowRecoveryCodes() below). + lowRecoveryCodesWarning: null, + }; - return ( - <> - - - {emailError == "" && - - } - - { emailError != "" && -

- } - - ); -} + if (props.authError != "" && !this.state.user_fullname) { + this.state.user_fullname = props.userName; + } -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 ( + 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.`; + } - 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. -

- - ); - } + 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.onContinueAfterLowRecoveryCodes = this.onContinueAfterLowRecoveryCodes.bind(this); + this.onUseRecovery = this.onUseRecovery.bind(this); + this.onBackToOtp = this.onBackToOtp.bind(this); + this.resetToPasswordFlow = this.resetToPasswordFlow.bind(this); + this.cancelPendingLogin = this.cancelPendingLogin.bind(this); + } - if (attempts > 0 && attempts === maxAttempts && !userIsActive) { - return ( - <> -

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

- - ); - } + /** + * Best-effort server-side invalidation of the pending MFA challenge. + * The UI resets optimistically; if the request fails the user is told the + * pending verification will only die by its own TTL. + */ + cancelPendingLogin() { + cancelLogin(this.props.token).catch((error) => { + console.error("cancelLogin failed", error); + this.showAlert( + "We couldn't cancel the pending verification on the server. It will expire on its own in a few minutes.", + "warning", + ); + }); + } - return

; - })()} - - - - - - - } - label="Remember me" - /> - - - - - - - - {shouldShowCaptcha() && captchaPublicKey && - - } - - - ); -} + showAlert(message, severity) { + this.setState({ + ...this.state, + notification: { + message: message, + severity: severity, + }, + }); + } -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 && - - } - - + 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, + // A fresh code was just issued: seed/reset its expiry countdown. + passwordlessLifetime: response?.otp_lifetime ?? null, + codeVersion: this.state.codeVersion + 1, + }); + this.showAlert( + CODE_RESENT_MESSAGE, + "success", + ); + }, + (error) => { + let { response, status, message } = error; + if (status == 412) { + const { message, errors } = response.body; + this.showAlert(errors[0], "error"); + return; + } + if (status === HTTP_CODES.TOO_MANY_REQUESTS) { + const msg = + response && response.body && response.body.error_message + ? response.body.error_message + : "Too many attempts. Please try again later."; + this.showAlert(msg, "warning"); + return; + } + this.showAlert("Oops... Something went wrong!", "error"); + }, ); -} + return false; + } -const HelpLinks = ({ - userName, - showEmitOtpAction, - forgotPasswordAction, - showForgotPasswordAction, - showVerifyEmailAction, - verifyEmailAction, - showHelpAction, - helpAction, - appName, - emitOtpAction - }) => { - if (userName) { - forgotPasswordAction = `${forgotPasswordAction}?email=${encodeURIComponent(userName)}`; - } + handleEmitOtpAction(ev) { + ev.preventDefault(); + return this.emitOtpAction(); + } + shouldShowCaptcha() { return ( - <> -
- { - showEmitOtpAction && - - Get A Single-use Code emailed to you - - } - { - showForgotPasswordAction && - - Reset your password - - } - { - showVerifyEmailAction && - - Verify {appName} - - } - {showHelpAction && - - Having trouble? - - } - + typeof this.props.maxLoginAttempts2ShowCaptcha !== "undefined" && + typeof this.props.loginAttempts !== "undefined" && + this.props.loginAttempts >= this.props.maxLoginAttempts2ShowCaptcha ); -} + } -const OTPHelpLinks = ({ emitOtpAction }) => { - return ( - <> -
-

Didn't receive it ?

-

Check your spam folder or resend email. -

- - ); -} + handleAuthenticateValidation() { -const EmailErrorActions = ({ emitOtpAction, createAccountAction, onValidateEmail, disableInput }) => { - return ( - - - - - - - - - - - - - - ); -} + switch (this.state.authFlow) { + case FLOW.OTP: + if (this.state.otpCode == "") { + this.setState({ + ...this.state, + disableInput: false, + errors: { ...this.state.errors, otp: "Single-use code is empty" }, + }); + return false; + } + break; + default: + if (this.state.user_password == "") { + this.setState({ + ...this.state, + disableInput: false, + errors: { ...this.state.errors, password: "Password is empty" }, + }); + return false; + } -const ExistingAccountActions = ({emitOtpAction, forgotPasswordAction, userName, disableInput}) => { - if (userName) { - forgotPasswordAction = `${forgotPasswordAction}?email=${encodeURIComponent(userName)}`; + if (this.state.captcha_value == "" && this.shouldShowCaptcha()) { + this.setState({ + ...this.state, + disableInput: false, + errors: { ...this.state.errors, password: "you must check CAPTCHA" }, + }); + return false; + } } - return ( - - - - - - - Reset your password - - - - ); -} + return true; + } -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.

- - ); -} + // Password and OTP flows submit as a native form POST: the backend login + // strategies answer with a redirect plus flashed/persisted session state + // (auth errors, login_attempts, the mfa_required '2fa' flow), which only a + // top-level navigation renders correctly. The 2FA screen is rehydrated from + // session by the blade on the post-redirect GET. + onAuthenticate() { -const otp_flow = 'otp'; -const password_flow = 'password'; + if (!this.handleAuthenticateValidation()) { + return false; + } -class LoginPage extends React.Component { + this.setState({ ...this.state, disableInput: true }); - 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, - disableInput: false, - authFlow: props.flow, - allowNativeAuth: props.allowNativeAuth, - showInfoBanner: props.showInfoBanner, - infoBannerContent: props.infoBannerContent, - } + return true; + } - if (props.authError != '' && !this.state.user_fullname) { - this.state.user_fullname = props.userName; - } + onChangeCaptchaProvider(value) { + this.setState({ ...this.state, captcha_value: value }); + } - 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.`; - } + onExpireCaptchaProvider() { + this.setState({ ...this.state, captcha_value: "" }); + } - 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({ - ...this.state, - notification: { - message: message, - severity: severity - } - }); - } + onErrorCaptchaProvider() { + this.setState({ ...this.state, captcha_value: "" }); + } - 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; - } + onHandleUserNameChange(ev) { + let { value, id } = ev.target; + this.setState({ ...this.state, user_name: value }); + } - handleEmitOtpAction(ev) { - ev.preventDefault(); - return this.emitOtpAction(); - } + 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 }, + }); + } - shouldShowCaptcha() { - return ( - this.props.hasOwnProperty('maxLoginAttempts2ShowCaptcha') && - this.props.hasOwnProperty('loginAttempts') && - this.props.loginAttempts >= this.props.maxLoginAttempts2ShowCaptcha - ) - } + onOTPCodeChange(value) { + this.setState({ ...this.state, otpCode: value }); + } - 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; - } + onTwoFactorCodeChange(value) { + this.setState({ + ...this.state, + twoFactorCode: value, + errors: { ...this.state.errors, twofactor: "" }, + }); + } - 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; - } + onRecoveryCodeChange(ev) { + let { value } = ev.target; + // Recovery codes are generated and hashed without the "-" separator; it is + // added only for on-screen readability (XXXX-XXXX). Strip any non-alphanumeric + // characters here so a code typed or pasted exactly as displayed still matches. + const normalized = value.replace(/[^A-Za-z0-9]/g, "").toUpperCase(); + this.setState({ + ...this.state, + recoveryCode: normalized, + 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_password: "", + recoveryCode: "", + trustDevice: false, + errors: { + ...this.state.errors, + twofactor: "", + recovery: "", + email: "", + otp: "", + password: "", + }, + }); + this.cancelPendingLogin(); + } - onChangeCaptchaProvider(value) { - this.setState({ ...this.state, captcha_value: value }); + /** + * 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; } - onExpireCaptchaProvider() { - 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; } - onErrorCaptchaProvider() { - this.setState({ ...this.state, captcha_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; } - onHandleUserNameChange(ev) { - let { value, id } = ev.target; - this.setState({ ...this.state, user_name: value }); + if (status === HTTP_CODES.PRECONDITION_FAILED) { + this.setState({ + ...this.state, + disableInput: false, + errors: { ...this.state.errors, [field]: "Please enter a valid code." }, + }); + 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}}); + /** + * 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; } - onOTPCodeChange(value) { - this.setState({...this.state, otpCode: value}); + 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 returns the same-origin post-login destination as JSON + // data (never a redirect for this XHR to follow) - a real top-level navigation + // to it lets the browser complete any further hop natively, cross-origin + // included, which this XHR never could. + const { response } = payload; + window.location.href = + (response && response.redirect_url) || window.location.href; + }, + (error) => { + this.handleMfaError(error, "twofactor"); + }, + ); + } - onValidateEmail(ev) { + onResend2FA() { + const promise = resend2FA(this.state.mfaMethod, this.props.token); - ev.preventDefault(); - let {user_name} = this.state; - user_name = user_name?.trim(); + 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( + CODE_RESENT_MESSAGE, + "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 (user_name == '') { - return false; + 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 }); + this.showAlert( + "Oops... Something went wrong while resending the code.", + "error", + ); + }, + ); - verifyAccount(user_name, this.props.token).then((payload) => { - let { response } = payload; + // Returned so the form can reset its expiry countdown once the resend resolves. + return promise; + } - 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.'; - } + 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; + } + this.setState({ + ...this.state, + disableInput: true, + errors: { ...this.state.errors, recovery: "" }, + }); - 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) => { + verifyRecoveryCode(recoveryCode, this.props.token).then( + (payload) => { + const { response } = payload; + const redirectUrl = + (response && response.redirect_url) || window.location.href; + const remaining = response && response.recovery_codes_remaining; + const threshold = + (response && response.recovery_codes_low_threshold) ?? + DEFAULT_RECOVERY_CODES_LOW_THRESHOLD; + const alreadyDismissed = + sessionStorage.getItem(RECOVERY_CODES_LOW_WARNING_DISMISSED_KEY) === "1"; - let { response, status, message } = error; + if (typeof remaining === "number" && remaining < threshold && !alreadyDismissed) { + this.setState({ + ...this.state, + lowRecoveryCodesWarning: { remaining, redirectUrl }, + }); + return; + } - let newErrors = {}; + // See onVerify2FA() for rationale on using a real top-level navigation. + window.location.href = redirectUrl; + }, + (error) => { + this.handleMfaError(error, "recovery"); + }, + ); + } - newErrors['password'] = ''; - newErrors['email'] = " "; + onContinueAfterLowRecoveryCodes() { + sessionStorage.setItem(RECOVERY_CODES_LOW_WARNING_DISMISSED_KEY, "1"); + window.location.href = this.state.lowRecoveryCodesWarning.redirectUrl; + } - if (status == 429) { - newErrors['email'] = "Too many requests. Try it later."; - } + onUseRecovery() { + this.setState({ + ...this.state, + authFlow: FLOW.RECOVERY, + errors: { ...this.state.errors, recovery: "" }, + }); + } - this.setState({ - ...this.state, - user_pic: null, - user_fullname: null, - user_verified: false, - errors: newErrors, - disableInput: false - }); - }); - return true; + 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 }); - resendVerificationEmail(ev) { - ev.preventDefault(); - let {user_name} = this.state; - user_name = user_name?.trim(); + verifyAccount(user_name, this.props.token).then( + (payload) => { + let { response } = payload; - if (!user_name) { - this.showAlert( - 'Something went wrong while trying to resend the verification email. Please try again later.', - 'error'); - return; + 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."; } - 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.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(); } + }, + ); + }, + (error) => { + let { response, status, message } = error; + + let newErrors = {}; + + newErrors["password"] = ""; + newErrors["email"] = " "; + + if (status == HTTP_CODES.TOO_MANY_REQUESTS) { + newErrors["email"] = "Too many requests. Try it later."; + } + + this.setState({ + ...this.state, + user_pic: null, + user_fullname: null, + user_verified: false, + errors: newErrors, + disableInput: false, }); - return false; - } + }, + ); + return true; + } - handleClickShowPassword(ev) { - ev.preventDefault(); - this.setState({ ...this.state, showPassword: !this.state.showPassword }) - } + resendVerificationEmail(ev) { + ev.preventDefault(); + let { user_name } = this.state; + user_name = user_name?.trim(); - handleMouseDownPassword(ev) { - ev.preventDefault(); + if (!user_name) { + this.showAlert( + "Something went wrong while trying to resend the verification email. Please try again later.", + "error", + ); + return; } - existingUserCanContinue() { - const { user_active, email_verified } = this.state; - return user_active !== false && email_verified !== false; + 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(); + if (this.isMfaFlow() || this.isPasswordlessFlow()) { + // A pending 2FA/recovery challenge or passwordless OTP was issued + // server-side (session state + an unredeemed OTP); invalidate it the + // same way "Cancel" does instead of leaving it live/restorable until + // its TTL. + this.cancelPendingLogin(); } + 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 + ); + } + + isPasswordlessFlow() { + return this.state.authFlow === FLOW.OTP; + } - getSignUpSignInTitle() { - const { errors, user_active } = this.state; + getSignUpSignInTitle() { + const { errors, user_active } = this.state; - if (errors.email && this.existingUserCanContinue()) { - return 'Create an account for:'; - } - return 'Sign in'; + if (errors.email && this.existingUserCanContinue()) { + return "Create an account for:"; } + return "Sign in"; + } - handleSnackbarClose() { - this.setState({ - ...this.state, - notification: { - message: null, - severity: 'info' - } - }); - }; + 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: '', - }); - } + 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 && !this.state.lowRecoveryCodesWarning && ( + + )} + {showRecoveryForm && this.state.lowRecoveryCodesWarning && ( +
+ + You have {this.state.lowRecoveryCodesWarning.remaining} recovery + code{this.state.lowRecoveryCodesWarning.remaining === 1 ? "" : "s"} left. + Regenerate them from your profile after signing in to avoid getting + locked out. + + +
+ )} + {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( +export { LoginPage }; + +const root = document.querySelector("#root"); +if (root) { + ReactDOM.render( - + , - document.querySelector('#root') -); + 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/profile/actions.js b/resources/js/profile/actions.js index c1c6c2f0..0c9ea422 100644 --- a/resources/js/profile/actions.js +++ b/resources/js/profile/actions.js @@ -1,4 +1,4 @@ -import {deleteRawRequest, getRawRequest, putFile, putRawRequest} from "../base_actions"; +import {deleteRawRequest, getRawRequest, postRawRequestFull, putFile, putRawRequest} from "../base_actions"; import moment from "moment"; export const PAGE_SIZE = 10; @@ -87,6 +87,16 @@ export const revokeAllTokens = async () => { return deleteRawRequest(window.REVOKE_ALL_TOKENS_ENDPOINT)({'X-CSRF-TOKEN': window.CSFR_TOKEN}); } +export const regenerateRecoveryCodes = async (currentPassword) => { + const params = {current_password: currentPassword}; + return postRawRequestFull(window.REGENERATE_RECOVERY_CODES_ENDPOINT)(params, {'X-CSRF-TOKEN': window.CSFR_TOKEN}); +} + +export const enableTwoFactor = async (method) => { + const params = {method}; + return postRawRequestFull(window.ENABLE_TWO_FACTOR_ENDPOINT)(params, {'X-CSRF-TOKEN': window.CSFR_TOKEN}); +} + const normalizeEntity = (entity) => { entity.public_profile_show_photo = entity.public_profile_show_photo ? 1 : 0; entity.public_profile_show_fullname = entity.public_profile_show_fullname ? 1 : 0; diff --git a/resources/js/profile/profile.js b/resources/js/profile/profile.js index a530d04a..878f86a5 100644 --- a/resources/js/profile/profile.js +++ b/resources/js/profile/profile.js @@ -1,5 +1,6 @@ import React, {useState} from "react"; import ReactDOM from "react-dom"; +import Box from "@material-ui/core/Box"; import Button from "@material-ui/core/Button"; import Card from "@material-ui/core/Card"; import CardContent from "@material-ui/core/CardContent"; @@ -26,6 +27,7 @@ import Navbar from "../components/navbar/navbar"; import Divider from "@material-ui/core/Divider"; import Link from "@material-ui/core/Link"; import PasswordChangePanel from "../components/password_change_panel"; +import TwoFactorSection from "../components/two_factor_section"; import LoadingIndicator from "../components/loading_indicator"; import TopLogo from "../components/top_logo/top_logo"; import {handleErrorResponse} from "../utils"; @@ -35,13 +37,18 @@ import styles from "./profile.module.scss"; const ProfilePage = ({ appLogo, + appName, countries, csrfToken, initialValues, languages, menuConfig, passwordPolicy, - redirectUri + redirectUri, + twoFactorEnabled, + recoveryCodesRemaining, + recoveryCodesTotal, + recoveryCodesLowThreshold }) => { const [pic, setPic] = useState(null); const [loading, setLoading] = useState(false); @@ -754,11 +761,27 @@ const ProfilePage = ({ )}
- - + + + + + + + Two-Factor Authentication + + + + { + 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/js/shared/recovery_codes.js b/resources/js/shared/recovery_codes.js new file mode 100644 index 00000000..c144f52b --- /dev/null +++ b/resources/js/shared/recovery_codes.js @@ -0,0 +1,5 @@ +// Shared between the profile page's RecoveryCodesPanel and the login page's +// post-MFA-recovery-login warning, so dismissing the low-code warning in +// either place suppresses it everywhere else for the rest of the session. +export const RECOVERY_CODES_LOW_WARNING_DISMISSED_KEY = "recovery_codes_low_warning_dismissed"; +export const DEFAULT_RECOVERY_CODES_LOW_THRESHOLD = 3; 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/resources/js/utils.js b/resources/js/utils.js index 7b35a202..5d5d711c 100644 --- a/resources/js/utils.js +++ b/resources/js/utils.js @@ -82,6 +82,18 @@ export const formatTime = (timeInSeconds) => { return res; } +export const downloadTextFile = (filename, content) => { + const blob = new Blob([content], {type: 'text/plain'}); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +}; + export const decodeHtmlEntities = (text) => { const textarea = document.createElement('textarea'); textarea.innerHTML = text; diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index d2ca52ee..1c825b23 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,23 @@ config.flow = '{{Session::get('flow')}}'; @endif + @if(Session::has('otp_length')) + config.otpLength = {{Session::get("otp_length")}}; + @endif + @if(Session::has('otp_lifetime')) + {{-- Seed the countdown with the REMAINING lifetime: on a mid-challenge + refresh the full TTL would overstate how long the code is valid and + let the user burn rate-limited attempts on a server-expired code. --}} + config.otpLifetime = {{ max(0, intval(Session::get("otp_lifetime")) - (Session::has("otp_issued_at") ? time() - intval(Session::get("otp_issued_at")) : 0)) }}; + @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; {!! script_to('assets/login.js') !!} @append \ No newline at end of file diff --git a/resources/views/profile.blade.php b/resources/views/profile.blade.php index 335094e7..672081ba 100644 --- a/resources/views/profile.blade.php +++ b/resources/views/profile.blade.php @@ -103,7 +103,11 @@ initialValues: initialValues, languages: languages, menuConfig: menuConfig, - passwordPolicy: passwordPolicy + passwordPolicy: passwordPolicy, + twoFactorEnabled: {{ $two_factor_enabled ? 'true' : 'false' }}, + recoveryCodesRemaining: {{ (int) $recovery_codes_remaining }}, + recoveryCodesTotal: {{ (int) $recovery_codes_total }}, + recoveryCodesLowThreshold: {{ (int) $recovery_codes_low_threshold }} } window.GET_USER_ACTIONS_ENDPOINT = '{{URL::action("Api\UserActionApiController@getActionsByCurrentUser")}}'; @@ -112,6 +116,8 @@ window.REVOKE_ALL_TOKENS_ENDPOINT = '{{URL::action("Api\UserApiController@revokeAllMyTokens")}}'; window.SAVE_PROFILE_ENDPOINT = '{!!URL::action("Api\UserApiController@updateMe")!!}'; window.SAVE_PIC_ENDPOINT = '{!!URL::action("Api\UserApiController@updateMyPic")!!}'; + window.REGENERATE_RECOVERY_CODES_ENDPOINT = '{!!URL::action("Api\UserApiController@regenerateRecoveryCodes")!!}'; + window.ENABLE_TWO_FACTOR_ENDPOINT = '{!!URL::action("Api\UserApiController@enableTwoFactor")!!}'; window.CSFR_TOKEN = document.head.querySelector('meta[name="csrf-token"]').content; {!! script_to('assets/profile.js') !!} diff --git a/routes/web.php b/routes/web.php index b49a3547..61d97573 100644 --- a/routes/web.php +++ b/routes/web.php @@ -45,12 +45,17 @@ Route::group(array('prefix' => 'login'), function () { Route::get('', "UserController@getLogin"); Route::post('account-verify', [ 'middleware' => ['csrf'], 'uses' => 'UserController@getAccount']); - Route::post('otp', ['middleware' => ['csrf'], 'uses' => 'UserController@emitOTP']); + Route::post('otp', ['middleware' => ['csrf', '2fa.rate:otp'], 'uses' => 'UserController@emitOTP']); 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::post('cancel', ['middleware' => 'csrf', 'uses' => 'UserController@cancelLogin']); Route::group(array('prefix' => '{provider}'), function () { Route::get('', 'SocialLoginController@redirect')->name("social_login"); Route::any('callback','SocialLoginController@callback')->name("social_login_callback"); @@ -192,6 +197,8 @@ Route::put('', "UserApiController@updateMe"); Route::put('pic', "UserApiController@updateMyPic"); Route::get('actions', "UserActionApiController@getActionsByCurrentUser"); + Route::post('recovery-codes/regenerate', "UserApiController@regenerateRecoveryCodes"); + Route::post('2fa/enable', "UserApiController@enableTwoFactor"); }); Route::get('access-tokens', ['middleware' => ['openstackid.currentuser.serveradmin.json'], 'uses' => 'ClientApiController@getAllAccessTokens']); 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/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/AuthServiceLoginUserTest.php b/tests/AuthServiceLoginUserTest.php new file mode 100644 index 00000000..e5acba90 --- /dev/null +++ b/tests/AuthServiceLoginUserTest.php @@ -0,0 +1,120 @@ +migrate(true)), closing + * the pre-auth session-fixation window (SDS idp-mfa.md §9.3) - no explicit + * Session::regenerate() call is needed. What matters is ordering: + * register() hashes the CURRENT session ID into op_browser_state (used for + * OIDC Session Management). If it ran BEFORE Auth::login(), that hash would + * be computed from the id Auth::login() is about to invalidate, desyncing + * the check-session iframe contract for any relying party using it. + */ +#[\PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses] +#[\PHPUnit\Framework\Attributes\PreserveGlobalState(false)] +final class AuthServiceLoginUserTest extends PHPUnitTestCase +{ + use MockeryPHPUnitIntegration; + + private AuthService $service; + + private $mock_principal_service; + + // Facade aliases + private $auth_mock; + + protected function setUp(): void + { + parent::setUp(); + + $mock_user_repository = $this->createMock(IUserRepository::class); + $mock_otp_repository = $this->createMock(IOAuth2OTPRepository::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); + $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'); + + $log_mock = Mockery::mock('alias:Illuminate\Support\Facades\Log'); + $log_mock->shouldReceive('debug')->zeroOrMoreTimes(); + + $this->service = new AuthService( + $mock_user_repository, + $mock_otp_repository, + $this->mock_principal_service, + $mock_user_service, + $mock_user_action_service, + $mock_cache_service, + $mock_auth_user_service, + $mock_security_context_service, + $mock_tx_service + ); + } + + private function mockLoggableUser(): Mockery\MockInterface + { + $user = Mockery::mock('Auth\User'); + $user->shouldReceive('canLogin')->andReturn(true); + $user->shouldReceive('getId')->andReturn(42); + return $user; + } + + public function testLoginUserCallsAuthLoginBeforeRegisteringPrincipal(): void + { + $user = $this->mockLoggableUser(); + $call_order = []; + + $this->auth_mock->shouldReceive('login')->once()->andReturnUsing(function () use (&$call_order) { + $call_order[] = 'auth_login'; + }); + + $this->mock_principal_service->expects($this->once())->method('clear'); + $this->mock_principal_service->expects($this->once())->method('register')->willReturnCallback( + function () use (&$call_order) { + $call_order[] = 'principal_register'; + } + ); + + $this->service->loginUser($user, false); + + $this->assertSame( + ['auth_login', 'principal_register'], + $call_order, + 'Auth::login() must run before register() computes op_browser_state from the session ID - ' . + 'Auth::login() regenerates the session ID internally, so register() must use the post-login id' + ); + } +} 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/DeviceTrustServiceTest.php b/tests/DeviceTrustServiceTest.php new file mode 100644 index 00000000..4d8ae8fb --- /dev/null +++ b/tests/DeviceTrustServiceTest.php @@ -0,0 +1,335 @@ +repo = Mockery::mock(IUserTrustedDeviceRepository::class); + $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 + { + parent::tearDown(); + Mockery::close(); + } + + // ------------------------------------------------------------------------- + // isDeviceTrusted + // ------------------------------------------------------------------------- + + 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)); + } + + 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, '')); + } + + public function testIsDeviceTrustedWrongCookie(): void + { + $user = Mockery::mock(User::class); + $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP); + $this->repo + ->shouldReceive('getByUserAndDeviceIdentifier') + ->once() + ->andReturn(null); + + $this->assertFalse($this->service->isDeviceTrusted($user, 'unknowntoken')); + } + + public function testIsDeviceTrustedRevokedDevice(): void + { + $user = Mockery::mock(User::class); + $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP); + + $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); + $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP); + + $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); + $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP); + + $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); + $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 + $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); + $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP); + + $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); + $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP); + + /** @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); + $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP); + + /** @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); + $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; + + $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); + $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); + } + + // ------------------------------------------------------------------------- + // 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; + } +} diff --git a/tests/OAuth2NativeMFALoginFlowTest.php b/tests/OAuth2NativeMFALoginFlowTest.php new file mode 100644 index 00000000..ea9ddb27 --- /dev/null +++ b/tests/OAuth2NativeMFALoginFlowTest.php @@ -0,0 +1,96 @@ +authorize('native'); + + $response = $this->postLoginPassword(); + + $this->assertResponseStatus(412); + $payload = json_decode($response->getContent(), true); + $this->assertSame('mfa_required', $payload['error_code']); + } + + public function testNonNativeClientReceives302RedirectOnMFARequired(): void + { + // No display param -> defaults to the non-native (page/popup/touch) + // display strategy. + $this->authorize(); + + $response = $this->postLoginPassword(); + + $this->assertResponseStatus(302, 'must redirect like every other login outcome, not return JSON'); + $this->assertSame('2fa', Session::get('flow'), 'the redirected page must be able to restore the 2FA screen'); + } + + /** + * Unauthenticated authorize request - the grant serializes the OAuth2 + * memento and hands off to the login flow. + */ + private function authorize(?string $display = null) + { + $params = [ + 'client_id' => self::CLIENT_ID, + 'redirect_uri' => 'https://www.test.com:443/oauth2?param=1&BackUrl=123344', + 'response_type' => 'code', + 'scope' => sprintf('%s/resource-server/read', Config::get('app.url')), + ]; + if (!is_null($display)) { + $params['display'] = $display; + } + + return $this->action('POST', 'OAuth2\OAuth2ProviderController@auth', $params); + } + + /** + * Submits the enforced-2FA admin's password within the same session - + * this is what postLogin() sees as an OAuth2-originated login attempt + * via the persisted memento. + */ + private function postLoginPassword() + { + return $this->action('POST', 'UserController@postLogin', [ + 'username' => self::ADMIN_EMAIL, + 'password' => self::SEED_PASSWORD, + 'flow' => 'password', + '_token' => Session::token(), + ]); + } +} diff --git a/tests/RecoveryCodeRegenerationTest.php b/tests/RecoveryCodeRegenerationTest.php new file mode 100644 index 00000000..cec67de8 --- /dev/null +++ b/tests/RecoveryCodeRegenerationTest.php @@ -0,0 +1,237 @@ +withoutMiddleware(); + $this->be($this->admin()); + Session::start(); + } + + public function testRegenerateWithCorrectPasswordInvalidatesOldCodesAndReturnsNewOnes(): void + { + $admin = $this->admin(); + $this->createRecoveryCode($admin, 'OLD-CODE-' . uniqid(), false); + + $response = $this->regenerate(self::SEED_PASSWORD); + + $this->assertResponseStatus(200); + $payload = json_decode($response->getContent(), true); + $this->assertArrayHasKey('recovery_codes', $payload); + + $expectedCount = (int)config('auth.recovery_codes.count', 10); + $this->assertCount($expectedCount, $payload['recovery_codes']); + foreach ($payload['recovery_codes'] as $code) { + $this->assertMatchesRegularExpression('/^[A-Z0-9]+-[A-Z0-9]+$/', $code); + } + + EntityManager::clear(); + $remaining = EntityManager::getRepository(UserRecoveryCode::class) + ->findBy(['user' => $admin->getId(), 'used_at' => null]); + $this->assertCount( + $expectedCount, + $remaining, + 'old codes must be invalidated and replaced by exactly the configured count' + ); + } + + public function testRegenerateWithWrongPasswordFailsAndDoesNotTouchExistingCodes(): void + { + $admin = $this->admin(); + $plain = 'KEEP-ME-' . uniqid(); + $this->createRecoveryCode($admin, $plain, false); + + $response = $this->regenerate('this-is-not-the-password'); + + $this->assertResponseStatus(412); + + EntityManager::clear(); + $remaining = EntityManager::getRepository(UserRecoveryCode::class) + ->findBy(['user' => $admin->getId(), 'used_at' => null]); + $this->assertNotEmpty($remaining, 'existing codes must not be touched when the password confirmation fails'); + } + + public function testRegenerateRequiresCurrentPassword(): void + { + $response = $this->action('POST', 'Api\\UserApiController@regenerateRecoveryCodes', [], [], [], []); + + $this->assertResponseStatus(412); + } + + public function testRegenerateLogsAuditEvent(): void + { + $admin = $this->admin(); + + $this->regenerate(self::SEED_PASSWORD); + + EntityManager::clear(); + $entries = EntityManager::getRepository(TwoFactorAuditLog::class) + ->findBy(['user' => $admin->getId(), 'event_type' => TwoFactorAuditLog::EventRecoveryCodesGenerated]); + $this->assertNotEmpty($entries, 'a recovery_codes_generated audit entry must be recorded'); + } + + public function testEnableTwoFactorGeneratesRecoveryCodes(): void + { + $admin = $this->admin(); + + $response = $this->enableTwoFactor('email_otp'); + + $this->assertResponseStatus(200); + $payload = json_decode($response->getContent(), true); + $this->assertArrayHasKey('recovery_codes', $payload); + + $expectedCount = (int)config('auth.recovery_codes.count', 10); + $this->assertCount($expectedCount, $payload['recovery_codes']); + + EntityManager::clear(); + $reloaded = EntityManager::getRepository(User::class)->find($admin->getId()); + $this->assertTrue($reloaded->isTwoFactorEnabled(), '2FA must be enabled on the user after enrollment'); + + $remaining = EntityManager::getRepository(UserRecoveryCode::class) + ->findBy(['user' => $admin->getId(), 'used_at' => null]); + $this->assertCount($expectedCount, $remaining); + } + + public function testDisplayedRecoveryCodeRedeemsThroughVerifyRecoveryCode(): void + { + $admin = $this->admin(); + + $response = $this->regenerate(self::SEED_PASSWORD); + $this->assertResponseStatus(200); + $payload = json_decode($response->getContent(), true); + $displayedCode = $payload['recovery_codes'][0]; + $this->assertMatchesRegularExpression('/^[A-Z0-9]+-[A-Z0-9]+$/', $displayedCode); + + EntityManager::clear(); + $admin = EntityManager::getRepository(User::class)->find($admin->getId()); + $unusedBefore = EntityManager::getRepository(UserRecoveryCode::class) + ->findBy(['user' => $admin->getId(), 'used_at' => null]); + + // The hash was generated over the dash-less string; redeeming the code + // exactly as it was displayed (with its "-" separator) must still work. + // Goes through IAuthService, like the real login flow, because + // verifyRecoveryCode() takes a PESSIMISTIC_WRITE row lock that requires + // an open transaction. + $strategy = MFAChallengeStrategyFactory::create(User::MFAMethod_OTP); + app(IAuthService::class)->verifyMFARecoveryCode($admin, $strategy, $displayedCode); + + EntityManager::clear(); + $unusedAfter = EntityManager::getRepository(UserRecoveryCode::class) + ->findBy(['user' => $admin->getId(), 'used_at' => null]); + + $this->assertCount( + count($unusedBefore) - 1, + $unusedAfter, + 'the code redeemed exactly as displayed must be consumed exactly once' + ); + } + + public function testEnableTwoFactorRejectsWhenAlreadyEnabled(): void + { + $this->enableTwoFactor('email_otp'); + $this->assertResponseStatus(200); + + $response = $this->enableTwoFactor('email_otp'); + + $this->assertResponseStatus(412); + + EntityManager::clear(); + $admin = $this->admin(); + $this->assertTrue($admin->isTwoFactorEnabled(), '2FA must remain enabled after the rejected second call'); + } + + public function testEnableTwoFactorRejectsUnavailableMethod(): void + { + // sms_otp is a stub in Phase I (isPhoneNumberVerified() is hardcoded false), + // so enable2FA() must reject it regardless of the requesting user. + $response = $this->enableTwoFactor('sms_otp'); + + $this->assertResponseStatus(412); + } + + public function testEnableTwoFactorRequiresMethod(): void + { + $response = $this->action('POST', 'Api\\UserApiController@enableTwoFactor', [], [], [], []); + + $this->assertResponseStatus(412); + } + + public function testEnableTwoFactorLogsEnrollmentAuditEvent(): void + { + $admin = $this->admin(); + + $this->enableTwoFactor('email_otp'); + + EntityManager::clear(); + $entries = EntityManager::getRepository(TwoFactorAuditLog::class) + ->findBy(['user' => $admin->getId(), 'event_type' => TwoFactorAuditLog::EventEnrollmentChanged]); + $this->assertNotEmpty($entries, 'an enrollment_changed audit entry must be recorded'); + } + + private function enableTwoFactor(string $method) + { + return $this->action('POST', 'Api\\UserApiController@enableTwoFactor', [ + 'method' => $method, + ], [], [], []); + } + + private function admin(): User + { + $user = EntityManager::getRepository(User::class)->findOneBy(['identifier' => self::ADMIN_IDENTIFIER]); + $this->assertInstanceOf(User::class, $user, 'seeded admin user not found'); + return $user; + } + + private function regenerate(string $password) + { + return $this->action('POST', 'Api\\UserApiController@regenerateRecoveryCodes', [ + 'current_password' => $password, + ], [], [], []); + } + + 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(); + } +} diff --git a/tests/TurnstileProtectedControllersTest.php b/tests/TurnstileProtectedControllersTest.php index c6076f40..dffc0134 100644 --- a/tests/TurnstileProtectedControllersTest.php +++ b/tests/TurnstileProtectedControllersTest.php @@ -17,9 +17,12 @@ /** * Class TurnstileProtectedControllersTest * - * Smoke tests verifying that cf-turnstile-response is always required on the - * five auth endpoints that gate every submission behind Turnstile (unlike - * UserController::postLogin, which only activates the rule above a threshold). + * Smoke tests verifying that cf-turnstile-response is required on the five auth + * endpoints that gate every submission behind Turnstile. + * + * Requests MUST go over HTTPS (callSecure) because .env.testing sets + * SSL_ENABLED=true, which causes SSLMiddleware to redirect plain HTTP requests + * to HTTPS before reaching any controller. */ final class TurnstileProtectedControllersTest extends BrowserKitTestCase { @@ -37,8 +40,8 @@ private function sessionHasValidationError(string $field): bool private function postWithSession(string $url, array $data = []): void { - $this->call('GET', $url); - $this->call('POST', $url, array_merge(['_token' => Session::token()], $data)); + $this->callSecure('GET', $url); + $this->callSecure('POST', $url, array_merge(['_token' => Session::token()], $data)); } // ------------------------------------------------------------------------- diff --git a/tests/TwoFactorLoginFlowTest.php b/tests/TwoFactorLoginFlowTest.php new file mode 100644 index 00000000..cff0ff0f --- /dev/null +++ b/tests/TwoFactorLoginFlowTest.php @@ -0,0 +1,1144 @@ +flushRateLimitCounters(); + } + + protected function tearDown(): void + { + $this->flushRateLimitCounters(); + parent::tearDown(); + } + + private function flushRateLimitCounters(): void + { + $admin = EntityManager::getRepository(User::class)->getByEmailOrName(self::ADMIN_EMAIL); + if ($admin) { + $userId = $admin->getId(); + foreach (['verify', 'recovery', 'resend'] as $action) { + Cache::forget("2fa_rate:{$action}:{$userId}"); + // RateLimiter::hit() also writes a companion ":timer" key holding + // the window's reset timestamp - must be cleared too, or a stale + // timer from an earlier test leaks into a later one for this + // same fixed subject (self::ADMIN_EMAIL's user id). + Cache::forget("2fa_rate:{$action}:{$userId}:timer"); + } + } + + // otp is keyed by the (lowercased) submitted email, not a user id - + // clear every literal email this test class submits to that action. + foreach ([self::ADMIN_EMAIL, 'someone-else@example.com'] as $email) { + Cache::forget('2fa_rate:otp:' . strtolower($email)); + Cache::forget('2fa_rate:otp:' . strtolower($email) . ':timer'); + } + } + + // ------------------------------------------------------------------------- + // postLogin gate + // ------------------------------------------------------------------------- + + public function testAdminLoginTriggersMFAChallenge(): void + { + $response = $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + + // The password flow submits as a native form POST, so the challenge + // is delivered via the same redirect+session-flash mechanism as every + // other login outcome (errorLogin()), not a live JSON response - + // see testAdminLoginPersistsUIStateForRefreshResilience for the + // session-state assertions the redirected page relies on. + $this->assertResponseStatus(302, 'must redirect back to the login screen, same as errorLogin(), not return JSON'); + $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 testAdminLoginPersistsUIStateForRefreshResilience(): void + { + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + + $this->assertSame('2fa', Session::get('flow'), 'a refresh mid-challenge must restore the 2FA screen, not the password form'); + $this->assertNotNull(Session::get('otp_length')); + $this->assertNotNull(Session::get('otp_lifetime')); + $this->assertNotNull(Session::get('otp_issued_at'), 'the issuance timestamp must be restorable so a refresh can seed the countdown with the REMAINING lifetime'); + $this->assertSame(User::MFAMethod_OTP, Session::get('mfa_method'), 'a refresh must restore the screen for the method actually challenged, not a hardcoded default'); + $this->assertSame(ILoginStrategy::MFA_REQUIRED, Session::get('error_code'), 'must match what DisplayResponseJsonStrategy sends native clients in its JSON body'); + } + + public function testRefreshMidChallengeSeedsCountdownWithRemainingLifetime(): void + { + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + + $lifetime = intval(Session::get('otp_lifetime')); + $this->assertGreaterThan(0, $lifetime); + + // Simulate a refresh 100 seconds into the challenge. + Session::put('otp_issued_at', time() - 100); + + $response = $this->action('GET', 'UserController@getLogin'); + $this->assertResponseOk(); + + $this->assertSame( + 1, + preg_match('/config\.otpLifetime = (\d+);/', $response->getContent(), $matches), + 'the login page must seed the countdown from session state' + ); + $remaining = intval($matches[1]); + // The countdown must be seeded with the REMAINING lifetime (~lifetime - 100), + // not restart at the full TTL - otherwise the UI overstates how long the + // code is valid and lets the user burn rate-limited attempts on a + // server-expired code. +/-2s tolerance for clock ticks between requests. + $this->assertLessThanOrEqual($lifetime - 98, $remaining); + $this->assertGreaterThanOrEqual($lifetime - 102, $remaining); + } + + public function testSuccessfulVerificationClearsUIState(): void + { + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + $code = $this->latestOtpCode(self::ADMIN_EMAIL); + + $this->verify($code); + + $this->assertNull(Session::get('flow'), 'completed challenge must not leave the 2FA screen re-derivable from a stale refresh'); + $this->assertNull(Session::get('otp_length')); + $this->assertNull(Session::get('otp_lifetime')); + $this->assertNull(Session::get('otp_issued_at')); + $this->assertNull(Session::get('mfa_method')); + $this->assertNull(Session::get('error_code')); + // Identity/display fields written by postLogin()'s challengeRequired() + // payload must not survive a completed login either - otherwise a + // later visitor on the same browser session inherits this identity. + $this->assertNull(Session::get('username')); + $this->assertNull(Session::get('user_fullname')); + $this->assertNull(Session::get('user_pic')); + $this->assertNull(Session::get('user_verified')); + $this->assertNull(Session::get('user_is_active')); + } + + 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'); + } + + // ------------------------------------------------------------------------- + // passwordless (flow=otp) login must not bypass the MFA gate + // ------------------------------------------------------------------------- + + public function testEnforcedUserCannotBypassMFAViaPasswordlessLogin(): void + { + $this->emitOTP(self::ADMIN_EMAIL); + $code = $this->latestOtpCode(self::ADMIN_EMAIL); + + $response = $this->postLoginOTP(self::ADMIN_EMAIL, $code); + + $this->assertFalse(Auth::check(), 'passwordless login must not authenticate an enforced-2FA user'); + + // The OTP flow still submits as a native form POST (PR #142 only + // converted the password flow to AJAX), so the rejection must go + // through the pre-existing errorLogin() redirect+flash mechanism, + // not the JSON contract built for the password flow's MFA gate. + $this->assertResponseStatus(302, 'must reuse errorLogin(), not a JSON response'); + $this->assertStringContainsString( + 'two-factor authentication', + Session::get('flash_notice'), + 'the flashed message must explain why passwordless login was rejected' + ); + $this->assertSame('otp', Session::get('flow'), 'a reload must land back on the OTP screen, not silently fall back to password'); + } + + public function testNonEnforcedUserStillLogsInViaPasswordlessLogin(): void + { + $email = $this->createPlainUser(); + $this->emitOTP($email); + $code = $this->latestOtpCode($email); + + $this->postLoginOTP($email, $code); + + $this->assertTrue(Auth::check(), 'passwordless login must keep working unchanged for non-enforced users'); + } + + public function testEnforcedUserCanUsePasswordlessWhenTwoFactorGloballyDisabled(): void + { + // Kill-switch (SDS idp-mfa.md §10.1): with 2FA globally disabled, the + // passwordless-login enforcement block must NOT fire - an enforced admin + // can log in passwordless again, matching "revert to password-only login". + // Regression guard for the gap where the block called shouldRequire2FA() + // without honoring config('two_factor.enabled'). + Config::set('two_factor.enabled', false); + + $this->emitOTP(self::ADMIN_EMAIL); + $code = $this->latestOtpCode(self::ADMIN_EMAIL); + + $this->postLoginOTP(self::ADMIN_EMAIL, $code); + + $this->assertTrue( + Auth::check(), + 'with the kill-switch off, passwordless login must not be blocked for an enforced admin' + ); + } + + // ------------------------------------------------------------------------- + // passwordless (flow=otp) refresh-resilience + // ------------------------------------------------------------------------- + + public function testEmitOtpPersistsSessionStateForRefreshResilience(): void + { + $this->emitOTP(self::ADMIN_EMAIL); + + $this->assertSame('otp', Session::get('flow'), 'a refresh mid-code-entry must restore the OTP screen, not the email form'); + $this->assertTrue(Session::get('user_verified')); + $this->assertNotNull(Session::get('otp_length')); + $this->assertNotNull(Session::get('otp_lifetime')); + $this->assertNotNull(Session::get('otp_issued_at'), 'the issuance timestamp must be restorable so a refresh can seed the countdown with the REMAINING lifetime'); + $this->assertSame(self::ADMIN_EMAIL, Session::get('username')); + + $admin = $this->user(self::ADMIN_EMAIL); + $this->assertSame($admin->getFullName(), Session::get('user_fullname')); + $this->assertSame($admin->getPic(), Session::get('user_pic')); + $this->assertSame(1, Session::get('user_is_active')); + } + + public function testEmitOtpForNewUserStillPersistsRefreshState(): void + { + // No createPlainUser() call - this email has no existing User row. + // AuthService::loginWithOTP() auto-registers new users at redemption + // time, so emitOTP() must not silently skip the refresh-restoration + // state just because the identity lookup comes up empty. + $email = 'never.seen.' . uniqid() . '@test.invalid'; + + $this->emitOTP($email); + + $this->assertSame('otp', Session::get('flow')); + $this->assertTrue(Session::get('user_verified'), 'user_verified must persist even when no User row exists yet'); + $this->assertNotNull(Session::get('otp_length')); + $this->assertNotNull(Session::get('otp_lifetime')); + $this->assertNotNull(Session::get('otp_issued_at')); + $this->assertSame($email, Session::get('username')); + + // login.js's emitOtpAction() falls back to the submitted email as the + // chip's display name when there's no real full name yet (login.js:165-167) - + // the persisted session state must match that same fallback, or the + // identity chip (visible right after opting into OTP) vanishes on refresh + // instead of being restored identically. + $this->assertSame($email, Session::get('user_fullname'), 'must fall back to the submitted email, matching emitOtpAction()\'s client-side fallback'); + $this->assertNull(Session::get('user_pic'), 'no picture to persist for a not-yet-registered user'); + $this->assertNull(Session::get('user_is_active'), 'no active-status to persist for a not-yet-registered user'); + } + + public function testSuccessfulPasswordlessLoginClearsOtpSessionState(): void + { + $email = $this->createPlainUser(); + $this->emitOTP($email); + $code = $this->latestOtpCode($email); + + $this->postLoginOTP($email, $code); + + $this->assertTrue(Auth::check(), 'sanity check: the login itself must have succeeded'); + + // A completed passwordless login must not leave the OTP screen + // restorable on a later refresh - otherwise a subsequent unrelated + // visitor on the same browser session inherits this identity. + $this->assertNull(Session::get('flow')); + $this->assertNull(Session::get('user_verified')); + $this->assertNull(Session::get('otp_length')); + $this->assertNull(Session::get('otp_lifetime')); + $this->assertNull(Session::get('otp_issued_at')); + $this->assertNull(Session::get('username')); + $this->assertNull(Session::get('user_fullname')); + $this->assertNull(Session::get('user_pic')); + $this->assertNull(Session::get('user_is_active')); + } + + // ------------------------------------------------------------------------- + // cancelLogin + // ------------------------------------------------------------------------- + + public function testCancelClearsUIStateAndPendingChallenge(): void + { + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + $code = $this->latestOtpCode(self::ADMIN_EMAIL); + + $this->cancelLogin(); + + $this->assertNull(Session::get('flow'), 'cancel must not leave a stale 2FA screen restorable on refresh'); + $this->assertNull(Session::get('mfa_method')); + $this->assertNull(Session::get('otp_length')); + $this->assertNull(Session::get('otp_lifetime')); + $this->assertNull(Session::get('otp_issued_at')); + // Identity/display fields written by postLogin()'s challengeRequired() + // payload must not survive cancel either - otherwise a later visitor + // on the same browser session inherits the cancelled attempt's identity. + $this->assertNull(Session::get('username')); + $this->assertNull(Session::get('user_fullname')); + $this->assertNull(Session::get('user_pic')); + $this->assertNull(Session::get('user_verified')); + $this->assertNull(Session::get('user_is_active')); + + // The strongest proof: the OTP issued before cancel must no longer + // complete a login. If pending state survived cancel, this would + // succeed with a 302 despite the user having explicitly cancelled. + $response = $this->verify($code); + $this->assertResponseStatus(401); + $payload = json_decode($response->getContent(), true); + $this->assertSame('mfa_session_expired', $payload['error_code']); + $this->assertFalse(Auth::check(), 'a cancelled challenge must never establish a session'); + } + + public function testCancelClearsPasswordlessOtpSessionState(): void + { + // Server-side proof for the client fix in login.js's handleDelete() + // (widened to call cancelLogin() for the OTP flow, not just MFA): + // cancelLogin() already unconditionally clears the same key set + // emitOTP() writes, so a refresh after "sign in using a different + // e-mail" must not resurrect the abandoned OTP screen. + $email = $this->createPlainUser(); + $this->emitOTP($email); + + $this->cancelLogin(); + + $this->assertNull(Session::get('flow'), 'cancel must not leave a stale OTP screen restorable on refresh'); + $this->assertNull(Session::get('user_verified')); + $this->assertNull(Session::get('otp_length')); + $this->assertNull(Session::get('otp_lifetime')); + $this->assertNull(Session::get('otp_issued_at')); + $this->assertNull(Session::get('username')); + $this->assertNull(Session::get('user_fullname')); + $this->assertNull(Session::get('user_pic')); + $this->assertNull(Session::get('user_is_active')); + } + + // ------------------------------------------------------------------------- + // email delivery + // ------------------------------------------------------------------------- + + public function testMFAChallengeQueuesEmailWithCorrectOTPCode(): void + { + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + + $dbCode = $this->latestOtpCode(self::ADMIN_EMAIL); + + Mail::assertQueued( + OAuth2PasswordlessOTPMail::class, + function (OAuth2PasswordlessOTPMail $mail) use ($dbCode): bool { + return $mail->email === self::ADMIN_EMAIL + && $mail->otp === $dbCode; + } + ); + } + + public function testResendMFAChallengeQueuesAdditionalEmail(): void + { + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + $this->resend(); + + Mail::assertQueued(OAuth2PasswordlessOTPMail::class, 2); + } + + // ------------------------------------------------------------------------- + // verify2FA + // ------------------------------------------------------------------------- + + public function testSuccessfulOTPVerificationCompletesLogin(): void + { + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + $code = $this->latestOtpCode(self::ADMIN_EMAIL); + + $response = $this->verify($code); + + // verify2FA returns the post-login destination as JSON data (not a raw redirect) + // so the caller's XHR never has to follow it itself - a real top-level navigation + // to redirect_url is what actually completes any further hop, cross-origin included. + $this->assertResponseStatus(200); + $payload = json_decode($response->getContent(), true); + $this->assertIsString($payload['redirect_url'] ?? null); + $this->assertStringStartsWith('http', $payload['redirect_url']); + $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); + // No "-" and all-uppercase: verifyRecoveryCode() now strips separators + // and uppercases the submitted code before Hash::check() (real codes are + // hashed dash-less and all-uppercase; the dash/case are display-only), + // so a fixture hashed with lowercase uniqid() hex would never match its + // own (normalized) submission. + $plain = 'RECOVERYREUSETX' . strtoupper(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 testOTPRedeemRollsBackOnMidTransactionFailure(): void + { + // Ticket CU-86ba2zc6p TESTS list: "OTP redeem is persisted only on + // commit; a failure inside the verify transaction rolls back the + // redeem." Wraps the REAL strategy so the OTP genuinely gets redeemed + // mid-transaction, then injects a failure before the transaction + // (AuthService::verifyMFAChallenge) can commit. + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + $code = $this->latestOtpCode(self::ADMIN_EMAIL); + $admin = $this->user(self::ADMIN_EMAIL); + + $realStrategy = MFAChallengeStrategyFactory::create(User::MFAMethod_OTP); + $faultyStrategy = new class($realStrategy) implements IMFAChallengeStrategy { + public function __construct(private IMFAChallengeStrategy $inner) + { + } + + public function issueChallenge(User $user, ?Client $client, bool $remember): array + { + return $this->inner->issueChallenge($user, $client, $remember); + } + + public function verifyChallenge(User $user, string $code, ?Client $client = null): void + { + $this->inner->verifyChallenge($user, $code, $client); + throw new \RuntimeException('Simulated mid-transaction failure after redeem'); + } + + public function resendChallenge(User $user, ?Client $client, bool $remember): array + { + return $this->inner->resendChallenge($user, $client, $remember); + } + + public function getPendingState(): ?array + { + return $this->inner->getPendingState(); + } + + public function clearPendingState(): void + { + $this->inner->clearPendingState(); + } + + public function verifyRecoveryCode(User $user, string $code): void + { + $this->inner->verifyRecoveryCode($user, $code); + } + }; + + /** @var IAuthService $authService */ + $authService = App::make(IAuthService::class); + + try { + $authService->verifyMFAChallenge($admin, $faultyStrategy, $code); + $this->fail('Expected the simulated mid-transaction failure to propagate'); + } catch (\RuntimeException $ex) { + $this->assertSame('Simulated mid-transaction failure after redeem', $ex->getMessage()); + } + + EntityManager::clear(); + /** @var IOAuth2OTPRepository $otpRepo */ + $otpRepo = App::make(IOAuth2OTPRepository::class); + $otp = $otpRepo->getByValue($code); + + $this->assertNotNull($otp, 'the OTP row itself must still exist - only the redeem must roll back'); + $this->assertFalse( + $otp->isRedeemed(), + 'a failure inside the verify transaction must roll back the OTP redeem, not persist it' + ); + } + + // ------------------------------------------------------------------------- + // concurrency: pessimistic-lock proof for OTP / recovery-code redemption + // + // refreshExclusiveLock() (EmailOTPMFAChallengeStrategy::verifyChallenge, + // AbstractMFAChallengeStrategy::verifyRecoveryCode) exists specifically to + // close a check-then-redeem TOCTOU race between two concurrent requests. + // testOTPCodeRejectsReuseAfterSuccessfulVerification / testRecoveryCodeRejects + // ReuseAfterTransactionCommit above only prove SEQUENTIAL reuse is rejected. + // These tests prove the row lock the production code acquires actually + // blocks a second, independent physical DB connection while held. + // ------------------------------------------------------------------------- + + public function testOTPRedeemRowLockBlocksConcurrentConnection(): void + { + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + $code = $this->latestOtpCode(self::ADMIN_EMAIL); + + /** @var IOAuth2OTPRepository $otpRepo */ + $otpRepo = App::make(IOAuth2OTPRepository::class); + $otp = $otpRepo->getByValue($code); + $this->assertNotNull($otp); + + $this->assertLockBlocksSecondConnection( + 'oauth2_otp', + $otp->getId(), + fn() => $otpRepo->refreshExclusiveLock($otp) + ); + } + + public function testRecoveryCodeRowLockBlocksConcurrentConnection(): void + { + $admin = $this->user(self::ADMIN_EMAIL); + $plain = 'RECOVERYLOCK' . strtoupper(uniqid()); + $codeId = $this->createRecoveryCode($admin, $plain, false); + + /** @var IUserRecoveryCodeRepository $recoveryRepo */ + $recoveryRepo = App::make(IUserRecoveryCodeRepository::class); + $recoveryCode = EntityManager::find(UserRecoveryCode::class, $codeId); + $this->assertNotNull($recoveryCode); + + $this->assertLockBlocksSecondConnection( + 'user_recovery_codes', + $codeId, + fn() => $recoveryRepo->refreshExclusiveLock($recoveryCode) + ); + } + + /** + * Proves that a PESSIMISTIC_WRITE lock acquired via $acquireLock (the same + * production method the MFA strategies call before redeeming) blocks a + * genuinely separate, concurrent physical DB connection from also locking + * that row - i.e. the fix actually closes the TOCTOU redemption race, not + * just rejects a sequential re-submission. + * + * $table is always an internal literal supplied by this test file, never + * external input, so interpolating it into the probe SQL below is safe. + */ + private function assertLockBlocksSecondConnection(string $table, int $id, \Closure $acquireLock): void + { + $primary = EntityManager::getConnection(); + $primary->beginTransaction(); + + try { + $acquireLock(); + + // Register a second connection under a distinct name so Laravel's + // DatabaseManager opens an independent physical connection instead + // of returning the already-cached primary one. + Config::set('database.connections.mfa_lock_test_secondary', Config::get('database.connections.openstackid')); + $secondary = DB::connection('mfa_lock_test_secondary'); + + $primaryConnId = (int) $primary->executeQuery('SELECT CONNECTION_ID()')->fetchOne(); + $secondaryConnId = (int) $secondary->selectOne('SELECT CONNECTION_ID() AS id')->id; + $this->assertNotSame( + $primaryConnId, + $secondaryConnId, + 'test requires two independent physical DB connections to prove real lock contention' + ); + + $secondary->statement('SET SESSION innodb_lock_wait_timeout = 1'); + $secondary->beginTransaction(); + + try { + $secondary->selectOne("SELECT id FROM {$table} WHERE id = ? FOR UPDATE", [$id]); + $this->fail('a second connection must not be able to lock a row already held by refreshExclusiveLock()'); + } catch (\Illuminate\Database\QueryException $ex) { + $this->assertStringContainsStringIgnoringCase( + 'lock wait timeout', + $ex->getMessage(), + 'the second connection must be blocked by the row lock, not fail for an unrelated reason' + ); + } finally { + $secondary->rollBack(); + DB::purge('mfa_lock_test_secondary'); + } + } finally { + $primary->rollBack(); + } + } + + 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']); + } + + // ------------------------------------------------------------------------- + // session security (SDS idp-mfa.md §9.3: session fixation) + // + // Session-fixation protection itself is already provided by Laravel's + // SessionGuard::login() (session->migrate(true), called via Auth::login() + // inside loginUser()) - not something this branch needs to add. What + // AuthServiceLoginUserTest and the test below actually cover is the + // ordering bug this investigation found: PrincipalService::register() + // must run AFTER Auth::login(), not before, or its op_browser_state hash + // is computed from a session ID Auth::login() is about to invalidate. + // ------------------------------------------------------------------------- + + public function testCompletedMFALoginKeepsOPBrowserStateConsistentWithSessionId(): void + { + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + $code = $this->latestOtpCode(self::ADMIN_EMAIL); + $this->verify($code); + + // PrincipalService::register() hashes the session ID into + // op_browser_state (OIDC Session Management). If it ran BEFORE + // Auth::login()'s internal session->migrate(true), this would be a + // hash of the id Auth::login() was about to invalidate instead. + $this->assertSame( + hash('sha256', Session::getId()), + Session::get(PrincipalService::OPBrowserState), + 'op_browser_state must be derived from the post-regeneration session ID' + ); + } + + // ------------------------------------------------------------------------- + // 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(200); + $payload = json_decode($response->getContent(), true); + $this->assertIsString($payload['redirect_url'] ?? null); + + 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'); + } + + // ------------------------------------------------------------------------- + // 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(200, $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 testRecoveryAuditFailureDoesNotBlockLogin(): void + { + // Audit is best-effort: a failure emitting recovery_used must NOT 500 a + // user whose recovery code is already burned and session established. + $admin = $this->user(self::ADMIN_EMAIL); + $plain = 'RECOVERYAUDITFAIL' . strtoupper(uniqid()); + $this->createRecoveryCode($admin, $plain, false); + + $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::EventRecoveryUsed) { + throw new \Exception('audit sink unavailable'); + } + }); + $this->app->instance(ITwoFactorAuditService::class, $auditMock); + + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + + $response = $this->recovery($plain); + + $this->assertEquals(200, $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(200, $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 + // ------------------------------------------------------------------------- + + public function testRecoveryCodeLoginSucceeds(): void + { + $admin = $this->user(self::ADMIN_EMAIL); + $plain = 'RECOVERYPLAIN123'; + $codeId = $this->createRecoveryCode($admin, $plain, false); + + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + $response = $this->recovery($plain); + + $this->assertResponseStatus(200); + $payload = json_decode($response->getContent(), true); + $this->assertIsString($payload['redirect_url'] ?? null); + $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 = 'RECOVERYUSED456'; + $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']); + $this->assertSame((string) $max, $response->headers->get('X-RateLimit-Limit')); + $this->assertSame('0', $response->headers->get('X-RateLimit-Remaining')); + $this->assertGreaterThan(0, (int) $response->headers->get('Retry-After')); + } + + 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']); + $this->assertSame((string) $max, $response->headers->get('X-RateLimit-Limit')); + $this->assertSame('0', $response->headers->get('X-RateLimit-Remaining')); + $this->assertGreaterThan(0, (int) $response->headers->get('Retry-After')); + } + + 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']); + $this->assertSame((string) $max, $response->headers->get('X-RateLimit-Limit')); + $this->assertSame('0', $response->headers->get('X-RateLimit-Remaining')); + $this->assertGreaterThan(0, (int) $response->headers->get('Retry-After')); + } + + public function testInitialChallengeIssuanceCountsAgainstResendRateLimitWindow(): void + { + // SDS idp-mfa.md §4.12: "The initial OTP issuance during postLogin() + // shares the 2fa_rate:resend:{user_id} cache key, ensuring the first + // challenge counts against the same 5-request issuance window as + // subsequent resends." Each postLogin() call re-issues a challenge and + // must count against that SAME window. + $max = (int) Config::get('two_factor.rate_limit.max_otp_requests'); + for ($i = 0; $i < $max; $i++) { + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + } + + $response = $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + + // Rate-limited postLogin() must reuse errorLogin(), not the JSON + // contract resend()/verify() use - the password form still submits + // as a native form POST. + $this->assertResponseStatus(302, 'must redirect like every other login outcome, not return JSON'); + $this->assertStringContainsString('Too many attempts', Session::get('flash_notice')); + $this->assertFalse(Auth::check()); + } + + public function testOtpEmailRateLimitBlocksAfterThreshold(): void + { + $max = (int) Config::get('two_factor.rate_limit.max_otp_email_requests'); + for ($i = 0; $i < $max; $i++) { + $this->emitOTP(self::ADMIN_EMAIL); + } + + $response = $this->emitOTP(self::ADMIN_EMAIL); + $this->assertResponseStatus(429); + $payload = json_decode($response->getContent(), true); + $this->assertSame('mfa_rate_limit', $payload['error_code']); + + // A 429 must give the client a standard, machine-readable retry signal - + // without these, callers have no way to know how long to back off. + $this->assertSame((string) $max, $response->headers->get('X-RateLimit-Limit')); + $this->assertSame('0', $response->headers->get('X-RateLimit-Remaining')); + $retryAfter = $response->headers->get('Retry-After'); + $this->assertNotNull($retryAfter, 'Retry-After must be present on a 429'); + $this->assertGreaterThan(0, (int) $retryAfter); + + // A different email must be unaffected - the subject is per-email, not global. + // emitOTP() never looks up an existing user before creating the OTP, so a + // non-seeded literal email is a valid, distinct rate-limit subject here. + $otherResponse = $this->emitOTP('someone-else@example.com'); + $this->assertNotEquals(429, $otherResponse->getStatusCode()); + } + + public function testOtpEmailRateLimitIsCaseInsensitive(): void + { + // users.email has a case-insensitive collation (utf8mb3_unicode_ci) and every + // session-keyed 2FA action resolves through a case-insensitive DB lookup before + // ever touching the rate limiter. The otp action has no such lookup - the raw + // submitted string IS the cache key - so casing must be canonicalized here or + // an attacker can reset the budget every request by cycling the target email's + // letter casing, defeating the limit entirely. + $max = (int) Config::get('two_factor.rate_limit.max_otp_email_requests'); + $casings = ['sebastian@tipit.net', 'Sebastian@Tipit.net', 'SEBASTIAN@TIPIT.NET', 'sEbAsTiAn@tIpIt.NeT']; + for ($i = 0; $i < $max; $i++) { + $this->emitOTP($casings[$i % count($casings)]); + } + + $response = $this->emitOTP('SEBASTIAN@TIPIT.NET'); + $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 cancelLogin() + { + return $this->action('POST', 'UserController@cancelLogin', [ + '_token' => Session::token(), + ]); + } + + private function emitOTP(string $username) + { + return $this->action('POST', 'UserController@emitOTP', [ + 'username' => $username, + 'connection' => 'email', + 'send' => 'code', + '_token' => Session::token(), + ]); + } + + private function postLoginOTP(string $username, string $code) + { + return $this->action('POST', 'UserController@postLogin', [ + 'username' => $username, + 'password' => $code, + 'connection' => 'email', + 'flow' => 'otp', + '_token' => Session::token(), + ]); + } + + 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/e2e/fixtures/index.ts b/tests/e2e/fixtures/index.ts new file mode 100644 index 00000000..f6d80a7c --- /dev/null +++ b/tests/e2e/fixtures/index.ts @@ -0,0 +1,87 @@ +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({ + 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)); + }, + + registerPage: async ({ page }, use) => { + await use(new RegisterPage(page)); + }, + + // 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 || 'e2e@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..d05b4be0 --- /dev/null +++ b/tests/e2e/pages/LoginPage.ts @@ -0,0 +1,67 @@ +import { type Page, type Locator } from '@playwright/test'; + +export class LoginPage { + readonly page: Page; + readonly emailInput: Locator; + readonly passwordInput: 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.emailSubmitButton = page.locator('button[title="Continue"]'); + this.passwordSubmitButton = page.getByRole('button', { name: 'Continue' }); + this.rememberMeCheckbox = page.locator('#remember'); + 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() { + await this.page.goto('/auth/login'); + } + + async fillEmail(email: string) { + await this.emailInput.fill(email); + await this.emailSubmitButton.click(); + } + + async fillPassword(password: string) { + await this.passwordInput.fill(password); + await this.passwordSubmitButton.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.passwordSubmitButton.click(); + } +} diff --git a/tests/e2e/pages/RegisterPage.ts b/tests/e2e/pages/RegisterPage.ts new file mode 100644 index 00000000..1c03f16e --- /dev/null +++ b/tests/e2e/pages/RegisterPage.ts @@ -0,0 +1,57 @@ +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 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; + 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.codeOfConductCheckbox = page.locator('[name="agree_code_of_conduct"]'); + this.submitButton = page.locator('button[type="submit"]'); + 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 . +const getPasswordInput = () => screen.getByTestId('recovery-codes-current-password').querySelector('input'); + +describe('RecoveryCodesPanel', () => { + beforeEach(() => { + window.sessionStorage.clear(); + jest.clearAllMocks(); + }); + + it('shows the remaining/total count', () => { + render( + + ); + expect(screen.getByTestId('recovery-codes-count')).toHaveTextContent('Recovery Codes: 7 of 10 remaining'); + }); + + it('shows a dismissable low-code warning when remaining is below the threshold', () => { + render( + + ); + expect(screen.getByTestId('low-code-warning')).toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText('dismiss')); + expect(screen.queryByTestId('low-code-warning')).not.toBeInTheDocument(); + }); + + it('does not show the low-code warning when there are enough codes', () => { + render( + + ); + expect(screen.queryByTestId('low-code-warning')).not.toBeInTheDocument(); + }); + + it('respects a custom lowCodeThreshold instead of the default of 3', () => { + // 4 remaining would NOT trigger the default threshold (3), but does with a custom threshold of 5. + render( + + ); + expect(screen.getByTestId('low-code-warning')).toBeInTheDocument(); + }); + + it('respects a custom lower threshold (2 remaining is not below a threshold of 2)', () => { + // With the default threshold (3) this would show the warning; a custom + // threshold of 2 must be honored instead of the hardcoded default. + render( + + ); + expect(screen.queryByTestId('low-code-warning')).not.toBeInTheDocument(); + }); + + it('opens the modal immediately when initialCodes is provided', () => { + const codes = ['AAAA-1111', 'BBBB-2222']; + render( + + ); + expect(screen.getByText('AAAA-1111')).toBeInTheDocument(); + }); + + it('regenerates codes after confirming the current password and opens the modal', async () => { + const newCodes = ['NEW1-CODE', 'NEW2-CODE']; + regenerateRecoveryCodes.mockResolvedValue({response: {recovery_codes: newCodes}}); + + render( + + ); + + fireEvent.click(screen.getByText('Regenerate Codes')); + fireEvent.change(getPasswordInput(), {target: {value: 'my-password'}}); + fireEvent.click(screen.getByTestId('confirm-regenerate-button')); + + expect(regenerateRecoveryCodes).toHaveBeenCalledWith('my-password'); + expect(await screen.findByText('NEW1-CODE')).toBeInTheDocument(); + expect(screen.getByTestId('recovery-codes-count')).toHaveTextContent('Recovery Codes: 2 of 2 remaining'); + }); + + it('shows an error and keeps the existing count when the password is wrong', async () => { + regenerateRecoveryCodes.mockRejectedValue({ + status: 412, + response: {body: {errors: ['current_password is not correct.']}}, + }); + + render( + + ); + + fireEvent.click(screen.getByText('Regenerate Codes')); + fireEvent.change(getPasswordInput(), {target: {value: 'wrong'}}); + fireEvent.click(screen.getByTestId('confirm-regenerate-button')); + + expect(regenerateRecoveryCodes).toHaveBeenCalledWith('wrong'); + await new Promise((resolve) => setImmediate(resolve)); + expect(screen.getByTestId('recovery-codes-count')).toHaveTextContent('Recovery Codes: 7 of 10 remaining'); + }); +}); diff --git a/tests/js/components/two_factor_section.test.js b/tests/js/components/two_factor_section.test.js new file mode 100644 index 00000000..e5e9dfef --- /dev/null +++ b/tests/js/components/two_factor_section.test.js @@ -0,0 +1,48 @@ +import React from 'react'; +import {render, screen, fireEvent} from '@testing-library/react'; +import TwoFactorSection from '../../../resources/js/components/two_factor_section'; +import {enableTwoFactor} from '../../../resources/js/profile/actions'; + +jest.mock('../../../resources/js/profile/actions'); +jest.mock('sweetalert2', () => jest.fn()); + +describe('TwoFactorSection', () => { + beforeEach(() => { + window.sessionStorage.clear(); + jest.clearAllMocks(); + }); + + it('shows the enable button when 2FA is not enabled', () => { + render( + + ); + expect(screen.getByTestId('enable-two-factor-button')).toBeInTheDocument(); + expect(screen.queryByTestId('recovery-codes-count')).not.toBeInTheDocument(); + }); + + it('shows the recovery codes panel when 2FA is already enabled', () => { + render( + + ); + expect(screen.queryByTestId('enable-two-factor-button')).not.toBeInTheDocument(); + expect(screen.getByTestId('recovery-codes-count')).toHaveTextContent('Recovery Codes: 7 of 10 remaining'); + }); + + it('enables 2FA and shows the enrollment codes in the modal', async () => { + const codes = ['AAAA-1111', 'BBBB-2222']; + enableTwoFactor.mockResolvedValue({response: {recovery_codes: codes}}); + + render( + + ); + + fireEvent.click(screen.getByTestId('enable-two-factor-button')); + + expect(enableTwoFactor).toHaveBeenCalledWith('email_otp'); + expect(await screen.findByText('AAAA-1111')).toBeInTheDocument(); + expect(screen.getByTestId('recovery-codes-count')).toHaveTextContent('Recovery Codes: 2 of 2 remaining'); + }); +}); diff --git a/tests/js/login/components/two-factor-form.test.js b/tests/js/login/components/two-factor-form.test.js new file mode 100644 index 00000000..d7278eec --- /dev/null +++ b/tests/js/login/components/two-factor-form.test.js @@ -0,0 +1,56 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import TwoFactorForm from '../../../../resources/js/login/components/two_factor_form'; + +// Suppress the 1-second interval so tests don't bleed real timers into each other. +beforeEach(() => jest.useFakeTimers()); +afterEach(() => jest.useRealTimers()); + +const baseProps = { + otpCode: '123456', + otpError: '', + otpLength: 6, + otpLifetime: 300, + codeVersion: 0, + disableInput: false, + trustDevice: false, + onCodeChange: jest.fn(), + onVerify: jest.fn(), + onTrustDeviceChange: jest.fn(), + onResend: jest.fn(), + onUseRecovery: jest.fn(), + onCancel: jest.fn(), +}; + +describe('TwoFactorForm', () => { + + it('renders countdown when otpLifetime > 0', () => { + render(); + // formatTime(300) → "5 minutes"; the paragraph reads "Code expires in 5 minutes." + expect(screen.getByText(/Code expires in 5 minutes\./)).toBeInTheDocument(); + expect(screen.queryByText(/has expired/)).not.toBeInTheDocument(); + }); + + it('renders expired state when otpLifetime is 0', () => { + render(); + expect( + screen.getByText(/Your verification code has expired\. Please request a new one\./) + ).toBeInTheDocument(); + expect(screen.queryByText(/Code expires in/)).not.toBeInTheDocument(); + }); + + it('VERIFY button is disabled when otpCode is empty', () => { + render(); + // MUI Button spreads unknown props to its root