Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions app/Console/Commands/Reset2FACommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<?php namespace App\Console\Commands;
/**
* Copyright 2026 OpenStack Foundation
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/

use App\libs\Auth\Models\TwoFactorAuditLog;
use App\Services\Auth\IRecoveryCodeService;
use App\Services\Auth\ITwoFactorAuditService;
use Auth\Repositories\IUserTrustedDeviceRepository;
use Auth\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use LaravelDoctrine\ORM\Facades\EntityManager;

/**
* Class Reset2FACommand
* Resets MFA for one user (lost second factor and recovery codes). It is a
* privileged operation that only runs with server access: the mandatory
* --reason and the settings_changed audit row are the accountability trail.
* @package App\Console\Commands
*/
final class Reset2FACommand extends Command
{
/**
* The audit ip_address for events originated from the console.
*/
private const ConsoleIp = '127.0.0.1';

/**
* @var string
*/
protected $signature = 'idp:reset-2fa {email : Email of the user to reset} {--reason= : Why the reset is needed (required, audited)}';

/**
* @var string
*/
protected $description = 'Reset 2FA for a user: clears the enrollment, deletes recovery codes and revokes trusted devices';

public function handle
(
IRecoveryCodeService $recovery_code_service,
ITwoFactorAuditService $audit_service,
IUserTrustedDeviceRepository $trusted_device_repository
): int
{
$reason = trim((string)$this->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;
}
}
1 change: 1 addition & 0 deletions app/Console/Kernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
153 changes: 153 additions & 0 deletions tests/Reset2FACommandIntegrationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
<?php namespace Tests;
/**
* Copyright 2026 OpenStack Foundation
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/

use App\libs\Auth\Models\TwoFactorAuditLog;
use App\Services\Auth\IDeviceTrustService;
use App\Services\Auth\IRecoveryCodeService;
use Auth\Group;
use Auth\User;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use LaravelDoctrine\ORM\Facades\EntityManager;

/**
* Class Reset2FACommandIntegrationTest
* Runs idp:reset-2fa through Artisan against a self-enrolled user and a
* group-enforced admin and asserts the database side effects.
* @package Tests
*/
final class Reset2FACommandIntegrationTest extends BrowserKitTestCase
{
private const GroupSlug = 'reset-2fa-test-group';

private User $self_enrolled;
private User $admin;

protected function setUp(): void
{
parent::setUp();
config(['two_factor.enforced_groups' => [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());
}
}
Loading
Loading