3
0
mirror of https://github.com/snipe/snipe-it.git synced 2026-08-18 11:15:42 +00:00
This commit is contained in:
snipe
2026-07-07 21:46:59 +01:00
parent 4675e4bbd9
commit e4fa0b463c
12 changed files with 267 additions and 56 deletions

View File

@ -29,6 +29,11 @@ class ImpersonateController extends Controller
->with('error', trans('admin/users/message.impersonate.cannot_impersonate_self'));
}
if ($user->isSuperUser()) {
return redirect()->route('users.show', $user)
->with('error', trans('admin/users/message.impersonate.cannot_impersonate_superuser'));
}
if ($user->deleted_at !== null || $user->activated != 1) {
return redirect()->route('users.show', $user)
->with('error', trans('admin/users/message.impersonate.target_not_active'));

View File

@ -834,4 +834,40 @@ class UsersController extends Controller
return redirect()->back()->with('error', trans('general.pwd_reset_not_sent'));
}
public function twoFactorReset(User $user): RedirectResponse
{
$this->authorize('update', $user);
if (! $user->twoFactorResettable()) {
return redirect()->back()->with('error', trans('general.unauthorized'));
}
if (! auth()->user()->can('canEditAuthFields', $user) || ! auth()->user()->can('editableOnDemo')) {
return redirect()->back()->with('error', trans('general.unauthorized'));
}
try {
$user->two_factor_secret = null;
$user->two_factor_enrolled = 0;
$user->saveQuietly();
$log = new Actionlog;
$log->target_type = User::class;
$log->target_id = $user->id;
$log->item_type = User::class;
$log->item_id = $user->id;
$log->created_at = date('Y-m-d H:i:s');
$log->created_by = auth()->id();
$log->logaction('2FA reset');
return redirect()->route('users.show', $user)
->with('success', trans('admin/settings/general.two_factor_reset_success'));
} catch (\Exception $e) {
Log::error($e);
return redirect()->route('users.show', $user)
->with('error', trans('admin/settings/general.two_factor_reset_error'));
}
}
}

View File

