diff --git a/app/Console/Commands/Reset2FACommand.php b/app/Console/Commands/Reset2FACommand.php new file mode 100644 index 00000000..24032df3 --- /dev/null +++ b/app/Console/Commands/Reset2FACommand.php @@ -0,0 +1,111 @@ +option('reason')); + if ($reason === '') { + $this->error('The --reason option is required.'); + return self::FAILURE; + } + + $email = trim((string)$this->argument('email')); + $user = EntityManager::getRepository(User::class)->findOneBy(['email' => $email]); + if (is_null($user)) { + $this->error(sprintf('User %s not found.', $email)); + return self::FAILURE; + } + + // counted before the reset, they are gone afterwards + $codes = $recovery_code_service->countUnusedRecoveryCodes($user); + $devices = count($trusted_device_repository->getActiveByUser($user)); + + // the service owns the side effects (flags, codes, devices) in one transaction + $recovery_code_service->disableTwoFactor($user, null, null); + + $operator = sprintf('%s@%s', get_current_user(), gethostname()); + + try { + $audit_service->log( + $user, + TwoFactorAuditLog::EventSettingsChanged, + $user->getTwoFactorMethod(), + self::ConsoleIp, + ['reason' => $reason, 'actor' => 'console', 'operator' => $operator] + ); + } catch (\Throwable $ex) { + // the reset is already committed: report it, but do not hide that the trail is missing + Log::error($ex); + $this->error(sprintf( + 'The 2FA reset of %s was applied but the settings_changed audit event could not be recorded: %s', + $email, + $ex->getMessage() + )); + return self::FAILURE; + } + + $this->info(sprintf('2FA reset for %s (reason: %s, operator: %s)', $email, $reason, $operator)); + $this->line(sprintf(' unused recovery codes deleted: %d', $codes)); + $this->line(sprintf(' trusted devices revoked: %d', $devices)); + + // enforcement is derived from group membership, so a reset cannot lift it + if ($user->shouldRequire2FA()) { + $this->warn( + 'This user belongs to a 2FA enforced group: they will still be challenged at the next login. ' . + 'They have no recovery codes now and must regenerate them from the profile Security section ' . + 'after logging in with the email OTP.' + ); + } + + return self::SUCCESS; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 52489b9e..6f0e63ad 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -30,6 +30,7 @@ class Kernel extends ConsoleKernel Commands\CleanOpenIdStaleData::class, Commands\CreateSuperAdmin::class, Commands\EnforceAdmin2FACommand::class, + Commands\Reset2FACommand::class, Commands\CreateRawUser::class, Commands\CreateOAuth2TestClient::class, Commands\GetLatestOtp::class, diff --git a/tests/Reset2FACommandIntegrationTest.php b/tests/Reset2FACommandIntegrationTest.php new file mode 100644 index 00000000..ddbc7d40 --- /dev/null +++ b/tests/Reset2FACommandIntegrationTest.php @@ -0,0 +1,153 @@ + [self::GroupSlug]]); + + $group = new Group(); + $group->setName(self::GroupSlug); + $group->setSlug(self::GroupSlug); + $group->setDefault(false); + $group->setActive(true); + EntityManager::persist($group); + EntityManager::flush(); + + $this->self_enrolled = $this->enrolledUser('reset-self@nomail.com'); + $this->admin = $this->enrolledUser('reset-admin@nomail.com'); + $this->admin->addToGroup($group); + EntityManager::flush(); + } + + /** + * enrolled by the user: stored flag, 10 recovery codes and 2 trusted devices + */ + private function enrolledUser(string $email): User + { + $user = new User(); + $user->setEmail($email); + $user->setFirstName('Reset'); + $user->setLastName('TwoFactor'); + $user->setIdentifier($email); + $user->setPassword('P@sswordS3cret'); + $user->verifyEmail(false); + EntityManager::persist($user); + EntityManager::flush(); + + $this->app->make(IRecoveryCodeService::class)->enableTwoFactorAndGenerateCodes($user, User::MFAMethod_OTP); + $devices = $this->app->make(IDeviceTrustService::class); + $devices->trustDevice($user, 'agent-a', '127.0.0.1'); + $devices->trustDevice($user, 'agent-b', '127.0.0.1'); + return $user; + } + + private function reloaded(User $user): User + { + EntityManager::clear(); + return EntityManager::getRepository(User::class)->find($user->getId()); + } + + private function codeCount(User $user): int + { + return DB::table('user_recovery_codes')->where('user_id', $user->getId())->count(); + } + + public function testWithoutReasonExitsNonZeroAndChangesNothing(): void + { + $code = Artisan::call('idp:reset-2fa', ['email' => 'reset-self@nomail.com']); + + $this->assertSame(1, $code); + $row = DB::table('users')->where('id', $this->self_enrolled->getId())->first(); + $this->assertSame(1, (int)$row->two_factor_enabled); + $this->assertNotNull($row->two_factor_enforced_at); + $this->assertSame(10, $this->codeCount($this->self_enrolled)); + $this->assertSame(0, DB::table('user_trusted_devices') + ->where('user_id', $this->self_enrolled->getId())->where('is_revoked', 1)->count()); + $this->assertSame(0, DB::table('two_factor_audit_log') + ->where('user_id', $this->self_enrolled->getId()) + ->where('event_type', TwoFactorAuditLog::EventSettingsChanged)->count()); + } + + public function testResetClearsSelfEnrolledUser(): void + { + $code = Artisan::call('idp:reset-2fa', ['email' => 'reset-self@nomail.com', '--reason' => 'ticket 123']); + $output = Artisan::output(); + + $this->assertSame(0, $code); + $id = $this->self_enrolled->getId(); + $row = DB::table('users')->where('id', $id)->first(); + $this->assertSame(0, (int)$row->two_factor_enabled); + $this->assertNull($row->two_factor_enforced_at); + $this->assertSame(0, $this->codeCount($this->self_enrolled)); + + $devices = DB::table('user_trusted_devices')->where('user_id', $id)->get(); + $this->assertCount(2, $devices); + foreach ($devices as $device) { + $this->assertSame(1, (int)$device->is_revoked); + } + + $audit = DB::table('two_factor_audit_log') + ->where('user_id', $id) + ->where('event_type', TwoFactorAuditLog::EventSettingsChanged) + ->get(); + $this->assertCount(1, $audit); + $metadata = json_decode($audit[0]->metadata, true); + $this->assertSame('ticket 123', $metadata['reason']); + $this->assertSame('console', $metadata['actor']); + $this->assertNotEmpty($metadata['operator']); + + $this->assertStringContainsString('unused recovery codes deleted: 10', $output); + $this->assertStringNotContainsString('2FA enforced group', $output); + + // a self-enrolled user is no longer challenged at the next password login + $this->assertFalse($this->reloaded($this->self_enrolled)->shouldRequire2FA()); + } + + public function testGroupEnforcedAdminIsStillChallengedAfterReset(): void + { + $code = Artisan::call('idp:reset-2fa', ['email' => 'reset-admin@nomail.com', '--reason' => 'lost phone']); + $output = Artisan::output(); + + $this->assertSame(0, $code); + $this->assertSame(0, $this->codeCount($this->admin)); + $this->assertSame(0, (int)DB::table('users')->where('id', $this->admin->getId())->value('two_factor_enabled')); + $this->assertStringContainsString('2FA enforced group', $output); + + // enforcement is derived from the group: the next login still lands on the challenge + $this->assertTrue($this->reloaded($this->admin)->shouldRequire2FA()); + } +} diff --git a/tests/unit/Console/Reset2FACommandTest.php b/tests/unit/Console/Reset2FACommandTest.php new file mode 100644 index 00000000..a85fc433 --- /dev/null +++ b/tests/unit/Console/Reset2FACommandTest.php @@ -0,0 +1,175 @@ + []]); + $this->recovery_service = Mockery::mock(IRecoveryCodeService::class); + $this->audit_service = Mockery::mock(ITwoFactorAuditService::class); + $this->device_repo = Mockery::mock(IUserTrustedDeviceRepository::class); + $this->app->instance(IRecoveryCodeService::class, $this->recovery_service); + $this->app->instance(ITwoFactorAuditService::class, $this->audit_service); + $this->app->instance(IUserTrustedDeviceRepository::class, $this->device_repo); + } + + protected function tearDown(): void + { + $this->addToAssertionCount(Mockery::getContainer()->mockery_getExpectationCount()); + Mockery::close(); + parent::tearDown(); + } + + private function createUser(): User + { + $user = new User(); + $user->setEmail(self::Email); + $user->setFirstName('Reset'); + $user->setLastName('TwoFactor'); + $user->setIdentifier(self::Email); + $user->setPassword('P@sswordS3cret'); + $user->verifyEmail(false); + EntityManager::persist($user); + EntityManager::flush(); + return $user; + } + + private function expectNothingTouched(): void + { + $this->recovery_service->shouldNotReceive('countUnusedRecoveryCodes'); + $this->recovery_service->shouldNotReceive('disableTwoFactor'); + $this->audit_service->shouldNotReceive('log'); + } + + public function testAbortsWithoutReason(): void + { + $this->createUser(); + $this->expectNothingTouched(); + + $code = Artisan::call('idp:reset-2fa', ['email' => self::Email]); + + $this->assertSame(1, $code); + $this->assertStringContainsString('--reason option is required', Artisan::output()); + } + + public function testAbortsWithBlankReason(): void + { + $this->createUser(); + $this->expectNothingTouched(); + + $code = Artisan::call('idp:reset-2fa', ['email' => self::Email, '--reason' => ' ']); + + $this->assertSame(1, $code); + } + + public function testFailsWhenUserNotFound(): void + { + $this->expectNothingTouched(); + + $code = Artisan::call('idp:reset-2fa', ['email' => 'nobody@nomail.com', '--reason' => 'ticket 123']); + + $this->assertSame(1, $code); + $this->assertStringContainsString('nobody@nomail.com not found', Artisan::output()); + } + + public function testResetsAndAuditsWithReasonAndOperator(): void + { + $user = $this->createUser(); + $this->recovery_service->shouldReceive('countUnusedRecoveryCodes')->once()->andReturn(7); + $this->device_repo->shouldReceive('getActiveByUser')->once()->andReturn([1, 2]); + $this->recovery_service->shouldReceive('disableTwoFactor') + ->once() + ->with(Mockery::on(fn($u) => $u->getId() === $user->getId()), null, null); + $this->audit_service->shouldReceive('log') + ->once() + ->with( + Mockery::on(fn($u) => $u->getId() === $user->getId()), + TwoFactorAuditLog::EventSettingsChanged, + User::MFAMethod_OTP, + Mockery::type('string'), + Mockery::on(fn(array $m) => $m['reason'] === 'ticket 123' + && $m['actor'] === 'console' + && !empty($m['operator'])) + ); + + $code = Artisan::call('idp:reset-2fa', ['email' => self::Email, '--reason' => 'ticket 123']); + $output = Artisan::output(); + + $this->assertSame(0, $code); + $this->assertStringContainsString('unused recovery codes deleted: 7', $output); + $this->assertStringContainsString('trusted devices revoked: 2', $output); + $this->assertStringNotContainsString('2FA enforced group', $output); + } + + public function testWarnsWhenUserIsGroupEnforced(): void + { + $user = $this->createUser(); + // a stand-in for group membership: the user is required by the stored flag + $user->enable2FA(User::MFAMethod_OTP); + EntityManager::flush(); + $this->recovery_service->shouldReceive('countUnusedRecoveryCodes')->once()->andReturn(0); + $this->device_repo->shouldReceive('getActiveByUser')->once()->andReturn([]); + $this->recovery_service->shouldReceive('disableTwoFactor')->once(); + $this->audit_service->shouldReceive('log')->once(); + + // the mocked service did not clear the flag, so shouldRequire2FA() is still true + $code = Artisan::call('idp:reset-2fa', ['email' => self::Email, '--reason' => 'ticket 123']); + + $this->assertSame(0, $code); + $this->assertStringContainsString('2FA enforced group', Artisan::output()); + } + + public function testAuditFailureIsReportedAsFailure(): void + { + $this->createUser(); + $this->recovery_service->shouldReceive('countUnusedRecoveryCodes')->once()->andReturn(0); + $this->device_repo->shouldReceive('getActiveByUser')->once()->andReturn([]); + $this->recovery_service->shouldReceive('disableTwoFactor')->once(); + $this->audit_service->shouldReceive('log')->once()->andThrow(new \RuntimeException('audit down')); + + $code = Artisan::call('idp:reset-2fa', ['email' => self::Email, '--reason' => 'ticket 123']); + + $this->assertSame(1, $code); + $this->assertStringContainsString('reset of ' . self::Email . ' was applied', Artisan::output()); + } +}