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 nav_pills_container %} + +
+ {{ parent() }} +
+ {% if entity.id is not null and is_granted('CAN_SWITCH_USER', entity) %} + {% trans %}user.impersonate.btn{% endtrans %} + {% endif %} +
+
+{% 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_error An error occurred during the registration of the security key. Try again or use another security key! - + log.target_type.none None - + ui.darkmode.light Light - + ui.darkmode.dark Dark - + ui.darkmode.auto Auto (decide based on system settings) - + label_generator.no_lines_given No text content given! The labels will remain empty. - + user.password_strength.very_weak Very weak - + user.password_strength.weak Weak - + user.password_strength.medium Medium - + user.password_strength.strong Strong - + user.password_strength.very_strong Very 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 + +