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
30 changes: 30 additions & 0 deletions app/Services/Auth/IRecoveryCodeService.php
Original file line number Diff line number Diff line change
Expand Up @@ -105,4 +105,34 @@ public function countUnusedRecoveryCodes(User $user): int;
* @return RecoveryCodesStatus remaining/total/low-threshold/acknowledged/enrollment-required standing
*/
public function getStatus(User $user): RecoveryCodesStatus;

/**
* Disables 2FA for the user: clears two_factor_enabled, two_factor_enforced_at and
* recovery_codes_acknowledged_at, deletes every recovery code and revokes every
* trusted device, all in a single transaction under the users-row lock. An
* enrollment_changed audit event with metadata {enabled: false, actor_id, reason}
* is recorded best-effort afterwards; reason is self, admin or console.
*
* $actor identifies who performs the operation:
* - null: a system/console operation (e.g. idp:reset-2fa), nothing is required;
* - the same user: self-service. With a password, $currentPassword must match.
* Without one (social-only, !hasPasswordSet()) the session must be inside the
* recovery enrollment window (isWithinEnrollmentWindow());
* - another user: an administrator acting on the account, no password is required.
* Authorizing the administrator is the caller's responsibility.
*
* Group-enforced users cannot be disabled by a user or an administrator: enforcement
* is derived from group membership (User::isTwoFactorEnforcedByGroup()), so the call
* is refused. The console path is allowed (manual recovery); such a user is still
* challenged at login afterwards.
*
* @param User $user
* @param string|null $currentPassword
* @param User|null $actor
* @return void
* @throws ValidationException if the user is enforced by group and $actor is not null, if
* the actor is the user and the password does not match, or the user has no password
* and the session is outside the enrollment window
*/
public function disableTwoFactor(User $user, ?string $currentPassword, ?User $actor): void;
}
64 changes: 64 additions & 0 deletions app/Services/Auth/RecoveryCodeService.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use App\libs\Auth\Models\UserRecoveryCode;
use Auth\MFAConstants;
use Auth\Repositories\IUserRecoveryCodeRepository;
use Auth\Repositories\IUserTrustedDeviceRepository;
use Auth\Repositories\IUserRepository;
use Auth\User;
use Illuminate\Support\Facades\Hash;
Expand All @@ -42,6 +43,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,
) {
Expand Down Expand Up @@ -230,6 +232,68 @@ private function logCodesGenerated(User $user, RecoveryCodesBatch $batch): void
}
}

/**
* @inheritDoc
*/
public function disableTwoFactor(User $user, ?string $currentPassword, ?User $actor): void

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@romanetar This adds a second RecoveryCodeService::disableTwoFactor(): #169 already defines one with the same signature, and git merges this file without a conflict (only IRecoveryCodeService.php and the two test files conflict). git merge-tree of this branch and feat/mfa-disable-two-factor-service leaves both methods in RecoveryCodeService.php, and php -l fails with Cannot redeclare App\Services\Auth\RecoveryCodeService::disableTwoFactor(). The container resolves this class in the UserController and UserApiController constructors, so a tree merged that way breaks those routes, login included. Details in the matching comment on #169.

Separately, line 275 calls revokeAllForUser(), which #160 and #169 replace with revokeAllActiveForUser(User): UserTrustedDevice[]. git merge-tree of this branch and #160 also merges this file without a conflict and keeps that call, so disabling 2FA would fail with "Call to undefined method" at runtime.

The two implementations also disagree on audit semantics: this one writes a single enrollment_changed row and no device_revoked, while #169 writes one device_revoked (with device_id) per revoked device and leaves the enrollment/settings event to the caller (idp:reset-2fa in #171 writes settings_changed). SDS rev 2.6 (fntechgit/ftn-docsnsklz#179) specifies the per-device device_revoked for disableTwoFactor.

