diff --git a/assets/controllers/elements/link_confirm_controller.js b/assets/controllers/elements/link_confirm_controller.js
new file mode 100644
index 00000000..3d59b492
--- /dev/null
+++ b/assets/controllers/elements/link_confirm_controller.js
@@ -0,0 +1,72 @@
+/*
+ * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
+ *
+ * Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import {Controller} from "@hotwired/stimulus";
+
+import * as bootbox from "bootbox";
+import "../../css/components/bootbox_extensions.css";
+
+export default class extends Controller
+{
+
+ static values = {
+ message: String,
+ title: String
+ }
+
+
+
+ connect()
+ {
+ this._confirmed = false;
+
+ this.element.addEventListener('click', this._onClick.bind(this));
+ }
+
+ _onClick(event)
+ {
+
+ //If a user has not already confirmed the deletion, just let turbo do its work
+ if (this._confirmed) {
+ this._confirmed = false;
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ const that = this;
+
+ bootbox.confirm({
+ title: this.titleValue,
+ message: this.messageValue,
+ callback: (result) => {
+ if (result) {
+ //Set a flag to prevent the dialog from popping up again and allowing turbo to submit the form
+ that._confirmed = true;
+
+ //Click the link
+ that.element.click();
+ } else {
+ that._confirmed = false;
+ }
+ }
+ });
+ }
+}
\ No newline at end of file
diff --git a/config/packages/security.yaml b/config/packages/security.yaml
index 342e38bf..92b9f188 100644
--- a/config/packages/security.yaml
+++ b/config/packages/security.yaml
@@ -21,6 +21,9 @@ security:
user_checker: App\Security\UserChecker
entry_point: form_login
+ # Enable user impersonation
+ switch_user: { role: CAN_SWITCH_USER }
+
two_factor:
auth_form_path: 2fa_login
check_path: 2fa_login_check
diff --git a/config/permissions.yaml b/config/permissions.yaml
index bcd3d79c..6cb798f5 100644
--- a/config/permissions.yaml
+++ b/config/permissions.yaml
@@ -190,6 +190,9 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co
set_password:
label: "perm.users.set_password"
alsoSet: 'read'
+ impersonate:
+ label: "perm.users.impersonate"
+ alsoSet: ['set_password']
change_user_settings:
label: "perm.users.change_user_settings"
show_history:
diff --git a/src/Entity/LogSystem/SecurityEventLogEntry.php b/src/Entity/LogSystem/SecurityEventLogEntry.php
index b1b6227a..ffcfd6a5 100644
--- a/src/Entity/LogSystem/SecurityEventLogEntry.php
+++ b/src/Entity/LogSystem/SecurityEventLogEntry.php
@@ -64,6 +64,7 @@ class SecurityEventLogEntry extends AbstractLogEntry
6 => SecurityEvents::GOOGLE_DISABLED,
7 => SecurityEvents::TRUSTED_DEVICE_RESET,
8 => SecurityEvents::TFA_ADMIN_RESET,
+ 9 => SecurityEvents::USER_IMPERSONATED,
];
public function __construct(string $type, string $ip_address, bool $anonymize = true)
diff --git a/src/EventSubscriber/LogSystem/SecurityEventLoggerSubscriber.php b/src/EventSubscriber/LogSystem/SecurityEventLoggerSubscriber.php
index 78941cd5..d9af32c5 100644
--- a/src/EventSubscriber/LogSystem/SecurityEventLoggerSubscriber.php
+++ b/src/EventSubscriber/LogSystem/SecurityEventLoggerSubscriber.php
@@ -48,6 +48,7 @@ use App\Events\SecurityEvents;
use App\Services\LogSystem\EventLogger;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RequestStack;
+use Symfony\Component\Security\Http\Event\SwitchUserEvent;
/**
* This subscriber writes entries to log if a security related event happens (e.g. the user changes its password).
@@ -70,6 +71,7 @@ final class SecurityEventLoggerSubscriber implements EventSubscriberInterface
SecurityEvents::GOOGLE_DISABLED => 'google_disabled',
SecurityEvents::GOOGLE_ENABLED => 'google_enabled',
SecurityEvents::TFA_ADMIN_RESET => 'tfa_admin_reset',
+ SecurityEvents::USER_IMPERSONATED => 'user_impersonated',
];
}
@@ -103,6 +105,11 @@ final class SecurityEventLoggerSubscriber implements EventSubscriberInterface
$this->addLog(SecurityEvents::U2F_REMOVED, $event);
}
+ public function user_impersonated(SecurityEvent $event): void
+ {
+ $this->addLog(SecurityEvents::USER_IMPERSONATED, $event);
+ }
+
public function u2f_added(SecurityEvent $event): void
{
$this->addLog(SecurityEvents::U2F_ADDED, $event);
diff --git a/src/EventSubscriber/SwitchUserEventSubscriber.php b/src/EventSubscriber/SwitchUserEventSubscriber.php
new file mode 100644
index 00000000..a7f2e39c
--- /dev/null
+++ b/src/EventSubscriber/SwitchUserEventSubscriber.php
@@ -0,0 +1,64 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\EventSubscriber;
+
+use App\Entity\UserSystem\User;
+use App\Events\SecurityEvent;
+use App\Events\SecurityEvents;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+use Symfony\Component\Security\Core\Authentication\Token\SwitchUserToken;
+use Symfony\Component\Security\Http\Event\SwitchUserEvent;
+use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
+
+class SwitchUserEventSubscriber implements EventSubscriberInterface
+{
+
+ public function __construct(private readonly EventDispatcherInterface $eventDispatcher)
+ {
+ }
+
+ public static function getSubscribedEvents()
+ {
+ return [
+ 'security.switch_user' => 'onSwitchUser',
+ ];
+ }
+
+ public function onSwitchUser(SwitchUserEvent $event): void
+ {
+ $target_user = $event->getTargetUser();
+ // We can only handle User objects
+ if (!$target_user instanceof User) {
+ return;
+ }
+
+ //We are only interested in impersonation (not unimpersonation)
+ if (!$event->getToken() instanceof SwitchUserToken) {
+ return;
+ }
+
+ $security_event = new SecurityEvent($target_user);
+ $this->eventDispatcher->dispatch($security_event, SecurityEvents::USER_IMPERSONATED);
+ }
+}
\ No newline at end of file
diff --git a/src/EventSubscriber/UserSystem/PasswordChangeNeededSubscriber.php b/src/EventSubscriber/UserSystem/PasswordChangeNeededSubscriber.php
index 0f12c6d9..b74c8c5f 100644
--- a/src/EventSubscriber/UserSystem/PasswordChangeNeededSubscriber.php
+++ b/src/EventSubscriber/UserSystem/PasswordChangeNeededSubscriber.php
@@ -78,6 +78,11 @@ final class PasswordChangeNeededSubscriber implements EventSubscriberInterface
return;
}
+ //If the user is impersonated, we don't need to redirect him
+ if ($this->security->isGranted('IS_IMPERSONATOR')) {
+ return;
+ }
+
//Abort if we dont need to redirect the user.
if (!$user->isNeedPwChange() && !static::TFARedirectNeeded($user)) {
return;
diff --git a/src/Events/SecurityEvents.php b/src/Events/SecurityEvents.php
index c7c43882..f2e44c6f 100644
--- a/src/Events/SecurityEvents.php
+++ b/src/Events/SecurityEvents.php
@@ -52,4 +52,6 @@ class SecurityEvents
final public const GOOGLE_DISABLED = 'security.google_disabled';
final public const TRUSTED_DEVICE_RESET = 'security.trusted_device_reset';
final public const TFA_ADMIN_RESET = 'security.2fa_admin_reset';
+
+ final public const USER_IMPERSONATED = 'security.user_impersonated';
}
diff --git a/src/Security/Voter/ImpersonateUserVoter.php b/src/Security/Voter/ImpersonateUserVoter.php
new file mode 100644
index 00000000..f1392568
--- /dev/null
+++ b/src/Security/Voter/ImpersonateUserVoter.php
@@ -0,0 +1,60 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Security\Voter;
+
+use App\Entity\UserSystem\User;
+use App\Services\UserSystem\PermissionManager;
+use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
+use Symfony\Component\Security\Core\Authorization\Voter\Voter;
+use Symfony\Component\Security\Core\User\UserInterface;
+
+class ImpersonateUserVoter extends Voter
+{
+
+ public function __construct(private PermissionManager $permissionManager)
+ {
+ }
+
+ protected function supports(string $attribute, mixed $subject): bool
+ {
+ return $attribute == 'CAN_SWITCH_USER'
+ && $subject instanceof UserInterface;
+ }
+
+ protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
+ {
+ $user = $token->getUser();
+
+ if (!$user instanceof User || !$subject instanceof UserInterface) {
+ return false;
+ }
+
+ //An disabled user is not allowed to do anything...
+ if ($user->isDisabled()) {
+ return false;
+ }
+
+ return $this->permissionManager->inherit($user, 'users', 'impersonate') ?? false;
+ }
+}
\ No newline at end of file
diff --git a/src/Twig/UserExtension.php b/src/Twig/UserExtension.php
index be5d3c41..0a06ef2d 100644
--- a/src/Twig/UserExtension.php
+++ b/src/Twig/UserExtension.php
@@ -46,6 +46,10 @@ use App\Entity\UserSystem\User;
use App\Entity\LogSystem\AbstractLogEntry;
use App\Repository\LogEntryRepository;
use Doctrine\ORM\EntityManagerInterface;
+use Symfony\Bundle\SecurityBundle\Security;
+use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
+use Symfony\Component\Security\Core\Authentication\Token\SwitchUserToken;
+use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;
@@ -57,7 +61,9 @@ final class UserExtension extends AbstractExtension
{
private readonly LogEntryRepository $repo;
- public function __construct(EntityManagerInterface $em)
+ public function __construct(EntityManagerInterface $em,
+ private readonly Security $security,
+ private readonly UrlGeneratorInterface $urlGenerator)
{
$this->repo = $em->getRepository(AbstractLogEntry::class);
}
@@ -76,9 +82,41 @@ final class UserExtension extends AbstractExtension
new TwigFunction('last_editing_user', fn(AbstractDBElement $element): ?User => $this->repo->getLastEditingUser($element)),
/* Returns the user which has created the given entity. */
new TwigFunction('creating_user', fn(AbstractDBElement $element): ?User => $this->repo->getCreatingUser($element)),
+ new TwigFunction('impersonator_user', $this->getImpersonatorUser(...)),
+ new TwigFunction('impersonation_active', $this->isImpersonationActive(...)),
+ new TwigFunction('impersonation_path', $this->getImpersonationPath(...)),
];
}
+ /**
+ * This function returns the user which has impersonated the current user.
+ * If the current user is not impersonated, null is returned.
+ * @return User|null
+ */
+ public function getImpersonatorUser(): ?User
+ {
+ $token = $this->security->getToken();
+ if ($token instanceof SwitchUserToken) {
+ return $token->getOriginalToken()->getUser();
+ }
+
+ return null;
+ }
+
+ public function isImpersonationActive(): bool
+ {
+ return $this->security->isGranted('IS_IMPERSONATOR');
+ }
+
+ public function getImpersonationPath(User $user, string $route_name = 'homepage'): string
+ {
+ if (! $this->security->isGranted('CAN_SWITCH_USER', $user)) {
+ throw new AccessDeniedException('You are not allowed to impersonate this user!');
+ }
+
+ return $this->urlGenerator->generate($route_name, ['_switch_user' => $user->getUsername()]);
+ }
+
/**
* This function/filter generates a path.
*/
diff --git a/templates/_navbar.html.twig b/templates/_navbar.html.twig
index e8ffdf3e..164848f1 100644
--- a/templates/_navbar.html.twig
+++ b/templates/_navbar.html.twig
@@ -37,23 +37,45 @@
+{% endblock %}
+
{% block additional_controls %}
{{ form_row(form.group) }}
{{ form_row(form.first_name) }}
diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf
index 3659bf0a..ffbd71df 100644
--- a/translations/messages.en.xlf
+++ b/translations/messages.en.xlf
@@ -731,10 +731,10 @@
user.edit.tfa.disable_tfa_message
- This will disable <b>all active two-factor authentication methods of the user</b> and delete the <b>backup codes</b>!
-<br>
-The user will have to set up all two-factor authentication methods again and print new backup codes! <br><br>
-<b>Only do this if you are absolutely sure about the identity of the user (seeking help), otherwise the account could be compromised by an attacker!</b>
+ all active two-factor authentication methods of the user and delete the backup codes!
+
+The user will have to set up all two-factor authentication methods again and print new backup codes!
+Only do this if you are absolutely sure about the identity of the user (seeking help), otherwise the account could be compromised by an attacker!]]>
@@ -11326,70 +11326,114 @@ Element 3
-
+ tfa_u2f.add_key.registration_errorAn error occurred during the registration of the security key. Try again or use another security key!
-
+ log.target_type.noneNone
-
+ ui.darkmode.lightLight
-
+ ui.darkmode.darkDark
-
+ ui.darkmode.autoAuto (decide based on system settings)
-
+ label_generator.no_lines_givenNo text content given! The labels will remain empty.
-
+ user.password_strength.very_weakVery weak
-
+ user.password_strength.weakWeak
-
+ user.password_strength.mediumMedium
-
+ user.password_strength.strongStrong
-
+ user.password_strength.very_strongVery strong
+
+
+ perm.users.impersonate
+ Impersonate other users
+
+
+
+
+ user.impersonated_by.label
+ Impersonated by
+
+
+
+
+ user.stop_impersonation
+ Stop impersonation
+
+
+
+
+ user.impersonate.btn
+ Impersonate
+
+
+
+
+ user.impersonate.confirm.title
+ Do you really want to impersonate this user?
+
+
+
+
+ user.impersonate.confirm.message
+ This will be logged. You should only do this with a good reason.
+
+Please note, that you can not impersonate a disabled user. If you try you will get an "Access Denied" message.
+
+
+
+
+ log.type.security.user_impersonated
+ User impersonated
+
+