mirror of
https://github.com/snipe/snipe-it.git
synced 2026-08-18 11:15:42 +00:00
Clean up
This commit is contained in:
@ -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'));
|
||||
|
||||
@ -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'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
*
|
||||
|
||||
@ -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.',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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.',
|
||||
],
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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',
|
||||
[
|
||||
|
||||
@ -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();
|
||||
|
||||
125
tests/Feature/Users/Ui/TwoFactorResetTest.php
Normal file
125
tests/Feature/Users/Ui/TwoFactorResetTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user