@ -563,6 +563,22 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
return in_array(mb_strtolower((string) $this->username), $allowed, true);
}
public function mayImpersonate(User $target): bool
{
return $this->canImpersonate()
&& $target->id !== $this->id
&& ! $target->isSuperUser()
&& $target->deleted_at === null
&& $target->activated == 1;
}
public function twoFactorResettable(): bool
{
return $this->activated == '1'
&& $this->two_factor_active_and_enrolled()
&& ! in_array(Setting::getSettings()->two_factor_enabled, ['0', '', null], true);
}
/**
* Checks if the user is an admin
*

View File

@ -306,6 +306,7 @@ return [
'two_factor_enabled_text' => 'Enable Two Factor',
'two_factor_reset' => 'Reset Two-Factor Secret',
'two_factor_reset_help' => 'This will force the user to enroll their device with their authenticator app again. This can be useful if their currently enrolled device is lost or stolen. ',
'two_factor_reset_confirm' => 'You are about to reset the two-factor secret for :name. They will need to re-enroll their device the next time they log in. Continue?',
'two_factor_reset_success' => 'Two factor device successfully reset',
'two_factor_reset_error' => 'Two factor device reset failed',
'two_factor_enabled_warning' => 'Enabling two-factor if it is not currently enabled will immediately force you to authenticate with a Google Auth enrolled device. You will have the ability to enroll your device if one is not currently enrolled.',

View File

@ -66,6 +66,8 @@ return [
'cannot_edit_privileged_user_companies' => 'Only an admin or super admin can modify the company assignments of an admin or super admin user.',
'cannot_manage_companies_without_membership' => 'Full multiple company support with floater mode is enabled, so you must be assigned to at least one company before you can manage another user\'s company assignments.',
'impersonate_user' => 'Login as :name',
'impersonate_confirm_title' => 'Confirm Impersonation',
'impersonate_confirm_body' => 'You are about to log in as :name. Any action you take will be recorded as if :name performed it. Continue?',
'impersonating_banner_title' => 'Impersonating:',
'impersonating_banner_text' => 'You are currently logged in as :name. Anything you do will be recorded as if :name did it. Your real account is :impersonator.',
'impersonating_stop_link' => 'Switch back to :name',

View File

@ -24,6 +24,7 @@ return [
'started' => 'You are now logged in as :name.',
'stopped' => 'You are back to your own account.',
'cannot_impersonate_self' => 'You cannot log in as yourself.',
'cannot_impersonate_superuser' => 'You cannot log in as another superuser.',
'target_not_active' => 'That user is deactivated or deleted and cannot be logged in as.',
'impersonator_missing' => 'The original account for this impersonation session no longer exists. Please log in again.',
],

View File

@ -1,5 +1,5 @@
<!-- Modal -->
<div class="modal fade" id="{{ $modal_name }}" tabindex="-1" role="dialog" aria-labelledby="{{ $modal_name }}Label" aria-hidden="true">
<div class="modal fade {{ $modal_class ?? '' }}" id="{{ $modal_name }}" tabindex="-1" role="dialog" aria-labelledby="{{ $modal_name }}Label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
@ -18,8 +18,8 @@
</div> <!-- /.modal-body-->
<div class="modal-footer">
<a href="#" class="pull-left" data-dismiss="modal">{{ trans('button.cancel') }}</a>
<button type="submit" class="btn btn-primary">{{ trans('general.confirm') }}</button>
<button type="button" class="btn btn-default pull-left" data-dismiss="modal">{{ trans('button.cancel') }}</button>
<button type="submit" class="btn {{ $button_class ?? 'btn-primary' }}">{{ $button_label ?? trans('general.confirm') }}</button>
</div>
</form>
</div>

View File

@ -1,12 +1,12 @@
@if (!empty($impersonator) && Auth::check())
<div class="row" role="alert" aria-live="polite" style="margin-bottom: 0px;">
<div class="col-md-12" style="background-color: #b94a48; color: #ffffff; padding: 14px 30px 14px 30px; font-size: 17px;">
<div class="col-md-12" style="background-color: #b94a48; color: #ffffff; padding: 20px 30px 14px 30px; font-size: 17px;">
<x-icon type="impersonate" class="pull-left" style="margin-right: 15px; margin-top: 2px;"/>
<strong>{{ trans('admin/users/general.impersonating_banner_title') }}</strong>
{{ trans('admin/users/general.impersonating_banner_text', ['name' => Auth::user()->display_name, 'impersonator' => $impersonator->display_name]) }}
<form action="{{ route('users.impersonate.stop') }}" method="POST" class="form-inline pull-right" style="display: inline;">
{{ csrf_field() }}
<button type="submit" class="btn btn-sm btn-default" style="background-color: #ffffff; color: #b94a48; border-color: #ffffff; font-weight: bold;">
<button type="submit" class="btn btn-sm btn-default" style="background-color: #ffffff; border-color: #ffffff; font-weight: bold;">
<x-icon type="undo" class="fa-fw"/>
{{ trans('admin/users/general.impersonating_stop_link', ['name' => $impersonator->display_name]) }}
</button>

View File

@ -293,38 +293,26 @@
<!-- Impersonation button -->
@if (Auth::check() && Auth::user()->canImpersonate() && ($user->id !== Auth::id()) && ($user->deleted_at === null) && ($user->activated == 1))
<form action="{{ route('users.impersonate.start', $user->id) }}" method="POST" class="form-inline" style="display: inline;">
{{ csrf_field() }}
<button type="submit" class="btn btn-danger hidden-print btn-social btn-block" data-tooltip="true" data-title="{{ trans('admin/users/general.impersonate_user', ['name' => $user->display_name]) }}">
<x-icon type="impersonate" class="fa-fw" style="font-size: 17px;"/>
{{ trans('general.impersonate') }}
<span class="sr-only">{{ trans('admin/users/general.impersonate_user', ['name' => $user->display_name]) }}</span>
</button>
</form>
@if (Auth::check() && Auth::user()->mayImpersonate($user))
<button type="button" class="btn btn-danger hidden-print btn-social btn-block" data-toggle="modal" data-target="#confirmImpersonateModal" data-tooltip="true" data-title="{{ trans('admin/users/general.impersonate_user', ['name' => $user->display_name]) }}">
<x-icon type="impersonate" class="fa-fw" style="font-size: 17px;"/>
{{ trans('general.impersonate') }}
<span class="sr-only">{{ trans('admin/users/general.impersonate_user', ['name' => $user->display_name]) }}</span>
</button>
@endif
@if ( ($user->activated == '1') && (auth()->user()->isSuperUser()) && ($user->two_factor_active_and_enrolled()) && ($snipeSettings->two_factor_enabled!='0') && ($snipeSettings->two_factor_enabled!=''))
<!-- 2FA reset -->
<a class="btn btn-theme hidden-print btn-social btn-block" id="two_factor_reset" style="margin-right: 10px; margin-top: 10px;">
<button type="button" class="btn btn-theme hidden-print btn-social btn-block" data-toggle="modal" data-target="#confirmTwoFactorResetModal" style="margin-right: 10px; margin-top: 10px;">
<x-icon type="mobile" class="fa-fw"/>
{{ trans('admin/settings/general.two_factor_reset') }}
</a>
<span id="two_factor_reseticon">
</span>
<span id="two_factor_resetresult">
</span>
<span id="two_factor_resetstatus">
</span>
</button>
<br>
<p class="help-block" style="line-height: 1.6;">
{{ trans('admin/settings/general.two_factor_reset_help') }}
</p>
@endif
</x-page-column>
@ -670,6 +658,27 @@
</x-page-column>
</x-container>
@if (Auth::check() && Auth::user()->mayImpersonate($user))
@include('modals.confirm-action', [
'modal_name' => 'confirmImpersonateModal',
'modal_class' => 'modal-danger',
'button_class' => 'btn-outline',
'button_label' => trans('general.yes'),
'route' => route('users.impersonate.start', $user->id),
'title' => trans('admin/users/general.impersonate_confirm_title'),
'body' => trans('admin/users/general.impersonate_confirm_body', ['name' => $user->display_name]),
])
@endif
@if (auth()->user()->isSuperUser() && $user->twoFactorResettable())
@include('modals.confirm-action', [
'modal_name' => 'confirmTwoFactorResetModal',
'route' => route('users.two_factor_reset', $user->id),
'title' => trans('admin/settings/general.two_factor_reset'),
'body' => trans('admin/settings/general.two_factor_reset_confirm', ['name' => $user->display_name]),
])
@endif
@endsection
@ -682,37 +691,6 @@
<script nonce="{{ csrf_token() }}">
$(function () {
$("#two_factor_reset").click(function(){
$("#two_factor_resetrow").removeClass('success');
$("#two_factor_resetrow").removeClass('danger');
$("#two_factor_resetstatus").html('');
$("#two_factor_reseticon").html('<x-icon type="spinner" />');
$.ajax({
url: '{{ route('api.users.two_factor_reset', ['id'=> $user->id]) }}',
type: 'POST',
data: {},
headers: {
"X-Requested-With": 'XMLHttpRequest',
"X-CSRF-TOKEN": $('meta[name="csrf-token"]').attr('content')
},
dataType: 'json',
success: function (data) {
$("#two_factor_reset_toggle").html('').html('<span class="text-danger"><x-icon type="x" /> {{ trans('general.no') }}</span>');
$("#two_factor_reseticon").html('');
$("#two_factor_resetstatus").html('<span class="text-success"><x-icon type="checkmark" /> ' + data.message + '</span>');
},
error: function (data) {
$("#two_factor_reseticon").html('');
$("#two_factor_reseticon").html('<x-icon type="warning" class="text-danger" />');
$('#two_factor_resetstatus').text(data.message);
}
});
});
$("#optional_info").on("click",function(){
$('#optional_details').fadeToggle(100);
$('#optional_info_icon').toggleClass('fa-caret-right fa-caret-down');

View File

@ -106,6 +106,14 @@ Route::group(['prefix' => 'users', 'middleware' => ['auth']], function () {
]
)->name('users.impersonate.stop');
Route::post(
'{user}/two-factor-reset',
[
Users\UsersController::class,
'twoFactorReset',
]
)->name('users.two_factor_reset');
Route::post(
'bulkedit',
[

View File

@ -89,6 +89,45 @@ class ImpersonateUserTest extends TestCase
$this->assertNull(session('impersonator_id'));
}
public function test_allowlisted_superuser_cannot_impersonate_another_superuser()
{
$actor = User::factory()->superuser()->create();
$target = User::factory()->superuser()->create(['activated' => 1]);
$this->allow($actor);
$this->actingAs($actor)
->post(route('users.impersonate.start', $target))
->assertRedirect(route('users.show', $target));
$this->assertSame($actor->id, auth()->id());
$this->assertNull(session('impersonator_id'));
}
public function test_button_hidden_when_target_is_a_superuser()
{
$actor = User::factory()->superuser()->create();
$target = User::factory()->superuser()->create(['activated' => 1]);
$this->allow($actor);
$this->actingAs($actor)
->get(route('users.show', $target))
->assertOk()
->assertDontSee('confirmImpersonateModal');
}
public function test_confirmation_modal_is_rendered_when_button_is_visible()
{
$actor = User::factory()->superuser()->create();
$target = User::factory()->create(['activated' => 1]);
$this->allow($actor);
$this->actingAs($actor)
->get(route('users.show', $target))
->assertOk()
->assertSee('confirmImpersonateModal')
->assertSee(trans('admin/users/general.impersonate_confirm_title'));
}
public function test_allowlisted_superuser_cannot_impersonate_themselves()
{
$actor = User::factory()->superuser()->create();

View File

@ -0,0 +1,125 @@
<?php
namespace Tests\Feature\Users\Ui;
use App\Models\Setting;
use App\Models\User;
use Tests\TestCase;
class TwoFactorResetTest extends TestCase
{
protected function enableSiteTwoFactor(): void
{
// Optional-mode ("1") lets the actor through the CheckForTwoFactor middleware
// as long as they don't opt in, while still satisfying the resetTwoFactor policy
// for an opted-in-and-enrolled target.
Setting::unguarded(fn () => Setting::getSettings()->update(['two_factor_enabled' => 1]));
Setting::$_cache = null;
}
protected function makeEnrolledTarget(): User
{
return User::factory()->create([
'activated' => 1,
'two_factor_optin' => 1,
'two_factor_enrolled' => 1,
'two_factor_secret' => 'target-seed',
]);
}
public function test_superuser_can_reset_another_users_two_factor()
{
$this->enableSiteTwoFactor();
$actor = User::factory()->superuser()->create();
$target = $this->makeEnrolledTarget();
$this->actingAs($actor)
->post(route('users.two_factor_reset', $target))
->assertRedirect(route('users.show', $target))
->assertSessionHas('success');
$target->refresh();
$this->assertNull($target->two_factor_secret);
$this->assertEquals(0, $target->two_factor_enrolled);
$this->assertDatabaseHas('action_logs', [
'item_type' => User::class,
'item_id' => $target->id,
'created_by' => $actor->id,
'action_type' => '2FA reset',
]);
}
public function test_profile_renders_modal_when_two_factor_is_enrolled_and_enabled()
{
$this->enableSiteTwoFactor();
$actor = User::factory()->superuser()->create();
$target = $this->makeEnrolledTarget();
$response = $this->actingAs($actor)->get(route('users.show', $target))->assertOk();
$html = $response->getContent();
$triggerCount = substr_count($html, 'data-target="#confirmTwoFactorResetModal"');
$modalIdCount = substr_count($html, 'id="confirmTwoFactorResetModal"');
$this->assertSame(1, $triggerCount, 'exactly one trigger button expected');
$this->assertSame(1, $modalIdCount, 'exactly one modal container expected');
}
public function test_non_admin_cannot_reset_two_factor()
{
$this->enableSiteTwoFactor();
$actor = User::factory()->create();
$target = $this->makeEnrolledTarget();
$originalSecret = $target->two_factor_secret;
$this->actingAs($actor)
->post(route('users.two_factor_reset', $target))
->assertForbidden();
$target->refresh();
$this->assertSame($originalSecret, $target->two_factor_secret);
$this->assertEquals(1, $target->two_factor_enrolled);
}
public function test_button_hidden_when_site_two_factor_is_off()
{
// Empty string is the CheckForTwoFactor middleware's real "off" signal —
// the middleware treats "0" as on-but-misconfigured and still redirects.
Setting::unguarded(fn () => Setting::getSettings()->update(['two_factor_enabled' => '']));
Setting::$_cache = null;
$actor = User::factory()->superuser()->create();
$target = User::factory()->create([
'activated' => 1,
'two_factor_optin' => 1,
'two_factor_enrolled' => 1,
'two_factor_secret' => 'seed',
]);
$this->actingAs($actor)
->get(route('users.show', $target))
->assertOk()
->assertDontSee('confirmTwoFactorResetModal');
}
public function test_button_hidden_when_target_has_no_two_factor_enrolled()
{
$this->enableSiteTwoFactor();
$actor = User::factory()->superuser()->create();
$target = User::factory()->create([
'activated' => 1,
'two_factor_enrolled' => 0,
]);
$response = $this->actingAs($actor)->get(route('users.show', $target))->assertOk();
$html = $response->getContent();
$this->assertStringNotContainsString('data-target="#confirmTwoFactorResetModal"', $html);
$this->assertStringNotContainsString('id="confirmTwoFactorResetModal"', $html);
}
}