Suggested fix: keep a single method. Move the group guard, lockUserRow(), the recovery_codes_acknowledged_at reset and the passwordless window into #169's disableTwoFactor() instead of adding a second definition here, switch the revocation to revokeAllActiveForUser() with one device_revoked per returned device, and decide whether enrollment_changed is written by the service or by the caller so a reset does not record it twice. Then run php -l and phpunit on the combined tree.

{
// Group enforcement does not depend on the stored flag: disabling would only
// delete the recovery codes and trusted devices of a user who is still
// challenged at login. The console (null actor) is the manual recovery path
// (idp:reset-2fa) and stays allowed.
if (!is_null($actor) && $user->isTwoFactorEnforcedByGroup()) {
throw new ValidationException('Two-factor authentication is enforced by group membership and cannot be disabled.');
}

$is_self = !is_null($actor) && $actor->getId() === $user->getId();

if ($is_self) {
if ($user->hasPasswordSet()) {
if (!$user->checkPassword(trim((string)$currentPassword))) {
throw new ValidationException('current_password is not correct.');
}
} elseif (!$this->isWithinEnrollmentWindow($user)) {
// Social-only account: no password to confirm. Proof of identity is a
// real 2FA challenge completed moments ago in this session.
throw new ValidationException(
'Sign out and sign in again, then turn off two-factor authentication right after the sign-in.'
);
}
}

// 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) {
// serializes with a concurrent regenerate: it also deletes and inserts
// codes. The lock re-hydrates $user, so it must precede disable2FA().
$this->repository->lockUserRow($user);
$user->disable2FA();
$user->setRecoveryCodesAcknowledgedAt(null);
$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::EventEnrollmentChanged,
$user->getTwoFactorMethod(),
IPHelper::getUserIp(),
[
'enabled' => false,
'actor_id' => $actor?->getId(),
'reason' => $is_self ? 'self' : (is_null($actor) ? 'console' : 'admin'),
]
);
} 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. The caller
Expand Down
18 changes: 16 additions & 2 deletions app/libs/Auth/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -2459,13 +2459,27 @@ public function shouldRequire2FA(): bool
if (!config('two_factor.enabled', true)) {
return false;
}
if ($this->isTwoFactorEnforcedByGroup()) {
return true;
}
return (bool) $this->two_factor_enabled;
}

/**
* Whether the user belongs to any of the groups listed in
* config('two_factor.enforced_groups'). Group enforcement does not depend on the
* stored two_factor_enabled flag, so it cannot be switched off by disabling it.
* Unlike shouldRequire2FA() this ignores the global two_factor.enabled kill-switch.
*/
public function isTwoFactorEnforcedByGroup(): bool
{
$enforcedGroups = config('two_factor.enforced_groups', []);
foreach ($enforcedGroups as $slug) {
if($this->belongToGroup($slug)) {
if ($this->belongToGroup($slug)) {
return true;
}
}
return (bool) $this->two_factor_enabled;
return false;
}

/**
Expand Down
108 changes: 108 additions & 0 deletions tests/DisableTwoFactorIntegrationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<?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\User;
use Illuminate\Support\Facades\DB;
use LaravelDoctrine\ORM\Facades\EntityManager;

/**
* Class DisableTwoFactorIntegrationTest
* IRecoveryCodeService::disableTwoFactor against the real database.
* @package Tests
*/
final class DisableTwoFactorIntegrationTest extends BrowserKitTestCase
{
private IRecoveryCodeService $service;
private User $user;

protected function setUp(): void
{
parent::setUp();
$this->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);
}

$audit = DB::table('two_factor_audit_log')
->where('user_id', $this->user->getId())
->where('event_type', TwoFactorAuditLog::EventEnrollmentChanged)
->orderByDesc('id')
->first();
$this->assertNotNull($audit);
$metadata = json_decode($audit->metadata, true);
$this->assertFalse($metadata['enabled']);
$this->assertNull($metadata['actor_id']);
$this->assertSame('console', $metadata['reason']);
// 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());
}
}
Loading
Loading