From dbf8657fd182f076d58882abdf3d9c07646181a8 Mon Sep 17 00:00:00 2001 From: romanetar Date: Mon, 5 Oct 2026 17:46:13 +0200 Subject: [PATCH 1/2] feat(mfa): add idp:enforce-admin-2fa command Onboards every user of the groups in two_factor.enforced_groups: generates a recovery code batch, emails the user that MFA is now required (no codes in the mail or the console output) and records an enrollment_changed audit event with {"source": "enforce-admin-2fa"}. Users that already have unused recovery codes are skipped so a saved batch is never invalidated. Supports --dry-run, chunked queries and processed / skipped / error counts. It deliberately does not set two_factor_enabled / two_factor_method / two_factor_enforced_at: enforcement is derived from group membership by User::shouldRequire2FA(). --- .../Commands/EnforceAdmin2FACommand.php | 153 +++++++++++++++ app/Console/Kernel.php | 1 + app/Mail/TwoFactorEnforcedMail.php | 68 +++++++ .../emails/auth/two_factor_enforced.blade.php | 16 ++ .../EnforceAdmin2FACommandIntegrationTest.php | 129 +++++++++++++ .../Console/EnforceAdmin2FACommandTest.php | 181 ++++++++++++++++++ 6 files changed, 548 insertions(+) create mode 100644 app/Console/Commands/EnforceAdmin2FACommand.php create mode 100644 app/Mail/TwoFactorEnforcedMail.php create mode 100644 resources/views/emails/auth/two_factor_enforced.blade.php create mode 100644 tests/EnforceAdmin2FACommandIntegrationTest.php create mode 100644 tests/unit/Console/EnforceAdmin2FACommandTest.php diff --git a/app/Console/Commands/EnforceAdmin2FACommand.php b/app/Console/Commands/EnforceAdmin2FACommand.php new file mode 100644 index 000000000..45f231df8 --- /dev/null +++ b/app/Console/Commands/EnforceAdmin2FACommand.php @@ -0,0 +1,153 @@ +option('dry-run'); + $slugs = array_values(array_filter((array)config('two_factor.enforced_groups', []))); + + if (empty($slugs)) { + $this->warn('No groups configured in two_factor.enforced_groups, nothing to do.'); + return self::SUCCESS; + } + + $this->info(sprintf( + '%sEnforced groups: %s', + $dry_run ? '[DRY RUN] ' : '', + implode(', ', $slugs) + )); + + $processed = 0; + $skipped = 0; + $errors = 0; + $last_id = 0; + + do { + // keyset pagination over ids: the loop never holds the whole user set in memory + $ids = $this->getUserIdsChunk($slugs, $last_id); + + foreach ($ids as $id) { + $last_id = $id; + $email = (string)$id; + try { + $user = EntityManager::getRepository(User::class)->find($id); + if (is_null($user)) { + continue; + } + $email = $user->getEmail(); + + // never overwrite an existing batch: the user may have saved those codes + if ($recovery_code_service->countUnusedRecoveryCodes($user) > 0) { + $skipped++; + $this->line(sprintf(' skipped %s (already has unused recovery codes)', $email)); + continue; + } + + if ($dry_run) { + $processed++; + $this->line(sprintf(' would process %s', $email)); + continue; + } + + // plaintext codes are shown once and must never be printed or mailed + $recovery_code_service->generateRecoveryCodes($user); + + $audit_service->log( + $user, + TwoFactorAuditLog::EventEnrollmentChanged, + TwoFactorAuditLog::MethodEmailOtp, + self::ConsoleIp, + ['source' => 'enforce-admin-2fa'] + ); + + // codes are already committed: a mail failure counts as an error + // for this user but is never rolled back, that would invalidate them + Mail::send(new TwoFactorEnforcedMail($user)); + + $processed++; + $this->line(sprintf(' processed %s', $email)); + } catch (\Throwable $ex) { + $errors++; + Log::error($ex); + $this->error(sprintf(' error %s: %s', $email, $ex->getMessage())); + } + } + } while (count($ids) === self::ChunkSize); + + $this->info(sprintf( + '%sSummary: processed %d, skipped %d, errors %d', + $dry_run ? '[DRY RUN] ' : '', + $processed, + $skipped, + $errors + )); + + return $errors > 0 ? self::FAILURE : self::SUCCESS; + } + + /** + * @param string[] $slugs + * @return int[] + */ + private function getUserIdsChunk(array $slugs, int $after_id): array + { + $rows = EntityManager::createQuery( + 'SELECT DISTINCT u.id FROM ' . User::class . ' u JOIN u.groups g ' . + 'WHERE g.slug IN (:slugs) AND u.id > :after_id ORDER BY u.id ASC' + ) + ->setParameter('slugs', $slugs) + ->setParameter('after_id', $after_id) + ->setMaxResults(self::ChunkSize) + ->getScalarResult(); + + return array_map(static fn(array $row) => (int)$row['id'], $rows); + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 03857599f..52489b9ee 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -29,6 +29,7 @@ class Kernel extends ConsoleKernel Commands\CleanOAuth2StaleData::class, Commands\CleanOpenIdStaleData::class, Commands\CreateSuperAdmin::class, + Commands\EnforceAdmin2FACommand::class, Commands\CreateRawUser::class, Commands\CreateOAuth2TestClient::class, Commands\GetLatestOtp::class, diff --git a/app/Mail/TwoFactorEnforcedMail.php b/app/Mail/TwoFactorEnforcedMail.php new file mode 100644 index 000000000..e0f77c5b0 --- /dev/null +++ b/app/Mail/TwoFactorEnforcedMail.php @@ -0,0 +1,68 @@ +user_email = $user->getEmail(); + $this->user_fullname = $user->getFullName(); + $this->profile_url = rtrim(Config::get('app.url'), '/') . '/accounts/user/profile'; + } + + /** + * @return $this + */ + public function build() + { + $subject = sprintf("[%s] Two-factor authentication is now required on your account", Config::get('app.app_name')); + Log::debug(sprintf("TwoFactorEnforcedMail::build to %s", $this->user_email)); + return $this->from(Config::get("mail.from")) + ->to($this->user_email) + ->subject($subject) + ->view('emails.auth.two_factor_enforced'); + } +} diff --git a/resources/views/emails/auth/two_factor_enforced.blade.php b/resources/views/emails/auth/two_factor_enforced.blade.php new file mode 100644 index 000000000..532d5ddf4 --- /dev/null +++ b/resources/views/emails/auth/two_factor_enforced.blade.php @@ -0,0 +1,16 @@ + + + + + + +

Dear {!! $user_fullname !!},

+

Two-factor authentication (MFA) is now required on your account. You will be asked for a second verification step each time you sign in.

+

Recovery codes have been set up for your account. Recovery codes are shown only once, so to get a set you can save, sign in, open the Security section of your profile and regenerate them:

+

{!! $profile_url !!}

+

Keep your recovery codes in a safe place: they let you sign in if you lose access to your second factor.

+
+
+

Cheers,
Your {!! Config::get('app.tenant_name') !!} Support Team

+ + diff --git a/tests/EnforceAdmin2FACommandIntegrationTest.php b/tests/EnforceAdmin2FACommandIntegrationTest.php new file mode 100644 index 000000000..22318b357 --- /dev/null +++ b/tests/EnforceAdmin2FACommandIntegrationTest.php @@ -0,0 +1,129 @@ +setName(self::TestGroupSlug); + $group->setSlug(self::TestGroupSlug); + $group->setDefault(false); + $group->setActive(true); + EntityManager::persist($group); + EntityManager::flush(); + return $group; + } + + protected function setUp(): void + { + parent::setUp(); + config(['two_factor.enforced_groups' => [self::TestGroupSlug]]); + Mail::fake(); + + $user = new User(); + $user->setEmail('enforce-admin@nomail.com'); + $user->setFirstName('Enforce'); + $user->setLastName('Admin'); + $user->setIdentifier('enforce-admin@nomail.com'); + $user->setPassword('P@sswordS3cret'); + $user->verifyEmail(false); + $user->addToGroup($this->createEnforcedGroup()); + EntityManager::persist($user); + EntityManager::flush(); + $this->admin = $user; + } + + private function userRow(): object + { + return DB::table('users')->where('id', $this->admin->getId())->first(); + } + + private function codeHashes(): array + { + return DB::table('user_recovery_codes') + ->where('user_id', $this->admin->getId()) + ->orderBy('id') + ->pluck('code_hash') + ->all(); + } + + public function testEnforceProcessesEnforcedAdmin(): void + { + $before = $this->userRow(); + + $code = Artisan::call('idp:enforce-admin-2fa'); + + $this->assertSame(0, $code); + $this->assertCount(10, $this->codeHashes()); + $this->assertSame(1, DB::table('two_factor_audit_log') + ->where('user_id', $this->admin->getId()) + ->where('event_type', TwoFactorAuditLog::EventEnrollmentChanged) + ->count()); + $metadata = DB::table('two_factor_audit_log') + ->where('user_id', $this->admin->getId()) + ->where('event_type', TwoFactorAuditLog::EventEnrollmentChanged) + ->value('metadata'); + $this->assertSame(['source' => 'enforce-admin-2fa'], json_decode($metadata, true)); + Mail::assertSent(TwoFactorEnforcedMail::class, 1); + + // enforcement is derived from the group: stored flags stay untouched + $after = $this->userRow(); + $this->assertSame($before->two_factor_enabled, $after->two_factor_enabled); + $this->assertSame($before->two_factor_method, $after->two_factor_method); + $this->assertSame($before->two_factor_enforced_at, $after->two_factor_enforced_at); + } + + public function testSecondRunSkipsAndKeepsExistingCodes(): void + { + Artisan::call('idp:enforce-admin-2fa'); + $hashes = $this->codeHashes(); + + Artisan::call('idp:enforce-admin-2fa'); + + $this->assertStringContainsString('processed 0, skipped 1, errors 0', Artisan::output()); + $this->assertSame($hashes, $this->codeHashes()); + Mail::assertSent(TwoFactorEnforcedMail::class, 1); + } + + public function testDryRunWritesNothing(): void + { + $code = Artisan::call('idp:enforce-admin-2fa', ['--dry-run' => true]); + + $this->assertSame(0, $code); + $this->assertStringContainsString('processed 1, skipped 0, errors 0', Artisan::output()); + $this->assertCount(0, $this->codeHashes()); + $this->assertSame(0, DB::table('two_factor_audit_log')->where('user_id', $this->admin->getId())->count()); + Mail::assertNothingSent(); + } +} diff --git a/tests/unit/Console/EnforceAdmin2FACommandTest.php b/tests/unit/Console/EnforceAdmin2FACommandTest.php new file mode 100644 index 000000000..5909b05e4 --- /dev/null +++ b/tests/unit/Console/EnforceAdmin2FACommandTest.php @@ -0,0 +1,181 @@ +setName(self::TestGroupSlug); + $group->setSlug(self::TestGroupSlug); + $group->setDefault(false); + $group->setActive(true); + EntityManager::persist($group); + EntityManager::flush(); + return $group; + } + + protected function setUp(): void + { + parent::setUp(); + config(['two_factor.enforced_groups' => [self::TestGroupSlug]]); + $this->group = $this->createEnforcedGroup(); + Mail::fake(); + $this->recovery_service = Mockery::mock(IRecoveryCodeService::class); + $this->audit_service = Mockery::mock(ITwoFactorAuditService::class); + $this->app->instance(IRecoveryCodeService::class, $this->recovery_service); + $this->app->instance(ITwoFactorAuditService::class, $this->audit_service); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + private function createUser(string $email, bool $in_group): User + { + $user = new User(); + $user->setEmail($email); + $user->setFirstName($email); + $user->setLastName($email); + $user->setIdentifier($email); + $user->setPassword('P@sswordS3cret'); + $user->verifyEmail(false); + if ($in_group) { + $user->addToGroup($this->group); + } + EntityManager::persist($user); + EntityManager::flush(); + return $user; + } + + public function testProcessesUsersWithoutUnusedCodes(): void + { + $user = $this->createUser('enforce-a@nomail.com', true); + + $this->recovery_service->shouldReceive('countUnusedRecoveryCodes')->once()->andReturn(0); + $this->recovery_service->shouldReceive('generateRecoveryCodes')->once()->andReturn(['AAAA-BBBB']); + $this->audit_service->shouldReceive('log') + ->once() + ->with( + Mockery::on(fn($u) => $u->getId() === $user->getId()), + TwoFactorAuditLog::EventEnrollmentChanged, + TwoFactorAuditLog::MethodEmailOtp, + Mockery::type('string'), + ['source' => 'enforce-admin-2fa'] + ); + + $code = Artisan::call('idp:enforce-admin-2fa'); + $output = Artisan::output(); + + $this->assertSame(0, $code); + $this->assertStringContainsString('processed 1, skipped 0, errors 0', $output); + // plaintext codes are shown once and never printed + $this->assertStringNotContainsString('AAAA-BBBB', $output); + Mail::assertSent(TwoFactorEnforcedMail::class, 1); + } + + public function testSkipsUsersWithUnusedCodes(): void + { + $this->createUser('enforce-b@nomail.com', true); + + $this->recovery_service->shouldReceive('countUnusedRecoveryCodes')->once()->andReturn(10); + $this->recovery_service->shouldNotReceive('generateRecoveryCodes'); + $this->audit_service->shouldNotReceive('log'); + + $code = Artisan::call('idp:enforce-admin-2fa'); + + $this->assertSame(0, $code); + $this->assertStringContainsString('processed 0, skipped 1, errors 0', Artisan::output()); + Mail::assertNothingSent(); + } + + public function testIgnoresUsersOutsideEnforcedGroups(): void + { + $this->createUser('enforce-c@nomail.com', false); + + $this->recovery_service->shouldNotReceive('countUnusedRecoveryCodes'); + $this->recovery_service->shouldNotReceive('generateRecoveryCodes'); + + Artisan::call('idp:enforce-admin-2fa'); + + $this->assertStringContainsString('processed 0, skipped 0, errors 0', Artisan::output()); + } + + public function testDryRunWritesNothingAndReportsSameCounts(): void + { + $this->createUser('enforce-d@nomail.com', true); + $this->createUser('enforce-e@nomail.com', true); + + $this->recovery_service->shouldReceive('countUnusedRecoveryCodes')->twice()->andReturn(0, 10); + $this->recovery_service->shouldNotReceive('generateRecoveryCodes'); + $this->audit_service->shouldNotReceive('log'); + + $code = Artisan::call('idp:enforce-admin-2fa', ['--dry-run' => true]); + + $this->assertSame(0, $code); + $this->assertStringContainsString('processed 1, skipped 1, errors 0', Artisan::output()); + Mail::assertNothingSent(); + } + + public function testErrorOnOneUserDoesNotStopTheRun(): void + { + $this->createUser('enforce-f@nomail.com', true); + $this->createUser('enforce-g@nomail.com', true); + + $this->recovery_service->shouldReceive('countUnusedRecoveryCodes')->twice()->andReturn(0); + $this->recovery_service->shouldReceive('generateRecoveryCodes') + ->twice() + ->andReturnUsing( + fn() => throw new \RuntimeException('boom'), + fn() => ['AAAA-BBBB'] + ); + $this->audit_service->shouldReceive('log')->once(); + + $code = Artisan::call('idp:enforce-admin-2fa'); + + $this->assertSame(1, $code); + $this->assertStringContainsString('processed 1, skipped 0, errors 1', Artisan::output()); + Mail::assertSent(TwoFactorEnforcedMail::class, 1); + } +} From fd29e5cd377f5f632aefd1f2b30940cac581d59e Mon Sep 17 00:00:00 2001 From: romanetar Date: Wed, 7 Oct 2026 19:42:26 +0200 Subject: [PATCH 2/2] feat(mfa): add IRecoveryCodeService::disableTwoFactor Shared service method that disables 2FA for a user: clears two_factor_enabled and two_factor_enforced_at, deletes every recovery code and revokes every trusted device in a single transaction, then records a device_revoked audit event best-effort. The actor decides the password rule: null (console) and another user (admin) need no password, the user acting on their own account must provide the current one. The settings_changed audit event is left to the caller, which knows the context (reason, actor, operator). Group-enforced users are still challenged at login afterwards, as enforcement is derived from group membership. --- app/Services/Auth/IRecoveryCodeService.php | 25 +++ app/Services/Auth/RecoveryCodeService.php | 38 +++++ tests/DisableTwoFactorIntegrationTest.php | 102 +++++++++++ ...ecoveryCodeServiceDisableTwoFactorTest.php | 159 ++++++++++++++++++ 4 files changed, 324 insertions(+) create mode 100644 tests/DisableTwoFactorIntegrationTest.php create mode 100644 tests/unit/RecoveryCodeServiceDisableTwoFactorTest.php diff --git a/app/Services/Auth/IRecoveryCodeService.php b/app/Services/Auth/IRecoveryCodeService.php index ab6de16ec..dd9977be9 100644 --- a/app/Services/Auth/IRecoveryCodeService.php +++ b/app/Services/Auth/IRecoveryCodeService.php @@ -67,4 +67,29 @@ public function countUnusedRecoveryCodes(User $user): int; * @return RecoveryCodesStatus remaining/total/low-threshold standing for the user */ public function getStatus(User $user): RecoveryCodesStatus; + + /** + * Disables 2FA for the user: clears two_factor_enabled and two_factor_enforced_at, + * deletes every recovery code and revokes every trusted device, all in a single + * transaction. A device_revoked audit event is recorded best-effort afterwards. + * + * $actor identifies who performs the operation: + * - null: a system/console operation (e.g. idp:reset-2fa), no password is required; + * - the same user: self-service, $currentPassword must match; + * - another user: an administrator acting on the account, no password is required. + * Authorizing the administrator is the caller's responsibility. + * + * The caller is responsible for emitting the settings_changed audit event, as only + * it knows the context (reason, actor, operator). + * + * Group-enforced users are still required to complete 2FA at login afterwards, + * as enforcement is derived from group membership (User::shouldRequire2FA()). + * + * @param User $user + * @param string|null $currentPassword + * @param User|null $actor + * @return void + * @throws ValidationException if the actor is the user and $currentPassword does not match + */ + public function disableTwoFactor(User $user, ?string $currentPassword, ?User $actor): void; } diff --git a/app/Services/Auth/RecoveryCodeService.php b/app/Services/Auth/RecoveryCodeService.php index a626dd82e..93a7a086a 100644 --- a/app/Services/Auth/RecoveryCodeService.php +++ b/app/Services/Auth/RecoveryCodeService.php @@ -16,6 +16,7 @@ use App\libs\Auth\Models\TwoFactorAuditLog; use App\libs\Auth\Models\UserRecoveryCode; use Auth\Repositories\IUserRecoveryCodeRepository; +use Auth\Repositories\IUserTrustedDeviceRepository; use Auth\Repositories\IUserRepository; use Auth\User; use Illuminate\Support\Facades\Hash; @@ -36,6 +37,7 @@ final class RecoveryCodeService implements IRecoveryCodeService public function __construct( private readonly IUserRecoveryCodeRepository $repository, private readonly IUserRepository $user_repository, + private readonly IUserTrustedDeviceRepository $trusted_device_repository, private readonly ITransactionService $tx_service, private readonly ITwoFactorAuditService $audit_service, ) { @@ -121,6 +123,42 @@ public function enableTwoFactorAndGenerateCodes(User $user, string $method): arr return $codes; } + /** + * @inheritDoc + */ + public function disableTwoFactor(User $user, ?string $currentPassword, ?User $actor): void + { + // self-service requires the current password; console and admin + // operations do not (see IRecoveryCodeService::disableTwoFactor) + if (!is_null($actor) && $actor->getId() === $user->getId() + && !$user->checkPassword(trim((string)$currentPassword))) { + throw new ValidationException('current_password is not correct.'); + } + + // a single transaction() call: nesting another one (e.g. through the audit + // service) inside it would let an inner failure close the entity manager + // under this still-running outer transaction + $this->tx_service->transaction(function () use ($user) { + $user->disable2FA(); + $this->user_repository->add($user, false); + $this->repository->deleteAllForUser($user); + $this->trusted_device_repository->revokeAllForUser($user); + }); + + // Best-effort: 2FA is already disabled and the codes and devices are gone, + // an audit-logging failure must not make the caller believe it did not happen. + try { + $this->audit_service->log( + $user, + TwoFactorAuditLog::EventDeviceRevoked, + $user->getTwoFactorMethod(), + IPHelper::getUserIp() + ); + } catch (\Throwable $ex) { + Log::warning($ex); + } + } + /** * Invalidates every existing recovery code for the user and generates a * fresh batch, within the caller's already-open transaction. diff --git a/tests/DisableTwoFactorIntegrationTest.php b/tests/DisableTwoFactorIntegrationTest.php new file mode 100644 index 000000000..ccaff2d87 --- /dev/null +++ b/tests/DisableTwoFactorIntegrationTest.php @@ -0,0 +1,102 @@ +service = $this->app->make(IRecoveryCodeService::class); + + $user = new User(); + $user->setEmail('disable-2fa@nomail.com'); + $user->setFirstName('Disable'); + $user->setLastName('TwoFactor'); + $user->setIdentifier('disable-2fa@nomail.com'); + $user->setPassword('P@sswordS3cret'); + $user->verifyEmail(false); + EntityManager::persist($user); + EntityManager::flush(); + $this->user = $user; + + // enrolled, with a code batch and two trusted devices + $this->service->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'); + } + + private function userRow(): object + { + return DB::table('users')->where('id', $this->user->getId())->first(); + } + + public function testDisableClearsEverything(): void + { + $this->assertSame(1, (int)$this->userRow()->two_factor_enabled); + $this->assertSame(10, DB::table('user_recovery_codes')->where('user_id', $this->user->getId())->count()); + + $this->service->disableTwoFactor($this->user, null, null); + + $row = $this->userRow(); + $this->assertSame(0, (int)$row->two_factor_enabled); + $this->assertNull($row->two_factor_enforced_at); + $this->assertSame(0, DB::table('user_recovery_codes')->where('user_id', $this->user->getId())->count()); + + $devices = DB::table('user_trusted_devices')->where('user_id', $this->user->getId())->get(); + $this->assertCount(2, $devices); + foreach ($devices as $device) { + $this->assertSame(1, (int)$device->is_revoked); + } + + $this->assertSame(1, DB::table('two_factor_audit_log') + ->where('user_id', $this->user->getId()) + ->where('event_type', TwoFactorAuditLog::EventDeviceRevoked) + ->count()); + // settings_changed is the caller's responsibility + $this->assertSame(0, DB::table('two_factor_audit_log') + ->where('user_id', $this->user->getId()) + ->where('event_type', TwoFactorAuditLog::EventSettingsChanged) + ->count()); + } + + public function testSelfServiceWithWrongPasswordLeavesStateUntouched(): void + { + try { + $this->service->disableTwoFactor($this->user, 'wrong', $this->user); + $this->fail('ValidationException expected'); + } catch (\models\exceptions\ValidationException $ex) { + } + + $this->assertSame(1, (int)$this->userRow()->two_factor_enabled); + $this->assertSame(10, DB::table('user_recovery_codes')->where('user_id', $this->user->getId())->count()); + $this->assertSame(0, DB::table('user_trusted_devices') + ->where('user_id', $this->user->getId())->where('is_revoked', 1)->count()); + } +} diff --git a/tests/unit/RecoveryCodeServiceDisableTwoFactorTest.php b/tests/unit/RecoveryCodeServiceDisableTwoFactorTest.php new file mode 100644 index 000000000..f65463e96 --- /dev/null +++ b/tests/unit/RecoveryCodeServiceDisableTwoFactorTest.php @@ -0,0 +1,159 @@ +code_repo = Mockery::mock(IUserRecoveryCodeRepository::class); + $this->user_repo = Mockery::mock(IUserRepository::class); + $this->device_repo = Mockery::mock(IUserTrustedDeviceRepository::class); + $this->audit_service = Mockery::mock(ITwoFactorAuditService::class); + $this->tx_service = Mockery::mock(ITransactionService::class); + $this->tx_service->shouldReceive('transaction')->andReturnUsing(fn($cb) => $cb())->byDefault(); + + $this->service = new RecoveryCodeService( + $this->code_repo, + $this->user_repo, + $this->device_repo, + $this->tx_service, + $this->audit_service + ); + } + + protected function tearDown(): void + { + // Mockery expectations are verified on close(): count them as assertions + $this->addToAssertionCount(Mockery::getContainer()->mockery_getExpectationCount()); + Mockery::close(); + parent::tearDown(); + } + + private function buildUser(int $id): \Mockery\MockInterface + { + $user = Mockery::mock(User::class); + $user->shouldReceive('getId')->andReturn($id); + $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP); + return $user; + } + + private function expectFullDisable(\Mockery\MockInterface $user): void + { + $user->shouldReceive('disable2FA')->once(); + $this->user_repo->shouldReceive('add')->once()->with($user, false); + $this->code_repo->shouldReceive('deleteAllForUser')->once()->with($user)->andReturn(10); + $this->device_repo->shouldReceive('revokeAllForUser')->once()->with($user); + $this->audit_service->shouldReceive('log') + ->once() + ->with($user, TwoFactorAuditLog::EventDeviceRevoked, User::MFAMethod_OTP, Mockery::type('string')); + } + + public function testConsoleActorNeedsNoPassword(): void + { + $user = $this->buildUser(1); + $user->shouldNotReceive('checkPassword'); + $this->expectFullDisable($user); + + $this->service->disableTwoFactor($user, null, null); + } + + public function testAdminActorNeedsNoPassword(): void + { + $user = $this->buildUser(1); + $user->shouldNotReceive('checkPassword'); + $admin = $this->buildUser(2); + $this->expectFullDisable($user); + + $this->service->disableTwoFactor($user, null, $admin); + } + + public function testSelfServiceWithCorrectPassword(): void + { + $user = $this->buildUser(1); + $user->shouldReceive('checkPassword')->once()->with('secret')->andReturn(true); + $this->expectFullDisable($user); + + $this->service->disableTwoFactor($user, ' secret ', $user); + } + + public function testSelfServiceWithWrongPasswordChangesNothing(): void + { + $user = $this->buildUser(1); + $user->shouldReceive('checkPassword')->once()->andReturn(false); + $user->shouldNotReceive('disable2FA'); + $this->tx_service->shouldNotReceive('transaction'); + $this->code_repo->shouldNotReceive('deleteAllForUser'); + $this->device_repo->shouldNotReceive('revokeAllForUser'); + $this->audit_service->shouldNotReceive('log'); + + $this->expectException(ValidationException::class); + $this->service->disableTwoFactor($user, 'wrong', $user); + } + + public function testSelfServiceWithoutPasswordIsRejected(): void + { + $user = $this->buildUser(1); + $user->shouldReceive('checkPassword')->once()->with('')->andReturn(false); + $user->shouldNotReceive('disable2FA'); + + $this->expectException(ValidationException::class); + $this->service->disableTwoFactor($user, null, $user); + } + + public function testAuditFailureDoesNotFailTheOperation(): void + { + $user = $this->buildUser(1); + $user->shouldReceive('disable2FA')->once(); + $this->user_repo->shouldReceive('add')->once(); + $this->code_repo->shouldReceive('deleteAllForUser')->once()->andReturn(0); + $this->device_repo->shouldReceive('revokeAllForUser')->once(); + $this->audit_service->shouldReceive('log')->once()->andThrow(new \RuntimeException('audit down')); + + $this->service->disableTwoFactor($user, null, null); + } +}