Initial commit: CloudOps infrastructure platform

This commit is contained in:
root
2026-04-09 19:58:57 +02:00
commit 1166a52f26
7762 changed files with 839452 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Mautic\UserBundle\ApiPlatform;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use Mautic\UserBundle\Entity\User;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
/**
* @implements ProcessorInterface<User, User>
*/
final class UserProcessor implements ProcessorInterface
{
public function __construct(
private ProcessorInterface $persistProcessor,
private UserPasswordHasherInterface $passwordHasher,
) {
}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if ($data instanceof User && null !== $data->getPlainPassword()) {
$hashedPassword = $this->passwordHasher->hashPassword($data, $data->getPlainPassword());
$data->setPassword($hashedPassword);
$data->setPlainPassword(null);
}
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
}

View File

@@ -0,0 +1,60 @@
/* UserBundle */
.profile-details {
margin-bottom: 20px;
}
.col-user-id, .col-role-id {
width: 75px;
}
.col-user-avatar {
width: 75px;
}
.mautic-logo {
width: 150px;
margin: 0 auto;
background: #ffffff;
}
.mautic-logo > svg.mautic-logo-figure .circle {
fill: #4e5e9e;
}
.mautic-logo > svg.mautic-logo-figure .m,
.mautic-logo > svg.mautic-logo-figure .m-arrow {
fill: #fdb933;
}
input:-webkit-autofill {
-webkit-box-shadow: 0 0 0px 1000px white inset;
}
span.input-group-addon i.fa {
width: 20px;
}
.inline-login .login-form {
margin: 20px;
}
.accent-color {
width: 28px;
height: 28px;
display: inline-flex;
cursor: pointer;
}
.accent-color,
.accent-dropdown {
outline: 2px var(--outline-style) transparent;
outline-offset: 2px;
transition: var(--transition-all-productive);
}
input:checked + .accent-color,
.dropdown.open .accent-dropdown {
outline: 2px var(--outline-style) var(--focus);
outline-offset: 2px;
}

View File

@@ -0,0 +1,144 @@
//UserBundle
Mautic.userOnLoad = function (container) {
if (mQuery(container + ' form[name="user"]').length) {
if (mQuery('#user_position').length) {
Mautic.activateTypeahead('#user_position', { displayKey: 'position' });
}
} else {
if (mQuery(container + ' #list-search').length) {
Mautic.activateSearchAutocomplete('list-search', 'user.user');
}
}
/**
* Initializes radio button states for UI settings and updates hidden inputs
* when settings are changed.
*/
// Initialize radio buttons based on hidden input values
document.querySelectorAll('input[type="radio"][data-attribute-toggle]').forEach(radio => {
const attributeName = radio.dataset.attributeToggle;
const hiddenInput = document.getElementById(`user_preferences_${attributeName.replace('-', '_')}`);
if (hiddenInput && hiddenInput.value) {
// If hidden input has a value, set the corresponding radio
const correspondingRadio = document.querySelector(
`input[name="${attributeName}"][data-attribute-value="${hiddenInput.value}"]`
);
if (correspondingRadio) correspondingRadio.checked = true;
} else if (radio.checked) {
// Use the checked state from the HTML as the default
if (hiddenInput) {
hiddenInput.value = radio.dataset.attributeValue;
}
}
});
// Handle radio button changes - update hidden inputs and HTML attributes
document.querySelectorAll('input[type="radio"][data-attribute-toggle]').forEach(radio => {
radio.addEventListener('change', function() {
if (this.checked) {
const attributeName = this.dataset.attributeToggle;
const hiddenInput = document.getElementById(`user_preferences_${attributeName.replace('-', '_')}`);
// Update hidden input value
if (hiddenInput) {
hiddenInput.value = this.dataset.attributeValue;
}
}
});
});
document.querySelector('[id^="user_buttons_save_toolbar"]').addEventListener('click', function() {
// Re-apply all current preferences after clicking save
document.querySelectorAll('input[type="radio"][data-attribute-toggle]:checked').forEach(radio => {
const attributeToggle = radio.dataset.attributeToggle;
const attributeValue = radio.dataset.attributeValue;
document.documentElement.setAttribute(attributeToggle, attributeValue);
});
});
};
Mautic.roleOnLoad = function (container, response) {
if (mQuery(container + ' #list-search').length) {
Mautic.activateSearchAutocomplete('list-search', 'user.role');
}
if (response && response.permissionList) {
MauticVars.permissionList = response.permissionList;
}
Mautic.togglePermissionVisibility();
};
/**
* Toggles permission panel visibility for roles
*/
Mautic.togglePermissionVisibility = function () {
//add a very slight delay in order for the clicked on checkbox to be selected since the onclick action
//is set to the parent div
setTimeout(function () {
if (mQuery('#role_isAdmin_0').prop('checked')) {
mQuery('#rolePermissions').removeClass('hide');
mQuery('#isAdminMessage').addClass('hide');
mQuery('#permissions-tab').removeClass('disabled');
} else {
mQuery('#rolePermissions').addClass('hide');
mQuery('#isAdminMessage').removeClass('hide');
mQuery('#permissions-tab').addClass('disabled');
}
}, 10);
};
/**
* Toggle permissions, update ratio, etc
*
* @param changedPermission
* @param bundle
*/
Mautic.onPermissionChange = function (changedPermission, bundle) {
var granted = 0;
if (mQuery(changedPermission).prop('checked')) {
if (mQuery(changedPermission).val() == 'full') {
//uncheck all of the others
mQuery(changedPermission).closest('.choice-wrapper').find("label input:checkbox:checked").map(function () {
if (mQuery(this).val() != 'full') {
mQuery(this).prop('checked', false);
mQuery(this).parent().toggleClass('active');
}
})
} else {
//uncheck full
mQuery(changedPermission).closest('.choice-wrapper').find("label input:checkbox:checked").map(function () {
if (mQuery(this).val() == 'full') {
granted = granted - 1;
mQuery(this).prop('checked', false);
mQuery(this).parent().toggleClass('active');
}
})
}
}
//update granted numbers
if (mQuery('.' + bundle + '_granted').length) {
var granted = 0;
var levelPerms = MauticVars.permissionList[bundle];
mQuery.each(levelPerms, function (level, perms) {
mQuery.each(perms, function (index, perm) {
var isChecked = mQuery('input[data-permission="' + bundle + ':' + level + ':' + perm + '"]').prop('checked');
if (perm == 'full') {
if (isChecked) {
if (perms.length === 1) {
granted++;
} else {
granted += perms.length - 1;
}
}
} else if (isChecked) {
granted++;
}
});
});
mQuery('.' + bundle + '_granted').html(granted);
}
};

View File

@@ -0,0 +1,273 @@
<?php
return [
'menu' => [
'admin' => [
'mautic.user_management' => [
'id' => 'mautic_user_management_root',
'priority' => 17,
'access' => ['user:users:view', 'user:roles:view'],
],
'mautic.user.users' => [
'access' => 'user:users:view',
'route' => 'mautic_user_index',
'parent' => 'mautic.user_management',
'iconClass' => 'ri-user-settings-line',
],
'mautic.user.roles' => [
'access' => 'user:roles:view',
'route' => 'mautic_role_index',
'parent' => 'mautic.user_management',
'iconClass' => 'ri-shield-user-line',
],
],
],
'routes' => [
'main' => [
'login' => [
'path' => '/login',
'controller' => 'Mautic\UserBundle\Controller\SecurityController::loginAction',
],
'mautic_user_logincheck' => [
'path' => '/login_check',
'controller' => 'Mautic\UserBundle\Controller\SecurityController::loginCheckAction',
],
'mautic_user_logout' => [
'path' => '/logout',
],
'mautic_sso_login' => [
'path' => '/sso_login/{integration}',
'controller' => 'Mautic\UserBundle\Controller\SecurityController::ssoLoginAction',
],
'mautic_sso_login_check' => [
'path' => '/sso_login_check/{integration}',
'controller' => 'Mautic\UserBundle\Controller\SecurityController::ssoLoginCheckAction',
],
'lightsaml_sp.login' => [
'path' => '/saml/login',
'controller' => 'LightSaml\SpBundle\Controller\DefaultController::loginAction',
],
'lightsaml_sp.login_check' => [
'path' => '/saml/login_check',
],
'mautic_user_index' => [
'path' => '/users/{page}',
'controller' => 'Mautic\UserBundle\Controller\UserController::indexAction',
],
'mautic_user_action' => [
'path' => '/users/{objectAction}/{objectId}',
'controller' => 'Mautic\UserBundle\Controller\UserController::executeAction',
],
'mautic_role_index' => [
'path' => '/roles/{page}',
'controller' => 'Mautic\UserBundle\Controller\RoleController::indexAction',
],
'mautic_role_action' => [
'path' => '/roles/{objectAction}/{objectId}',
'controller' => 'Mautic\UserBundle\Controller\RoleController::executeAction',
],
'mautic_user_account' => [
'path' => '/account',
'controller' => 'Mautic\UserBundle\Controller\ProfileController::indexAction',
],
],
'api' => [
'mautic_api_usersstandard' => [
'standard_entity' => true,
'name' => 'users',
'path' => '/users',
'controller' => Mautic\UserBundle\Controller\Api\UserApiController::class,
],
'mautic_api_getself' => [
'path' => '/users/self',
'controller' => 'Mautic\UserBundle\Controller\Api\UserApiController::getSelfAction',
],
'mautic_api_checkpermission' => [
'path' => '/users/{id}/permissioncheck',
'controller' => 'Mautic\UserBundle\Controller\Api\UserApiController::isGrantedAction',
'method' => 'POST',
],
'mautic_api_getuserroles' => [
'path' => '/users/list/roles',
'controller' => 'Mautic\UserBundle\Controller\Api\UserApiController::getRolesAction',
],
'mautic_api_rolesstandard' => [
'standard_entity' => true,
'name' => 'roles',
'path' => '/roles',
'controller' => Mautic\UserBundle\Controller\Api\RoleApiController::class,
],
],
'public' => [
'mautic_user_passwordreset' => [
'path' => '/passwordreset',
'controller' => 'Mautic\UserBundle\Controller\PublicController::passwordResetAction',
],
'mautic_user_passwordresetconfirm' => [
'path' => '/passwordresetconfirm',
'controller' => 'Mautic\UserBundle\Controller\PublicController::passwordResetConfirmAction',
],
'lightsaml_sp.metadata' => [
'path' => '/saml/metadata.xml',
'controller' => 'LightSaml\SpBundle\Controller\DefaultController::metadataAction',
],
'lightsaml_sp.discovery' => [
'path' => '/saml/discovery',
'controller' => 'LightSaml\SpBundle\Controller\DefaultController::discoveryAction',
],
'mautic_saml_login_retry' => [
'path' => '/saml/login_retry',
'controller' => 'Mautic\UserBundle\Controller\SecurityController::samlLoginRetryAction',
],
],
],
'services' => [
'other' => [
// Authentication
'mautic.user.manager' => [
'class' => Doctrine\ORM\EntityManager::class,
'arguments' => Mautic\UserBundle\Entity\User::class,
'factory' => ['@doctrine', 'getManagerForClass'],
],
'mautic.permission.manager' => [
'class' => Doctrine\ORM\EntityManager::class,
'arguments' => Mautic\UserBundle\Entity\Permission::class,
'factory' => ['@doctrine', 'getManagerForClass'],
],
'mautic.user.provider' => [
'class' => Mautic\UserBundle\Security\Provider\UserProvider::class,
'arguments' => [
'mautic.user.repository',
'mautic.permission.repository',
'event_dispatcher',
'security.password_hasher',
],
],
'mautic.security.authentication_handler' => [
'class' => Mautic\UserBundle\Security\Authentication\AuthenticationHandler::class,
'arguments' => [
'router',
],
],
'mautic.security.logout_handler' => [
'class' => Mautic\UserBundle\EventListener\LogoutListener::class,
'tagArguments' => [
'event' => Symfony\Component\Security\Http\Event\LogoutEvent::class,
],
'tag' => 'kernel.event_listener',
'arguments' => [
'mautic.user.model.user',
'event_dispatcher',
'mautic.helper.user',
],
],
// SAML
'mautic.security.saml.credential_store' => [
'class' => Mautic\UserBundle\Security\SAML\Store\CredentialsStore::class,
'arguments' => [
'mautic.helper.core_parameters',
'%mautic.saml_idp_entity_id%',
],
'tag' => 'lightsaml.own_credential_store',
],
'mautic.security.saml.trust_store' => [
'class' => Mautic\UserBundle\Security\SAML\Store\TrustOptionsStore::class,
'arguments' => [
'mautic.helper.core_parameters',
'%mautic.saml_idp_entity_id%',
],
'tag' => 'lightsaml.trust_options_store',
],
'mautic.security.saml.entity_descriptor_provider' => [
'class' => LightSaml\Builder\EntityDescriptor\SimpleEntityDescriptorBuilder::class,
'factory' => [Mautic\UserBundle\Security\SAML\EntityDescriptorProviderFactory::class, 'build'],
'arguments' => [
'%lightsaml.own.entity_id%',
'router',
'%lightsaml.route.login_check%',
'lightsaml.own.credential_store',
],
],
'mautic.security.saml.entity_descriptor_store' => [
'class' => Mautic\UserBundle\Security\SAML\Store\EntityDescriptorStore::class,
'arguments' => [
'mautic.helper.core_parameters',
],
'tag' => 'lightsaml.idp_entity_store',
],
'mautic.security.saml.id_store' => [
'class' => Mautic\UserBundle\Security\SAML\Store\IdStore::class,
'arguments' => [
'doctrine.orm.entity_manager',
'lightsaml.system.time_provider',
],
],
'mautic.security.saml.username_mapper' => [
'class' => Mautic\UserBundle\Security\SAML\User\UserMapper::class,
'arguments' => [
[
'email' => '%mautic.saml_idp_email_attribute%',
'username' => '%mautic.saml_idp_username_attribute%',
'firstname' => '%mautic.saml_idp_firstname_attribute%',
'lastname' => '%mautic.saml_idp_lastname_attribute%',
],
],
],
'mautic.security.saml.user_creator' => [
'class' => Mautic\UserBundle\Security\SAML\User\UserCreator::class,
'arguments' => [
'doctrine.orm.entity_manager',
'mautic.security.saml.username_mapper',
'mautic.user.model.user',
'security.password_hasher',
'%mautic.saml_idp_default_role%',
],
],
'mautic.security.user_token_setter' => [
'class' => Mautic\UserBundle\Security\UserTokenSetter::class,
'arguments' => ['mautic.user.model.user', 'security.token_storage'],
],
'mautic.user.model.user_token_service' => [
'class' => Mautic\UserBundle\Model\UserToken\UserTokenService::class,
'arguments' => [
'mautic.helper.random',
'mautic.user.repository.user_token',
],
],
],
'fixtures' => [
'mautic.user.fixture.role' => [
'class' => Mautic\UserBundle\DataFixtures\ORM\LoadRoleData::class,
'tag' => Doctrine\Bundle\FixturesBundle\DependencyInjection\CompilerPass\FixturesCompilerPass::FIXTURE_TAG,
'arguments' => ['mautic.user.model.role'],
],
'mautic.user.fixture.user' => [
'class' => Mautic\UserBundle\DataFixtures\ORM\LoadUserData::class,
'tag' => Doctrine\Bundle\FixturesBundle\DependencyInjection\CompilerPass\FixturesCompilerPass::FIXTURE_TAG,
'arguments' => ['security.password_hasher'],
],
],
],
'parameters' => [
'saml_idp_metadata' => '',
'saml_idp_entity_id' => '',
'saml_idp_own_certificate' => '',
'saml_idp_own_private_key' => '',
'saml_idp_own_password' => '',
'saml_idp_email_attribute' => '',
'saml_idp_username_attribute' => '',
'saml_idp_firstname_attribute' => '',
'saml_idp_lastname_attribute' => '',
'saml_idp_default_role' => '',
],
];

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
use Mautic\CoreBundle\DependencyInjection\MauticCoreExtension;
use Mautic\UserBundle\EventListener\ApiUserSubscriber;
use Mautic\UserBundle\Security\Authentication\Token\Permissions\TokenPermissions;
use Mautic\UserBundle\Security\Authenticator\PluginAuthenticator;
use Mautic\UserBundle\Security\Authenticator\SsoAuthenticator;
use Mautic\UserBundle\Security\EntryPoint\MainEntryPoint;
use function Symfony\Component\DependencyInjection\Loader\Configurator\abstract_arg;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use function Symfony\Component\DependencyInjection\Loader\Configurator\service;
return function (ContainerConfigurator $configurator): void {
$services = $configurator->services()
->defaults()
->autowire()
->autoconfigure()
->public();
$excludes = [
];
$services->load('Mautic\\UserBundle\\', '../')
->exclude('../{'.implode(',', array_merge(MauticCoreExtension::DEFAULT_EXCLUDES, $excludes)).'}');
$services->load('Mautic\\UserBundle\\Entity\\', '../Entity/*Repository.php')
->tag(Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\ServiceRepositoryCompilerPass::REPOSITORY_SERVICE_TAG);
$services->set(Mautic\UserBundle\ApiPlatform\UserProcessor::class)
->args([
service('api_platform.doctrine.orm.state.persist_processor'),
service('security.user_password_hasher'),
])
->tag('api_platform.state_processor');
$services->set('security.authenticator.mautic_sso', SsoAuthenticator::class)
->abstract()
->args([
'$httpUtils' => service('security.http_utils'),
'$userProvider' => abstract_arg('user provider'),
'$successHandler' => abstract_arg('authentication success handler'),
'$failureHandler' => abstract_arg('authentication failure handler'),
'$options' => abstract_arg('options'),
]);
$services->set('security.authenticator.mautic_api', PluginAuthenticator::class)
->abstract()
->args([
'$oAuth2' => service('fos_oauth_server.server'),
]);
$services->set(Mautic\UserBundle\Security\SAML\Helper::class);
$services->set('security.token.permissions', TokenPermissions::class);
$services->load('Mautic\\UserBundle\\Security\\EntryPoint\\', '../Security/EntryPoint/*.php');
$services->load('Mautic\\UserBundle\\Security\\Authentication\\Token\\Permissions\\', '../Security/Authentication/Token/Permissions/*.php');
$services->alias(Mautic\UserBundle\Entity\UserTokenRepositoryInterface::class, Mautic\UserBundle\Entity\UserTokenRepository::class);
$services->alias('mautic.user.model.role', Mautic\UserBundle\Model\RoleModel::class);
$services->alias('mautic.user.model.user', Mautic\UserBundle\Model\UserModel::class);
$services->alias('mautic.user.repository.user_token', Mautic\UserBundle\Entity\UserTokenRepository::class);
$services->alias('mautic.user.repository', Mautic\UserBundle\Entity\UserRepository::class);
$services->alias('mautic.permission.repository', Mautic\UserBundle\Entity\PermissionRepository::class);
$services->alias('mautic.user.model.password_strength_estimator', Mautic\UserBundle\Model\PasswordStrengthEstimatorModel::class);
$services->get(Mautic\UserBundle\Form\Validator\Constraints\NotWeakValidator::class)->tag('validator.constraint_validator');
$services->load('Mautic\\UserBundle\\Security\\SAML\Store\\Request\\', '../Security/SAML/Store/Request/*.php');
$services->get(Mautic\UserBundle\Security\SAML\Store\Request\RequestStateStore::class)
->arg('$prefix', '%lightsaml.store.request_session_prefix%')
->arg('$suffix', '%lightsaml.store.request_session_sufix%');
$services->get(MainEntryPoint::class)->arg('$samlEnabled', '%env(MAUTIC_SAML_ENABLED)%');
$services->get(ApiUserSubscriber::class)->arg('$userProvider', service('security.user_providers'));
// Below are fixes for autowiring of SAML SpBundle.
$services->alias(LightSaml\SymfonyBridgeBundle\Bridge\Container\BuildContainer::class, 'lightsaml.container.build');
$services->load('LightSaml\\SpBundle\\Controller\\', '%kernel.project_dir%/vendor/javer/sp-bundle/src/LightSaml/SpBundle/Controller/*.php')
->tag('controller.service_arguments');
// Decorate the form_login class to ensure no user enumeration can
// happen via timing attacks.
$services->set('mautic.security.authenticator.form_login.decorator', Mautic\UserBundle\Security\TimingSafeFormLoginAuthenticator::class)
->decorate('security.authenticator.form_login.main')
->args([
service('.inner'),
service('mautic.user.provider'),
service('security.password_hasher_factory'),
[], // This will be replaced by the compiler pass
]);
};

View File

@@ -0,0 +1,54 @@
<?php
namespace Mautic\UserBundle\Controller\Api;
use Doctrine\Persistence\ManagerRegistry;
use Mautic\ApiBundle\Controller\CommonApiController;
use Mautic\ApiBundle\Helper\EntityResultHelper;
use Mautic\CoreBundle\Factory\ModelFactory;
use Mautic\CoreBundle\Helper\AppVersion;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\UserBundle\Entity\Role;
use Mautic\UserBundle\Model\RoleModel;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Routing\RouterInterface;
/**
* @extends CommonApiController<Role>
*/
class RoleApiController extends CommonApiController
{
/**
* @var RoleModel|null
*/
protected $model;
public function __construct(CorePermissions $security, Translator $translator, EntityResultHelper $entityResultHelper, RouterInterface $router, FormFactoryInterface $formFactory, AppVersion $appVersion, RequestStack $requestStack, ManagerRegistry $doctrine, ModelFactory $modelFactory, EventDispatcherInterface $dispatcher, CoreParametersHelper $coreParametersHelper)
{
$roleModel = $modelFactory->getModel('user.role');
\assert($roleModel instanceof RoleModel);
$this->model = $roleModel;
$this->entityClass = Role::class;
$this->entityNameOne = 'role';
$this->entityNameMulti = 'roles';
$this->serializerGroups = ['roleDetails', 'publishDetails'];
parent::__construct($security, $translator, $entityResultHelper, $router, $formFactory, $appVersion, $requestStack, $doctrine, $modelFactory, $dispatcher, $coreParametersHelper);
}
/**
* @param Role &$entity
* @param string $action
*/
protected function preSaveEntity(&$entity, $form, $parameters, $action = 'edit')
{
if (isset($parameters['rawPermissions'])) {
$this->model->setRolePermissions($entity, $parameters['rawPermissions']);
}
}
}

View File

@@ -0,0 +1,226 @@
<?php
namespace Mautic\UserBundle\Controller\Api;
use Doctrine\Persistence\ManagerRegistry;
use Mautic\ApiBundle\Controller\CommonApiController;
use Mautic\ApiBundle\Helper\EntityResultHelper;
use Mautic\CoreBundle\Factory\ModelFactory;
use Mautic\CoreBundle\Helper\AppVersion;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\UserBundle\Entity\User;
use Mautic\UserBundle\Model\UserModel;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
/**
* @extends CommonApiController<User>
*/
class UserApiController extends CommonApiController
{
/**
* @var UserModel|null
*/
protected $model;
public function __construct(
CorePermissions $security,
Translator $translator,
EntityResultHelper $entityResultHelper,
RouterInterface $router,
FormFactoryInterface $formFactory,
AppVersion $appVersion,
private UserPasswordHasherInterface $hasher,
RequestStack $requestStack,
ManagerRegistry $doctrine,
ModelFactory $modelFactory,
EventDispatcherInterface $dispatcher,
CoreParametersHelper $coreParametersHelper,
) {
$userModel = $modelFactory->getModel('user.user');
\assert($userModel instanceof UserModel);
$this->model = $userModel;
$this->entityClass = User::class;
$this->entityNameOne = 'user';
$this->entityNameMulti = 'users';
$this->serializerGroups = ['userDetails', 'roleList', 'publishDetails'];
$this->dataInputMasks = ['signature' => 'html'];
parent::__construct($security, $translator, $entityResultHelper, $router, $formFactory, $appVersion, $requestStack, $doctrine, $modelFactory, $dispatcher, $coreParametersHelper);
}
/**
* Obtains the logged in user's data.
*
* @return Response
*
* @throws NotFoundHttpException
*/
public function getSelfAction(TokenStorageInterface $tokenStorage)
{
$currentUser = $tokenStorage->getToken()->getUser();
$view = $this->view($currentUser, Response::HTTP_OK);
return $this->handleView($view);
}
/**
* Creates a new user.
*/
public function newEntityAction(Request $request)
{
$entity = $this->model->getEntity();
if (!$this->security->isGranted('user:users:create')) {
return $this->accessDenied();
}
$parameters = $request->request->all();
if (isset($parameters['plainPassword']['password'])) {
$submittedPassword = $parameters['plainPassword']['password'];
$entity->setPassword($this->model->checkNewPassword($entity, $this->hasher, $submittedPassword));
}
return $this->processForm($request, $entity, $parameters, 'POST');
}
/**
* Edits an existing user or creates a new one on PUT if not found.
*
* @param int $id User ID
*
* @return Response
*
* @throws NotFoundHttpException
*/
public function editEntityAction(Request $request, $id)
{
$entity = $this->model->getEntity($id);
$parameters = $request->request->all();
$method = $request->getMethod();
if (!$this->security->isGranted('user:users:edit')) {
return $this->accessDenied();
}
if (null === $entity) {
if ('PATCH' === $method
|| ('PUT' === $method && !$this->security->isGranted('user:users:create'))
) {
// PATCH requires that an entity exists or must have create access for PUT
return $this->notFound();
} else {
$entity = $this->model->getEntity();
if (isset($parameters['plainPassword']['password'])) {
$submittedPassword = $parameters['plainPassword']['password'];
$entity->setPassword($this->model->checkNewPassword($entity, $this->hasher, $submittedPassword));
}
}
} else {
// Changing passwords via API is forbidden
if (!empty($parameters['plainPassword'])) {
unset($parameters['plainPassword']);
}
if ('PATCH' == $method) {
// PATCH will accept a diff so just remove the entities
// Changing username via API is forbidden
if (!empty($parameters['username'])) {
unset($parameters['username']);
}
} else {
// PUT requires the entire entity so overwrite the username with the original
$parameters['username'] = $entity->getUsername();
$parameters['role'] = $entity->getRole()->getId();
}
}
return $this->processForm($request, $entity, $parameters, $method);
}
protected function preSaveEntity(&$entity, $form, $parameters, $action = 'edit')
{
switch ($action) {
case 'new':
$submittedPassword = null;
if (isset($parameters['plainPassword'])) {
if (is_array($parameters['plainPassword']) && isset($parameters['plainPassword']['password'])) {
$submittedPassword = $parameters['plainPassword']['password'];
} else {
$submittedPassword = $parameters['plainPassword'];
}
}
$entity->setPassword($this->model->checkNewPassword($entity, $this->hasher, $submittedPassword, true));
break;
}
}
/**
* Verifies if a user has permission(s) to a action.
*
* @param int $id User ID
*
* @return Response
*
* @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
* @throws NotFoundHttpException
*/
public function isGrantedAction(Request $request, $id)
{
$entity = $this->model->getEntity($id);
if (!$entity instanceof $this->entityClass) {
return $this->notFound();
}
$permissions = $request->request->all()['permissions'] ?? [];
if (empty($permissions)) {
return $this->badRequest('mautic.api.call.permissionempty');
} elseif (!is_array($permissions)) {
$permissions = [$permissions];
}
$return = $this->security->isGranted($permissions, 'RETURN_ARRAY', $entity);
$view = $this->view($return, Response::HTTP_OK);
return $this->handleView($view);
}
/**
* Obtains a list of roles for user edits.
*
* @return Response
*/
public function getRolesAction(Request $request)
{
if (!$this->security->isGranted(
['user:users:create', 'user:users:edit'],
'MATCH_ONE'
)
) {
return $this->accessDenied();
}
$filter = $request->query->get('filter', null);
$limit = (int) $request->query->get('limit', null);
$roles = $this->model->getLookupResults('role', $filter, $limit);
$view = $this->view($roles, Response::HTTP_OK);
$context = $view->getContext()->setGroups(['roleList']);
$view->setContext($context);
return $this->handleView($view);
}
}

View File

@@ -0,0 +1,252 @@
<?php
declare(strict_types=1);
namespace Mautic\UserBundle\Controller;
use Mautic\CoreBundle\Controller\FormController;
use Mautic\CoreBundle\Helper\LanguageHelper;
use Mautic\UserBundle\Entity\User;
use Mautic\UserBundle\Model\UserModel;
use Mautic\UserBundle\Security\SAML\Helper as SAMLHelper;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
class ProfileController extends FormController
{
/**
* Generate's account profile.
*/
public function indexAction(Request $request, LanguageHelper $languageHelper, UserPasswordHasherInterface $hasher,
TokenStorageInterface $tokenStorage, SAMLHelper $samlHelper): Response|RedirectResponse
{
// get current user
$me = $tokenStorage->getToken()->getUser();
\assert($me instanceof User);
/** @var UserModel $model */
$model = $this->getModel('user');
// set some permissions
$permissions = [
'apiAccess' => ($this->coreParametersHelper->get('api_enabled')) ?
$this->security->isGranted('api:access:full')
: 0,
'editName' => $this->security->isGranted('user:profile:editname'),
'editUsername' => $this->security->isGranted('user:profile:editusername'),
'editPosition' => $this->security->isGranted('user:profile:editposition'),
'editEmail' => $this->security->isGranted('user:profile:editemail'),
];
$action = $this->generateUrl('mautic_user_account');
$form = $model->createForm($me, $this->formFactory, $action, ['in_profile' => true]);
$overrides = [];
// make sure this user has access to edit privileged fields
foreach ($permissions as $permName => $hasAccess) {
if ('apiAccess' == $permName) {
continue;
}
if (!$hasAccess) {
// set the value to its original
switch ($permName) {
case 'editName':
$overrides['firstName'] = $me->getFirstName();
$overrides['lastName'] = $me->getLastName();
$form->remove('firstName');
$form->add(
'firstName_unbound',
TextType::class,
[
'label' => 'mautic.core.firstname',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control'],
'mapped' => false,
'disabled' => true,
'data' => $me->getFirstName(),
'required' => false,
]
);
$form->remove('lastName');
$form->add(
'lastName_unbound',
TextType::class,
[
'label' => 'mautic.core.lastname',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control'],
'mapped' => false,
'disabled' => true,
'data' => $me->getLastName(),
'required' => false,
]
);
break;
case 'editUsername':
$overrides['username'] = $me->getUserIdentifier();
$form->remove('username');
$form->add(
'username_unbound',
TextType::class,
[
'label' => 'mautic.core.username',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control'],
'mapped' => false,
'disabled' => true,
'data' => $me->getUserIdentifier(),
'required' => false,
]
);
break;
case 'editPosition':
$overrides['position'] = $me->getPosition();
$form->remove('position');
$form->add(
'position_unbound',
TextType::class,
[
'label' => 'mautic.core.position',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control'],
'mapped' => false,
'disabled' => true,
'data' => $me->getPosition(),
'required' => false,
]
);
break;
case 'editEmail':
$overrides['email'] = $me->getEmail();
$form->remove('email');
$form->add(
'email_unbound',
TextType::class,
[
'label' => 'mautic.core.type.email',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control'],
'mapped' => false,
'disabled' => true,
'data' => $me->getEmail(),
'required' => false,
]
);
break;
}
}
}
// Check for a submitted form and process it
$submitted = $request->getSession()->get('formProcessed', 0);
if ('POST' === $request->getMethod() && !$submitted) {
$request->getSession()->set('formProcessed', 1);
// check to see if the password needs to be rehashed
$formUser = $request->request->all()['user'] ?? [];
$submittedPassword = $formUser['plainPassword']['password'] ?? null;
$overrides['password'] = $model->checkNewPassword($me, $hasher, $submittedPassword);
if (!$cancelled = $this->isFormCancelled($form)) {
if ($this->isFormValid($form)) {
foreach ($overrides as $k => $v) {
$func = 'set'.ucfirst($k);
$me->$func($v);
}
// form is valid so process the data
$model->saveEntity($me);
// check if the user's locale has been downloaded already, fetch it if not
$installedLanguages = $languageHelper->getSupportedLanguages();
if ($me->getLocale() && !array_key_exists($me->getLocale(), $installedLanguages)) {
$fetchLanguage = $languageHelper->extractLanguagePackage($me->getLocale());
// If there is an error, we need to reset the user's locale to the default
if ($fetchLanguage['error']) {
$me->setLocale(null);
$model->saveEntity($me);
$message = 'mautic.core.could.not.set.language';
$messageVars = [];
if (isset($fetchLanguage['message'])) {
$message = $fetchLanguage['message'];
}
if (isset($fetchLanguage['vars'])) {
$messageVars = $fetchLanguage['vars'];
}
$this->addFlashMessage($message, $messageVars);
}
}
// Update timezone and locale
$tz = $me->getTimezone();
if (empty($tz)) {
$tz = $this->coreParametersHelper->getDefaultTimezone();
}
$request->getSession()->set('_timezone', $tz);
$locale = $me->getLocale();
if (empty($locale)) {
$locale = $this->coreParametersHelper->get('locale');
}
$request->getSession()->set('_locale', $locale);
$returnUrl = $this->generateUrl('mautic_user_account');
return $this->postActionRedirect(
[
'returnUrl' => $returnUrl,
'contentTemplate' => 'Mautic\UserBundle\Controller\ProfileController::indexAction',
'passthroughVars' => [
'mauticContent' => 'user',
],
'flashes' => [ // success
[
'type' => 'notice',
'msg' => 'mautic.user.account.notice.updated',
],
],
]
);
}
} else {
return $this->redirectToRoute('mautic_dashboard_index');
}
}
$request->getSession()->set('formProcessed', 0);
$isSamlUser = $samlHelper->isSamlSession();
if ($isSamlUser) {
$form->remove('plainPassword');
}
$parameters = [
'permissions' => $permissions,
'me' => $me,
'userForm' => $form->createView(),
'isSamlUser' => $isSamlUser,
'authorizedClients' => $this->forward('Mautic\ApiBundle\Controller\ClientController::authorizedClientsAction')->getContent(),
];
return $this->delegateView(
[
'viewParameters' => $parameters,
'contentTemplate' => '@MauticUser/Profile/index.html.twig',
'passthroughVars' => [
'route' => $this->generateUrl('mautic_user_account'),
'mauticContent' => 'user',
],
]
);
}
}

View File

@@ -0,0 +1,142 @@
<?php
namespace Mautic\UserBundle\Controller;
use Mautic\CoreBundle\Controller\FormController;
use Mautic\UserBundle\Entity\User;
use Mautic\UserBundle\Form\Type\PasswordResetConfirmType;
use Mautic\UserBundle\Form\Type\PasswordResetType;
use Mautic\UserBundle\Model\UserModel;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
class PublicController extends FormController
{
/**
* Generates a new password for the user and emails it to them.
*/
public function passwordResetAction(Request $request): \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
{
/** @var UserModel $model */
$model = $this->getModel('user');
$data = ['identifier' => ''];
$action = $this->generateUrl('mautic_user_passwordreset');
$form = $this->formFactory->create(PasswordResetType::class, $data, ['action' => $action]);
// /Check for a submitted form and process it
if ('POST' === $request->getMethod()) {
if ($isValid = $this->isFormValid($form)) {
// find the user
$data = $form->getData();
$user = $model->getRepository()->findByIdentifier($data['identifier']);
/**
* Calculation of time to standardize fix response for vulnerability
* Users enumeration - forgot password. Constant response time is 1s.
*/
$desiredTime = 1.0;
$startTime = microtime(true);
try {
if (null !== $user) {
$model->sendResetEmail($user);
}
$this->addFlashMessage('mautic.user.user.notice.passwordreset');
} catch (\Exception) {
$this->addFlashMessage('mautic.user.user.notice.passwordreset.error', [], 'error');
}
$endTime = microtime(true);
$executionTime = $endTime - $startTime;
if ($executionTime < $desiredTime) {
usleep((int) (($desiredTime - $executionTime) * 1000000));
}
return $this->redirectToRoute('login');
}
}
return $this->delegateView([
'viewParameters' => [
'form' => $form->createView(),
],
'contentTemplate' => '@MauticUser/Security/reset.html.twig',
'passthroughVars' => [
'route' => $action,
],
]);
}
public function passwordResetConfirmAction(Request $request, UserPasswordHasherInterface $hasher): mixed
{
/** @var UserModel $model */
$model = $this->getModel('user');
$data = ['identifier' => '', 'password' => '', 'password_confirm' => ''];
$action = $this->generateUrl('mautic_user_passwordresetconfirm');
$form = $this->formFactory->create(PasswordResetConfirmType::class, [], ['action' => $action]);
$token = $request->query->get('token');
if ($token) {
$request->getSession()->set('resetToken', $token);
}
// /Check for a submitted form and process it
if ('POST' === $request->getMethod()) {
if ($isValid = $this->isFormValid($form)) {
// find the user
$data = $form->getData();
/** @var User $user */
$user = $model->getRepository()->findByIdentifier($data['identifier']);
if (null == $user) {
$this->addFlashMessage('mautic.user.user.notice.passwordreset.success');
return $this->redirectToRoute('login');
} else {
if ($request->getSession()->has('resetToken')) {
$resetToken = $request->getSession()->get('resetToken');
if ($model->confirmResetToken($user, $resetToken)) {
$encodedPassword = $model->checkNewPassword($user, $hasher, $data['plainPassword']);
$user->setPassword($encodedPassword);
$model->saveEntity($user);
$this->addFlashMessage('mautic.user.user.notice.passwordreset.success');
$request->getSession()->remove('resetToken');
return $this->redirectToRoute('login');
}
return $this->delegateView([
'viewParameters' => [
'form' => $form->createView(),
],
'contentTemplate' => '@MauticUser/Security/resetconfirm.html.twig',
'passthroughVars' => [
'route' => $action,
],
]);
} else {
$this->addFlashMessage('mautic.user.user.notice.passwordreset.missingtoken');
return $this->redirectToRoute('mautic_user_passwordresetconfirm');
}
}
}
}
return $this->delegateView([
'viewParameters' => [
'form' => $form->createView(),
],
'contentTemplate' => '@MauticUser/Security/resetconfirm.html.twig',
'passthroughVars' => [
'route' => $action,
],
]);
}
}

View File

@@ -0,0 +1,485 @@
<?php
namespace Mautic\UserBundle\Controller;
use Mautic\CoreBundle\Controller\FormController;
use Mautic\CoreBundle\Factory\PageHelperFactoryInterface;
use Mautic\UserBundle\Entity;
use Mautic\UserBundle\Entity\PermissionRepository;
use Mautic\UserBundle\Entity\UserRepository;
use Mautic\UserBundle\Model\RoleModel;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\PreconditionRequiredHttpException;
class RoleController extends FormController
{
/**
* Generate's default role list view.
*
* @param int $page
*
* @return Response
*/
public function indexAction(Request $request, PageHelperFactoryInterface $pageHelperFactory, $page = 1)
{
if (!$this->security->isGranted('user:roles:view')) {
return $this->accessDenied();
}
$this->setListFilters();
$pageHelper = $pageHelperFactory->make('mautic.role', $page);
$limit = $pageHelper->getLimit();
$start = $pageHelper->getStart();
$orderBy = $request->getSession()->get('mautic.role.orderby', 'r.name');
$orderByDir = $request->getSession()->get('mautic.role.orderbydir', 'ASC');
$filter = $request->get('search', $request->getSession()->get('mautic.role.filter', ''));
$tmpl = $request->isXmlHttpRequest() ? $request->get('tmpl', 'index') : 'index';
$model = $this->getModel('user.role');
\assert($model instanceof RoleModel);
$items = $model->getEntities(
[
'start' => $start,
'limit' => $limit,
'filter' => $filter,
'orderBy' => $orderBy,
'orderByDir' => $orderByDir,
]
);
$request->getSession()->set('mautic.role.filter', $filter);
$count = count($items);
if ($count && $count < ($start + 1)) {
$lastPage = $pageHelper->countPage($count);
$returnUrl = $this->generateUrl('mautic_role_index', ['page' => $lastPage]);
$pageHelper->rememberPage($lastPage);
return $this->postActionRedirect([
'returnUrl' => $returnUrl,
'viewParameters' => [
'page' => $lastPage,
'tmpl' => $tmpl,
],
'contentTemplate' => 'Mautic\UserBundle\Controller\RoleController::indexAction',
'passthroughVars' => [
'activeLink' => '#mautic_role_index',
'mauticContent' => 'role',
],
]);
}
$roleIds = [];
foreach ($items as $role) {
$roleIds[] = $role->getId();
}
$pageHelper->rememberPage($page);
return $this->delegateView([
'viewParameters' => [
'items' => $items,
'userCounts' => (!empty($roleIds)) ? $model->getRepository()->getUserCount($roleIds) : [],
'searchValue' => $filter,
'page' => $page,
'limit' => $limit,
'tmpl' => $tmpl,
'permissions' => [
'create' => $this->security->isGranted('user:roles:create'),
'edit' => $this->security->isGranted('user:roles:edit'),
'delete' => $this->security->isGranted('user:roles:delete'),
],
],
'contentTemplate' => '@MauticUser/Role/list.html.twig',
'passthroughVars' => [
'route' => $this->generateUrl('mautic_role_index', ['page' => $page]),
'mauticContent' => 'role',
],
]);
}
/**
* Generate's new role form and processes post data.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse|\Symfony\Component\HttpFoundation\RedirectResponse|Response
*/
public function newAction(Request $request)
{
if (!$this->security->isGranted('user:roles:create')) {
return $this->accessDenied();
}
// retrieve the entity
$entity = new Entity\Role();
$model = $this->getModel('user.role');
\assert($model instanceof RoleModel);
// set the return URL for post actions
$returnUrl = $this->generateUrl('mautic_role_index');
// set the page we came from
$page = $request->getSession()->get('mautic.role.page', 1);
$action = $this->generateUrl('mautic_role_action', ['objectAction' => 'new']);
// get the user form factory
$permissionsConfig = $this->getPermissionsConfig($entity);
$form = $model->createForm($entity, $this->formFactory, $action, ['permissionsConfig' => $permissionsConfig['config']]);
// /Check for a submitted form and process it
if ('POST' === $request->getMethod()) {
$valid = false;
if (!$cancelled = $this->isFormCancelled($form)) {
if ($valid = $this->isFormValid($form)) {
// set the permissions
$role = $request->request->all()['role'] ?? [];
$permissions = $role['permissions'] ?? null;
$model->setRolePermissions($entity, $permissions);
// form is valid so process the data
$model->saveEntity($entity);
$this->addFlashMessage('mautic.core.notice.created', [
'%name%' => $entity->getName(),
'%menu_link%' => 'mautic_role_index',
'%url%' => $this->generateUrl('mautic_role_action', [
'objectAction' => 'edit',
'objectId' => $entity->getId(),
]),
]);
}
}
if ($cancelled || ($valid && $this->getFormButton($form, ['buttons', 'save'])->isClicked())) {
return $this->postActionRedirect([
'returnUrl' => $returnUrl,
'viewParameters' => ['page' => $page],
'contentTemplate' => 'Mautic\UserBundle\Controller\RoleController::indexAction',
'passthroughVars' => [
'activeLink' => '#mautic_role_index',
'mauticContent' => 'role',
],
]);
} elseif ($valid) {
return $this->editAction($request, $entity->getId(), true);
}
}
return $this->delegateView([
'viewParameters' => [
'form' => $form->createView(),
'permissionsConfig' => $permissionsConfig,
],
'contentTemplate' => '@MauticUser/Role/form.html.twig',
'passthroughVars' => [
'activeLink' => '#mautic_role_new',
'route' => $this->generateUrl('mautic_role_action', ['objectAction' => 'new']),
'mauticContent' => 'role',
'permissionList' => $permissionsConfig['list'],
],
]);
}
/**
* Generate's role edit form and processes post data.
*
* @param int $objectId
* @param bool $ignorePost
*
* @return \Symfony\Component\HttpFoundation\JsonResponse|Response
*/
public function editAction(Request $request, $objectId, $ignorePost = false)
{
if (!$this->security->isGranted('user:roles:edit')) {
return $this->accessDenied();
}
/** @var RoleModel $model */
$model = $this->getModel('user.role');
$entity = $model->getEntity($objectId);
// set the page we came from
$page = $request->getSession()->get('mautic.role.page', 1);
// set the return URL
$returnUrl = $this->generateUrl('mautic_role_index', ['page' => $page]);
$postActionVars = [
'returnUrl' => $returnUrl,
'viewParameters' => ['page' => $page],
'contentTemplate' => 'Mautic\UserBundle\Controller\RoleController::indexAction',
'passthroughVars' => [
'activeLink' => '#mautic_role_index',
'mauticContent' => 'role',
],
];
// user not found
if (null === $entity) {
return $this->postActionRedirect(
array_merge($postActionVars, [
'flashes' => [
[
'type' => 'error',
'msg' => 'mautic.user.role.error.notfound',
'msgVars' => ['%id' => $objectId],
],
],
])
);
} elseif ($model->isLocked($entity)) {
// deny access if the entity is locked
return $this->isLocked($postActionVars, $entity, 'user.role');
}
$permissionsConfig = $this->getPermissionsConfig($entity);
$action = $this->generateUrl('mautic_role_action', ['objectAction' => 'edit', 'objectId' => $objectId]);
$form = $model->createForm($entity, $this->formFactory, $action, ['permissionsConfig' => $permissionsConfig['config']]);
// /Check for a submitted form and process it
if (!$ignorePost && 'POST' === $request->getMethod()) {
$valid = false;
if (!$cancelled = $this->isFormCancelled($form)) {
if ($valid = $this->isFormValid($form)) {
// set the permissions
$role = $request->request->all()['role'] ?? [];
$permissions = $role['permissions'] ?? null;
$model->setRolePermissions($entity, $permissions);
// form is valid so process the data
$model->saveEntity($entity, $this->getFormButton($form, ['buttons', 'save'])->isClicked());
$this->addFlashMessage('mautic.core.notice.updated', [
'%name%' => $entity->getName(),
'%menu_link%' => 'mautic_role_index',
'%url%' => $this->generateUrl('mautic_role_action', [
'objectAction' => 'edit',
'objectId' => $entity->getId(),
]),
]);
}
} else {
// unlock the entity
$model->unlockEntity($entity);
}
if ($cancelled || ($valid && $this->getFormButton($form, ['buttons', 'save'])->isClicked())) {
return $this->postActionRedirect($postActionVars);
} else {
// the form has to be rebuilt because the permissions were updated
$permissionsConfig = $this->getPermissionsConfig($entity);
$form = $model->createForm($entity, $this->formFactory, $action, ['permissionsConfig' => $permissionsConfig['config']]);
}
} else {
// lock the entity
$model->lockEntity($entity);
}
return $this->delegateView([
'viewParameters' => [
'form' => $form->createView(),
'permissionsConfig' => $permissionsConfig,
],
'contentTemplate' => '@MauticUser/Role/form.html.twig',
'passthroughVars' => [
'activeLink' => '#mautic_role_index',
'route' => $action,
'mauticContent' => 'role',
'permissionList' => $permissionsConfig['list'],
],
]);
}
private function getPermissionsConfig(Entity\Role $role): array
{
$permissionObjects = $this->security->getPermissionObjects();
$translator = $this->translator;
$permissionRepo = $this->doctrine->getRepository(Entity\Permission::class);
\assert($permissionRepo instanceof PermissionRepository);
$permissionsArray = ($role->getId()) ? $permissionRepo->getPermissionsByRole($role, true) : [];
$permissions = [];
$permissionsList = [];
/** @var \Mautic\CoreBundle\Security\Permissions\AbstractPermissions $object */
foreach ($permissionObjects as $object) {
if (!is_object($object)) {
continue;
}
if ($object->isEnabled()) {
$bundle = $object->getName();
$label = $translator->trans("mautic.{$bundle}.permissions.header");
// convert the permission bits from the db into readable names
$data = $object->convertBitsToPermissionNames($permissionsArray);
// get the ratio of granted/total
[$granted, $total] = $object->getPermissionRatio($data);
$permissions[$bundle] = [
'label' => $label,
'permissionObject' => $object,
'ratio' => [$granted, $total],
'data' => $data,
];
$perms = $object->getPermissions();
foreach ($perms as $level => $perm) {
$levelPerms = array_keys($perm);
$object->parseForJavascript($levelPerms);
$permissionsList[$bundle][$level] = $levelPerms;
}
}
}
// order permissions by label
uasort($permissions, fn ($a, $b): int => strnatcmp($a['label'], $b['label']));
return ['config' => $permissions, 'list' => $permissionsList];
}
/**
* Delete's a role.
*
* @param int $objectId
*
* @return Response
*/
public function deleteAction(Request $request, $objectId)
{
if (!$this->security->isGranted('user:roles:delete')) {
return $this->accessDenied();
}
$page = $request->getSession()->get('mautic.role.page', 1);
$returnUrl = $this->generateUrl('mautic_role_index', ['page' => $page]);
$success = 0;
$flashes = [];
$postActionVars = [
'returnUrl' => $returnUrl,
'viewParameters' => ['page' => $page],
'contentTemplate' => 'Mautic\UserBundle\Controller\RoleController::indexAction',
'passthroughVars' => [
'activeLink' => '#mautic_role_index',
'success' => $success,
'mauticContent' => 'role',
],
];
if (Request::METHOD_POST === $request->getMethod()) {
try {
$model = $this->getModel('user.role');
\assert($model instanceof RoleModel);
$entity = $model->getEntity($objectId);
if (null === $entity) {
$flashes[] = [
'type' => 'error',
'msg' => 'mautic.user.role.error.notfound',
'msgVars' => ['%id%' => $objectId],
];
} elseif ($model->isLocked($entity)) {
return $this->isLocked($postActionVars, $entity, 'user.role');
} else {
$model->deleteEntity($entity);
$name = $entity->getName();
$flashes[] = [
'type' => 'notice',
'msg' => 'mautic.core.notice.deleted',
'msgVars' => [
'%name%' => $name,
'%id%' => $objectId,
],
];
}
} catch (PreconditionRequiredHttpException $e) {
$flashes[] = [
'type' => 'error',
'msg' => $e->getMessage(),
];
}
} // else don't do anything
return $this->postActionRedirect(
array_merge($postActionVars, [
'flashes' => $flashes,
])
);
}
/**
* Deletes a group of entities.
*/
public function batchDeleteAction(Request $request, RoleModel $model): Response
{
$page = $request->getSession()->get('mautic.role.page', 1);
$returnUrl = $this->generateUrl('mautic_role_index', ['page' => $page]);
$flashes = [];
$postActionVars = [
'returnUrl' => $returnUrl,
'viewParameters' => ['page' => $page],
'contentTemplate' => 'Mautic\UserBundle\Controller\RoleController::indexAction',
'passthroughVars' => [
'activeLink' => '#mautic_role_index',
'mauticContent' => 'role',
],
];
if (Request::METHOD_POST === $request->getMethod()) {
$ids = json_decode($request->query->get('ids', ''));
$deleteIds = [];
$userRepo = $this->doctrine->getRepository(Entity\User::class);
\assert($userRepo instanceof UserRepository);
// Loop over the IDs to perform access checks pre-delete
foreach ($ids as $objectId) {
$entity = $model->getEntity($objectId);
$users = $userRepo->findByRole($entity);
if (null === $entity) {
$flashes[] = [
'type' => 'error',
'msg' => 'mautic.user.role.error.notfound',
'msgVars' => ['%id%' => $objectId],
];
} elseif (count($users)) {
$flashes[] = [
'type' => 'error',
'msg' => 'mautic.user.role.error.deletenotallowed',
'msgVars' => ['%name%' => $entity->getName()],
];
} elseif (!$this->security->isGranted('user:roles:delete')) {
$flashes[] = $this->accessDenied(true);
} elseif ($model->isLocked($entity)) {
$flashes[] = $this->isLocked($postActionVars, $entity, 'user.role', true);
} else {
$deleteIds[] = $objectId;
}
}
// Delete everything we are able to
if (!empty($deleteIds)) {
$entities = $model->deleteEntities($deleteIds);
$flashes[] = [
'type' => 'notice',
'msg' => 'mautic.user.role.notice.batch_deleted',
'msgVars' => [
'%count%' => count($entities),
],
];
}
} // else don't do anything
return $this->postActionRedirect(
array_merge($postActionVars, [
'flashes' => $flashes,
])
);
}
}

View File

@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace Mautic\UserBundle\Controller;
use Doctrine\Persistence\ManagerRegistry;
use Mautic\CoreBundle\Controller\CommonController;
use Mautic\CoreBundle\Factory\ModelFactory;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Service\FlashBag;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\PluginBundle\Helper\IntegrationHelper;
use Mautic\UserBundle\Exception\WeakPasswordException;
use Mautic\UserBundle\Security\SAML\Helper as SAMLHelper;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Security\Core\Exception;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
use Symfony\Contracts\Translation\TranslatorInterface;
class SecurityController extends CommonController implements EventSubscriberInterface
{
public function __construct(
ManagerRegistry $doctrine,
ModelFactory $modelFactory,
UserHelper $userHelper,
CoreParametersHelper $coreParametersHelper,
EventDispatcherInterface $dispatcher,
Translator $translator,
FlashBag $flashBag,
?RequestStack $requestStack,
?CorePermissions $security,
private AuthorizationCheckerInterface $authorizationChecker,
) {
parent::__construct($doctrine, $modelFactory, $userHelper, $coreParametersHelper, $dispatcher, $translator, $flashBag, $requestStack, $security);
}
public function onRequest(RequestEvent $event): void
{
$controller = $event->getRequest()->attributes->get('_controller');
\assert(is_string($controller));
if (!str_contains($controller, self::class)) {
return;
}
// redirect user if they are already authenticated
if ($this->authorizationChecker->isGranted('IS_AUTHENTICATED_FULLY')
|| $this->authorizationChecker->isGranted('IS_AUTHENTICATED_REMEMBERED')
) {
$redirectUrl = $this->generateUrl('mautic_dashboard_index');
$event->setResponse(new RedirectResponse($redirectUrl));
}
}
/**
* Generates login form and processes login.
*/
public function loginAction(Request $request, AuthenticationUtils $authenticationUtils, IntegrationHelper $integrationHelper, TranslatorInterface $translator): Response
{
$error = $authenticationUtils->getLastAuthenticationError();
if (null !== $error) {
if ($error instanceof WeakPasswordException) {
$this->addFlash(FlashBag::LEVEL_ERROR, $translator->trans('mautic.user.auth.error.weakpassword', [], 'flashes'));
return $this->forward('Mautic\UserBundle\Controller\PublicController::passwordResetAction');
} elseif ($error instanceof Exception\BadCredentialsException) {
$msg = 'mautic.user.auth.error.invalidlogin';
} elseif ($error instanceof Exception\DisabledException) {
$msg = 'mautic.user.auth.error.disabledaccount';
} else {
$msg = $error->getMessage();
}
$this->addFlashMessage($msg, [], FlashBag::LEVEL_ERROR, null, false);
}
$request->query->set('tmpl', 'login');
// Get a list of SSO integrations
$integrations = $integrationHelper->getIntegrationObjects(null, ['sso_service'], true, null, true);
return $this->delegateView([
'viewParameters' => [
'last_username' => $authenticationUtils->getLastUsername(),
'integrations' => $integrations,
],
'contentTemplate' => '@MauticUser/Security/login.html.twig',
'passthroughVars' => [
'route' => $this->generateUrl('login'),
'mauticContent' => 'user',
'sessionExpired' => true,
],
]);
}
/**
* Do nothing.
*/
public function loginCheckAction(): void
{
}
/**
* The plugin should be handling this in it's listener.
*/
public function ssoLoginAction($integration): RedirectResponse
{
return new RedirectResponse($this->generateUrl('login'));
}
/**
* The plugin should be handling this in it's listener.
*/
public function ssoLoginCheckAction($integration): RedirectResponse
{
// The plugin should be handling this in it's listener
return new RedirectResponse($this->generateUrl('login'));
}
public function samlLoginRetryAction(Request $request, SAMLHelper $samlHelper, SessionInterface $session): Response
{
if (!$samlHelper->isSamlEnabled()) {
return new RedirectResponse($this->generateUrl('login'));
}
$session->invalidate();
$this->addFlashMessage('mautic.user.security.saml.clearsession', [], FlashBag::LEVEL_ERROR);
return $this->delegateView([
'viewParameters' => [
'loginRoute' => $this->generateUrl('lightsaml_sp.discovery'),
],
'contentTemplate' => '@MauticUser/Security/saml_login_retry.html.twig',
'passthroughVars' => [
'route' => $this->generateUrl('mautic_base_index'),
'mauticContent' => 'user',
'sessionExpired' => true,
],
]);
}
/**
* @return array<string, string>
*/
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => 'onRequest',
];
}
}

View File

@@ -0,0 +1,628 @@
<?php
declare(strict_types=1);
namespace Mautic\UserBundle\Controller;
use JMS\Serializer\SerializerInterface;
use Mautic\CoreBundle\Controller\FormController;
use Mautic\CoreBundle\Factory\PageHelperFactoryInterface;
use Mautic\CoreBundle\Helper\InputHelper;
use Mautic\CoreBundle\Helper\IpLookupHelper;
use Mautic\CoreBundle\Helper\LanguageHelper;
use Mautic\CoreBundle\Model\AuditLogModel;
use Mautic\CoreBundle\Model\FormModel;
use Mautic\EmailBundle\Helper\MailHelper;
use Mautic\UserBundle\Form\Type\ContactType;
use Mautic\UserBundle\Model\RoleModel;
use Mautic\UserBundle\Model\UserModel;
use Mautic\UserBundle\Security\SAML\Helper as SAMLHelper;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
class UserController extends FormController
{
/**
* Generate's default user list.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse|Response
*/
public function indexAction(Request $request, PageHelperFactoryInterface $pageHelperFactory, int $page = 1)
{
if (!$this->security->isGranted('user:users:view')) {
return $this->accessDenied();
}
$pageHelper = $pageHelperFactory->make('mautic.user', $page);
$this->setListFilters();
$currentUserId = $this->user->getId();
$limit = $pageHelper->getLimit();
$start = $pageHelper->getStart();
$orderBy = $request->getSession()->get('mautic.user.orderby', 'u.lastName, u.firstName, u.username');
$orderByDir = $request->getSession()->get('mautic.user.orderbydir', 'ASC');
$search = $request->get('search', $request->getSession()->get('mautic.user.filter', ''));
$search = html_entity_decode($search);
$request->getSession()->set('mautic.user.filter', $search);
// do some default filtering
$filter = ['string' => $search, 'force' => ''];
$tmpl = $request->isXmlHttpRequest() ? $request->get('tmpl', 'index') : 'index';
$users = $this->getModel('user.user')->getEntities(
[
'start' => $start,
'limit' => $limit,
'filter' => $filter,
'orderBy' => $orderBy,
'orderByDir' => $orderByDir,
]);
// Check to see if the number of pages match the number of users
$count = count($users);
if ($count && $count < ($start + 1)) {
// the number of entities are now less then the current page so redirect to the last page
$lastPage = $pageHelper->countPage($count);
$pageHelper->rememberPage($lastPage);
$returnUrl = $this->generateUrl('mautic_user_index', ['page' => $lastPage]);
return $this->postActionRedirect([
'returnUrl' => $returnUrl,
'viewParameters' => [
'page' => $lastPage,
'tmpl' => $tmpl,
],
'contentTemplate' => 'Mautic\UserBundle\Controller\UserController::indexAction',
'passthroughVars' => [
'activeLink' => '#mautic_user_index',
'mauticContent' => 'user',
],
]);
}
$pageHelper->rememberPage($page);
return $this->delegateView([
'viewParameters' => [
'items' => $users,
'searchValue' => $search,
'page' => $page,
'limit' => $limit,
'tmpl' => $tmpl,
'currentUserId' => $currentUserId,
'permissions' => [
'create' => $this->security->isGranted('user:users:create'),
'edit' => $this->security->isGranted('user:users:editother'),
'delete' => $this->security->isGranted('user:users:deleteother'),
],
],
'contentTemplate' => '@MauticUser/User/list.html.twig',
'passthroughVars' => [
'route' => $this->generateUrl('mautic_user_index', ['page' => $page]),
'mauticContent' => 'user',
],
]);
}
/**
* Generate's form and processes new post data.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse|Response
*/
public function newAction(Request $request, LanguageHelper $languageHelper, UserPasswordHasherInterface $hasher, SAMLHelper $samlHelper)
{
if (!$this->security->isGranted('user:users:create')) {
return $this->accessDenied();
}
/** @var UserModel $model */
$model = $this->getModel('user.user');
// retrieve the user entity
$user = $model->getEntity();
// set the return URL for post actions
$returnUrl = $this->generateUrl('mautic_user_index');
// set the page we came from
$page = $request->getSession()->get('mautic.user.page', 1);
// get the user form factory
$action = $this->generateUrl('mautic_user_action', ['objectAction' => 'new']);
$form = $model->createForm($user, $this->formFactory, $action);
// Check for a submitted form and process it
if ('POST' === $request->getMethod()) {
$valid = false;
if (!$cancelled = $this->isFormCancelled($form)) {
// check to see if the password needs to be rehashed
$formUser = $request->request->all()['user'] ?? [];
$submittedPassword = $formUser['plainPassword']['password'] ?? null;
$password = $model->checkNewPassword($user, $hasher, $submittedPassword);
if ($valid = $this->isFormValid($form)) {
// form is valid so process the data
$user->setPassword($password);
$model->saveEntity($user);
// check if the user's locale has been downloaded already, fetch it if not
$installedLanguages = $languageHelper->getSupportedLanguages();
if ($user->getLocale() && !array_key_exists($user->getLocale(), $installedLanguages)) {
$fetchLanguage = $languageHelper->extractLanguagePackage($user->getLocale());
// If there is an error, we need to reset the user's locale to the default
if ($fetchLanguage['error']) {
$user->setLocale(null);
$model->saveEntity($user);
$message = 'mautic.core.could.not.set.language';
$messageVars = [];
if (isset($fetchLanguage['message'])) {
$message = $fetchLanguage['message'];
}
if (isset($fetchLanguage['vars'])) {
$messageVars = $fetchLanguage['vars'];
}
$this->addFlashMessage($message, $messageVars);
}
}
$this->addFlashMessage('mautic.core.notice.created', [
'%name%' => $user->getName(),
'%menu_link%' => 'mautic_user_index',
'%url%' => $this->generateUrl('mautic_user_action', [
'objectAction' => 'edit',
'objectId' => $user->getId(),
]),
]);
}
}
if ($cancelled || ($valid && $this->getFormButton($form, ['buttons', 'save'])->isClicked())) {
return $this->postActionRedirect([
'returnUrl' => $returnUrl,
'viewParameters' => ['page' => $page, 'isSamlUser' => false],
'contentTemplate' => 'Mautic\UserBundle\Controller\UserController::indexAction',
'passthroughVars' => [
'activeLink' => '#mautic_user_index',
'mauticContent' => 'user',
],
]);
} elseif ($valid && !$cancelled) {
return $this->editAction($request, $languageHelper, $hasher, $samlHelper, $user->getId(), true);
}
}
return $this->delegateView([
'viewParameters' => ['form' => $form->createView(), 'isSamlUser' => false],
'contentTemplate' => '@MauticUser/User/form.html.twig',
'passthroughVars' => [
'activeLink' => '#mautic_user_new',
'route' => $action,
'mauticContent' => 'user',
],
]);
}
/**
* Generates edit form and processes post data.
*
* @param int $objectId
* @param bool $ignorePost
*
* @return \Symfony\Component\HttpFoundation\JsonResponse|Response
*/
public function editAction(Request $request, LanguageHelper $languageHelper, UserPasswordHasherInterface $hasher, SAMLHelper $samlHelper, $objectId, $ignorePost = false)
{
if (!$this->security->isGranted('user:users:edit')) {
return $this->accessDenied();
}
$model = $this->getModel('user.user');
\assert($model instanceof UserModel);
$user = $model->getEntity($objectId);
if (null === $user) {
return $this->postActionRedirect([
'returnUrl' => $this->generateUrl('mautic_user_index'),
'flashes' => [
[
'type' => 'error',
'msg' => 'mautic.user.user.error.notfound',
'msgVars' => ['%id%' => $objectId],
],
],
]);
}
$oldEmail = $user->getEmail();
/** @var AuditLogModel $auditLogModel */
$auditLogModel = $this->getModel('core.auditlog');
$auditLogRepository = $auditLogModel->getRepository();
$userActivity = $auditLogRepository->getLogsForUser($user);
$users = $model->getEntities();
$roleModel = $this->getModel('user.role');
\assert($roleModel instanceof RoleModel);
$roleRepository = $roleModel->getRepository();
$roles = $roleRepository->getEntities();
// set the page we came from
$page = $request->getSession()->get('mautic.user.page', 1);
// set the return URL
$returnUrl = $this->generateUrl('mautic_user_index', ['page' => $page]);
$postActionVars = [
'returnUrl' => $returnUrl,
'viewParameters' => ['page' => $page],
'contentTemplate' => 'Mautic\UserBundle\Controller\UserController::indexAction',
'passthroughVars' => [
'activeLink' => '#mautic_user_index',
'mauticContent' => 'user',
],
];
if ($model->isLocked($user)) {
// deny access if the entity is locked
return $this->isLocked($postActionVars, $user, 'user.user');
}
$action = $this->generateUrl('mautic_user_action', ['objectAction' => 'edit', 'objectId' => $objectId]);
$form = $model->createForm($user, $this->formFactory, $action);
$isSamlUser = $samlHelper->isSamlSession();
if ($isSamlUser) {
$form->remove('plainPassword');
}
// /Check for a submitted form and process it
if (!$ignorePost && 'POST' === $request->getMethod()) {
$valid = false;
if (!$cancelled = $this->isFormCancelled($form)) {
// check to see if the password needs to be rehashed
$formUser = $request->request->all()['user'] ?? [];
$submittedPassword = $formUser['plainPassword']['password'] ?? null;
$password = $model->checkNewPassword($user, $hasher, $submittedPassword);
$newEmail = $formUser['email'] ?? null;
if ($valid = $this->isFormValid($form)) {
// form is valid so process the data
$user->setPassword($password);
$model->saveEntity($user, $this->getFormButton($form, ['buttons', 'save'])->isClicked());
if (!empty($submittedPassword)) {
$model->sendChangePasswordInfo($user);
}
if ($newEmail !== $oldEmail) {
$model->sendChangeEmailInfo($oldEmail, $user);
}
// check if the user's locale has been downloaded already, fetch it if not
$installedLanguages = $languageHelper->getSupportedLanguages();
if ($user->getLocale() && !array_key_exists($user->getLocale(), $installedLanguages)) {
$fetchLanguage = $languageHelper->extractLanguagePackage($user->getLocale());
// If there is an error, we need to reset the user's locale to the default
if ($fetchLanguage['error']) {
$user->setLocale(null);
$model->saveEntity($user);
$message = 'mautic.core.could.not.set.language';
$messageVars = [];
if (isset($fetchLanguage['message'])) {
$message = $fetchLanguage['message'];
}
if (isset($fetchLanguage['vars'])) {
$messageVars = $fetchLanguage['vars'];
}
$this->addFlashMessage($message, $messageVars);
}
}
$this->addFlashMessage('mautic.core.notice.updated', [
'%name%' => $user->getName(),
'%menu_link%' => 'mautic_user_index',
'%url%' => $this->generateUrl('mautic_user_action', [
'objectAction' => 'edit',
'objectId' => $user->getId(),
]),
]);
}
} else {
// unlock the entity
$model->unlockEntity($user);
}
if ($cancelled || ($valid && $this->getFormButton($form, ['buttons', 'save'])->isClicked())) {
return $this->postActionRedirect($postActionVars);
}
} else {
// lock the entity
$model->lockEntity($user);
}
return $this->delegateView([
'viewParameters' => [
'form' => $form->createView(),
'logs' => $userActivity,
'users' => $users,
'roles' => $roles,
'editAction' => true,
'isSamlUser' => $isSamlUser,
],
'contentTemplate' => '@MauticUser/User/form.html.twig',
'passthroughVars' => [
'activeLink' => '#mautic_user_index',
'route' => $action,
'mauticContent' => 'user',
],
]);
}
/**
* Deletes a user object.
*
* @param int $objectId
*
* @return Response
*/
public function deleteAction(Request $request, $objectId)
{
if (!$this->security->isGranted('user:users:delete')) {
return $this->accessDenied();
}
$currentUser = $this->user;
$page = $request->getSession()->get('mautic.user.page', 1);
$returnUrl = $this->generateUrl('mautic_user_index', ['page' => $page]);
$success = 0;
$flashes = [];
$postActionVars = [
'returnUrl' => $returnUrl,
'viewParameters' => ['page' => $page],
'contentTemplate' => 'Mautic\UserBundle\Controller\UserController::indexAction',
'passthroughVars' => [
'activeLink' => '#mautic_user_index',
'route' => $returnUrl,
'success' => $success,
'mauticContent' => 'user',
],
];
if ('POST' === $request->getMethod()) {
// ensure the user logged in is not getting deleted
if ((int) $currentUser->getId() !== (int) $objectId) {
$model = $this->getModel('user.user');
\assert($model instanceof UserModel);
$entity = $model->getEntity($objectId);
if (null === $entity) {
$flashes[] = [
'type' => 'error',
'msg' => 'mautic.user.user.error.notfound',
'msgVars' => ['%id%' => $objectId],
];
} elseif ($model->isLocked($entity)) {
return $this->isLocked($postActionVars, $entity, 'user.user');
} else {
$model->deleteEntity($entity);
$name = $entity->getName();
$flashes[] = [
'type' => 'notice',
'msg' => 'mautic.core.notice.deleted',
'msgVars' => [
'%name%' => $name,
'%id%' => $objectId,
],
];
}
} else {
$flashes[] = [
'type' => 'error',
'msg' => 'mautic.user.user.error.cannotdeleteself',
];
}
} // else don't do anything
return $this->postActionRedirect(
array_merge($postActionVars, [
'flashes' => $flashes,
])
);
}
/**
* Contacts a user.
*
* @param int $objectId
*/
public function contactAction(Request $request, SerializerInterface $serializer, MailHelper $mailer, IpLookupHelper $ipLookupHelper, $objectId): Response|\Symfony\Component\HttpFoundation\RedirectResponse
{
$model = $this->getModel('user.user');
$user = $model->getEntity($objectId);
// user not found
if (null === $user) {
return $this->postActionRedirect([
'returnUrl' => $this->generateUrl('mautic_dashboard_index'),
'contentTemplate' => 'Mautic\UserBundle\Controller\UserController::contactAction',
'flashes' => [
[
'type' => 'error',
'msg' => 'mautic.user.user.error.notfound',
'msgVars' => ['%id%' => $objectId],
],
],
]);
}
$action = $this->generateUrl('mautic_user_action', ['objectAction' => 'contact', 'objectId' => $objectId]);
$form = $this->createForm(ContactType::class, [], ['action' => $action]);
$currentUser = $this->user;
if ('POST' === $request->getMethod()) {
$contact = $request->request->all()['contact'] ?? [];
$formUrl = $contact['returnUrl'] ?? '';
$returnUrl = $formUrl ? urldecode($formUrl) : $this->generateUrl('mautic_dashboard_index');
$valid = false;
if (!$cancelled = $this->isFormCancelled($form)) {
if ($valid = $this->isFormValid($form)) {
$subject = InputHelper::clean($form->get('msg_subject')->getData());
$body = InputHelper::clean($form->get('msg_body')->getData());
$mailer->setFrom($currentUser->getEmail(), $currentUser->getName());
$mailer->setSubject($subject);
$mailer->setTo($user->getEmail(), $user->getName());
$mailer->setBody($body);
$mailer->send();
$reEntity = $form->get('entity')->getData();
if (empty($reEntity)) {
$bundle = $object = 'user';
$entityId = $user->getId();
} else {
$bundle = $object = $reEntity;
if (strpos($reEntity, ':')) {
[$bundle, $object] = explode(':', $reEntity);
}
$entityId = $form->get('id')->getData();
}
$details = $serializer->serialize([
'from' => $currentUser->getName(),
'to' => $user->getName(),
'subject' => $subject,
'message' => $body,
], 'json');
$log = [
'bundle' => $bundle,
'object' => $object,
'objectId' => $entityId,
'action' => 'communication',
'details' => $details,
'ipAddress' => $ipLookupHelper->getIpAddressFromRequest(),
];
$auditLogModel = $this->getModel('core.auditlog');
\assert($auditLogModel instanceof AuditLogModel);
$auditLogModel->writeToLog($log);
$this->addFlashMessage('mautic.user.user.notice.messagesent', ['%name%' => $user->getName()]);
}
}
if ($cancelled || $valid) {
return $this->redirect($returnUrl);
}
} else {
$reEntityId = (int) $request->get('id');
$reSubject = InputHelper::clean($request->get('subject'));
$returnUrl = InputHelper::clean($request->get('returnUrl', $this->generateUrl('mautic_dashboard_index')));
$reEntity = InputHelper::clean($request->get('entity'));
$form->get('entity')->setData($reEntity);
$form->get('id')->setData($reEntityId);
$form->get('returnUrl')->setData($returnUrl);
if (!empty($reEntity) && !empty($reEntityId)) {
/** @var FormModel<object> $model */
$model = $this->getModel($reEntity);
$entity = $model->getEntity($reEntityId);
if (null !== $entity) {
$subject = $model->getUserContactSubject($reSubject, $entity);
$form->get('msg_subject')->setData($subject);
}
}
}
return $this->delegateView([
'viewParameters' => [
'form' => $form->createView(),
'user' => $user,
],
'contentTemplate' => '@MauticUser/User/contact.html.twig',
'passthroughVars' => [
'route' => $action,
'mauticContent' => 'user',
],
]);
}
/**
* Deletes a group of entities.
*/
public function batchDeleteAction(Request $request): Response
{
$page = $request->getSession()->get('mautic.user.page', 1);
$returnUrl = $this->generateUrl('mautic_user_index', ['page' => $page]);
$flashes = [];
$postActionVars = [
'returnUrl' => $returnUrl,
'viewParameters' => ['page' => $page],
'contentTemplate' => 'Mautic\UserBundle\Controller\UserController::indexAction',
'passthroughVars' => [
'activeLink' => '#mautic_user_index',
'mauticContent' => 'user',
],
];
if (Request::METHOD_POST === $request->getMethod()) {
$model = $this->getModel('user');
\assert($model instanceof UserModel);
$ids = json_decode($request->query->get('ids', ''));
$deleteIds = [];
$currentUser = $this->user;
// Loop over the IDs to perform access checks pre-delete
foreach ($ids as $objectId) {
$entity = $model->getEntity($objectId);
if ((int) $currentUser->getId() === (int) $objectId) {
$flashes[] = [
'type' => 'error',
'msg' => 'mautic.user.user.error.cannotdeleteself',
];
} elseif (null === $entity) {
$flashes[] = [
'type' => 'error',
'msg' => 'mautic.user.user.error.notfound',
'msgVars' => ['%id%' => $objectId],
];
} elseif (!$this->security->isGranted('user:users:delete')) {
$flashes[] = $this->accessDenied(true);
} elseif ($model->isLocked($entity)) {
$flashes[] = $this->isLocked($postActionVars, $entity, 'user', true);
} else {
$deleteIds[] = $objectId;
}
}
// Delete everything we are able to
if (!empty($deleteIds)) {
$entities = $model->deleteEntities($deleteIds);
$flashes[] = [
'type' => 'notice',
'msg' => 'mautic.user.user.notice.batch_deleted',
'msgVars' => [
'%count%' => count($entities),
],
];
}
} // else don't do anything
return $this->postActionRedirect(
array_merge($postActionVars, [
'flashes' => $flashes,
])
);
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace Mautic\UserBundle\DataFixtures\ORM;
use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface;
use Doctrine\Common\DataFixtures\AbstractFixture;
use Doctrine\Common\DataFixtures\OrderedFixtureInterface;
use Doctrine\Persistence\ObjectManager;
use Mautic\UserBundle\Entity\Role;
use Mautic\UserBundle\Model\RoleModel;
class LoadRoleData extends AbstractFixture implements OrderedFixtureInterface, FixtureGroupInterface
{
public static function getGroups(): array
{
return ['group_mautic_install_data'];
}
public function __construct(
private RoleModel $roleModel,
) {
}
public function load(ObjectManager $manager): void
{
if (!$this->hasReference('admin-role')) {
$role = new Role();
$role->setName('Administrators');
$role->setDescription('Has access to everything.');
$role->setIsAdmin(1);
$manager->persist($role);
$manager->flush();
$this->addReference('admin-role', $role);
}
$role = new Role();
$role->setName('Sales Team');
$role->setDescription('Has access to sales');
$role->setIsAdmin(0);
$permissions = [
'user:profile' => ['editname'],
'lead:leads' => ['full'],
];
$this->roleModel->setRolePermissions($role, $permissions);
$manager->persist($role);
$manager->flush();
$this->addReference('sales-role', $role);
}
public function getOrder()
{
return 1;
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace Mautic\UserBundle\DataFixtures\ORM;
use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface;
use Doctrine\Common\DataFixtures\AbstractFixture;
use Doctrine\Common\DataFixtures\OrderedFixtureInterface;
use Doctrine\Persistence\ObjectManager;
use Mautic\UserBundle\Entity\User;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasher;
class LoadUserData extends AbstractFixture implements OrderedFixtureInterface, FixtureGroupInterface
{
public static function getGroups(): array
{
return ['group_mautic_install_data'];
}
public function __construct(
private UserPasswordHasher $hasher,
) {
}
public function load(ObjectManager $manager): void
{
$user = new User();
$user->setFirstName('Admin');
$user->setLastName('User');
$user->setUsername('admin');
$user->setEmail('admin@yoursite.com');
$user->setPassword($this->hasher->hashPassword($user, 'Maut1cR0cks!'));
$user->setRole($this->getReference('admin-role'));
$manager->persist($user);
$manager->flush();
$this->addReference('admin-user', $user);
$user = new User();
$user->setFirstName('Sales');
$user->setLastName('User');
$user->setUsername('sales');
$user->setEmail('sales@yoursite.com');
$user->setPassword($this->hasher->hashPassword($user, 'Maut1cR0cks!'));
$user->setRole($this->getReference('sales-role'));
$manager->persist($user);
$manager->flush();
$this->addReference('sales-user', $user);
}
public function getOrder()
{
return 2;
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Mautic\UserBundle\DependencyInjection\Compiler;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
class FormLoginAuthenticatorOptionsPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
if (!$container->has('mautic.security.authenticator.form_login.decorator')) {
return;
}
$decoratedServiceId = 'mautic.security.authenticator.form_login.decorator.inner';
if (!$container->has($decoratedServiceId)) {
return;
}
$decoratedService = $container->getDefinition($decoratedServiceId);
// Grab the options from the original definition
$options = $decoratedService->getArgument(4);
$decorator = $container->getDefinition('mautic.security.authenticator.form_login.decorator');
// Set the options for our decorated service
$decorator->replaceArgument(3, $options);
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Mautic\UserBundle\DependencyInjection\Compiler;
use Mautic\UserBundle\Security\Authenticator\Oauth2Authenticator;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
class OAuthReplacePass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
if (!$container->hasDefinition('fos_oauth_server.security.authenticator.manager')) {
return;
}
$oAuthAuthenticatorDefinition = $container->getDefinition('fos_oauth_server.security.authenticator.manager');
$oAuthAuthenticatorDefinition->setClass(Oauth2Authenticator::class);
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Mautic\UserBundle\DependencyInjection\Compiler;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException;
/**
* This will replace $options in the
* \Mautic\UserBundle\DependencyInjection\Firewall\Factory\MauticSsoFactory::createAuthenticator.
*/
class SsoAuthenticatorPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
$ssoAuthenticatorId = 'security.authenticator.mautic_sso.main';
if (!$container->hasDefinition($ssoAuthenticatorId)) {
throw new ServiceNotFoundException($ssoAuthenticatorId);
}
$formLoginAuthenticatorId = 'security.authenticator.form_login.main';
if (!$container->hasDefinition($formLoginAuthenticatorId)) {
throw new ServiceNotFoundException($formLoginAuthenticatorId);
}
$loginFormAuthenticator = $container->getDefinition($formLoginAuthenticatorId);
$formLoginOptions = $loginFormAuthenticator->getArgument(4);
if (!is_array($formLoginOptions)) {
throw new InvalidArgumentException('The $options parameter must be an array. Maybe Symfony moved the parameter for the "form_login"?');
}
$container->getDefinition($ssoAuthenticatorId)
->replaceArgument('$options', $formLoginOptions);
}
}

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace Mautic\UserBundle\DependencyInjection\Firewall\Factory;
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\AbstractFactory;
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\AuthenticatorFactoryInterface;
use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
final class MauticSsoFactory extends AbstractFactory implements AuthenticatorFactoryInterface
{
/**
* Before form_login, otherwise the \Symfony\Component\Security\Http\Authenticator\FormLoginAuthenticator::supports
* of form_login will return true, and the authentication will be finished with an error.
* SSO Authenticator will be executed only if it has same options as form_login + request
* will have 'integration_parameter' in the Request.
*/
public const PRIORITY = -25;
public function __construct()
{
$this->addOption('username_parameter', '_username');
$this->addOption('password_parameter', '_password');
$this->addOption('integration_parameter', 'integration');
$this->addOption('csrf_parameter', '_csrf_token');
$this->addOption('csrf_token_id', 'authenticate');
$this->addOption('enable_csrf', true);
$this->addOption('post_only', true);
$this->addOption('form_only', false);
$this->addOption('login_path', '/s/login');
$this->addOption('check_path', '/s/login_check');
}
public function createAuthenticator(ContainerBuilder $container, string $firewallName, array $config, string $userProviderId): string
{
$authenticatorId = 'security.authenticator.mautic_sso.'.$firewallName;
// use same auth handlers as in login_form
$authenticationSuccessHandlerId = $this->getSuccessHandlerId($firewallName);
$formLoginSuccessHandlerId = str_replace('mautic_sso', 'form_login', $authenticationSuccessHandlerId);
$authenticationFailureHandlerId = $this->getFailureHandlerId($firewallName);
$formLoginFailureHandlerId = str_replace('mautic_sso', 'form_login', $authenticationFailureHandlerId);
$authenticator = $container
->setDefinition($authenticatorId, new ChildDefinition('security.authenticator.mautic_sso'))
->replaceArgument('$options', []) // Options will be replaced in \Mautic\UserBundle\DependencyInjection\Compiler\SsoAuthenticatorPass
->replaceArgument('$userProvider', new Reference($userProviderId))
->replaceArgument('$successHandler', new Reference($formLoginSuccessHandlerId))
->replaceArgument('$failureHandler', new Reference($formLoginFailureHandlerId));
$container->setDefinition($authenticatorId, $authenticator);
return $authenticatorId;
}
public function getKey(): string
{
return 'mautic-sso';
}
public function getPriority(): int
{
return self::PRIORITY;
}
public function getPosition(): string
{
return 'form';
}
/**
* @param array<mixed> $config
*/
protected function createAuthProvider(ContainerBuilder $container, string $id, array $config, string $userProviderId): string
{
throw new \Exception('The old authentication system is not supported with mautic-sso.');
}
protected function getListenerId(): string
{
throw new \Exception('The old authentication system is not supported with mautic-sso.');
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace Mautic\UserBundle\DependencyInjection\Firewall\Factory;
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\AuthenticatorFactoryInterface;
use Symfony\Component\Config\Definition\Builder\NodeDefinition;
use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
class PluginFactory implements AuthenticatorFactoryInterface
{
public const PRIORITY = -30;
public function createAuthenticator(ContainerBuilder $container, string $firewallName, array $config, string $userProviderId): string
{
$authenticatorId = 'security.authentication.provider.mautic.'.$firewallName;
$authenticator = $container
->setDefinition($authenticatorId, new ChildDefinition('security.authenticator.mautic_api'))
->replaceArgument('$firewallName', $firewallName)
->replaceArgument('$userProvider', new Reference($userProviderId));
$container->setDefinition($authenticatorId, $authenticator);
return $authenticatorId;
}
public function getPriority(): int
{
return self::PRIORITY;
}
public function getKey(): string
{
return 'mautic_plugin_auth';
}
public function addConfiguration(NodeDefinition $node): void
{
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Mautic\UserBundle\DependencyInjection;
use Mautic\UserBundle\EventListener\SAMLSubscriber;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension;
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
class MauticUserExtension extends Extension
{
/**
* @param mixed[] $configs
*/
public function load(array $configs, ContainerBuilder $container): void
{
$loader = new PhpFileLoader($container, new FileLocator(__DIR__.'/../Config'));
$loader->load('services.php');
$samlEnabled = $container->getParameter('mautic.saml_enabled');
if (true !== $samlEnabled) {
$container->removeDefinition(SAMLSubscriber::class);
}
}
}

View File

@@ -0,0 +1,104 @@
<?php
namespace Mautic\UserBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
#[ORM\Entity]
class IdEntry
{
/**
* @var string
*/
protected $id;
/**
* @var string
*/
protected $entityId;
/**
* @var int
*/
protected $expiryTimestamp;
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable('saml_id_entry');
$builder->createField('id', 'string')
->makePrimaryKey()
->generatedValue('NONE')
->build();
$builder->createField('entityId', 'string')
->columnName('entity_id')
->makePrimaryKey()
->generatedValue('NONE')
->build();
$builder->createField('expiryTimestamp', 'integer')
->build();
}
/**
* @return string
*/
public function getEntityId()
{
return $this->entityId;
}
/**
* @param string $entityId
*
* @return IdEntry
*/
public function setEntityId($entityId)
{
$this->entityId = $entityId;
return $this;
}
public function getExpiryTime(): \DateTime
{
$dt = new \DateTime();
$dt->setTimestamp($this->expiryTimestamp);
return $dt;
}
/**
* @return IdEntry
*/
public function setExpiryTime(\DateTime $expiryTime)
{
$this->expiryTimestamp = $expiryTime->getTimestamp();
return $this;
}
/**
* @return string
*/
public function getId()
{
return $this->id;
}
/**
* @param string $id
*
* @return IdEntry
*/
public function setId($id)
{
$this->id = $id;
return $this;
}
}

View File

@@ -0,0 +1,209 @@
<?php
namespace Mautic\UserBundle\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use Doctrine\ORM\Mapping as ORM;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
use Mautic\CoreBundle\Entity\CacheInvalidateInterface;
use Mautic\CoreBundle\Entity\UuidInterface;
use Mautic\CoreBundle\Entity\UuidTrait;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(security: "is_granted('user:roles:viewown')"),
new Post(security: "is_granted('user:roles:create')"),
new Get(security: "is_granted('user:roles:viewown')"),
new Put(security: "is_granted('user:roles:editown')"),
new Patch(security: "is_granted('user:roles:editother')"),
new Delete(security: "is_granted('user:roles:deleteown')"),
],
normalizationContext: [
'groups' => ['permission:read'],
'swagger_definition_name' => 'Read',
],
denormalizationContext: [
'groups' => ['permission:write'],
'swagger_definition_name' => 'Write',
]
)]
class Permission implements CacheInvalidateInterface, UuidInterface
{
use UuidTrait;
public const CACHE_NAMESPACE = 'Permission';
/**
* @var int
*/
#[Groups(['permission:read', 'role:read'])]
protected $id;
/**
* @var string
*/
#[Groups(['permission:read', 'permission:write', 'role:read'])]
protected $bundle;
/**
* @var string
*/
#[Groups(['permission:read', 'permission:write', 'role:read'])]
protected $name;
/**
* @var Role
*/
#[Groups(['permission:read', 'permission:write', 'role:read'])]
protected $role;
/**
* @var int
*/
#[Groups(['permission:read', 'permission:write', 'role:read'])]
protected $bitwise;
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable('permissions')
->setCustomRepositoryClass(PermissionRepository::class)
->addUniqueConstraint(['bundle', 'name', 'role_id'], 'unique_perm');
$builder->addId();
$builder->createField('bundle', 'string')
->length(50)
->build();
$builder->createField('name', 'string')
->length(50)
->build();
$builder->createManyToOne('role', 'Role')
->inversedBy('permissions')
->addJoinColumn('role_id', 'id', false, false, 'CASCADE')
->build();
$builder->addField('bitwise', 'integer');
static::addUuidField($builder);
}
/**
* Get id.
*
* @return int
*/
public function getId()
{
return $this->id;
}
/**
* Set bundle.
*
* @param string $bundle
*
* @return Permission
*/
public function setBundle($bundle)
{
$this->bundle = $bundle;
return $this;
}
/**
* Get bundle.
*
* @return string
*/
public function getBundle()
{
return $this->bundle;
}
/**
* Set bitwise.
*
* @param int $bitwise
*
* @return Permission
*/
public function setBitwise($bitwise)
{
$this->bitwise = $bitwise;
return $this;
}
/**
* Get bitwise.
*
* @return int
*/
public function getBitwise()
{
return $this->bitwise;
}
/**
* Set role.
*
* @return Permission
*/
public function setRole(?Role $role = null)
{
$this->role = $role;
return $this;
}
/**
* Get role.
*
* @return Role
*/
public function getRole()
{
return $this->role;
}
/**
* Set name.
*
* @param string $name
*
* @return Permission
*/
public function setName($name)
{
$this->name = $name;
return $this;
}
/**
* Get name.
*
* @return string
*/
public function getName()
{
return $this->name;
}
public function getCacheNamespacesToDelete(): array
{
return [self::CACHE_NAMESPACE];
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace Mautic\UserBundle\Entity;
use Doctrine\ORM\Query;
use Mautic\CoreBundle\Cache\ResultCacheHelper;
use Mautic\CoreBundle\Cache\ResultCacheOptions;
use Mautic\CoreBundle\Entity\CommonRepository;
/**
* @extends CommonRepository<Permission>
*/
class PermissionRepository extends CommonRepository
{
/**
* Delete all permissions for a specific role.
*/
public function purgeRolePermissions(Role $role): void
{
$query = $this
->createQueryBuilder('p')
->delete(Permission::class, 'p')
->where('p.role = :role')
->setParameter('role', $role)
->getQuery();
$query->execute();
}
/**
* Retrieves array of permissions for a set role. If $forForm, then the array will contain.
*
* @param bool $forForm
*/
public function getPermissionsByRole(Role $role, $forForm = false): array
{
$query = $this
->createQueryBuilder('p')
->where('p.role = :role')
->orderBy('p.bundle')
->setParameter(':role', $role)
->getQuery();
ResultCacheHelper::enableOrmQueryCache($query, new ResultCacheOptions(Permission::CACHE_NAMESPACE));
$results = $query->getResult(Query::HYDRATE_ARRAY);
// rearrange the array to meet needs
$permissions = [];
foreach ($results as $r) {
if ($forForm) {
$permissions[$r['bundle']][$r['id']] = [
'name' => $r['name'],
'bitwise' => $r['bitwise'],
];
} else {
$permissions[$r['bundle']][$r['name']] = $r['bitwise'];
}
}
return $permissions;
}
}

View File

@@ -0,0 +1,335 @@
<?php
namespace Mautic\UserBundle\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Mapping as ORM;
use Mautic\ApiBundle\Serializer\Driver\ApiMetadataDriver;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
use Mautic\CoreBundle\Entity\CacheInvalidateInterface;
use Mautic\CoreBundle\Entity\FormEntity;
use Mautic\CoreBundle\Entity\UuidInterface;
use Mautic\CoreBundle\Entity\UuidTrait;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Mapping\ClassMetadata;
#[ApiResource(
operations: [
new GetCollection(security: "is_granted('user:roles:viewown')"),
new Post(security: "is_granted('user:roles:create')"),
new Get(security: "is_granted('user:roles:viewown')"),
new Put(security: "is_granted('user:roles:editown')"),
new Patch(security: "is_granted('user:roles:editother')"),
new Delete(security: "is_granted('user:roles:deleteown')"),
],
normalizationContext: [
'groups' => ['role:read'],
'swagger_definition_name' => 'Read',
'api_included' => ['permissions'],
],
denormalizationContext: [
'groups' => ['role:write'],
'swagger_definition_name' => 'Write',
]
)]
class Role extends FormEntity implements CacheInvalidateInterface, UuidInterface
{
use UuidTrait;
public const CACHE_NAMESPACE = 'Role';
/**
* @var int
*/
#[Groups(['role:read'])]
private $id;
/**
* @var string
*/
#[Groups(['role:read', 'role:write'])]
private $name;
/**
* @var string|null
*/
#[Groups(['role:read', 'role:write'])]
private $description;
/**
* @var bool
*/
#[Groups(['role:read', 'role:write'])]
private $isAdmin = false;
/**
* @var ArrayCollection<int, Permission>
*/
#[Groups(['role:read', 'role:write'])]
private $permissions;
/**
* @var array
*/
#[Groups(['role:read', 'role:write'])]
private $rawPermissions;
/**
* @var ArrayCollection<int, User>
*/
private $users;
public function __construct()
{
$this->permissions = new ArrayCollection();
$this->users = new ArrayCollection();
}
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable('roles')
->setCustomRepositoryClass(RoleRepository::class);
$builder->addIdColumns();
$builder->createField('isAdmin', 'boolean')
->columnName('is_admin')
->build();
$builder->createOneToMany('permissions', 'Permission')
->orphanRemoval()
->mappedBy('role')
->cascadePersist()
->cascadeRemove()
->fetchExtraLazy()
->build();
$builder->createField('rawPermissions', 'array')
->columnName('readable_permissions')
->build();
$builder->createOneToMany('users', 'User')
->mappedBy('role')
->fetchExtraLazy()
->build();
static::addUuidField($builder);
}
public static function loadValidatorMetadata(ClassMetadata $metadata): void
{
$metadata->addPropertyConstraint('name', new Assert\NotBlank(
['message' => 'mautic.core.name.required']
));
}
/**
* Prepares the metadata for API usage.
*/
public static function loadApiMetadata(ApiMetadataDriver $metadata): void
{
$metadata->setGroupPrefix('role')
->addListProperties(
[
'id',
'name',
'description',
'isAdmin',
'rawPermissions',
]
)
->build();
}
/**
* Get id.
*
* @return int
*/
public function getId()
{
return $this->id;
}
/**
* Set name.
*
* @param string $name
*
* @return Role
*/
public function setName($name)
{
$this->isChanged('name', $name);
$this->name = $name;
return $this;
}
/**
* Get name.
*
* @return string
*/
public function getName()
{
return $this->name;
}
/**
* Add permissions.
*
* @return Role
*/
public function addPermission(Permission $permissions)
{
$permissions->setRole($this);
$this->permissions[] = $permissions;
return $this;
}
/**
* Remove permissions.
*/
public function removePermission(Permission $permissions): void
{
$this->permissions->removeElement($permissions);
}
/**
* Get permissions.
*
* @return \Doctrine\Common\Collections\Collection
*/
public function getPermissions()
{
return $this->permissions;
}
/**
* Set description.
*
* @param string $description
*
* @return Role
*/
public function setDescription($description)
{
$this->isChanged('description', $description);
$this->description = $description;
return $this;
}
/**
* Get description.
*
* @return string
*/
public function getDescription()
{
return $this->description;
}
/**
* Set isAdmin.
*
* @param bool $isAdmin
*
* @return Role
*/
public function setIsAdmin($isAdmin)
{
$this->isChanged('isAdmin', $isAdmin);
$this->isAdmin = $isAdmin;
return $this;
}
/**
* Get isAdmin.
*
* @return bool
*/
public function getIsAdmin()
{
return $this->isAdmin;
}
/**
* Get isAdmin.
*
* @return bool
*/
public function isAdmin()
{
return $this->getIsAdmin();
}
/**
* Simply used to store a readable format of permissions for the changelog.
*/
public function setRawPermissions(array $permissions): void
{
$this->isChanged('rawPermissions', $permissions);
$this->rawPermissions = $permissions;
}
/**
* Get rawPermissions.
*
* @return array
*/
public function getRawPermissions()
{
return $this->rawPermissions;
}
/**
* Add users.
*
* @return Role
*/
public function addUser(User $users)
{
$this->users[] = $users;
return $this;
}
/**
* Remove users.
*/
public function removeUser(User $users): void
{
$this->users->removeElement($users);
}
/**
* Get users.
*
* @return \Doctrine\Common\Collections\Collection
*/
public function getUsers()
{
return $this->users;
}
public function getCacheNamespacesToDelete(): array
{
return [
self::CACHE_NAMESPACE,
User::CACHE_NAMESPACE,
];
}
}

View File

@@ -0,0 +1,168 @@
<?php
namespace Mautic\UserBundle\Entity;
use Doctrine\ORM\Tools\Pagination\Paginator;
use Mautic\CoreBundle\Entity\CommonRepository;
/**
* @extends CommonRepository<Role>
*/
class RoleRepository extends CommonRepository
{
/**
* Get a list of roles.
*
* @return Paginator
*/
public function getEntities(array $args = [])
{
$q = $this->createQueryBuilder('r');
$args['qb'] = $q;
return parent::getEntities($args);
}
/**
* Get a list of roles.
*
* @param string $search
* @param int $limit
* @param int $start
*
* @return array
*/
public function getRoleList($search = '', $limit = 10, $start = 0)
{
$q = $this->_em->createQueryBuilder();
$q->select('partial r.{id, name}')
->from(Role::class, 'r');
if (!empty($search)) {
$q->where('r.name LIKE :search')
->setParameter('search', "{$search}%");
}
$q->orderBy('r.name');
if (!empty($limit)) {
$q->setFirstResult($start)
->setMaxResults($limit);
}
return $q->getQuery()->getArrayResult();
}
protected function addCatchAllWhereClause($q, $filter): array
{
return $this->addStandardCatchAllWhereClause(
$q,
$filter,
[
'r.name',
'r.description',
]
);
}
protected function addSearchCommandWhereClause($q, $filter): array
{
$command = $filter->command;
$unique = $this->generateRandomParameterName();
$returnParameter = false; // returning a parameter that is not used will lead to a Doctrine error
[$expr, $parameters] = parent::addSearchCommandWhereClause($q, $filter);
switch ($command) {
case $this->translator->trans('mautic.user.user.searchcommand.isadmin'):
case $this->translator->trans('mautic.user.user.searchcommand.isadmin', [], null, 'en_US'):
$expr = $q->expr()->eq('r.isAdmin', 1);
break;
case $this->translator->trans('mautic.core.searchcommand.name'):
case $this->translator->trans('mautic.core.searchcommand.name', [], null, 'en_US'):
$expr = $q->expr()->like('r.name', ':'.$unique);
$returnParameter = true;
break;
}
if ($filter->not) {
$expr = $q->expr()->not($expr);
}
if ($returnParameter) {
$string = ($filter->strict) ? $filter->string : "%{$filter->string}%";
$parameters = ["$unique" => $string];
}
return [
$expr,
$parameters,
];
}
/**
* Get a count of users that belong to the role.
*
* @return array
*/
public function getUserCount($roleIds)
{
$q = $this->_em->getConnection()->createQueryBuilder();
$q->select('count(u.id) as thecount, u.role_id')
->from(MAUTIC_TABLE_PREFIX.'users', 'u');
$returnArray = is_array($roleIds);
if (!$returnArray) {
$roleIds = [$roleIds];
}
$q->where(
$q->expr()->in('u.role_id', $roleIds)
)
->groupBy('u.role_id');
$result = $q->executeQuery()->fetchAllAssociative();
$return = [];
foreach ($result as $r) {
$return[$r['role_id']] = $r['thecount'];
}
// Ensure lists without leads have a value
foreach ($roleIds as $r) {
if (!isset($return[$r])) {
$return[$r] = 0;
}
}
return ($returnArray) ? $return : $return[$roleIds[0]];
}
/**
* @return string[]
*/
public function getSearchCommands(): array
{
$commands = [
'mautic.user.user.searchcommand.isadmin',
'mautic.core.searchcommand.name',
];
return array_merge($commands, parent::getSearchCommands());
}
protected function getDefaultOrder(): array
{
return [
['r.name', 'ASC'],
];
}
public function getTableAlias(): string
{
return 'r';
}
}

View File

@@ -0,0 +1,841 @@
<?php
namespace Mautic\UserBundle\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use Doctrine\ORM\Mapping as ORM;
use Mautic\ApiBundle\Serializer\Driver\ApiMetadataDriver;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
use Mautic\CoreBundle\Entity\CacheInvalidateInterface;
use Mautic\CoreBundle\Entity\FormEntity;
use Mautic\UserBundle\Form\Validator\Constraints\NotWeak;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Form\Form;
use Symfony\Component\Security\Core\User\EquatableInterface;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Mapping\ClassMetadata;
#[ApiResource(
shortName: 'User',
operations: [
new GetCollection(uriTemplate: '/users', security: "is_granted('user:users:viewown')"),
new Post(uriTemplate: '/users', security: "is_granted('user:users:create')", processor: \Mautic\UserBundle\ApiPlatform\UserProcessor::class),
new Get(uriTemplate: '/users/{id}', security: "is_granted('user:users:viewown')"),
new Put(uriTemplate: '/users/{id}', security: "is_granted('user:users:editown')", processor: \Mautic\UserBundle\ApiPlatform\UserProcessor::class),
new Patch(uriTemplate: '/users/{id}', security: "is_granted('user:users:editother')", processor: \Mautic\UserBundle\ApiPlatform\UserProcessor::class),
new Delete(uriTemplate: '/users/{id}', security: "is_granted('user:users:deleteown')"),
],
normalizationContext: [
'groups' => ['user:read'],
'swagger_definition_name' => 'Read',
],
denormalizationContext: [
'groups' => ['user:write'],
'swagger_definition_name' => 'Write',
]
)]
class User extends FormEntity implements UserInterface, EquatableInterface, PasswordAuthenticatedUserInterface, CacheInvalidateInterface
{
public const CACHE_NAMESPACE = 'User';
/**
* @var ?int
*/
#[Groups(['user:read'])]
protected $id;
#[Groups(['user:read', 'user:write'])]
protected ?string $username = null;
/**
* @var string
*/
protected $password;
/**
* Used for when updating the password.
*
* @var ?string
*/
#[Groups(['user:write'])]
private $plainPassword;
/**
* Used for updating account.
*
* @var ?string
*/
private $currentPassword;
/**
* @var string
*/
#[Groups(['user:read', 'user:write'])]
private $firstName;
/**
* @var string
*/
#[Groups(['user:read', 'user:write'])]
private $lastName;
/**
* @var string
*/
#[Groups(['user:read', 'user:write'])]
private $email;
/**
* @var string|null
*/
#[Groups(['user:read', 'user:write'])]
private $position;
/**
* @var Role
*/
#[Groups(['user:read', 'user:write'])]
private $role;
/**
* @var string|null
*/
#[Groups(['user:read', 'user:write'])]
private $timezone = '';
/**
* @var string|null
*/
#[Groups(['user:read', 'user:write'])]
private $locale = '';
/**
* @var \DateTimeInterface
*/
#[Groups(['user:read'])]
private $lastLogin;
/**
* @var \DateTimeInterface
*/
#[Groups(['user:read'])]
private $lastActive;
/**
* Stores active role permissions.
*/
private $activePermissions;
/**
* @var array
*/
#[Groups(['user:read', 'user:write'])]
private $preferences = [];
/**
* @var string|null
*/
#[Groups(['user:read', 'user:write'])]
private $signature;
/**
* @param bool $guest
*/
public function __construct(
private $guest = false,
) {
}
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable('users')
->setCustomRepositoryClass(UserRepository::class);
$builder->addId();
$builder->createField('username', 'string')
->length(191)
->unique()
->build();
$builder->createField('password', 'string')
->length(64)
->build();
$builder->createField('firstName', 'string')
->columnName('first_name')
->length(191)
->build();
$builder->createField('lastName', 'string')
->columnName('last_name')
->length(191)
->build();
$builder->createField('email', 'string')
->length(191)
->unique()
->build();
$builder->createField('position', 'string')
->length(191)
->nullable()
->build();
$builder->createManyToOne('role', 'Role')
->inversedBy('users')
->cascadeMerge()
->addJoinColumn('role_id', 'id', false)
->build();
$builder->createField('timezone', 'string')
->nullable()
->build();
$builder->createField('locale', 'string')
->nullable()
->build();
$builder->createField('lastLogin', 'datetime')
->columnName('last_login')
->nullable()
->build();
$builder->createField('lastActive', 'datetime')
->columnName('last_active')
->nullable()
->build();
$builder->createField('preferences', 'array')
->nullable()
->build();
$builder->createField('signature', 'text')
->nullable()
->build();
}
public static function loadValidatorMetadata(ClassMetadata $metadata): void
{
$metadata->addPropertyConstraint('username', new Assert\NotBlank(
['message' => 'mautic.user.user.username.notblank']
));
$metadata->addConstraint(new UniqueEntity(
[
'fields' => ['username'],
'message' => 'mautic.user.user.username.unique',
'repositoryMethod' => 'checkUniqueUsernameEmail',
]
));
$metadata->addPropertyConstraint('firstName', new Assert\NotBlank(
['message' => 'mautic.user.user.firstname.notblank']
));
$metadata->addPropertyConstraint('lastName', new Assert\NotBlank(
['message' => 'mautic.user.user.lastname.notblank']
));
$metadata->addPropertyConstraint('email', new Assert\NotBlank(
['message' => 'mautic.user.user.email.valid']
));
$metadata->addPropertyConstraint('email', new Assert\Email(
[
'message' => 'mautic.user.user.email.valid',
'groups' => ['SecondPass'],
]
));
$metadata->addConstraint(new UniqueEntity(
[
'fields' => ['email'],
'message' => 'mautic.user.user.email.unique',
'repositoryMethod' => 'checkUniqueUsernameEmail',
]
));
$metadata->addPropertyConstraint('role', new Assert\NotBlank(
['message' => 'mautic.user.user.role.notblank']
));
$metadata->addPropertyConstraint('plainPassword', new Assert\NotBlank(
[
'message' => 'mautic.user.user.password.notblank',
'groups' => ['CheckPasswordNotBlank'],
]
));
$metadata->addPropertyConstraint('plainPassword', new Assert\Length(
[
'min' => 6,
'minMessage' => 'mautic.user.user.password.minlength',
'groups' => ['CheckPassword'],
]
));
$metadata->addPropertyConstraint('plainPassword', new NotWeak(
[
'message' => 'mautic.user.user.password.weak',
'groups' => ['CheckPassword'],
]
));
$metadata->setGroupSequence(['User', 'SecondPass', 'CheckPassword']);
}
public static function determineValidationGroups(Form $form): array
{
$data = $form->getData();
$groups = ['User', 'SecondPass'];
if ($data instanceof User) {
$isNewUser = !$data->getId();
$hasPlainPassword = !empty($data->getPlainPassword());
if ($isNewUser) {
$groups[] = $hasPlainPassword ? 'CheckPassword' : 'CheckPasswordNotBlank';
} elseif ($hasPlainPassword) {
$groups[] = 'CheckPassword';
}
}
return $groups;
}
/**
* Prepares the metadata for API usage.
*/
public static function loadApiMetadata(ApiMetadataDriver $metadata): void
{
$metadata->setGroupPrefix('user')
->addListProperties(
[
'id',
'username',
'firstName',
'lastName',
]
)
->addProperties(
[
'email',
'position',
'role',
'timezone',
'locale',
'lastLogin',
'lastActive',
'signature',
]
)
->build();
}
protected function isChanged($prop, $val)
{
$getter = 'get'.ucfirst($prop);
$current = $this->$getter();
if ('role' == $prop) {
if ($current && !$val) {
$this->changes['role'] = [$current->getName().' ('.$current->getId().')', $val];
} elseif (!$this->role && $val) {
$this->changes['role'] = [$current, $val->getName().' ('.$val->getId().')'];
} elseif ($current && $val && $current->getId() != $val->getId()) {
$this->changes['role'] = [
$current->getName().'('.$current->getId().')',
$val->getName().'('.$val->getId().')',
];
}
} else {
parent::isChanged($prop, $val);
}
}
public function getUsername(): ?string
{
return $this->username;
}
public function getUserIdentifier(): string
{
return $this->username ?? '';
}
public function getSalt(): ?string
{
// bcrypt generates its own salt
return null;
}
public function getPassword(): ?string
{
return $this->password;
}
/**
* Get plain password.
*
* @return ?string
*/
public function getPlainPassword()
{
return $this->plainPassword;
}
/**
* Get current password (that a user has typed into a form).
*
* @return ?string
*/
public function getCurrentPassword()
{
return $this->currentPassword;
}
public function getRoles(): array
{
$roles = [];
if ($this->username) {
$roles = [
($this->isAdmin()) ? 'ROLE_ADMIN' : 'ROLE_USER',
];
if (defined('MAUTIC_API_REQUEST') && MAUTIC_API_REQUEST) {
$roles[] = 'ROLE_API';
}
}
return $roles;
}
#[\Deprecated]
public function eraseCredentials(): void
{
}
/**
* @return array<int, mixed>
*/
public function __serialize(): array
{
$this->plainPassword = null;
$this->currentPassword = null;
return [
$this->id,
$this->username,
$this->password,
$this->isPublished(),
];
}
/**
* @param array<int, mixed> $data
*/
public function __unserialize(array $data): void
{
[
$this->id,
$this->username,
$this->password,
$published,
] = $data;
$this->setIsPublished($published);
}
/**
* @return ?int
*/
public function getId()
{
return $this->id;
}
/**
* Set username.
*
* @param string $username
*
* @return User
*/
public function setUsername($username)
{
$this->isChanged('username', $username);
$this->username = $username;
return $this;
}
/**
* Set password.
*
* @param string $password
*
* @return User
*/
public function setPassword($password)
{
$this->password = $password;
return $this;
}
/**
* Set plain password.
*
* @return User
*/
public function setPlainPassword($plainPassword)
{
$this->plainPassword = $plainPassword;
return $this;
}
/**
* Set current password.
*
* @return User
*/
public function setCurrentPassword($currentPassword)
{
$this->currentPassword = $currentPassword;
return $this;
}
/**
* Set firstName.
*
* @param string $firstName
*
* @return User
*/
public function setFirstName($firstName)
{
$this->isChanged('firstName', $firstName);
$this->firstName = $firstName;
return $this;
}
/**
* Get firstName.
*
* @return string
*/
public function getFirstName()
{
return $this->firstName;
}
/**
* Set lastName.
*
* @param string $lastName
*
* @return User
*/
public function setLastName($lastName)
{
$this->isChanged('lastName', $lastName);
$this->lastName = $lastName;
return $this;
}
/**
* Get lastName.
*
* @return string
*/
public function getLastName()
{
return $this->lastName;
}
/**
* Get full name.
*
* @param bool $lastFirst
*/
public function getName($lastFirst = false): string
{
return ($lastFirst) ? $this->lastName.', '.$this->firstName : $this->firstName.' '.$this->lastName;
}
/**
* Set email.
*
* @param string $email
*
* @return User
*/
public function setEmail($email)
{
$this->isChanged('email', $email);
$this->email = $email;
return $this;
}
/**
* Get email.
*
* @return string
*/
public function getEmail()
{
return $this->email;
}
/**
* Set role.
*
* @return User
*/
public function setRole(?Role $role = null)
{
$this->isChanged('role', $role);
$this->role = $role;
return $this;
}
/**
* Get role.
*
* @return Role
*/
public function getRole()
{
return $this->role;
}
/**
* Set active permissions.
*
* @return User
*/
public function setActivePermissions(array $permissions)
{
$this->activePermissions = $permissions;
return $this;
}
/**
* Get active permissions.
*
* @return mixed
*/
public function getActivePermissions()
{
return $this->activePermissions;
}
/**
* Set position.
*
* @param string $position
*
* @return User
*/
public function setPosition($position)
{
$this->isChanged('position', $position);
$this->position = $position;
return $this;
}
/**
* Get position.
*
* @return string
*/
public function getPosition()
{
return $this->position;
}
/**
* Set timezone.
*
* @param string $timezone
*
* @return User
*/
public function setTimezone($timezone)
{
$this->isChanged('timezone', $timezone);
$this->timezone = $timezone;
return $this;
}
/**
* Get timezone.
*
* @return string
*/
public function getTimezone()
{
return $this->timezone;
}
/**
* @return User
*/
public function setLocale(?string $locale)
{
$this->isChanged('locale', $locale);
$this->locale = $locale;
return $this;
}
/**
* Get locale.
*
* @return string
*/
public function getLocale()
{
return $this->locale;
}
/**
* Determines if user is admin.
*
* @return bool
*/
public function isAdmin()
{
if (null !== $this->role) {
return $this->role->isAdmin();
} else {
return false;
}
}
/**
* @return mixed
*/
public function getLastLogin()
{
return $this->lastLogin;
}
/**
* @param mixed $lastLogin
*/
public function setLastLogin($lastLogin = null): void
{
if (empty($lastLogin)) {
$lastLogin = new \DateTime();
}
$this->lastLogin = $lastLogin;
}
/**
* @return mixed
*/
public function getLastActive()
{
return $this->lastActive;
}
/**
* @param mixed $lastActive
*/
public function setLastActive($lastActive = null): void
{
if (empty($lastActive)) {
$lastActive = new \DateTime();
}
$this->lastActive = $lastActive;
}
/**
* @return mixed
*/
public function getPreferences()
{
return $this->preferences;
}
/**
* @param mixed $preferences
*/
public function setPreferences(array $preferences): void
{
$this->preferences = $preferences;
}
/**
* Set signature.
*
* @param string $signature
*
* @return User
*/
public function setSignature($signature)
{
$this->isChanged('signature', $signature);
$this->signature = $signature;
return $this;
}
/**
* Get signature.
*
* @return string
*/
public function getSignature()
{
return $this->signature;
}
/**
* Needed for SAML to work correctly.
*/
public function isEqualTo(UserInterface $user): bool
{
if (!$user instanceof self) {
return false;
}
$thisUser = $this->getId().$this->getUserIdentifier().$this->getPassword();
$thatUser = $user->getId().$user->getUserIdentifier().$user->getPassword();
return $thisUser === $thatUser;
}
/**
* @return bool
*/
public function isGuest()
{
return $this->guest;
}
public function getCacheNamespacesToDelete(): array
{
return [self::CACHE_NAMESPACE];
}
}

View File

@@ -0,0 +1,340 @@
<?php
namespace Mautic\UserBundle\Entity;
use Doctrine\DBAL\Query\QueryBuilder;
use Doctrine\ORM\Tools\Pagination\Paginator;
use Mautic\CoreBundle\Entity\CommonRepository;
use Mautic\CoreBundle\Helper\DateTimeHelper;
/**
* @extends CommonRepository<User>
*/
class UserRepository extends CommonRepository
{
/**
* Find user by username or email.
*/
public function findByIdentifier(string $identifier): ?User
{
$q = $this->createQueryBuilder('u')
->where('u.username = :identifier OR u.email = :identifier')
->setParameter('identifier', $identifier);
$result = $q->getQuery()->getResult();
return (!empty($result)) ? $result[0] : null;
}
public function setLastLogin($user): void
{
$now = new DateTimeHelper();
$datetime = $now->toUtcString();
$conn = $this->_em->getConnection();
$conn->update(MAUTIC_TABLE_PREFIX.'users', [
'last_login' => $datetime,
'last_active' => $datetime,
], ['id' => (int) $user->getId()]);
}
public function setLastActive($user): void
{
$now = new DateTimeHelper();
$conn = $this->_em->getConnection();
$conn->update(MAUTIC_TABLE_PREFIX.'users', ['last_active' => $now->toUtcString()], ['id' => (int) $user->getId()]);
}
/**
* Checks to ensure that a username and/or email is unique.
*
* @return array
*/
public function checkUniqueUsernameEmail($params)
{
$q = $this->createQueryBuilder('u');
if (isset($params['email'])) {
$q->where('u.username = :email OR u.email = :email')
->setParameter('email', $params['email']);
}
if (isset($params['username'])) {
$q->orWhere('u.username = :username OR u.email = :username')
->setParameter('username', $params['username']);
}
return $q->getQuery()->getResult();
}
/**
* Get a list of users.
*
* @return Paginator
*/
public function getEntities(array $args = [])
{
$q = $this
->createQueryBuilder('u')
->select('u, r')
->leftJoin('u.role', 'r');
$args['qb'] = $q;
return parent::getEntities($args);
}
/**
* Get a list of users for an autocomplete input.
*
* @param string $search
* @param int $limit
* @param int $start
* @param array $permissionLimiter
*
* @return array
*/
public function getUserList($search = '', $limit = 10, $start = 0, $permissionLimiter = [])
{
$q = $this->_em->createQueryBuilder();
$q->select('DISTINCT partial u.{id, firstName, lastName, email}')
->from(User::class, 'u')
->leftJoin('u.role', 'r')
->leftJoin('r.permissions', 'p');
if (!empty($search)) {
$q->where(
$q->expr()->orX(
$q->expr()->like('u.firstName', ':search'),
$q->expr()->like('u.lastName', ':search'),
$q->expr()->like(
$q->expr()->concat('u.firstName',
$q->expr()->concat(
$q->expr()->literal(' '),
'u.lastName'
)
),
':search'
)
)
)
->setParameter('search', "{$search}%");
}
if (!empty($permissionLimiter)) {
// only get users with a role that has some sort of access to set permissions
$expr = $q->expr()->andX();
foreach ($permissionLimiter as $bundle => $level) {
$expr->add(
$q->expr()->andX(
$q->expr()->eq('p.bundle', $q->expr()->literal($bundle)),
$q->expr()->eq('p.name', $q->expr()->literal($level))
)
);
}
$expr = $q->expr()->orX(
$q->expr()->eq('r.isAdmin', ':true'),
$expr
);
$q->andWhere($expr);
}
$q->andWhere('u.isPublished = :true')
->setParameter('true', true, 'boolean')
->orderBy('u.firstName, u.lastName');
if (!empty($limit)) {
$q->setFirstResult($start)
->setMaxResults($limit);
}
return $q->getQuery()->getArrayResult();
}
/**
* Return list of Users for formType Choice.
*/
public function getOwnerListChoices(): array
{
$q = $this->createQueryBuilder('u');
$q->select('partial u.{id, firstName, lastName}');
$q->andWhere('u.isPublished = true')
->orderBy('u.firstName, u.lastName');
$users = $q->getQuery()->getResult();
$result = [];
/** @var User $user */
foreach ($users as $user) {
$result[$user->getName(true)] = $user->getId();
}
return $result;
}
/**
* @param string $search
* @param int $limit
* @param int $start
*
* @return array
*/
public function getPositionList($search = '', $limit = 10, $start = 0)
{
$q = $this->_em->createQueryBuilder()
->select('u.position')
->distinct()
->from(User::class, 'u')
->where("u.position != ''")
->andWhere('u.position IS NOT NULL');
if (!empty($search)) {
$q->andWhere('u.position LIKE :search')
->setParameter('search', "{$search}%");
}
$q->orderBy('u.position');
if (!empty($limit)) {
$q->setFirstResult($start)
->setMaxResults($limit);
}
return $q->getQuery()->getArrayResult();
}
protected function addCatchAllWhereClause($q, $filter): array
{
return $this->addStandardCatchAllWhereClause(
$q,
$filter,
[
'u.username',
'u.email',
'u.firstName',
'u.lastName',
'u.position',
'r.name',
]
);
}
protected function addSearchCommandWhereClause($q, $filter): array
{
$command = $filter->command;
$unique = $this->generateRandomParameterName();
$returnParameter = false; // returning a parameter that is not used will lead to a Doctrine error
[$expr, $parameters] = parent::addSearchCommandWhereClause($q, $filter);
switch ($command) {
case $this->translator->trans('mautic.core.searchcommand.ispublished'):
case $this->translator->trans('mautic.core.searchcommand.ispublished', [], null, 'en_US'):
$expr = $q->expr()->eq('u.isPublished', ":$unique");
$forceParameters = [$unique => true];
break;
case $this->translator->trans('mautic.core.searchcommand.isunpublished'):
case $this->translator->trans('mautic.core.searchcommand.isunpublished', [], null, 'en_US'):
$expr = $q->expr()->eq('u.isPublished', ":$unique");
$forceParameters = [$unique => false];
break;
case $this->translator->trans('mautic.user.user.searchcommand.isadmin'):
case $this->translator->trans('mautic.user.user.searchcommand.isadmin', [], null, 'en_US'):
$expr = $q->expr()->eq('r.isAdmin', ":$unique");
$forceParameters = [$unique => true];
break;
case $this->translator->trans('mautic.core.searchcommand.email'):
case $this->translator->trans('mautic.core.searchcommand.email', [], null, 'en_US'):
$expr = $q->expr()->like('u.email', ':'.$unique);
$returnParameter = true;
break;
case $this->translator->trans('mautic.user.user.searchcommand.position'):
case $this->translator->trans('mautic.user.user.searchcommand.position', [], null, 'en_US'):
$expr = $q->expr()->like('u.position', ':'.$unique);
$returnParameter = true;
break;
case $this->translator->trans('mautic.user.user.searchcommand.username'):
case $this->translator->trans('mautic.user.user.searchcommand.username', [], null, 'en_US'):
$expr = $q->expr()->like('u.username', ':'.$unique);
$returnParameter = true;
break;
case $this->translator->trans('mautic.user.user.searchcommand.role'):
case $this->translator->trans('mautic.user.user.searchcommand.role', [], null, 'en_US'):
$expr = $q->expr()->like('r.name', ':'.$unique);
$returnParameter = true;
break;
case $this->translator->trans('mautic.core.searchcommand.name'):
case $this->translator->trans('mautic.core.searchcommand.name', [], null, 'en_US'):
// This if/else can be removed once we upgrade to Dotrine 2.11 as both builders have the or() method there.
if ($q instanceof QueryBuilder) {
$expr = $q->expr()->or(
$q->expr()->like('u.firstName', ':'.$unique),
$q->expr()->like('u.lastName', ':'.$unique)
);
} else {
$expr = $q->expr()->orX(
$q->expr()->like('u.firstName', ':'.$unique),
$q->expr()->like('u.lastName', ':'.$unique)
);
}
$returnParameter = true;
break;
}
if (!empty($forceParameters)) {
$parameters = $forceParameters;
} elseif ($returnParameter) {
$string = ($filter->strict) ? $filter->string : "%{$filter->string}%";
$parameters = ["$unique" => $string];
}
return [$expr, $parameters];
}
/**
* @return string[]
*/
public function getSearchCommands(): array
{
$commands = [
'mautic.core.searchcommand.email',
'mautic.core.searchcommand.ispublished',
'mautic.core.searchcommand.isunpublished',
'mautic.user.user.searchcommand.isadmin',
'mautic.core.searchcommand.name',
'mautic.user.user.searchcommand.position',
'mautic.user.user.searchcommand.role',
'mautic.user.user.searchcommand.username',
];
return array_merge($commands, parent::getSearchCommands());
}
protected function getDefaultOrder(): array
{
return [
['u.lastName', 'ASC'],
['u.firstName', 'ASC'],
['u.username', 'ASC'],
];
}
public function getTableAlias(): string
{
return 'u';
}
/**
* @return User[]
*/
public function getAllAdminUsers(): array
{
return $this->createQueryBuilder('u')
->join('u.role', 'r')
->where('r.isAdmin = 1')
->getQuery()
->getResult();
}
}

View File

@@ -0,0 +1,172 @@
<?php
namespace Mautic\UserBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
class UserToken
{
/**
* @var int
*/
private $id;
/**
* @var User
*/
private $user;
/**
* @var string
*/
private $authorizator;
/**
* @var string
*/
private $secret;
/**
* @var \DateTimeInterface|null
*/
private $expiration;
/**
* @var bool
*/
private $oneTimeOnly = true;
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable('user_tokens')
->setCustomRepositoryClass(UserTokenRepository::class);
$builder->addId();
$builder->createManyToOne('user', User::class)
->addJoinColumn('user_id', 'id', false, false, 'CASCADE')
->build();
$builder->createField('authorizator', 'string')
->length(32)
->build();
$builder->createField('secret', 'string')
->length(120)
->unique()
->build();
$builder->createField('expiration', 'datetime')
->nullable()
->build();
$builder->createField('oneTimeOnly', 'boolean')
->columnName('one_time_only')
->build();
}
/**
* @return User
*/
public function getUser()
{
return $this->user;
}
/**
* @param User $user
*
* @return UserToken
*/
public function setUser($user)
{
$this->user = $user;
return $this;
}
/**
* @return string
*/
public function getAuthorizator()
{
return $this->authorizator;
}
/**
* @param string $authorizator
*
* @return UserToken
*/
public function setAuthorizator($authorizator)
{
$this->authorizator = $authorizator;
return $this;
}
/**
* @return string
*/
public function getSecret()
{
return $this->secret;
}
/**
* Use \Mautic\UserBundle\Entity\UserTokenRepositoryInterface::generateSecret to get valid secret.
*
* @param string $secret
*
* @return UserToken
*/
public function setSecret($secret)
{
$this->secret = $secret;
return $this;
}
/**
* @return \DateTimeInterface|null
*/
public function getExpiration()
{
return $this->expiration;
}
/**
* @param \DateTime|null $expiration
*
* @return UserToken
*/
public function setExpiration($expiration = null)
{
$this->expiration = $expiration;
return $this;
}
/**
* @return bool
*/
public function isOneTimeOnly()
{
return $this->oneTimeOnly;
}
/**
* @param bool $oneTimeOnly
*
* @return UserToken
*/
public function setOneTimeOnly($oneTimeOnly = true)
{
$this->oneTimeOnly = $oneTimeOnly;
return $this;
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace Mautic\UserBundle\Entity;
use Mautic\CoreBundle\Entity\CommonRepository;
/**
* @extends CommonRepository<UserToken>
*/
final class UserTokenRepository extends CommonRepository implements UserTokenRepositoryInterface
{
/**
* @param string $secret
*/
public function isSecretUnique($secret): bool
{
$tokens = $this->createQueryBuilder('ut')
->where('ut.secret = :secret')
->setParameter('secret', $secret)
->setMaxResults(1)
->getQuery()->execute();
return 0 === count($tokens);
}
public function verify(UserToken $token): bool
{
/** @var UserToken[] $userTokens */
$userTokens = $this->createQueryBuilder('ut')
->where('ut.user = :user AND ut.authorizator = :authorizator AND ut.secret = :secret AND (ut.expiration IS NULL OR ut.expiration >= :now)')
->setParameter('user', $token->getUser())
->setParameter('authorizator', $token->getAuthorizator())
->setParameter('secret', $token->getSecret())
->setParameter('now', new \DateTime())
->setMaxResults(1)
->getQuery()->execute();
$verified = (0 !== count($userTokens));
if (false === $verified) {
return false;
}
$userToken = reset($userTokens);
if ($userToken->isOneTimeOnly()) {
$this->deleteEntity($userToken);
}
return true;
}
public function deleteExpired($isDryRun = false): int
{
$qb = $this->createQueryBuilder('ut');
if ($isDryRun) {
$qb->select('count(ut.id) as records');
} else {
$qb->delete(UserToken::class, 'ut');
}
return (int) $qb
->where('ut.expiration <= :current_datetime')
->setParameter('current_datetime', new \DateTime())
->getQuery()
->execute();
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace Mautic\UserBundle\Entity;
/**
* Interface UserTokenRepositoryInterface.
*/
interface UserTokenRepositoryInterface
{
/**
* @param string $secret
*
* @return UserToken
*/
public function isSecretUnique($secret);
/**
* @return bool
*/
public function verify(UserToken $token);
/**
* Delete expired user tokens.
*
* @param bool $isDryRun
*
* @return int Number of selected or deleted rows
*/
public function deleteExpired($isDryRun = false);
}

View File

@@ -0,0 +1,8 @@
<?php
namespace Mautic\UserBundle\Enum;
final class UserTokenAuthorizator
{
public const RESET_PASSWORD_AUTHORIZATOR = 'reset-password';
}

View File

@@ -0,0 +1,51 @@
<?php
namespace Mautic\UserBundle\Event;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Contracts\EventDispatcher\Event;
class AuthenticationContentEvent extends Event
{
/**
* @var array
*/
protected $content = [];
/**
* @var bool
*/
protected $postLogout = false;
public function __construct(
protected Request $request,
) {
$this->postLogout = $request->getSession()->get('post_logout', false);
}
/**
* @return Request
*/
public function getRequest()
{
return $this->request;
}
/**
* @return bool
*/
public function isLogout()
{
return $this->postLogout;
}
public function addContent($content): void
{
$this->content[] = $content;
}
public function getContent(): string
{
return implode("\n\n", $this->content);
}
}

View File

@@ -0,0 +1,277 @@
<?php
namespace Mautic\UserBundle\Event;
use Mautic\PluginBundle\Integration\AbstractIntegration;
use Mautic\UserBundle\Entity\User;
use Mautic\UserBundle\Security\Authentication\Token\PluginToken;
use Mautic\UserBundle\Security\Provider\UserProvider;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\User\ChainUserProvider;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Contracts\EventDispatcher\Event;
class AuthenticationEvent extends Event
{
/**
* @var Response
*/
protected $response;
/**
* @var bool
*/
protected $isAuthenticated = false;
/**
* @var bool
*/
protected $forceFailedAuthentication = false;
/**
* @var UserProvider
*/
protected UserProviderInterface $userProvider;
protected bool $isFormLogin;
/**
* Message to display to user if there is a failed authentication.
*
* @var string
*/
protected $failedAuthMessage;
/**
* @param string|User|null $user
* @param bool $isLoginCheck Event executed from the mautic_sso_login_check route typically used as the SSO callback
* @param string $authenticatingService Service Service requesting authentication
* @param array<AbstractIntegration>|null $integrations
*/
public function __construct(
protected $user,
protected TokenInterface $token,
UserProviderInterface $userProvider,
protected Request $request,
protected $isLoginCheck = false,
protected $authenticatingService = null,
protected $integrations = null,
) {
$this->isFormLogin = $token instanceof UsernamePasswordToken;
if ($userProvider instanceof ChainUserProvider) {
// Chain of user providers so let's find Mautic's
$providers = $userProvider->getProviders();
foreach ($providers as $provider) {
if ($provider instanceof UserProvider) {
$userProvider = $provider;
break;
}
}
}
$this->userProvider = $userProvider;
}
/**
* Get user returned by username search.
*
* @return string|User|null
*/
public function getUser()
{
return $this->user;
}
/**
* Set the user to be used after authentication.
*
* @param bool|true $saveUser
* @param bool|true $createIfNotExists If true, the user will be created if it does not exist
*/
public function setUser(User $user, $saveUser = true, $createIfNotExists = true): void
{
if ($saveUser) {
$user = $this->userProvider->saveUser($user, $createIfNotExists);
}
$this->user = $user;
}
/**
* Get the token that has credentials, etc used to login.
*
* @return PluginToken
*/
public function getToken()
{
return $this->token;
}
public function setToken($service, TokenInterface $token): void
{
$this->token = $token;
$this->authenticatingService = $service;
$this->isAuthenticated = null !== $token->getUser();
$this->stopPropagation();
}
/**
* Get the username used.
*/
public function getUsername(): string
{
return $this->token->getUserIdentifier();
}
/**
* Get user provider to find and/or create new users.
*
* @return UserProvider
*/
public function getUserProvider()
{
return $this->userProvider;
}
/**
* Set if this user is successfully authenticated.
*
* @param string $service Service that authenticated the user; if using a Integration, it should match that of AbstractIntegration::getName();
* @param bool|true $createIfNotExists
*/
public function setIsAuthenticated($service, ?User $user = null, $createIfNotExists = true): void
{
$this->authenticatingService = $service;
if (null !== $user) {
$this->isAuthenticated = true;
$this->setUser($user, $createIfNotExists);
}
// Authenticated so stop propagation
$this->stopPropagation();
}
/**
* Check if the user has been authenticated.
*
* @return bool
*/
public function isAuthenticated()
{
return $this->isAuthenticated;
}
/**
* Prevent any other authentication method from authorizing the user.
* Mainly used to prevent a form login from trying to auth with the given password for a local user (think two-factor requirements).
*/
public function setIsFailedAuthentication(): void
{
$this->forceFailedAuthentication = true;
// Authenticated so stop propagation
$this->stopPropagation();
}
/**
* Set the message to display to the user for failing auth.
*/
public function setFailedAuthenticationMessage($message): void
{
$this->failedAuthMessage = $message;
}
/**
* Returns message to display to user for failing auth.
*
* @return string
*/
public function getFailedAuthenticationMessage()
{
return $this->failedAuthMessage;
}
/**
* Returns true if a plugin has forcefully failed authentication.
*
* @return bool
*/
public function isFailed()
{
return $this->forceFailedAuthentication;
}
/**
* Get the service that authenticated the user.
*
* @return string
*/
public function getAuthenticatingService()
{
return $this->authenticatingService;
}
/**
* Set a response such as a redirect.
*/
public function setResponse(Response $response): void
{
$this->response = $response;
// A response has been requested so stop propagation
$this->stopPropagation();
}
/**
* Get the response if set by the listener.
*
* @return Response|null
*/
public function getResponse()
{
return $this->response;
}
/**
* Get the request.
*
* @return Request
*/
public function getRequest()
{
return $this->request;
}
/**
* Check if this is a form login authentication request or pre-auth.
*/
public function isFormLogin(): bool
{
return $this->isFormLogin;
}
/**
* Check if the event is executed as the result of accessing mautic_sso_login_check.
*
* @return bool
*/
public function isLoginCheck()
{
return $this->isLoginCheck;
}
/**
* @return AbstractIntegration|bool
*/
public function getIntegration($integrationName)
{
return $this->integrations[$integrationName] ?? false;
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Mautic\UserBundle\Event;
use Mautic\UserBundle\Entity\User;
use Symfony\Contracts\EventDispatcher\Event;
class LoginEvent extends Event
{
public function __construct(
private User $user,
) {
}
/**
* @return User|null
*/
public function getUser()
{
return $this->user;
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace Mautic\UserBundle\Event;
use Mautic\UserBundle\Entity\User;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Contracts\EventDispatcher\Event;
class LogoutEvent extends Event
{
private array $session = [];
public function __construct(
private User $user,
private Request $request,
) {
}
/**
* @return User
*/
public function getUser()
{
return $this->user;
}
/**
* Add value to session after it's been cleared.
*/
public function setPostSessionItem($key, $value): void
{
$this->session[$key] = $value;
}
/**
* Get session items to be added after session has been cleared.
*
* @return array
*/
public function getPostSessionItems()
{
return $this->session;
}
/**
* @return Request
*/
public function getRequest()
{
return $this->request;
}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Mautic\UserBundle\Event;
use Symfony\Contracts\EventDispatcher\Event;
final class PasswordStrengthValidateEvent extends Event
{
public function __construct(
public bool $isValid,
public string $password,
) {
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace Mautic\UserBundle\Event;
use Mautic\CoreBundle\Event\CommonEvent;
use Mautic\UserBundle\Entity\Role;
class RoleEvent extends CommonEvent
{
/**
* @param bool $isNew
*/
public function __construct(Role &$role, $isNew = false)
{
$this->entity = &$role;
$this->isNew = $isNew;
}
/**
* Returns the Role entity.
*
* @return Role
*/
public function getRole()
{
return $this->entity;
}
/**
* Sets the Role entity.
*/
public function setRole(Role $role): void
{
$this->entity = $role;
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace Mautic\UserBundle\Event;
use Mautic\CoreBundle\Event\CommonEvent;
use Mautic\UserBundle\Entity\User;
class UserEvent extends CommonEvent
{
/**
* @param bool $isNew
*/
public function __construct(User &$user, $isNew = false)
{
$this->entity = &$user;
$this->isNew = $isNew;
}
/**
* Returns the User entity.
*
* @return User
*/
public function getUser()
{
return $this->entity;
}
/**
* Sets the User entity.
*/
public function setUser(User $user): void
{
$this->entity = $user;
}
}

View File

@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace Mautic\UserBundle\EventListener;
use FOS\OAuthServerBundle\Security\Authenticator\Passport\Badge\AccessTokenBadge;
use FOS\OAuthServerBundle\Security\Authenticator\Token\OAuthToken;
use Mautic\UserBundle\Security\Authentication\Token\Permissions\TokenPermissions;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Event\AuthenticationTokenCreatedEvent;
use Symfony\Component\Security\Http\Event\CheckPassportEvent;
class ApiUserSubscriber implements EventSubscriberInterface
{
public function __construct(private UserProviderInterface $userProvider, private TokenPermissions $tokenPermissions)
{
}
/**
* Execute the authentication if authentication is oAuth and has no UserLoader set.
* Sets permissions, and if user is not yet fetched - gets the user from TokenStorage, or creates one.
*/
public function onCheckPassport(CheckPassportEvent $event): void
{
$passport = $event->getPassport();
if (!$passport->hasBadge(UserBadge::class)) {
return;
}
$badge = $passport->getBadge(UserBadge::class);
\assert($badge instanceof UserBadge);
if (null !== $badge->getUserLoader()) {
return;
}
if (!$passport->hasBadge(AccessTokenBadge::class)) {
return;
}
$accessTokenBadge = $passport->getBadge(AccessTokenBadge::class);
\assert($accessTokenBadge instanceof AccessTokenBadge);
$badge->setUserLoader(function (string $userIdentifier) use ($passport, $accessTokenBadge): ?UserInterface {
$user = null;
try {
$user = $this->userProvider->loadUserByIdentifier($userIdentifier);
} catch (UserNotFoundException) {
// ignore and get the user from oAuth2 token.
}
$accessToken = $accessTokenBadge->getAccessToken();
if (null === $user) {
$user = $this->tokenPermissions->setActivePermissionsOnAuthToken($accessToken);
}
if (null === $user) {
return null;
}
$passport->addBadge(new AccessTokenBadge($accessToken, $user->getRoles()));
return $user;
});
}
/**
* Transfers User instance from \Mautic\UserBundle\Security\Authentication\Token\Permissions\TokenPermissions::setActivePermissionsOnAuthToken
* to the token, to be authorized later.
*/
public function onTokenCreated(AuthenticationTokenCreatedEvent $event): void
{
$passport = $event->getPassport();
if (!$passport->hasBadge(AccessTokenBadge::class)) {
return;
}
$accessTokenBadge = $passport->getBadge(AccessTokenBadge::class);
\assert($accessTokenBadge instanceof AccessTokenBadge);
$authenticatedToken = $event->getAuthenticatedToken();
\assert($authenticatedToken instanceof OAuthToken);
if (null !== $authenticatedToken->getUser()) {
return;
}
$authenticatedToken->setUser($accessTokenBadge->getAccessToken()->getUser());
}
/**
* @return array<string, string|array{0: string, 1: int}|list<array{0: string, 1?: int}>>
*/
public static function getSubscribedEvents(): array
{
return [
CheckPassportEvent::class => ['onCheckPassport', 2048],
AuthenticationTokenCreatedEvent::class => 'onTokenCreated',
];
}
}

View File

@@ -0,0 +1,108 @@
<?php
namespace Mautic\UserBundle\EventListener;
use Mautic\ConfigBundle\ConfigEvents;
use Mautic\ConfigBundle\Event\ConfigBuilderEvent;
use Mautic\ConfigBundle\Event\ConfigEvent;
use Mautic\UserBundle\Form\Type\ConfigType;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\File\UploadedFile;
class ConfigSubscriber implements EventSubscriberInterface
{
/**
* @var string[]
*/
private array $fileFields = [
'saml_idp_metadata',
'saml_idp_own_certificate',
'saml_idp_own_private_key',
];
public static function getSubscribedEvents(): array
{
return [
ConfigEvents::CONFIG_ON_GENERATE => ['onConfigGenerate', 0],
ConfigEvents::CONFIG_PRE_SAVE => ['onConfigSave', 0],
];
}
public function onConfigGenerate(ConfigBuilderEvent $event): void
{
$event->addFileFields($this->fileFields)
->addForm(
[
'bundle' => 'UserBundle',
'formAlias' => 'userconfig',
'formType' => ConfigType::class,
'formTheme' => '@MauticUser/FormTheme/Config/_config_userconfig_widget.html.twig',
'parameters' => $event->getParametersFromConfig('MauticUserBundle'),
]
);
}
public function onConfigSave(ConfigEvent $event): void
{
// Preserve existing value
$event->unsetIfEmpty('saml_idp_own_password');
$data = $event->getConfig('userconfig');
foreach ($this->fileFields as $field) {
if (!isset($data[$field]) || !$data[$field] instanceof UploadedFile) {
continue;
}
$data[$field] = $event->getFileContent($data[$field]);
switch ($field) {
case 'saml_idp_metadata':
if (!$this->validateXml($data[$field])) {
$event->setError('mautic.user.saml.metadata.invalid', [], 'userconfig', $field);
}
break;
case 'saml_idp_own_certificate':
if (!str_starts_with($data[$field], '-----BEGIN CERTIFICATE-----')) {
$event->setError('mautic.user.saml.certificate.invalid', [], 'userconfig', $field);
}
break;
case 'saml_idp_own_private_key':
$encryptedKey = str_starts_with($data[$field], '-----BEGIN ENCRYPTED PRIVATE KEY-----');
$decryptedKey = str_starts_with($data[$field], '-----BEGIN RSA PRIVATE KEY-----');
if (!$encryptedKey && !$decryptedKey) {
$event->setError('mautic.user.saml.private_key.invalid', [], 'userconfig', $field);
}
if ($encryptedKey && empty($data['saml_idp_own_password'])) {
$event->setError('mautic.user.saml.private_key.password_needed', [], 'userconfig', 'saml_idp_own_password');
}
if ($encryptedKey && !empty($data['saml_idp_own_password']) && !openssl_get_privatekey($data[$field], $data['saml_idp_own_password'])) {
$event->setError('mautic.user.saml.private_key.password_invalid', [], 'userconfig', 'saml_idp_own_password');
}
break;
}
$data[$field] = $event->encodeFileContents($data[$field]);
}
$event->setConfig($data, 'userconfig');
}
private function validateXml($content): bool
{
$valid = true;
libxml_use_internal_errors(true);
$doc = simplexml_load_string($content);
if (false === $doc) {
$valid = false;
libxml_clear_errors();
}
return $valid;
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace Mautic\UserBundle\EventListener;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\UserBundle\Event\LogoutEvent;
use Mautic\UserBundle\Model\UserModel;
use Mautic\UserBundle\UserEvents;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
class LogoutListener implements \Symfony\Component\EventDispatcher\EventSubscriberInterface
{
protected ?\Mautic\UserBundle\Entity\User $user;
public function __construct(
protected UserModel $userModel,
protected EventDispatcherInterface $dispatcher,
UserHelper $userHelper,
) {
$this->user = $userHelper->getUser();
}
public function onLogout(\Symfony\Component\Security\Http\Event\LogoutEvent $logoutEvent): void
{
$request = $logoutEvent->getRequest();
$session = $request->getSession();
if ($this->dispatcher->hasListeners(UserEvents::USER_LOGOUT)) {
$mauticEvent = new LogoutEvent($this->user, $request);
$this->dispatcher->dispatch($mauticEvent, UserEvents::USER_LOGOUT);
$sessionItems = $mauticEvent->getPostSessionItems();
foreach ($sessionItems as $key => $value) {
$session->set($key, $value);
}
}
// Clear session
$session->clear();
// Note that a logout occurred
$session->set('post_logout', true);
}
/**
* @return array<string, mixed>
*/
public static function getSubscribedEvents(): array
{
return [\Symfony\Component\Security\Http\Event\LogoutEvent::class => 'onLogout'];
}
public function onSymfonyComponentSecurityHttpEventLogoutEvent(\Symfony\Component\Security\Http\Event\LogoutEvent $logoutEvent): void
{
$this->onLogout($logoutEvent);
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Mautic\UserBundle\EventListener;
use Mautic\UserBundle\Security\Authenticator\Passport\Badge\PasswordStrengthBadge;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
use Symfony\Component\Security\Http\Event\CheckPassportEvent;
/**
* This must record a PW before CheckCredentialsListener will take an effect.
* Subscriber will add a password check badge.
*/
final class PasswordStrengthSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
CheckPassportEvent::class => ['checkPassport', 100],
];
}
public function checkPassport(CheckPassportEvent $event): void
{
$passport = $event->getPassport();
if (!$passport->hasBadge(PasswordCredentials::class)) {
return;
}
$badge = $passport->getBadge(PasswordCredentials::class);
\assert($badge instanceof PasswordCredentials);
$presentedPassword = $badge->getPassword();
$passport->addBadge(new PasswordStrengthBadge($presentedPassword));
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Mautic\UserBundle\EventListener;
use Mautic\UserBundle\Exception\WeakPasswordException;
use Mautic\UserBundle\Model\PasswordStrengthEstimatorModel;
use Mautic\UserBundle\Security\Authenticator\Passport\Badge\PasswordStrengthBadge;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Http\Event\CheckPassportEvent;
final class PasswordSubscriber implements EventSubscriberInterface
{
public function __construct(private PasswordStrengthEstimatorModel $passwordStrengthEstimatorModel)
{
}
public static function getSubscribedEvents(): array
{
return [
CheckPassportEvent::class => ['checkPassport', -100], // After default password checker
];
}
public function checkPassport(CheckPassportEvent $event): void
{
$passport = $event->getPassport();
if (!$passport->hasBadge(PasswordStrengthBadge::class)) {
return;
}
$badge = $passport->getBadge(PasswordStrengthBadge::class);
\assert($badge instanceof PasswordStrengthBadge);
$presentedPassword = $badge->getPresentedPassword();
if ('' === $presentedPassword) {
throw new BadCredentialsException('The presented password cannot be empty.');
}
if (!$this->passwordStrengthEstimatorModel->validate($presentedPassword)) {
throw new WeakPasswordException();
}
$badge->setResolved();
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace Mautic\UserBundle\EventListener;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Routing\RouterInterface;
class SAMLSubscriber implements EventSubscriberInterface
{
public function __construct(
private RouterInterface $router,
) {
}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => ['onKernelRequest', 256],
];
}
/**
* Block access to SAML URLs if SAML is disabled.
* This listener is removed from Kernel if SAML is not enabled. See mautic.saml_enabled parameter.
*/
public function onKernelRequest(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$request = $event->getRequest();
$route = (string) $request->attributes->get('_route');
$url = (string) $request->getRequestUri();
if (!str_contains($route, 'lightsaml') && !str_contains($url, '/saml/')) {
return;
}
// Redirect to standard login page if SAML is disabled
$event->setResponse(
new RedirectResponse($this->router->generate('login'))
);
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace Mautic\UserBundle\EventListener;
use Mautic\CoreBundle\CoreEvents;
use Mautic\CoreBundle\DTO\GlobalSearchFilterDTO;
use Mautic\CoreBundle\Event as MauticEvents;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Service\GlobalSearch;
use Mautic\UserBundle\Model\RoleModel;
use Mautic\UserBundle\Model\UserModel;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class SearchSubscriber implements EventSubscriberInterface
{
public function __construct(
private UserModel $userModel,
private RoleModel $userRoleModel,
private CorePermissions $security,
private GlobalSearch $globalSearch,
) {
}
public static function getSubscribedEvents(): array
{
return [
CoreEvents::GLOBAL_SEARCH => [
['onGlobalSearchUser', 0],
['onGlobalSearchRoles', 0],
],
CoreEvents::BUILD_COMMAND_LIST => ['onBuildCommandList', 0],
];
}
public function onGlobalSearchUser(MauticEvents\GlobalSearchEvent $event): void
{
$filterDTO = new GlobalSearchFilterDTO($event->getSearchString());
$results = $this->globalSearch->performSearch(
$filterDTO,
$this->userModel,
'@MauticUser/SubscribedEvents/Search/global_user.html.twig'
);
if (!empty($results)) {
$event->addResults('mautic.user.users', $results);
}
}
public function onGlobalSearchRoles(MauticEvents\GlobalSearchEvent $event): void
{
$filterDTO = new GlobalSearchFilterDTO($event->getSearchString());
$results = $this->globalSearch->performSearch(
$filterDTO,
$this->userRoleModel,
'@MauticUser/SubscribedEvents/Search/global_role.html.twig'
);
if (!empty($results)) {
$event->addResults('mautic.user.roles', $results);
}
}
public function onBuildCommandList(MauticEvents\CommandListEvent $event): void
{
if ($this->security->isGranted('user:users:view')) {
$event->addCommands(
'mautic.user.users',
$this->userModel->getCommandList()
);
}
if ($this->security->isGranted('user:roles:view')) {
$event->addCommands(
'mautic.user.roles',
$this->userRoleModel->getCommandList()
);
}
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace Mautic\UserBundle\EventListener;
use Mautic\CoreBundle\Helper\IpLookupHelper;
use Mautic\CoreBundle\Model\AuditLogModel;
use Mautic\UserBundle\Event\LoginEvent;
use Mautic\UserBundle\UserEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class SecuritySubscriber implements EventSubscriberInterface
{
public function __construct(
private IpLookupHelper $ipLookupHelper,
private AuditLogModel $auditLogModel,
) {
}
public static function getSubscribedEvents(): array
{
return [
UserEvents::USER_LOGIN => ['onSecurityInteractiveLogin', 0],
];
}
public function onSecurityInteractiveLogin(LoginEvent $event): void
{
$userId = (int) $event->getUser()->getId();
$useName = $event->getUser()->getUserIdentifier();
$log = [
'bundle' => 'user',
'object' => 'security',
'objectId' => $userId,
'action' => 'login',
'details' => ['username' => $useName],
'ipAddress' => $this->ipLookupHelper->getIpAddressFromRequest(),
];
$this->auditLogModel->writeToLog($log);
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace Mautic\UserBundle\EventListener;
use Mautic\CoreBundle\Helper\IpLookupHelper;
use Mautic\CoreBundle\Model\AuditLogModel;
use Mautic\UserBundle\Event as Events;
use Mautic\UserBundle\UserEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class UserSubscriber implements EventSubscriberInterface
{
public function __construct(
private IpLookupHelper $ipLookupHelper,
private AuditLogModel $auditLogModel,
) {
}
public static function getSubscribedEvents(): array
{
return [
UserEvents::USER_POST_SAVE => ['onUserPostSave', 0],
UserEvents::USER_POST_DELETE => ['onUserDelete', 0],
UserEvents::ROLE_POST_SAVE => ['onRolePostSave', 0],
UserEvents::ROLE_POST_DELETE => ['onRoleDelete', 0],
];
}
/**
* Add a user entry to the audit log.
*/
public function onUserPostSave(Events\UserEvent $event): void
{
$user = $event->getUser();
if ($details = $event->getChanges()) {
$log = [
'bundle' => 'user',
'object' => 'user',
'objectId' => $user->getId(),
'action' => ($event->isNew()) ? 'create' : 'update',
'details' => $details,
'ipAddress' => $this->ipLookupHelper->getIpAddressFromRequest(),
];
$this->auditLogModel->writeToLog($log);
}
}
/**
* Add a user delete entry to the audit log.
*/
public function onUserDelete(Events\UserEvent $event): void
{
$user = $event->getUser();
$log = [
'bundle' => 'user',
'object' => 'user',
'objectId' => $user->deletedId,
'action' => 'delete',
'details' => ['name' => $user->getName()],
'ipAddress' => $this->ipLookupHelper->getIpAddressFromRequest(),
];
$this->auditLogModel->writeToLog($log);
}
/**
* Add a role entry to the audit log.
*/
public function onRolePostSave(Events\RoleEvent $event): void
{
$role = $event->getRole();
if ($details = $event->getChanges()) {
$log = [
'bundle' => 'user',
'object' => 'role',
'objectId' => $role->getId(),
'action' => ($event->isNew()) ? 'create' : 'update',
'details' => $details,
'ipAddress' => $this->ipLookupHelper->getIpAddressFromRequest(),
];
$this->auditLogModel->writeToLog($log);
}
}
/**
* Add a role delete entry to the audit log.
*/
public function onRoleDelete(Events\RoleEvent $event): void
{
$role = $event->getRole();
$log = [
'bundle' => 'user',
'object' => 'role',
'objectId' => $role->deletedId,
'action' => 'delete',
'details' => ['name' => $role->getName()],
'ipAddress' => $this->ipLookupHelper->getIpAddressFromRequest(),
];
$this->auditLogModel->writeToLog($log);
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Mautic\UserBundle\Exception;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
class WeakPasswordException extends AuthenticationException
{
}

View File

@@ -0,0 +1,205 @@
<?php
namespace Mautic\UserBundle\Form\Type;
use Mautic\ConfigBundle\Form\Type\ConfigFileType;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\Validator\Constraints\File;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @extends AbstractType<array<mixed>>
*/
class ConfigType extends AbstractType
{
public function __construct(
protected CoreParametersHelper $parameters,
protected TranslatorInterface $translator,
) {
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$samlEntityIdChoices = ['', rtrim($this->parameters->get('mautic.site_url'), '/')];
if (!empty($this->parameters->get('mautic.subdomain_url'))) {
$samlEntityIdChoices[] = rtrim($this->parameters->get('mautic.subdomain_url'), '/');
}
$builder->add('saml_idp_entity_id', ChoiceType::class,
[
'choices' => array_combine($samlEntityIdChoices, $samlEntityIdChoices),
'label' => 'mautic.user.config.form.saml.idp_entity_id_label',
'label_attr' => ['class' => 'control-label'],
'required' => true,
'multiple' => false,
'attr' => [
'class' => 'form-control',
],
]);
$builder->add(
'saml_idp_metadata',
ConfigFileType::class,
[
'label' => 'mautic.user.config.form.saml.idp.metadata',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.user.config.form.saml.idp.metadata.tooltip',
'rows' => 10,
],
'required' => false,
'constraints' => [
new File(
[
'mimeTypes' => ['text/plain', 'text/xml', 'application/xml'],
'mimeTypesMessage' => 'mautic.core.invalid_file_type',
]
),
],
]
);
$builder->add(
'saml_idp_own_certificate',
ConfigFileType::class,
[
'label' => 'mautic.user.config.form.saml.idp.own_certificate',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.user.config.form.saml.idp.own_certificate.tooltip',
],
'required' => false,
'constraints' => [
new File(
[
'mimeTypes' => ['text/plain'],
'mimeTypesMessage' => 'mautic.core.invalid_file_type',
]
),
],
]
);
$builder->add(
'saml_idp_own_private_key',
ConfigFileType::class,
[
'label' => 'mautic.user.config.form.saml.idp.own_private_key',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.user.config.form.saml.idp.own_private_key.tooltip',
],
'required' => false,
'constraints' => [
new File(
[
'mimeTypes' => ['text/plain'],
'mimeTypesMessage' => 'mautic.core.invalid_file_type',
]
),
],
]
);
$builder->add(
'saml_idp_own_password',
PasswordType::class,
[
'label' => 'mautic.user.config.form.saml.idp.own_password',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.user.config.form.saml.idp.own_password.tooltip',
],
'required' => false,
]
);
$builder->add(
'saml_idp_email_attribute',
TextType::class,
[
'label' => 'mautic.user.config.form.saml.idp.attribute_email',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
],
'empty_data' => 'EmailAddress',
]
);
$builder->add(
'saml_idp_username_attribute',
TextType::class,
[
'label' => 'mautic.user.config.form.saml.idp.attribute_username',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
],
'required' => false,
]
);
$builder->add(
'saml_idp_firstname_attribute',
TextType::class,
[
'label' => 'mautic.user.config.form.saml.idp.attribute_firstname',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
],
'empty_data' => 'FirstName',
]
);
$builder->add(
'saml_idp_lastname_attribute',
TextType::class,
[
'label' => 'mautic.user.config.form.saml.idp.attribute_lastname',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
],
'empty_data' => 'LastName',
]
);
$builder->add(
'saml_idp_default_role',
RoleListType::class,
[
'label' => 'mautic.user.config.form.saml.idp.default_role',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'data-placeholder' => $this->translator->trans('mautic.user.config.form.saml.idp.disable_creation'),
'tooltip' => 'mautic.user.config.form.saml.idp.default_role.tooltip',
],
'required' => false,
'placeholder' => '',
]
);
}
public function buildView(FormView $view, FormInterface $form, array $options): void
{
$view->vars['entityId'] = $this->parameters->get('mautic.saml_idp_entity_id');
}
public function getBlockPrefix(): string
{
return 'userconfig';
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace Mautic\UserBundle\Form\Type;
use Mautic\CoreBundle\Form\Type\FormButtonsType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
/**
* @extends AbstractType<array<mixed>>
*/
class ContactType extends AbstractType
{
/**
* @param FormBuilderInterface<array<mixed>|null> $builder
* @param array<string, mixed> $options
*/
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add(
'msg_subject',
TextType::class,
[
'label' => 'mautic.email.subject',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control'],
'constraints' => [
new NotBlank(['message' => 'Subject should not be blank.']),
new Length(['min' => 3]),
],
]
)
->add(
'msg_body',
TextareaType::class,
[
'label' => 'mautic.user.user.contact.message',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'rows' => 10,
],
'constraints' => [
new NotBlank(['message' => 'Message should not be blank.']),
new Length(['min' => 5]),
],
]
)
->add(
'entity',
HiddenType::class,
[
'attr' => [
'autocomplete' => 'off',
],
]
)
->add(
'id',
HiddenType::class,
[
'attr' => [
'autocomplete' => 'off',
],
]
)
->add(
'returnUrl',
HiddenType::class,
[
'attr' => [
'autocomplete' => 'off',
],
]
)
->add('buttons', FormButtonsType::class, [
'save_text' => 'mautic.user.user.contact.send',
'save_icon' => 'ri-send-plane-line',
'apply_text' => false,
]);
if (!empty($options['action'])) {
$builder->setAction($options['action']);
}
}
}

View File

@@ -0,0 +1,114 @@
<?php
namespace Mautic\UserBundle\Form\Type;
use Mautic\CoreBundle\Form\EventListener\CleanFormSubscriber;
use Mautic\UserBundle\Form\Validator\Constraints\NotWeak;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @extends AbstractType<array<mixed>>
*/
class PasswordResetConfirmType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addEventSubscriber(new CleanFormSubscriber([]));
$builder->add(
'identifier',
TextType::class,
[
'label' => 'mautic.user.auth.form.loginusername',
'label_attr' => ['class' => 'sr-only'],
'attr' => [
'class' => 'form-control',
'preaddon' => 'ri-user-6-fill',
'placeholder' => 'mautic.user.auth.form.loginusername',
],
'required' => true,
'constraints' => [
new Assert\NotBlank(['message' => 'mautic.user.user.passwordreset.notblank']),
],
]
);
$builder->add(
'plainPassword',
RepeatedType::class,
[
'first_name' => 'password',
'first_options' => [
'label' => 'mautic.core.password',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'placeholder' => 'mautic.user.user.passwordreset.password.placeholder',
'tooltip' => 'mautic.user.user.form.help.passwordrequirements',
'preaddon' => 'ri-lock-fill',
'autocomplete' => 'off',
],
'required' => true,
'error_bubbling' => false,
'constraints' => [
new Assert\NotBlank(['message' => 'mautic.user.user.passwordreset.notblank']),
new Assert\Length([
'min' => 6,
'minMessage' => 'mautic.user.user.password.minlength',
]),
new NotWeak([
'message' => 'mautic.user.user.password.weak',
]),
],
],
'second_name' => 'confirm',
'second_options' => [
'label' => 'mautic.user.user.form.passwordconfirm',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'placeholder' => 'mautic.user.user.passwordreset.confirm.placeholder',
'tooltip' => 'mautic.user.user.form.help.passwordrequirements',
'preaddon' => 'ri-lock-fill',
'autocomplete' => 'off',
],
'required' => true,
'error_bubbling' => false,
'constraints' => [
new Assert\NotBlank(['message' => 'mautic.user.user.passwordreset.notblank']),
],
],
'type' => PasswordType::class,
'invalid_message' => 'mautic.user.user.password.mismatch',
'required' => true,
'error_bubbling' => false,
]
);
$builder->add(
'submit',
SubmitType::class,
[
'attr' => [
'class' => 'btn btn-lg btn-primary btn-block',
],
'label' => 'mautic.user.user.passwordreset.reset',
]
);
if (!empty($options['action'])) {
$builder->setAction($options['action']);
}
}
public function getBlockPrefix(): string
{
return 'passwordresetconfirm';
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace Mautic\UserBundle\Form\Type;
use Mautic\CoreBundle\Form\EventListener\CleanFormSubscriber;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @extends AbstractType<array<mixed>>
*/
class PasswordResetType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addEventSubscriber(new CleanFormSubscriber([]));
$builder->add(
'identifier',
TextType::class,
[
'label' => 'mautic.user.auth.form.loginusername',
'label_attr' => ['class' => 'sr-only'],
'attr' => [
'class' => 'form-control',
'preaddon' => 'ri-user-6-fill',
'placeholder' => 'mautic.user.auth.form.loginusername',
],
'constraints' => [
new Assert\NotBlank(['message' => 'mautic.user.user.passwordreset.notblank']),
],
]
);
$builder->add(
'submit',
SubmitType::class,
[
'attr' => [
'class' => 'btn btn-lg btn-primary btn-block',
],
'label' => 'mautic.user.user.passwordreset.reset',
]
);
if (!empty($options['action'])) {
$builder->setAction($options['action']);
}
}
public function getBlockPrefix(): string
{
return 'passwordreset';
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace Mautic\UserBundle\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @extends AbstractType<array<mixed>>
*/
class PermissionListType extends AbstractType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setRequired(['bundle', 'level']);
$resolver->setDefaults([
'multiple' => true,
'expanded' => true,
'label_attr' => ['class' => 'control-label'],
'attr' => fn (Options $options): array => [
'data-permission' => $options['bundle'].':'.$options['level'],
'onchange' => 'Mautic.onPermissionChange(this, \''.$options['bundle'].'\')',
],
'choices_as_values' => false,
]);
}
public function getParent(): ?string
{
return ChoiceType::class;
}
public function getBlockPrefix(): string
{
return 'permissionlist';
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace Mautic\UserBundle\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Valid;
/**
* @extends AbstractType<array<mixed>>
*/
class PermissionsType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
foreach ($options['permissionsConfig'] as $bundle => $config) {
$builder->add(
$bundle,
HiddenType::class,
[
'data' => 'newbundle',
'label' => false,
'mapped' => false,
]
);
$config['permissionObject']->buildForm($builder, $options, $config['data']);
}
if (!empty($options['action'])) {
$builder->setAction($options['action']);
}
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'permissionsConfig' => [],
'constraints' => [new Valid()],
]);
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace Mautic\UserBundle\Form\Type;
use Mautic\UserBundle\Model\RoleModel;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @extends AbstractType<array<mixed>>
*/
class RoleListType extends AbstractType
{
public function __construct(
private RoleModel $roleModel,
) {
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults(
[
'choices' => $this->getRoleChoices(),
'expanded' => false,
'multiple' => false,
'required' => false,
'placeholder' => 'mautic.core.form.chooseone',
]
);
}
public function getParent(): ?string
{
return ChoiceType::class;
}
private function getRoleChoices(): array
{
$choices = [];
$roles = $this->roleModel->getRepository()->getEntities(
[
'filter' => [
'force' => [
[
'column' => 'r.isPublished',
'expr' => 'eq',
'value' => true,
],
],
],
]
);
foreach ($roles as $role) {
$choices[$role->getName(true)] = $role->getId();
}
// sort by name
ksort($choices);
return $choices;
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace Mautic\UserBundle\Form\Type;
use Mautic\CoreBundle\Form\EventListener\CleanFormSubscriber;
use Mautic\CoreBundle\Form\EventListener\FormExitSubscriber;
use Mautic\CoreBundle\Form\Type\FormButtonsType;
use Mautic\CoreBundle\Form\Type\YesNoButtonGroupType;
use Mautic\UserBundle\Entity\Role;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Valid;
/**
* @extends AbstractType<RoleType>
*/
class RoleType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addEventSubscriber(new CleanFormSubscriber(['description' => 'html']));
$builder->addEventSubscriber(new FormExitSubscriber('user.role', $options));
$builder->add(
'name',
TextType::class,
[
'label' => 'mautic.core.name',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control'],
]
);
$builder->add(
'description',
TextareaType::class,
[
'label' => 'mautic.core.description',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control editor'],
'required' => false,
]
);
$builder->add('isAdmin', YesNoButtonGroupType::class, [
'label' => 'mautic.user.role.form.isadmin',
'attr' => [
'onchange' => 'Mautic.togglePermissionVisibility();',
'tooltip' => 'mautic.user.role.form.isadmin.tooltip',
],
]);
// add a normal text field, but add your transformer to it
$hidden = ($options['data']->isAdmin()) ? ' hide' : '';
$builder->add(
'permissions',
PermissionsType::class,
[
'label' => 'mautic.user.role.permissions',
'mapped' => false, // we'll have to manually build the permissions for persisting
'required' => false,
'attr' => [
'class' => $hidden,
],
'permissionsConfig' => $options['permissionsConfig'],
]
);
$builder->add('buttons', FormButtonsType::class);
if (!empty($options['action'])) {
$builder->setAction($options['action']);
}
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Role::class,
'constraints' => [new Valid()],
'permissionsConfig' => [],
]);
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace Mautic\UserBundle\Form\Type;
use Mautic\UserBundle\Model\UserModel;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @extends AbstractType<array<mixed>>
*/
class UserListType extends AbstractType
{
/**
* @var array<string,int>
*/
private array $choices = [];
public function __construct(
private UserModel $userModel,
) {
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults(
[
'choices' => $this->getUserChoices(),
'expanded' => false,
'multiple' => true,
'required' => false,
'placeholder' => 'mautic.core.form.chooseone',
]
);
}
public function getParent(): ?string
{
return ChoiceType::class;
}
/**
* @return array<string,int>
*/
private function getUserChoices(): array
{
if ($this->choices) {
return $this->choices;
}
$users = $this->userModel->getRepository()->getEntities(
[
'filter' => [
'force' => [
[
'column' => 'u.isPublished',
'expr' => 'eq',
'value' => true,
],
],
],
]
);
foreach ($users as $user) {
$this->choices[$user->getName(true).' ('.$user->getId().')'] = $user->getId();
}
// sort by user name
ksort($this->choices);
return $this->choices;
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace Mautic\UserBundle\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\FormBuilderInterface;
/**
* @extends AbstractType<mixed>
*/
class UserPreferencesType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
// Theme
$builder->add(
'theme',
HiddenType::class,
[
'label' => 'mautic.user.preferences.theme',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
],
'required' => false,
]
);
// Reduce Transparency
$builder->add(
'reduce_transparency',
HiddenType::class,
[
'label' => 'mautic.user.preferences.reduce_transparency',
'label_attr' => ['class' => 'control-label'],
'required' => false,
]
);
// Reduce Motion
$builder->add(
'reduce_motion',
HiddenType::class,
[
'label' => 'mautic.user.preferences.reduce_motion',
'label_attr' => ['class' => 'control-label'],
'required' => false,
]
);
// Contrast Borders
$builder->add(
'contrast_borders',
HiddenType::class,
[
'label' => 'mautic.user.preferences.contrast_borders',
'label_attr' => ['class' => 'control-label'],
'required' => false,
]
);
// Enable Underlines
$builder->add(
'enable_underlines',
HiddenType::class,
[
'label' => 'mautic.user.preferences.enable_underlines',
'label_attr' => ['class' => 'control-label'],
'required' => false,
]
);
}
}

View File

@@ -0,0 +1,279 @@
<?php
namespace Mautic\UserBundle\Form\Type;
use Doctrine\Common\Collections\Order;
use Doctrine\ORM\EntityRepository;
use Mautic\CoreBundle\Form\EventListener\CleanFormSubscriber;
use Mautic\CoreBundle\Form\EventListener\FormExitSubscriber;
use Mautic\CoreBundle\Form\Type\FormButtonsType;
use Mautic\CoreBundle\Form\Type\YesNoButtonGroupType;
use Mautic\CoreBundle\Helper\LanguageHelper;
use Mautic\UserBundle\Entity\Role;
use Mautic\UserBundle\Entity\User;
use Mautic\UserBundle\Model\UserModel;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\TimezoneType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @extends AbstractType<User>
*/
class UserType extends AbstractType
{
public function __construct(
private TranslatorInterface $translator,
private UserModel $model,
private LanguageHelper $languageHelper,
) {
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addEventSubscriber(new CleanFormSubscriber(['signature' => 'html', 'email' => 'email']));
$builder->addEventSubscriber(new FormExitSubscriber('user.user', $options));
$builder->add(
'username',
TextType::class,
[
'label' => 'mautic.core.username',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'preaddon' => 'ri-user-6-fill',
'autocomplete' => 'off',
],
]
);
$builder->add(
'firstName',
TextType::class,
[
'label' => 'mautic.core.firstname',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control'],
]
);
$builder->add(
'lastName',
TextType::class,
[
'label' => 'mautic.core.lastname',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control'],
]
);
$positions = $this->model->getLookupResults('position', null, 0);
$builder->add(
'position',
TextType::class,
[
'label' => 'mautic.core.position',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'data-options' => json_encode($positions),
],
'required' => false,
]
);
$builder->add(
'email',
EmailType::class,
[
'label' => 'mautic.core.type.email',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'preaddon' => 'ri-mail-line',
],
]
);
$existing = (!empty($options['data']) && $options['data']->getId());
$placeholder = ($existing) ?
$this->translator->trans('mautic.user.user.form.passwordplaceholder') : '';
$required = ($existing) ? false : true;
$builder->add(
'plainPassword',
RepeatedType::class,
[
'first_name' => 'password',
'first_options' => [
'label' => 'mautic.core.password',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'placeholder' => $placeholder,
'tooltip' => 'mautic.user.user.form.help.passwordrequirements',
'preaddon' => 'ri-lock-fill',
'autocomplete' => 'off',
],
'required' => $required,
'error_bubbling' => false,
],
'second_name' => 'confirm',
'second_options' => [
'label' => 'mautic.user.user.form.passwordconfirm',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'placeholder' => $placeholder,
'tooltip' => 'mautic.user.user.form.help.passwordrequirements',
'preaddon' => 'ri-lock-fill',
'autocomplete' => 'off',
],
'required' => $required,
'error_bubbling' => false,
],
'type' => PasswordType::class,
'invalid_message' => 'mautic.user.user.password.mismatch',
'required' => $required,
'error_bubbling' => false,
]
);
$builder->add(
'timezone',
TimezoneType::class,
[
'label' => 'mautic.core.timezone',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
],
'multiple' => false,
'placeholder' => 'mautic.user.user.form.defaulttimezone',
]
);
$builder->add(
'locale',
ChoiceType::class,
[
'choices' => $this->getSupportedLanguageChoices(),
'label' => 'mautic.core.language',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
],
'multiple' => false,
'placeholder' => 'mautic.user.user.form.defaultlocale',
]
);
$builder->add(
'preferences',
UserPreferencesType::class,
[
'label' => false,
]
);
$defaultSignature = '';
if (isset($options['data']) && null === $options['data']->getSignature()) {
$defaultSignature = $this->translator->trans('mautic.email.default.signature', ['%from_name%' => '|FROM_NAME|']);
} elseif (isset($options['data'])) {
$defaultSignature = $options['data']->getSignature();
}
$builder->add(
'signature',
TextareaType::class,
[
'label' => 'mautic.email.token.signature',
'label_attr' => ['class' => 'control-label'],
'required' => false,
'attr' => [
'class' => 'form-control',
],
'data' => $defaultSignature,
'help' => 'mautic.user.config.signature.helper',
]
);
if (empty($options['in_profile'])) {
$builder->add(
$builder->create(
'role',
EntityType::class,
[
'label' => 'mautic.user.role',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
],
'class' => Role::class,
'choice_label' => 'name',
'query_builder' => fn (EntityRepository $er) => $er->createQueryBuilder('r')
->where('r.isPublished = true')
->orderBy('r.name', Order::Ascending->value),
]
)
);
$builder->add('isPublished', YesNoButtonGroupType::class);
$builder->add('buttons', FormButtonsType::class);
} else {
$builder->add(
'buttons',
FormButtonsType::class,
[
'save_text' => 'mautic.core.form.apply',
'apply_text' => false,
]
);
}
if (!empty($options['action'])) {
$builder->setAction($options['action']);
}
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults(
[
'data_class' => User::class,
'validation_groups' => [
User::class,
'determineValidationGroups',
],
'ignore_formexit' => false,
'in_profile' => false,
]
);
}
private function getSupportedLanguageChoices(): array
{
// Get the list of available languages
$languages = $this->languageHelper->fetchLanguages(false, false);
$choices = [];
foreach ($languages as $code => $langData) {
$choices[$langData['name']] = $code;
}
$choices = array_merge($choices, array_flip($this->languageHelper->getSupportedLanguages()));
// Alpha sort the languages by name
ksort($choices);
return $choices;
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Mautic\UserBundle\Form\Validator\Constraints;
use Mautic\UserBundle\Model\PasswordStrengthEstimatorModel;
use Symfony\Component\Validator\Constraint;
final class NotWeak extends Constraint
{
public const TOO_WEAK = 'f61e730a-284e-11eb-adc1-0242ac120002';
protected const ERROR_NAMES = [
self::TOO_WEAK => 'PASSWORD_TOO_WEAK_ERROR',
];
public string $message = 'This password is too weak. Consider using a stronger password.';
public int $score = PasswordStrengthEstimatorModel::MINIMUM_PASSWORD_STRENGTH_ALLOWED;
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Mautic\UserBundle\Form\Validator\Constraints;
use Mautic\UserBundle\Model\PasswordStrengthEstimatorModel;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
final class NotWeakValidator extends ConstraintValidator
{
public function __construct(private PasswordStrengthEstimatorModel $passwordStrengthEstimatorModel)
{
}
public function validate(mixed $value, Constraint $constraint): void
{
if (!$constraint instanceof NotWeak) {
throw new UnexpectedTypeException($constraint, NotWeak::class);
}
if ($this->passwordStrengthEstimatorModel->validate($value, $constraint->score)) {
return;
}
$this->context->buildViolation($constraint->message)
->setCode(NotWeak::TOO_WEAK)
->addViolation();
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Mautic\UserBundle\Hash;
class UserHash
{
public const FAKE_USER_HASH = 'xxxxxxxxxxxxxx';
/**
* Return fake user hash for emails etc. Users does not have hash, only Contacts.
*/
public static function getFakeUserHash(): string
{
return self::FAKE_USER_HASH;
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace Mautic\UserBundle;
use Mautic\UserBundle\DependencyInjection\Compiler\FormLoginAuthenticatorOptionsPass;
use Mautic\UserBundle\DependencyInjection\Compiler\OAuthReplacePass;
use Mautic\UserBundle\DependencyInjection\Compiler\SsoAuthenticatorPass;
use Mautic\UserBundle\DependencyInjection\Firewall\Factory\MauticSsoFactory;
use Mautic\UserBundle\DependencyInjection\Firewall\Factory\PluginFactory;
use Symfony\Bundle\SecurityBundle\DependencyInjection\SecurityExtension;
use Symfony\Component\DependencyInjection\Compiler\PassConfig;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Bundle\Bundle;
class MauticUserBundle extends Bundle
{
public function build(ContainerBuilder $container): void
{
parent::build($container);
$extension = $container->getExtension('security');
\assert($extension instanceof SecurityExtension);
$extension->addAuthenticatorFactory(new PluginFactory());
$extension->addAuthenticatorFactory(new MauticSsoFactory());
$container->addCompilerPass(new OAuthReplacePass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 0);
$container->addCompilerPass(new SsoAuthenticatorPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 0);
$container->addCompilerPass(new FormLoginAuthenticatorOptionsPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 0);
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Mautic\UserBundle\Model;
use Mautic\UserBundle\Event\PasswordStrengthValidateEvent;
use Mautic\UserBundle\UserEvents;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use ZxcvbnPhp\Zxcvbn as PasswordStrengthEstimator;
class PasswordStrengthEstimatorModel
{
public const MINIMUM_PASSWORD_STRENGTH_ALLOWED = 3;
private const DICTIONARY = [
'mautic',
'user',
'lead',
'bundle',
'campaign',
'company',
];
private PasswordStrengthEstimator $passwordStrengthEstimator;
public function __construct(private EventDispatcherInterface $dispatcher)
{
$this->passwordStrengthEstimator = new PasswordStrengthEstimator();
}
/**
* @param string[] $dictionary
*/
public function validate(?string $password, int $score = self::MINIMUM_PASSWORD_STRENGTH_ALLOWED, array $dictionary = self::DICTIONARY): bool
{
$isValid = $score <= $this->passwordStrengthEstimator->passwordStrength($password, $this->sanitizeDictionary($dictionary))['score'];
$passwordStrengthValidateEvent = new PasswordStrengthValidateEvent($isValid, $password);
$this->dispatcher->dispatch($passwordStrengthValidateEvent, UserEvents::USER_PASSWORD_STRENGTH_VALIDATION);
return $passwordStrengthValidateEvent->isValid;
}
/**
* @param string[] $dictionary
*
* @return string[]
*/
private function sanitizeDictionary(array $dictionary): array
{
return array_unique(array_filter($dictionary));
}
}

View File

@@ -0,0 +1,151 @@
<?php
namespace Mautic\UserBundle\Model;
use Mautic\CoreBundle\Model\FormModel;
use Mautic\CoreBundle\Model\GlobalSearchInterface;
use Mautic\UserBundle\Entity\Role;
use Mautic\UserBundle\Entity\RoleRepository;
use Mautic\UserBundle\Event\RoleEvent;
use Mautic\UserBundle\Form\Type\RoleType;
use Mautic\UserBundle\UserEvents;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Symfony\Component\HttpKernel\Exception\PreconditionRequiredHttpException;
use Symfony\Contracts\EventDispatcher\Event;
/**
* @extends FormModel<Role>
*/
class RoleModel extends FormModel implements GlobalSearchInterface
{
public function getRepository(): RoleRepository
{
return $this->em->getRepository(Role::class);
}
public function getPermissionBase(): string
{
return 'user:roles';
}
/**
* @throws MethodNotAllowedHttpException
*/
public function saveEntity($entity, $unlock = true): void
{
if (!$entity instanceof Role) {
throw new MethodNotAllowedHttpException(['Role'], 'Entity must be of class Role()');
}
$isNew = ($entity->getId()) ? 0 : 1;
if (!$isNew) {
// delete all existing
$this->em->getRepository(\Mautic\UserBundle\Entity\Permission::class)->purgeRolePermissions($entity);
}
parent::saveEntity($entity, $unlock);
}
/**
* Generate the role's permissions.
*
* @param array $rawPermissions (i.e. from request)
*/
public function setRolePermissions(Role &$entity, $rawPermissions): void
{
if (!is_array($rawPermissions)) {
return;
}
// set permissions if applicable and if the user is not an admin
$permissions = (!$entity->isAdmin() && !empty($rawPermissions)) ?
$this->security->generatePermissions($rawPermissions) :
[];
foreach ($permissions as $permissionEntity) {
$entity->addPermission($permissionEntity);
}
$entity->setRawPermissions($rawPermissions);
}
/**
* @throws PreconditionRequiredHttpException
*/
public function deleteEntity($entity): void
{
if (!$entity instanceof Role) {
throw new MethodNotAllowedHttpException(['Role'], 'Entity must be of class Role()');
}
$users = $this->em->getRepository(\Mautic\UserBundle\Entity\User::class)->findByRole($entity);
if (count($users)) {
throw new PreconditionRequiredHttpException($this->translator->trans('mautic.user.role.error.deletenotallowed', ['%name%' => $entity->getName()], 'flashes'));
}
parent::deleteEntity($entity);
}
public function createForm($entity, FormFactoryInterface $formFactory, $action = null, $options = []): \Symfony\Component\Form\FormInterface
{
if (!$entity instanceof Role) {
throw new MethodNotAllowedHttpException(['Role']);
}
if (!empty($action)) {
$options['action'] = $action;
}
return $formFactory->create(RoleType::class, $entity, $options);
}
public function getEntity($id = null): ?Role
{
if (null === $id) {
return new Role();
}
return parent::getEntity($id);
}
/**
* @throws MethodNotAllowedHttpException
*/
protected function dispatchEvent($action, &$entity, $isNew = false, ?Event $event = null): ?Event
{
if (!$entity instanceof Role) {
throw new MethodNotAllowedHttpException(['Role'], 'Entity must be of class Role()');
}
switch ($action) {
case 'pre_save':
$name = UserEvents::ROLE_PRE_SAVE;
break;
case 'post_save':
$name = UserEvents::ROLE_POST_SAVE;
break;
case 'pre_delete':
$name = UserEvents::ROLE_PRE_DELETE;
break;
case 'post_delete':
$name = UserEvents::ROLE_POST_DELETE;
break;
default:
return null;
}
if ($this->dispatcher->hasListeners($name)) {
if (empty($event)) {
$event = new RoleEvent($entity, $isNew);
$event->setEntityManager($this->em);
}
$this->dispatcher->dispatch($event, $name);
return $event;
}
return null;
}
}

View File

@@ -0,0 +1,394 @@
<?php
namespace Mautic\UserBundle\Model;
use Doctrine\ORM\EntityManager;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Model\FormModel;
use Mautic\CoreBundle\Model\GlobalSearchInterface;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\EmailBundle\Helper\MailHelper;
use Mautic\UserBundle\Entity\Role;
use Mautic\UserBundle\Entity\User;
use Mautic\UserBundle\Entity\UserRepository;
use Mautic\UserBundle\Entity\UserToken;
use Mautic\UserBundle\Enum\UserTokenAuthorizator;
use Mautic\UserBundle\Event\UserEvent;
use Mautic\UserBundle\Form\Type\UserType;
use Mautic\UserBundle\Model\UserToken\UserTokenServiceInterface;
use Mautic\UserBundle\UserEvents;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasher;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\EventDispatcher\Event;
/**
* @extends FormModel<User>
*/
class UserModel extends FormModel implements GlobalSearchInterface
{
public function __construct(
protected MailHelper $mailHelper,
private UserTokenServiceInterface $userTokenService,
EntityManager $em,
CorePermissions $security,
EventDispatcherInterface $dispatcher,
UrlGeneratorInterface $router,
Translator $translator,
UserHelper $userHelper,
LoggerInterface $mauticLogger,
CoreParametersHelper $coreParametersHelper,
) {
parent::__construct($em, $security, $dispatcher, $router, $translator, $userHelper, $mauticLogger, $coreParametersHelper);
}
public function getRepository(): UserRepository
{
return $this->em->getRepository(User::class);
}
public function getPermissionBase(): string
{
return 'user:users';
}
/**
* @throws MethodNotAllowedHttpException
*/
public function saveEntity($entity, $unlock = true): void
{
if (!$entity instanceof User) {
throw new MethodNotAllowedHttpException(['User'], 'Entity must be of class User()');
}
parent::saveEntity($entity, $unlock);
}
/**
* Get a list of users for an autocomplete input.
*
* @param string $search
* @param int $limit
* @param int $start
* @param array $permissionLimiter
*
* @return array
*/
public function getUserList($search = '', $limit = 10, $start = 0, $permissionLimiter = [])
{
return $this->getRepository()->getUserList($search, $limit, $start, $permissionLimiter);
}
/**
* Checks for a new password and rehashes if necessary.
*
* @param string $submittedPassword
* @param bool|false $validate
*/
public function checkNewPassword(User $entity, UserPasswordHasherInterface $hasher, $submittedPassword, $validate = false): ?string
{
if ($validate) {
if (strlen($submittedPassword) < 6) {
throw new \InvalidArgumentException($this->translator->trans('mautic.user.user.password.minlength', [], 'validators'));
}
}
if (!empty($submittedPassword)) {
// hash the clear password submitted via the form
return $hasher->hashPassword($entity, $submittedPassword);
}
return $entity->getPassword();
}
public function createForm($entity, FormFactoryInterface $formFactory, $action = null, $options = []): \Symfony\Component\Form\FormInterface
{
if (!$entity instanceof User) {
throw new MethodNotAllowedHttpException(['User'], 'Entity must be of class User()');
}
if (!empty($action)) {
$options['action'] = $action;
}
return $formFactory->create(UserType::class, $entity, $options);
}
public function getEntity($id = null): ?User
{
if (null === $id) {
return new User();
}
$entity = parent::getEntity($id);
if ($entity) {
// add user's permissions
$entity->setActivePermissions(
$this->em->getRepository(\Mautic\UserBundle\Entity\Permission::class)->getPermissionsByRole($entity->getRole())
);
}
return $entity;
}
/**
* @return User|null
*/
public function getSystemAdministrator()
{
$adminRole = $this->em->getRepository(Role::class)->findOneBy(['isAdmin' => true]);
return $this->getRepository()->findOneBy(
[
'role' => $adminRole,
'isPublished' => true,
]
);
}
/**
* @throws MethodNotAllowedHttpException
*/
protected function dispatchEvent($action, &$entity, $isNew = false, ?Event $event = null): ?Event
{
if (!$entity instanceof User) {
throw new MethodNotAllowedHttpException(['User'], 'Entity must be of class User()');
}
switch ($action) {
case 'pre_save':
$name = UserEvents::USER_PRE_SAVE;
break;
case 'post_save':
$name = UserEvents::USER_POST_SAVE;
break;
case 'pre_delete':
$name = UserEvents::USER_PRE_DELETE;
break;
case 'post_delete':
$name = UserEvents::USER_POST_DELETE;
break;
default:
return null;
}
if ($this->dispatcher->hasListeners($name)) {
if (empty($event)) {
$event = new UserEvent($entity, $isNew);
$event->setEntityManager($this->em);
}
$this->dispatcher->dispatch($event, $name);
return $event;
}
return null;
}
/**
* Get list of entities for autopopulate fields.
*
* @param string $type
* @param string $filter
* @param int $limit
*
* @return array
*/
public function getLookupResults($type, $filter = '', $limit = 10)
{
$results = [];
return match ($type) {
'role' => $this->em->getRepository(Role::class)->getRoleList($filter, $limit),
'user' => $this->em->getRepository(User::class)->getUserList($filter, $limit),
'position' => $this->em->getRepository(User::class)->getPositionList($filter, $limit),
default => $results,
};
}
/**
* Resets the user password and emails it.
*
* @param string $newPassword
*/
public function resetPassword(User $user, UserPasswordHasher $hasher, $newPassword): void
{
$hashedPassword = $this->checkNewPassword($user, $hasher, $newPassword);
$user->setPassword($hashedPassword);
$this->saveEntity($user);
}
/**
* @return UserToken
*/
protected function getResetToken(User $user)
{
$userToken = new UserToken();
$userToken->setUser($user)
->setAuthorizator(UserTokenAuthorizator::RESET_PASSWORD_AUTHORIZATOR)
->setExpiration((new \DateTime())->add(new \DateInterval('PT24H')))
->setOneTimeOnly();
return $this->userTokenService->generateSecret($userToken, 64);
}
/**
* @param string $token
*
* @return bool
*/
public function confirmResetToken(User $user, $token)
{
$userToken = new UserToken();
$userToken->setUser($user)
->setAuthorizator(UserTokenAuthorizator::RESET_PASSWORD_AUTHORIZATOR)
->setSecret($token);
return $this->userTokenService->verify($userToken);
}
/**
* @throws \RuntimeException
*/
public function sendResetEmail(User $user): void
{
$mailer = $this->mailHelper->getMailer();
$resetToken = $this->getResetToken($user);
$this->em->persist($resetToken);
try {
$this->em->flush();
} catch (\Exception $exception) {
$this->logger->error($exception->getMessage());
throw new \RuntimeException();
}
$resetLink = $this->router->generate('mautic_user_passwordresetconfirm', ['token' => $resetToken->getSecret()], UrlGeneratorInterface::ABSOLUTE_URL);
$mailer->setTo([$user->getEmail() => $user->getName()]);
$mailer->setSubject($this->translator->trans('mautic.user.user.passwordreset.subject'));
$text = $this->translator->trans(
'mautic.user.user.passwordreset.email.body',
['%name%' => $user->getFirstName(), '%resetlink%' => '<a href="'.$resetLink.'">'.$resetLink.'</a>']
);
$text = str_replace('\\n', "\n", $text);
$html = nl2br($text);
$this->emailUser(
$user,
$this->translator->trans('mautic.user.user.passwordreset.subject'),
$html
);
}
/**
* @throws \RuntimeException
*/
public function sendChangePasswordInfo(User $user): void
{
$text = $this->translator->trans(
'mautic.user.user.passwordchange.email.body',
['%name%' => $user->getFirstName()]
);
$text = str_replace('\\n', "\n", $text);
$html = nl2br($text);
$this->emailUser(
$user,
$this->translator->trans('mautic.user.user.passwordchange.subject'),
$html
);
}
/**
* @throws \RuntimeException
*/
public function sendChangeEmailInfo(string $oldEmail, User $user): void
{
$mailer = $this->mailHelper->getMailer();
$text = $this->translator->trans(
'mautic.user.user.emailchange.email.body',
['%name%' => $user->getFirstName()]
);
$text = str_replace('\\n', "\n", $text);
$html = nl2br($text);
$mailer->setTo([$oldEmail => $user->getName()]);
$mailer->setBody($html);
$mailer->setSubject($this->translator->trans('mautic.user.user.emailchange.subject'));
$mailer->send();
}
public function emailUser(User $user, string $subject, string $content): void
{
$mailer = $this->prepareEMail($subject, $content);
$mailer->setTo([$user->getEmail() => $user->getName()]);
$mailer->send();
}
/**
* @param string[] $emailAddresses
*/
public function sendMailToEmailAddresses(array $emailAddresses, string $subject, string $content): void
{
$mailer = $this->prepareEMail($subject, $content);
$mailer->setTo($emailAddresses);
$mailer->send();
}
private function prepareEMail(string $subject, string $content): MailHelper
{
$mailer = $this->mailHelper->getMailer();
$content = str_replace('\\n', "\n", $content);
$html = nl2br($content);
$mailer->setSubject($subject);
$mailer->setBody($html);
$mailer->setPlainText(strip_tags($content));
return $mailer;
}
/**
* Set user preference.
*/
public function setPreference($key, $value = null, ?User $user = null): void
{
if (null == $user) {
$user = $this->userHelper->getUser();
}
$preferences = $user->getPreferences();
$preferences[$key] = $value;
$user->setPreferences($preferences);
$this->getRepository()->saveEntity($user);
}
/**
* Get user preference.
*/
public function getPreference($key, $default = null, ?User $user = null)
{
if (null == $user) {
$user = $this->userHelper->getUser();
}
$preferences = $user->getPreferences();
return $preferences[$key] ?? $default;
}
/**
* Return list of Users for formType Choice.
*/
public function getOwnerListChoices(): array
{
return $this->getRepository()->getOwnerListChoices();
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace Mautic\UserBundle\Model\UserToken;
use Mautic\CoreBundle\Helper\RandomHelper\RandomHelperInterface;
use Mautic\UserBundle\Entity\UserToken;
use Mautic\UserBundle\Entity\UserTokenRepositoryInterface;
final class UserTokenService implements UserTokenServiceInterface
{
public function __construct(
private RandomHelperInterface $randomHelper,
private UserTokenRepositoryInterface $userTokenRepository,
) {
}
/**
* @param int $secretLength
*
* @return UserToken
*/
public function generateSecret(UserToken $token, $secretLength = 32)
{
do {
$randomSecret = $this->randomHelper->generate($secretLength);
$isSecretUnique = $this->userTokenRepository->isSecretUnique($randomSecret);
} while (false === $isSecretUnique);
return $token->setSecret($randomSecret);
}
/**
* @return bool
*/
public function verify(UserToken $token)
{
return $this->userTokenRepository->verify($token);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Mautic\UserBundle\Model\UserToken;
use Mautic\UserBundle\Entity\UserToken;
/**
* Interface UserTokenServiceInterface.
*/
interface UserTokenServiceInterface
{
/**
* @param int $secretLength
*
* @return UserToken
*/
public function generateSecret(UserToken $token, $secretLength = 32);
/**
* @return bool
*/
public function verify(UserToken $token);
}

View File

@@ -0,0 +1,65 @@
{% block _config_userconfig_widget %}
{% set fields = form.children %}
{% set fieldKeys = fields|keys %}
{% if fieldKeys|filter(v => v in ['saml_idp_metadata', 'saml_idp_own_certificate', 'saml_idp_own_private_key', 'saml_idp_own_password'])|length > 0 %}
<h4 class="fw-sb mt-48 mb-xs">{{ 'mautic.user.config.header.saml'|trans }}</h4>
<div class="text-muted small pb-md">{{ 'mautic.core.config.header.saml.description'|trans }}</div>
<div class="row">
<div class="panel panel-default mb-md">
<div class="panel-body">
<div class="row">
{% if fields.saml_idp_entity_id is defined %}
<div class="col-xs-12">
{{ form_row(fields.saml_idp_entity_id) }}
</div>
{% endif %}
<div class="col-xs-12">
{{ form_row(fields.saml_idp_metadata, {'fieldValue': formConfig.parameters.saml_idp_metadata}) }}
</div>
<div class="col-xs-12">
{{ form_row(fields.saml_idp_default_role) }}
</div>
</div>
<hr />
<div class="alert alert-info">{{ 'mautic.user.config.form.saml.idp_attributes'|trans }}</div>
<div class="row">
<div class="col-xs-12">
{{ form_row(fields.saml_idp_email_attribute) }}
</div>
<div class="col-xs-12">
{{ form_row(fields.saml_idp_username_attribute) }}
</div>
</div>
<div class="row">
<div class="col-xs-12">
{{ form_row(fields.saml_idp_firstname_attribute) }}
</div>
<div class="col-xs-12">
{{ form_row(fields.saml_idp_lastname_attribute) }}
</div>
</div>
<hr />
<div class="alert alert-info">{{ 'mautic.user.config.form.saml.idp.own_certificate.description'|trans }}</div>
<div class="row">
<div class="col-xs-12">
{{ form_row(fields.saml_idp_own_certificate, {'fieldValue': formConfig.parameters.saml_idp_own_certificate}) }}
</div>
<div class="col-xs-12">
{{ form_row(fields.saml_idp_own_private_key, {'fieldValue': formConfig.parameters.saml_idp_own_private_key}) }}
</div>
</div>
<div class="row">
<div class="col-xs-12">
{{ form_row(fields.saml_idp_own_password) }}
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,20 @@
{% for theme in themes %}
<div class="radio-option col-xs-12 col-md-4 mb-lg">
<label class="d-flex fd-column" for="theme-{{ theme.id }}">
{% if theme.preview ends with '.twig' %}
{{ include(theme.preview) }}
{% else %}
<img src="{{ asset(theme.preview) }}" alt="{{ theme.name|trans }} preview" aria-hidden="true" class="mb-sm">
{% endif %}
<div class="d-flex fd-row gap-3">
<input type="radio" id="theme-{{ theme.id }}" name="theme" value="{{ theme.id }}"
data-attribute-toggle="theme" data-attribute-value="{{ theme.id }}"
{% if theme.checked|default(false) %}checked{% endif %}>
<div class="text-content">
<strong>{{ theme.name|trans }}</strong>
<div class="text-help">{{ theme.description|trans }}</div>
</div>
</div>
</label>
</div>
{% endfor %}

View File

@@ -0,0 +1,29 @@
<svg class="mb-sm" viewBox="0 0 228 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_795_75)">
<path d="M0 0H228V120H0V0Z" fill="#161616"/>
<path d="M75 39H47C45.8954 39 45 39.8954 45 41V73C45 74.1046 45.8954 75 47 75H75C76.1046 75 77 74.1046 77 73V41C77 39.8954 76.1046 39 75 39Z" fill="#262626"/>
<path d="M0 0H40V120H0V0Z" fill="#262626"/>
<path d="M90 20H48C46.3431 20 45 21.3431 45 23C45 24.6569 46.3431 26 48 26H90C91.6569 26 93 24.6569 93 23C93 21.3431 91.6569 20 90 20Z" fill="#393939"/>
<path d="M214.153 39H84.8472C83.827 39 83 40.132 83 41.5283V103.472C83 104.868 83.827 106 84.8472 106H214.153C215.173 106 216 104.868 216 103.472V41.5283C216 40.132 215.173 39 214.153 39Z" fill="#262626"/>
<path opacity="0.3" d="M216 44H83V56H216V44Z" fill="var(--primary-60)"/>
<path d="M143.958 47H90.389C88.8587 47 87.6182 48.3431 87.6182 50C87.6182 51.6569 88.8587 53 90.389 53H143.958C145.489 53 146.729 51.6569 146.729 50C146.729 48.3431 145.489 47 143.958 47Z" fill="var(--primary-60)"/>
<g clip-path="url(#clip1_795_75)">
<path d="M213 20H209C208.448 20 208 20.4477 208 21V25C208 25.5523 208.448 26 209 26H213C213.552 26 214 25.5523 214 25V21C214 20.4477 213.552 20 213 20Z" fill="white"/>
</g>
<g clip-path="url(#clip2_795_75)">
<path d="M205 20H201C200.448 20 200 20.4477 200 21V25C200 25.5523 200.448 26 201 26H205C205.552 26 206 25.5523 206 25V21C206 20.4477 205.552 20 205 20Z" fill="var(--primary-60)"/>
</g>
<path d="M213 9H187C185.343 9 184 10.3431 184 12C184 13.6569 185.343 15 187 15H213C214.657 15 216 13.6569 216 12C216 10.3431 214.657 9 213 9Z" fill="#393939"/>
</g>
<defs>
<clipPath id="clip0_795_75">
<rect width="228" height="120" fill="white"/>
</clipPath>
<clipPath id="clip1_795_75">
<rect x="208" y="20" width="6" height="6" rx="3" fill="white"/>
</clipPath>
<clipPath id="clip2_795_75">
<rect x="200" y="20" width="6" height="6" rx="3" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,29 @@
<svg width="228" height="120" viewBox="0 0 228 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_795_117)">
<path d="M0 0H228V120H0V0Z" fill="#1C1C2E"/>
<path d="M75 39H47C45.8954 39 45 39.8954 45 41V73C45 74.1046 45.8954 75 47 75H75C76.1046 75 77 74.1046 77 73V41C77 39.8954 76.1046 39 75 39Z" fill="#232336"/>
<path d="M0 0H40V120H0V0Z" fill="#232336"/>
<path d="M90 20H48C46.3431 20 45 21.3431 45 23C45 24.6569 46.3431 26 48 26H90C91.6569 26 93 24.6569 93 23C93 21.3431 91.6569 20 90 20Z" fill="#323248"/>
<path d="M214.153 39H84.8472C83.827 39 83 40.132 83 41.5283V103.472C83 104.868 83.827 106 84.8472 106H214.153C215.173 106 216 104.868 216 103.472V41.5283C216 40.132 215.173 39 214.153 39Z" fill="#232336"/>
<path opacity="0.3" d="M216 44H83V56H216V44Z" fill="#F7B84B"/>
<path d="M143.958 47H90.389C88.8587 47 87.6182 48.3431 87.6182 50C87.6182 51.6569 88.8587 53 90.389 53H143.958C145.489 53 146.729 51.6569 146.729 50C146.729 48.3431 145.489 47 143.958 47Z" fill="#F7B84B"/>
<g clip-path="url(#clip1_795_117)">
<path d="M213 20H209C208.448 20 208 20.4477 208 21V25C208 25.5523 208.448 26 209 26H213C213.552 26 214 25.5523 214 25V21C214 20.4477 213.552 20 213 20Z" fill="#8C98C6"/>
</g>
<g clip-path="url(#clip2_795_117)">
<path d="M205 20H201C200.448 20 200 20.4477 200 21V25C200 25.5523 200.448 26 201 26H205C205.552 26 206 25.5523 206 25V21C206 20.4477 205.552 20 205 20Z" fill="#F7B84B"/>
</g>
<path d="M213 9H187C185.343 9 184 10.3431 184 12C184 13.6569 185.343 15 187 15H213C214.657 15 216 13.6569 216 12C216 10.3431 214.657 9 213 9Z" fill="#323248"/>
</g>
<defs>
<clipPath id="clip0_795_117">
<rect width="228" height="120" fill="white"/>
</clipPath>
<clipPath id="clip1_795_117">
<rect x="208" y="20" width="6" height="6" rx="3" fill="white"/>
</clipPath>
<clipPath id="clip2_795_117">
<rect x="200" y="20" width="6" height="6" rx="3" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,29 @@
<svg class="mb-sm" viewBox="0 0 228 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_794_43)">
<path d="M0 0H228V120H0V0Z" fill="white"/>
<path d="M75 39H47C45.8954 39 45 39.8954 45 41V73C45 74.1046 45.8954 75 47 75H75C76.1046 75 77 74.1046 77 73V41C77 39.8954 76.1046 39 75 39Z" fill="#F4F4F4"/>
<path d="M0 0H40V120H0V0Z" fill="#F4F4F4"/>
<path d="M90 20H48C46.3431 20 45 21.3431 45 23C45 24.6569 46.3431 26 48 26H90C91.6569 26 93 24.6569 93 23C93 21.3431 91.6569 20 90 20Z" fill="#DADADA"/>
<path d="M214.153 39H84.8472C83.827 39 83 40.132 83 41.5283V103.472C83 104.868 83.827 106 84.8472 106H214.153C215.173 106 216 104.868 216 103.472V41.5283C216 40.132 215.173 39 214.153 39Z" fill="#F4F4F4"/>
<path opacity="0.3" d="M216 44H83V56H216V44Z" fill="var(--primary-60)"/>
<path d="M143.958 47H90.389C88.8587 47 87.6182 48.3431 87.6182 50C87.6182 51.6569 88.8587 53 90.389 53H143.958C145.489 53 146.729 51.6569 146.729 50C146.729 48.3431 145.489 47 143.958 47Z" fill="var(--primary-60)"/>
<g clip-path="url(#clip1_794_43)">
<path d="M213 20H209C208.448 20 208 20.4477 208 21V25C208 25.5523 208.448 26 209 26H213C213.552 26 214 25.5523 214 25V21C214 20.4477 213.552 20 213 20Z" fill="#161616"/>
</g>
<g clip-path="url(#clip2_794_43)">
<path d="M205 20H201C200.448 20 200 20.4477 200 21V25C200 25.5523 200.448 26 201 26H205C205.552 26 206 25.5523 206 25V21C206 20.4477 205.552 20 205 20Z" fill="var(--primary-60)"/>
</g>
<path d="M213 9H187C185.343 9 184 10.3431 184 12C184 13.6569 185.343 15 187 15H213C214.657 15 216 13.6569 216 12C216 10.3431 214.657 9 213 9Z" fill="#DADADA"/>
</g>
<defs>
<clipPath id="clip0_794_43">
<rect width="228" height="120" fill="white"/>
</clipPath>
<clipPath id="clip1_794_43">
<rect x="208" y="20" width="6" height="6" rx="3" fill="white"/>
</clipPath>
<clipPath id="clip2_794_43">
<rect x="200" y="20" width="6" height="6" rx="3" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,29 @@
<svg width="228" height="120" viewBox="0 0 228 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_795_103)">
<path d="M0 0H228V120H0V0Z" fill="#002B36"/>
<path d="M75 39H47C45.8954 39 45 39.8954 45 41V73C45 74.1046 45.8954 75 47 75H75C76.1046 75 77 74.1046 77 73V41C77 39.8954 76.1046 39 75 39Z" fill="#073642"/>
<path d="M0 0H40V120H0V0Z" fill="#073642"/>
<path d="M90 20H48C46.3431 20 45 21.3431 45 23C45 24.6569 46.3431 26 48 26H90C91.6569 26 93 24.6569 93 23C93 21.3431 91.6569 20 90 20Z" fill="#586E75"/>
<path d="M214.153 39H84.8472C83.827 39 83 40.132 83 41.5283V103.472C83 104.868 83.827 106 84.8472 106H214.153C215.173 106 216 104.868 216 103.472V41.5283C216 40.132 215.173 39 214.153 39Z" fill="#073642"/>
<path opacity="0.3" d="M216 44H83V56H216V44Z" fill="#268BD2"/>
<path d="M143.958 47H90.389C88.8587 47 87.6182 48.3431 87.6182 50C87.6182 51.6569 88.8587 53 90.389 53H143.958C145.489 53 146.729 51.6569 146.729 50C146.729 48.3431 145.489 47 143.958 47Z" fill="#268BD2"/>
<g clip-path="url(#clip1_795_103)">
<path d="M213 20H209C208.448 20 208 20.4477 208 21V25C208 25.5523 208.448 26 209 26H213C213.552 26 214 25.5523 214 25V21C214 20.4477 213.552 20 213 20Z" fill="#839496"/>
</g>
<g clip-path="url(#clip2_795_103)">
<path d="M205 20H201C200.448 20 200 20.4477 200 21V25C200 25.5523 200.448 26 201 26H205C205.552 26 206 25.5523 206 25V21C206 20.4477 205.552 20 205 20Z" fill="#268BD2"/>
</g>
<path d="M213 9H187C185.343 9 184 10.3431 184 12C184 13.6569 185.343 15 187 15H213C214.657 15 216 13.6569 216 12C216 10.3431 214.657 9 213 9Z" fill="#586E75"/>
</g>
<defs>
<clipPath id="clip0_795_103">
<rect width="228" height="120" fill="white"/>
</clipPath>
<clipPath id="clip1_795_103">
<rect x="208" y="20" width="6" height="6" rx="3" fill="white"/>
</clipPath>
<clipPath id="clip2_795_103">
<rect x="200" y="20" width="6" height="6" rx="3" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,29 @@
<svg width="228" height="120" viewBox="0 0 228 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_795_89)">
<path d="M0 0H228V120H0V0Z" fill="#FDF6E3"/>
<path d="M75 39H47C45.8954 39 45 39.8954 45 41V73C45 74.1046 45.8954 75 47 75H75C76.1046 75 77 74.1046 77 73V41C77 39.8954 76.1046 39 75 39Z" fill="#EEE8D5"/>
<path d="M0 0H40V120H0V0Z" fill="#EEE8D5"/>
<path d="M90 20H48C46.3431 20 45 21.3431 45 23C45 24.6569 46.3431 26 48 26H90C91.6569 26 93 24.6569 93 23C93 21.3431 91.6569 20 90 20Z" fill="#DCD4C0"/>
<path d="M214.153 39H84.8472C83.827 39 83 40.132 83 41.5283V103.472C83 104.868 83.827 106 84.8472 106H214.153C215.173 106 216 104.868 216 103.472V41.5283C216 40.132 215.173 39 214.153 39Z" fill="#EEE8D5"/>
<path opacity="0.3" d="M216 44H83V56H216V44Z" fill="#268BD2"/>
<path d="M143.958 47H90.389C88.8587 47 87.6182 48.3431 87.6182 50C87.6182 51.6569 88.8587 53 90.389 53H143.958C145.489 53 146.729 51.6569 146.729 50C146.729 48.3431 145.489 47 143.958 47Z" fill="#268BD2"/>
<g clip-path="url(#clip1_795_89)">
<path d="M213 20H209C208.448 20 208 20.4477 208 21V25C208 25.5523 208.448 26 209 26H213C213.552 26 214 25.5523 214 25V21C214 20.4477 213.552 20 213 20Z" fill="#586E75"/>
</g>
<g clip-path="url(#clip2_795_89)">
<path d="M205 20H201C200.448 20 200 20.4477 200 21V25C200 25.5523 200.448 26 201 26H205C205.552 26 206 25.5523 206 25V21C206 20.4477 205.552 20 205 20Z" fill="#268BD2"/>
</g>
<path d="M213 9H187C185.343 9 184 10.3431 184 12C184 13.6569 185.343 15 187 15H213C214.657 15 216 13.6569 216 12C216 10.3431 214.657 9 213 9Z" fill="#DCD4C0"/>
</g>
<defs>
<clipPath id="clip0_795_89">
<rect width="228" height="120" fill="white"/>
</clipPath>
<clipPath id="clip1_795_89">
<rect x="208" y="20" width="6" height="6" rx="3" fill="white"/>
</clipPath>
<clipPath id="clip2_795_89">
<rect x="200" y="20" width="6" height="6" rx="3" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,285 @@
{% extends '@MauticCore/Default/content.html.twig' %}
{% block headerTitle %}{{ 'mautic.user.account.settings'|trans }}{% endblock %}
{% block mauticContent %}user{% endblock %}
{% block content %}
<!-- start: box layout -->
<div class="container">
<!-- step container -->
<div class="row">
<div class="col-md-3 height-auto">
{% if me.getId() %}
<div class="media">
<div class="pull-left">
<img class="img-circle mt-md mb-xs img-bordered media-object" src="{{ gravatarGetImage(me.getEmail()) }}"
alt="" width="65px">
</div>
<div class="media-body">
<h3 class="fw-sb">{{ me.getName() }}</h3>
<span class="text-secondary">{{ me.getPosition() }}</span>
</div>
</div>
<hr />
{% endif %}
<ul class="list-group list-group-tabs">
<li class="list-group-item active">
<a href="#profile" class="list-group-item-heading steps" data-toggle="tab">
{{ 'mautic.user.account.header.details'|trans }}
</a>
</li>
{% if permissions['apiAccess'] %}
<li class="list-group-item">
<a href="#clients" class="list-group-item-heading steps" data-toggle="tab">
{{ 'mautic.user.account.header.authorizedclients'|trans }}
</a>
</li>
{% endif %}
<li class="list-group-item">
<a href="#appearance" class="list-group-item-heading steps" data-toggle="tab">{{
'mautic.user.account.appearance'|trans }}</a>
</li>
<li class="list-group-item">
<a href="#accessibility" class="list-group-item-heading steps" data-toggle="tab">{{
'mautic.user.account.accessibility'|trans }}</a>
</li>
</ul>
</div>
<!--/ step container -->
<!-- container -->
<div class="col-md-9 height-auto">
<div class="tab-content">
<div class="tab-pane fade in active bdr-rds-0 bdr-w-0" id="profile">
{{ form_start(userForm) }}
<div class="row pa-md bdr-b">
<h4 class="fw-sb">{{ 'mautic.user.account.header.details'|trans }}</h4>
</div>
<div class="row">
<div class="col-md-6">
<h3 id="personal-info" class="pt-20 mb-xs">{{ 'mautic.user.config.title.get_to_know_you'|trans }}</h3>
<div class="text-secondary small pb-20">{{ 'mautic.user.config.personalize.interface.helper'|trans }}</div>
{{ permissions.editName ? form_row(userForm.firstName) : form_row(userForm.firstName_unbound) }}
{{ permissions.editName ? form_row(userForm.lastName) : form_row(userForm.lastName_unbound) }}
{{ permissions.editPosition ? form_row(userForm.position) : form_row(userForm.position_unbound) }}
{{ form_row(userForm.signature) }}
<h3 id="locale" class="pt-20 mb-xs">{{ 'mautic.user.config.title.locale'|trans }}</h3>
<div class="text-secondary small pb-20">{{ 'mautic.user.config.region.preferences.helper'|trans }}</div>
{{ form_row(userForm.timezone) }}
{{ form_row(userForm.locale) }}
</div>
<div class="col-md-6">
<h3 id="security" class="pt-20 mb-xs">{{ 'mautic.user.config.title.account_data_security'|trans }}</h3>
<div class="text-secondary small pb-20">{{ 'mautic.user.config.account.security.helper'|trans }}</div>
{{ permissions.editUsername ? form_row(userForm.username) : form_row(userForm.username_unbound) }}
{{ permissions.editEmail ? form_row(userForm.email) : form_row(userForm.email_unbound) }}
{% if not isSamlUser %}
{{ form_row(userForm.plainPassword.password) }}
{{ form_row(userForm.plainPassword.confirm) }}
{% endif %}
<h3 id="learning" class="pt-20 mb-xs">{{ 'mautic.user.config.title.experience_and_learning'|trans }}</h3>
<div class="text-secondary small pb-20">{{ 'mautic.user.config.title.experience_and_learning.description'|trans }}</div>
{% include '@MauticCore/Helper/button.html.twig' with {
buttons: [
{
label: 'mautic.user.config.title.experience_and_learning.reset_dismissed',
variant: 'tertiary',
size: 'sm',
icon: 'ri-refresh-line',
danger: true,
onclick: 'Mautic.resetDismissedElements()',
}
]
} %}
</div>
</div>
{{ form_row(userForm.preferences) }}
{{ form_end(userForm) }}
</div>
{% if permissions.apiAccess is defined and permissions.apiAccess %}
<div class="tab-pane fade bdr-rds-0 bdr-w-0" id="clients">
<div class="row pa-md bdr-b">
<h4 class="fw-sb">{{ 'mautic.user.account.header.authorizedclients'|trans }}</h4>
</div>
<div class="row">
{{ authorizedClients|raw }}
</div>
</div>
{% endif %}
<div class="tab-pane fade bdr-rds-0 bdr-w-0" id="appearance">
<div class="row pa-md bdr-b">
<h4 class="fw-sb">{{ 'mautic.user.account.appearance'|trans }}</h4>
</div>
<div class="row">
<div class="col-xs-12 mb-lg">
<h3 class="pt-20">
{{ 'mautic.user.account.appearance.theme_preferences'|trans }}
{% include '@MauticCore/Components/toggletip.html.twig' with {
title: 'mautic.user.account.appearance.theme_preferences.popover.title',
content: 'mautic.user.account.appearance.theme_preferences.popover.content',
} %}
</h3>
<div class="help-block mb-md">{{ 'mautic.user.account.appearance.theme_help'|trans }}</div>
<div class="radio-group radio-cards row">
{{ include('@MauticUser/Helper/theme.html.twig', {
themes: [
{
id: 'light',
name: 'mautic.user.account.appearance.theme_light',
description: 'mautic.user.account.appearance.theme_light_description',
preview: '@MauticUser/Profile/images/_light_preview.html.twig',
checked: true
},
{
id: 'dark',
name: 'mautic.user.account.appearance.theme_dark',
description: 'mautic.user.account.appearance.theme_dark_description',
preview: '@MauticUser/Profile/images/_dark_preview.html.twig'
},
{
id: 'solarized-light',
name: 'mautic.user.account.appearance.theme_solarized_light',
description: 'mautic.user.account.appearance.theme_solarized_light_description',
preview: 'app/bundles/UserBundle/Resources/views/Profile/images/_solarized_light_preview.svg'
},
{
id: 'solarized-dark',
name: 'mautic.user.account.appearance.theme_solarized_dark',
description: 'mautic.user.account.appearance.theme_solarized_dark_description',
preview: 'app/bundles/UserBundle/Resources/views/Profile/images/_solarized_dark_preview.svg'
},
{
id: 'dark-freire',
name: 'mautic.user.account.appearance.theme_freire',
description: 'mautic.user.account.appearance.theme_freire_description',
preview: 'app/bundles/UserBundle/Resources/views/Profile/images/_freire_preview.svg'
}
]
}) }}
</div>
</div>
</div>
</div>
<div class="tab-pane fade bdr-rds-0 bdr-w-0" id="accessibility">
<div class="row pa-md bdr-b">
<h4 class="fw-sb">{{ 'mautic.user.account.accessibility'|trans }}</h4>
</div>
<div class="row">
<div class="col-xs-12 mb-lg">
<h3 class="pt-20 mb-md">{{ 'mautic.user.account.accessibility.appearance'|trans }}</h3>
<h4>{{ 'mautic.user.account.accessibility.smooth_color_transitions'|trans }}</h4>
<div class="help-block">{{ 'mautic.user.account.accessibility.smooth_color_transitions_help'|trans }}</div>
<div class="radio-group">
<div class="radio-option">
<input type="radio" id="transitions-enabled" name="reduce-motion" value="false"
data-attribute-toggle="reduce-motion" data-attribute-value="false" checked>
<label for="transitions-enabled">
<strong>{{ 'mautic.user.account.accessibility.enabled'|trans }}</strong>
<div class="text-help">{{ 'mautic.user.account.accessibility.transitions_enabled_description'|trans }}
</div>
</label>
</div>
<div class="radio-option">
<input type="radio" id="transitions-disabled" name="reduce-motion" value="true"
data-attribute-toggle="reduce-motion" data-attribute-value="true">
<label for="transitions-disabled">
<strong>{{ 'mautic.user.account.accessibility.disabled'|trans }}</strong>
<div class="text-help">{{ 'mautic.user.account.accessibility.transitions_disabled_description'|trans }}</div>
</label>
</div>
</div>
<hr>
<h4>{{ 'mautic.user.account.accessibility.borders_outline_style'|trans }}</h4>
<div class="help-block">{{ 'mautic.user.account.accessibility.borders_outline_style_help'|trans }}</div>
<div class="radio-group">
<div class="radio-option">
<input type="radio" id="borders-disabled" name="contrast-borders" value="false"
data-attribute-toggle="contrast-borders" data-attribute-value="false" checked>
<label for="borders-disabled">
<strong>{{ 'mautic.user.account.accessibility.disabled'|trans }}</strong>
<div class="text-help">{{ 'mautic.user.account.accessibility.borders_disabled_description'|trans }}
</div>
</label>
</div>
<div class="radio-option">
<input type="radio" id="borders-enabled" name="contrast-borders" value="true"
data-attribute-toggle="contrast-borders" data-attribute-value="true">
<label for="borders-enabled">
<strong>{{ 'mautic.user.account.accessibility.enabled'|trans }}</strong>
<div class="text-help">{{ 'mautic.user.account.accessibility.borders_enabled_description'|trans }}
</div>
</label>
</div>
</div>
<hr>
<h4>{{ 'mautic.user.account.accessibility.reduce_transparency_blur'|trans }}</h4>
<div class="help-block">{{ 'mautic.user.account.accessibility.reduce_transparency_blur_help'|trans }}</div>
<div class="radio-group">
<div class="radio-option">
<input type="radio" id="transparency-normal" name="reduce-transparency"
value="false" data-attribute-toggle="reduce-transparency"
data-attribute-value="false" checked>
<label for="transparency-normal">
<strong>{{ 'mautic.user.account.accessibility.normal'|trans }}</strong>
<div class="text-help">{{ 'mautic.user.account.accessibility.transparency_normal_description'|trans }}
</div>
</label>
</div>
<div class="radio-option">
<input type="radio" id="transparency-reduced" name="reduce-transparency"
value="true" data-attribute-toggle="reduce-transparency"
data-attribute-value="true">
<label for="transparency-reduced">
<strong>{{ 'mautic.user.account.accessibility.reduced'|trans }}</strong>
<div class="text-help">{{ 'mautic.user.account.accessibility.transparency_reduced_description'|trans }}</div>
</label>
</div>
</div>
<hr>
<h3 class="pt-20 mb-md">{{ 'mautic.user.account.accessibility.content'|trans }}</h3>
<h4>{{ 'mautic.user.account.accessibility.link_underlines'|trans }}</h4>
<div class="help-block">{{ 'mautic.user.account.accessibility.link_underlines_help'|trans }}
</div>
<div class="radio-group">
<div class="radio-option">
<input type="radio" id="underlines-disabled" name="enable-underlines" value="false"
data-attribute-toggle="enable-underlines" data-attribute-value="false" checked>
<label for="underlines-disabled">
<strong>{{ 'mautic.user.account.accessibility.disabled'|trans }}</strong>
<div class="text-help">{{ 'mautic.user.account.accessibility.underlines_disabled_description'|trans }}
</div>
</label>
</div>
<div class="radio-option">
<input type="radio" id="underlines-enabled" name="enable-underlines" value="true"
data-attribute-toggle="enable-underlines" data-attribute-value="true">
<label for="underlines-enabled">
<strong>{{ 'mautic.user.account.accessibility.enabled'|trans }}</strong>
<div class="text-help">{{ 'mautic.user.account.accessibility.underlines_enabled_description'|trans }}
</div>
</label>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!--/ end: container -->
</div>
{% endblock %}

View File

@@ -0,0 +1,105 @@
{% extends '@MauticCore/Default/content.html.twig' %}
{% block mauticContent %}role{% endblock %}
{% do assetAddScriptDeclaration('MauticVars.permissionList = ' ~ (permissionsConfig['list']|json_encode), 'bodyClose') %}
{% set objectId = form.vars.data.getId() %}
{% if objectId is not empty %}
{% set name = form.vars.data.getName() %}
{% set header = 'mautic.user.role.header.edit'|trans({'%name%' : name}) %}
{% else %}
{% set header = 'mautic.user.role.header.new'|trans %}
{% endif %}
{% block headerTitle %}{{ header }}{% endblock %}
{% block content %}
{{ form_start(form) }}
<div class="box-layout">
<div class="col-xs-12 height-auto">
<!-- tabs controls -->
<ul class="nav nav-tabs nav-tabs-contained">
<li class="active"><a href="#details-container" role="tab" data-toggle="tab">{% trans %}mautic.core.details{% endtrans %}</a></li>
<li class="" id="permissions-tab"><a href="#permissions-container" role="tab" data-toggle="tab">{% trans %}mautic.user.role.permissions{% endtrans %}</a></li>
</ul>
<!--/ tabs controls -->
<div class="tab-content pa-md">
<div class="tab-pane fade in active bdr-w-0 height-auto" id="details-container">
<div class="row">
<div class="pa-md">
<div class="col-md-6">
{{ form_row(form.name) }}
</div>
<div class="col-md-6">
{{ form_row(form.isAdmin) }}
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="pa-md">
{{ form_row(form.description) }}
</div>
</div>
</div>
</div>
{% set hidePerms = form.isAdmin.vars.data %}
<div class="tab-pane fade bdr-w-0" id="permissions-container">
<div id="rolePermissions"{% if hidePerms %}class="hide"{% endif %}>
<!-- start: box layout -->
<div class="box-layout">
<!-- step container -->
<div class="col-md-5 height-auto">
<div class="pr-lg pl-lg pt-md pb-md">
<!-- Nav tabs -->
<ul class="list-group list-group-tabs" role="tablist">
{% for bundle, config in permissionsConfig['config'] %}
<li role="presentation" class="list-group-item {{ loop.index0 is same as(0) ? 'in active' : '' }}">
<a href="#{{ bundle }}PermissionTab" aria-controls="{{ bundle }}PermissionTab" role="tab" data-toggle="tab" class="list-group-item-heading steps">
<span>{{ config['label'] }}</span>
<span class="permission-ratio"> (<span class="{{ bundle }}_granted">{{ config['ratio'][0] }}</span> / <span class="{{ bundle }}_total">{{ config['ratio'][1] }}</span>)</span>
</a>
</li>
{% endfor %}
</ul>
</div>
</div>
<!-- container -->
<div class="col-md-7 height-auto bdr-l">
<div class="tab-content">
{% set permissions = form.permissions.children %}
{% for child in permissions %}
{% if 'newbundle' == child.vars.value %}
{% if loop.index0 > 0 %}
{# // Close tab panel #}
</div>{{ "\n" }}</div>{{ "\n" }}
{% endif %}
<div role="tabpanel" class="tab-pane fade{{ 0 is same as(loop.index0) ? ' in active' : '' }} bdr-w-0" id="{{ child.vars.name }}PermissionTab">{{ "\n" }}
<div class="pt-md pr-md pl-md pb-md"> {{ "\n" }}
{% do child.setRendered() %}
{% else %}
{{ form_row(child) }}
{% endif %}
{% endfor %}
{# //close last tab #}
</div>{{ "\n" }}
{% do form.permissions.setRendered() %}
</div>
</div>
</div>
</div>
<div id="isAdminMessage"{% if not hidePerms %} class="hide"{% endif %}>
<div class="alert alert-warning">
<h4>{% trans %}mautic.user.role.permission.isadmin.header{% endtrans %}</h4>
<p>{% trans %}mautic.user.role.permission.isadmin.message{% endtrans %}</p>
</div>
</div>
</div>
</div>
</div>
</div>
{{ form_end(form) }}
{% endblock %}

View File

@@ -0,0 +1,150 @@
{% set isIndex = tmpl == 'index' ? true : false %}
{% set tmpl = 'list' %}
{% extends isIndex ? '@MauticCore/Default/content.html.twig' : '@MauticCore/Default/raw_output.html.twig' %}
{% block mauticContent %}role{% endblock %}
{% block headerTitle %}{% trans %}mautic.user.roles{% endtrans %}{% endblock %}
{% block content %}
{% if isIndex %}
<div id="page-list-wrapper" class="panel panel-default">
{{- include('@MauticCore/Helper/list_toolbar.html.twig', {
'searchValue': searchValue,
'action': currentRoute,
'page_actions': {
'templateButtons': {
'new': permissions['create'],
},
'routeBase': 'role',
'langVar': 'user.role',
},
'bulk_actions': {
'routeBase': 'role',
'langVar': 'user.role',
'templateButtons': {
'delete': permissions['delete']
}
},
'quickFilters': [
{
'search': 'mautic.user.user.searchcommand.isadmin',
'label': 'mautic.user.role.form.isadmin',
'tooltip': 'mautic.core.search.quickfilter.is_admin',
'icon': 'ri-admin-line'
}
]
}) -}}
<div class="page-list">
{{ block('listResults') }}
</div>
</div>
{% else %}
{{ block('listResults') }}
{% endif %}
{% endblock %}
{% block listResults %}
<div class="table-responsive">
<table class="table table-hover role-list" id="roleTable">
<thead>
<tr>
{{- include('@MauticCore/Helper/tableheader.html.twig',
{
'checkall' : 'true',
'target' : '#roleTable'
}
) -}}
{{- include(
'@MauticCore/Helper/tableheader.html.twig',
{
'sessionVar' : 'role',
'orderBy' : 'r.name',
'text' : 'mautic.core.name',
'class' : 'col-role-name',
'default' : true,
}
) -}}
{{- include(
'@MauticCore/Helper/tableheader.html.twig',
{
'sessionVar' : 'role',
'orderBy' : 'r.description',
'text' : 'mautic.core.description',
'class' : 'visible-md visible-lg col-role-desc',
}
) -}}
<th class="visible-md visible-lg col-rolelist-usercount">
{% trans %}mautic.user.role.list.thead.usercount{% endtrans %}
</th>
{{- include('@MauticCore/Helper/tableheader.html.twig',
{
'sessionVar' : 'role',
'orderBy' : 'r.id',
'text' : 'mautic.core.id',
'class' : 'visible-md visible-lg col-role-id',
}
) -}}
</tr>
</thead>
<tbody>
{% for item in items %}
{% set mauticTemplateVars = _context|merge({'item' : item}) %}
<tr>
<td>
{{- include('@MauticCore/Helper/list_actions.html.twig',
{
'item' : item,
'templateButtons' : {
'edit' : permissions['edit'],
'delete' : permissions['delete'],
},
'routeBase' : 'role',
'langVar' : 'user.role',
'pull' : 'left',
}) -}}
</td>
<td>
{% if permissions['edit'] %}
<a href="{{ path('mautic_role_action',
{'objectAction' : 'edit', 'objectId' : item.getId()}) }}" data-toggle="ajax">
{{ item.getName() }}
{{ customContent('role.name', mauticTemplateVars) }}
</a>
{% else %}
{{ item.getName() }}
{% endif %}
</td>
<td class="visible-md visible-lg">
{{ item.getDescription()|purify }}
</td>
<td class="visible-md visible-lg">
<a size="sm" class="label label-gray" href="{{ path('mautic_user_index',
{'search' : ('mautic.user.user.searchcommand.role'|trans) ~ ':&quot;' ~ item.getName() ~ '&quot;'}) }}" data-toggle="ajax"{{ userCounts[item.getId()] == 0 ? 'disabled=disabled' : '' }}>
{{ 'mautic.user.role.list.viewusers_count'|trans(
{'%count%' : userCounts[item.getId()]}
) }}
</a>
</td>
<td class="visible-md visible-lg">
{{ item.getId() }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="panel-footer">
{{- include('@MauticCore/Helper/pagination.html.twig',
{
'totalItems' : items|length,
'page' : page,
'limit' : limit,
'baseUrl' : path('mautic_role_index'),
'sessionVar' : 'role',
}
) -}}
</div>
{% endblock %}

View File

@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>{% block pageTitle %}Mautic{% endblock %}</title>
<meta name="robots" content="noindex, nofollow" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<link rel="icon" type="image/x-icon" href="{{ getOverridableUrl('images/favicon.ico') }}" />
<link rel="apple-touch-icon" href="{{ getOverridableUrl('images/apple-touch-icon.png') }}" />
{{ outputSystemStylesheets() }}
{{- include('@MauticCore/Default/script.html.twig') -}}
{{ outputHeadDeclarations() }}
</head>
<body>
<section id="main" role="main">
<div class="container ml-a mr-a" style="margin-top:100px;">
<div class="row">
<div class="col-lg-4 col-lg-offset-4">
<div class="panel" name="form-login">
<div class="panel-body">
<div class="mautic-logo img-circle mb-md text-center">
{{ source('@MauticCore/Assets/images/logo--minimized.svg') }}
</div>
<div id="main-panel-flash-msgs">
{{- include('@MauticCore/Notification/flashes.html.twig') -}}
</div>
{% block content %}{% endblock %}
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-4 col-lg-offset-4 text-center text-secondary">
{{ 'mautic.core.copyright'|trans({'%date%' : date('Y').format('Y') }) }}
</div>
</div>
</div>
</section>
{{ securityGetAuthenticationContext() }}
</body>
</html>

View File

@@ -0,0 +1,69 @@
{% extends app.request.xmlHttpRequest ? '@MauticCore/Default/content.html.twig' : '@MauticUser/Security/base.html.twig' %}
{% block header %}{% trans %}mautic.user.auth.header{% endtrans %}{% endblock %}
{% block headerTitle %}{% trans %}mautic.user.auth.expired.header{% endtrans %}{% endblock %}
{% block content %}
{% if app.request.xmlHttpRequest %}
<div class="row">
<div class="col-xs-12 col-sm-8 col-md-6 inline-login">
{{ block('mainContent') }}
</div>
</div>
{% else %}
{{ block('mainContent') }}
{% endif %}
{% endblock %}
{% block mainContent %}
<form class="form-group login-form" name="login" data-toggle="ajax" role="form" action="{{ path('mautic_user_logincheck') }}" method="post">
<div class="input-group mb-md">
<span class="input-group-addon">
<i class="ri-user-6-fill"></i>
</span>
<label for="username" class="sr-only">{% trans %}mautic.user.auth.form.loginusername{% endtrans %}</label>
<input type="text" id="username" name="_username" class="form-control input-lg" value="{{ last_username|escape }}" required autofocus placeholder="{% trans %}mautic.user.auth.form.loginusername{% endtrans %}"/>
</div>
<div class="input-group mb-md">
<span class="input-group-addon">
<i class="ri-key-2-line"></i>
</span>
<label for="password" class="sr-only">{% trans %}mautic.core.password{% endtrans %}:</label>
<input type="password" id="password" name="_password" class="form-control input-lg" required placeholder="{% trans %}mautic.core.password{% endtrans %}"/>
</div>
<div class="checkbox-inline custom-primary pull-left mb-md">
<label for="remember_me">
<input type="checkbox" id="remember_me" name="_remember_me"/>
<span></span>
{% trans %}mautic.user.auth.form.rememberme{% endtrans %}
</label>
</div>
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate')|escape }}"/>
{% include '@MauticCore/Helper/button.html.twig' with {
buttons: [
{
label: 'mautic.user.auth.form.loginbtn',
variant: 'primary',
size: 'lg',
wide: true
}
]
} %}
<div class="mt-sm text-right">
<a href="{{ path('mautic_user_passwordreset') }}">{% trans %}mautic.user.user.passwordreset.link{% endtrans %}</a>
</div>
</form>
{% if integrations is not empty %}
<ul class="list-group">
{% for sso in integrations %}
<a href="{{ path('mautic_sso_login', {'integration' : sso.getName()}) }}" class="list-group-item">
<img class="pull-left mr-xs" style="height: 16px;" src="{{ getAssetUrl(sso.getIcon()) }}">
<p class="list-group-item-heading">{{ ('mautic.integration.sso.' ~ sso.getName())|trans }}</p>
</a>
{% endfor %}
</ul>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,12 @@
{% extends app.request.xmlHttpRequest ? '@MauticCore/Default/content.html.twig' : '@MauticUser/Security/base.html.twig' %}
{% block content %}
<div class="alert alert-warning">{% trans %}mautic.user.user.passwordreset.info{% endtrans %}</div>
{{ form_start(form) }}
{{ form_row(form.identifier) }}
{{ form_widget(form.submit) }}
{{ form_end(form) }}
<div class="mt-sm">
<a href="{{ path('login') }}">{% trans %}mautic.user.user.passwordreset.back{% endtrans %}</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,15 @@
{% extends app.request.xmlHttpRequest ? '@MauticCore/Default/content.html.twig' : '@MauticUser/Security/base.html.twig' %}
{% block content %}
<div class="alert alert-warning">{% trans %}mautic.user.user.passwordresetconfirm.info{% endtrans %}</div>
{{ form_start(form) }}
{{ form_row(form.identifier) }}
{{ form_row(form.plainPassword.password) }}
{{ form_row(form.plainPassword.confirm) }}
{{ form_widget(form.submit) }}
{{ form_end(form) }}
<div class="mt-sm">
<a href="{{ path('login') }}">{% trans %}mautic.user.user.passwordreset.back{% endtrans %}</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,6 @@
{% extends app.request.xmlHttpRequest ? '@MauticCore/Default/content.html.twig' : '@MauticUser/Security/base.html.twig' %}
{% block content %}
<a class="btn btn-lg btn-primary btn-block" href="{{ loginRoute }}">
{{ 'mautic.user.auth.form.loginbtn'|trans }}
</a>
{% endblock %}

View File

@@ -0,0 +1,10 @@
{% if showMore is defined and showMore is not empty %}
<a href="{{ url('mautic_role_index', {'filter-user' : searchString}) }}" data-toggle="ajax">
<span>{{ 'mautic.core.search.more'|trans({'%count%' : remaining}) }}</span>
</a>
{% else %}
<a href="{{ url('mautic_role_action', {'objectAction' : 'edit', 'objectId' : item.getId()}) }}">
<span class="fw-sb">{{ item.name }}</span>
<span class="ml-4 mr-sm">#{{ item.id }}</span>
</a>
{% endif %}

View File

@@ -0,0 +1,25 @@
{% if showMore is defined and showMore is not empty %}
<a href="{{ url('mautic_user_index', {'filter-user' : searchString}) }}" data-toggle="ajax">
<span>{{ 'mautic.core.search.more'|trans({'%count%' : remaining}) }}</span>
</a>
{% else %}
<div class="d-flex ai-center">
<span class="pull-left mr-sm pt-xs" style="width:36px">
<span class="img-wrapper img-rounded"><img src="{{ gravatarGetImage(item.getEmail(), '100') }}" /></span>
</span>
<div class="d-flex fd-column">
<div>
<a class="fw-sb mr-4" href="{{ url('mautic_user_action', {'objectAction' : 'edit', 'objectId' : item.getId()}) }}">
{{ item.getName() }}
</a>
{{- include('@MauticCore/Helper/publishstatus_badge.html.twig', {
'entity': item,
'status': 'active',
'simplified': 'true'
}) -}}
</div>
<div class="small text-muted">{{ item.getPosition() }}</div>
</div>
<div class="clearfix"></div>
</div>
{% endif %}

View File

@@ -0,0 +1,11 @@
{% extends '@MauticCore/Default/content.html.twig' %}
{% block mauticContent %}user{% endblock %}
{% block headerTitle %}{{ 'mautic.user.user.header.contact'|trans({'%name%': user.getName()}) }}{% endblock %}
{% block content %}
<div class="panel">
<div class="panel-body pa-md">
{{ form(form) }}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,94 @@
{% extends '@MauticCore/Default/content.html.twig' %}
{% block mauticContent %}user
{% endblock %}
{% set userId = form.vars.data.getId() %}
{% if userId is not empty %}
{% set user = form.vars.data.getName() %}
{% set header = 'mautic.user.user.header.edit'|trans({'%name%': user}) %}
{% else %}
{% set header = 'mautic.user.user.header.new'|trans %}
{% endif %}
{% block headerTitle %}
{{ header }}
{% endblock %}
{% block content %}
<!-- start: box layout -->
<div
class="box-layout">
<!-- container -->
{{ form_start(form) }}
<div class="col-md-9 height-auto bdr-r">
<div class="pa-md">
<div class="form-group mb-0">
<div class="row">
<div class="col-sm-6{{ form.firstName.vars.errors|length ? ' has-error' : '' }}">
<h3 class="mb-lg mt-lg">{{ 'mautic.user.config.title.personal_information'|trans }}</h3>
<div class="form-group">
<label class="control-label mb-xs">{{ form_label(form.firstName) }}</label>
{{ form_widget(form.firstName, {'attr' : {'placeholder' : form.firstName.vars.label }}) }}
{{ form_errors(form.firstName) }}
</div>
<div class="form-group{{ form.lastName.vars.errors|length ? ' has-error' : '' }}">
<label class="control-label mb-xs">{{ form_label(form.lastName) }}</label>
{{ form_widget(form.lastName, {'attr' : {'placeholder' : form.lastName.vars.label }}) }}
{{ form_errors(form.lastName) }}
</div>
<div class="form-group{{ form.role.vars.errors|length ? ' has-error' : '' }}">
<label class="control-label mb-xs">{{ form_label(form.role) }}</label>
{{ form_widget(form.role, {'attr' : {'placeholder' : form.role.vars.label }}) }}
{{ form_errors(form.role) }}
</div>
<div class="form-group pb-lg{{ form.position.vars.errors|length ? ' has-error' : '' }}">
<label class="control-label mb-xs">{{ form_label(form.position) }}</label>
{{ form_widget(form.position, {'attr' : {'placeholder' : form.position.vars.label }}) }}
{{ form_errors(form.position) }}
</div>
<div class="form-group pt-md{{ form.signature.vars.errors|length ? ' has-error' : '' }}">
<label class="control-label mb-xs">{{ form_label(form.signature) }}</label>
{{ form_widget(form.signature, {'attr' : {'placeholder' : form.signature.vars.label }}) }}
{{ form_errors(form.signature) }}
<p class="help-text">{{ 'mautic.user.config.signature.admin.helper'|trans }} </p>
</div>
</div>
<div class="col-sm-6">
<h3 class="mb-lg mt-lg">{{ 'mautic.user.config.title.account_data_security'|trans }}</h3>
<div class="form-group{{ form.username.vars.errors|length ? ' has-error' : '' }}">
{{ form_label(form.username) }}
{{ form_widget(form.username, {'attr' : {'placeholder' : form.username.vars.label }}) }}
{{ form_errors(form.username) }}
</div>
<div class="form-group{{ form.email.vars.errors|length ? ' has-error' : '' }}">
{{ form_label(form.email) }}
{{ form_widget(form.email, {'attr' : {'placeholder' : form.email.vars.label }}) }}
{{ form_errors(form.email) }}
</div>
{% if isSamlUser is empty %}
{{ form_widget(form.plainPassword, {'attr' : {'placeholder' : form.plainPassword.vars.label }}) }}
{% endif %}
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 height-auto">
<div class="pr-lg pl-lg pt-md pb-md">
<h3 class="mb-lg mt-lg">{{ 'mautic.user.config.title.locale'|trans }}</h3>
{{ form_rest(form) }}
</div>
</div>
{% if editAction is defined and editAction == true %}
{{- include('@MauticUser/User/recent_activity.html.twig', {
'logs' : logs,
'users' : users,
'roles' : roles,
}) -}}
{% endif %}
{{ form_end(form) }}
</div>
{% endblock %}

View File

@@ -0,0 +1,174 @@
{% set isIndex = tmpl == 'index' ? true : false %}
{% set tmpl = 'list' %}
{% extends isIndex ? '@MauticCore/Default/content.html.twig' : '@MauticCore/Default/raw_output.html.twig' %}
{% block mauticContent %}user
{% endblock %}
{% block headerTitle %}
{% trans %}mautic.user.users{% endtrans %}
{% endblock %}
{% block content %}
{% if isIndex %}
<div id="page-list-wrapper" class="panel panel-default">
{{- include('@MauticCore/Helper/list_toolbar.html.twig', {
'searchValue': searchValue,
'action': currentRoute,
'page_actions': {
'templateButtons': {
'new': permissions['create'],
},
'routeBase': 'user',
'langVar': 'user.user',
},
'bulk_actions': {
'routeBase': 'user',
'langVar': 'user.user',
'templateButtons': {
'delete': permissions['delete']
}
},
'quickFilters': [
{
'search': 'mautic.user.user.searchcommand.isadmin',
'label': 'mautic.user.role.form.isadmin',
'tooltip': 'mautic.core.search.quickfilter.is_admin',
'icon': 'ri-admin-line'
},
{
'search': 'mautic.core.searchcommand.ispublished',
'label': 'mautic.core.form.active',
'tooltip': 'mautic.core.search.quickfilter.is_published',
'icon': 'ri-check-line'
},
{
'search': 'mautic.core.searchcommand.isunpublished',
'label': 'mautic.core.form.inactive',
'tooltip': 'mautic.core.search.quickfilter.is_unpublished',
'icon': 'ri-close-line'
}
]
}) -}}
<div class="page-list">
{{ block('listResults') }}
</div>
</div>
{% else %}
{{ block('listResults') }}
{% endif %}
{% endblock %}
{% block listResults %}
<div class="table-responsive">
<table class="table table-hover user-list" id="userTable">
<thead>
<tr>
{{- include('@MauticCore/Helper/tableheader.html.twig', {
'checkall' : 'true',
'target' : '#userTable'
}) -}}
<th class="visible-md visible-lg col-user-avatar"></th>
{{- include('@MauticCore/Helper/tableheader.html.twig', {
'sessionVar' : 'user',
'orderBy' : 'u.lastName, u.firstName, u.username',
'text' : 'mautic.core.name',
'class' : 'col-user-name',
'default' : true,
}) -}}
{{- include('@MauticCore/Helper/tableheader.html.twig', {
'sessionVar' : 'user',
'orderBy' : 'u.username',
'text' : 'mautic.core.username',
'class' : 'col-user-username',
}) -}}
{{- include('@MauticCore/Helper/tableheader.html.twig', {
'sessionVar' : 'user',
'orderBy' : 'u.email',
'text' : 'mautic.core.type.email',
'class' : 'visible-md visible-lg col-user-email',
}) -}}
{{- include('@MauticCore/Helper/tableheader.html.twig', {
'sessionVar' : 'user',
'orderBy' : 'r.name',
'text' : 'mautic.user.role',
'class' : 'visible-md visible-lg col-user-role',
}) -}}
{{- include('@MauticCore/Helper/tableheader.html.twig', {
'sessionVar' : 'user',
'orderBy' : 'u.lastLogin',
'text' : 'mautic.user.lastlogin',
'class' : 'visible-md visible-lg col-user-lastlogin',
}) -}}
{{- include('@MauticCore/Helper/tableheader.html.twig', {
'sessionVar' : 'user',
'orderBy' : 'u.id',
'text' : 'mautic.core.id',
'class' : 'visible-md visible-lg col-user-id',
}) -}}
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>
{{- include('@MauticCore/Helper/list_actions.html.twig', {
'item' : item,
'templateButtons' : {
'edit' : permissions['edit'],
'delete' : permissions['delete'],
},
'routeBase' : 'user',
'langVar' : 'user.user',
'pull' : 'left',
}) -}}
</td>
<td class="visible-md visible-lg">
<img class="img img-responsive img-thumbnail w-44" src="{{ gravatarGetImage(item.getEmail(), '50') }}"/>
</td>
<td>
<div>
{% if item.getId() != currentUserId %}
{{- include('@MauticCore/Helper/publishstatus_icon.html.twig', {
'item' : item,
'model' : 'user',
}) -}}
{% endif %}
{% if permissions['edit'] %}
<a href="{{ path( 'mautic_user_action', {'objectAction' : 'edit', 'objectId' : item.getId()} ) }}" data-toggle="ajax">
{{ item.getName(true) }}
</a>
{% else %}
{{ item.getName(true) }}
{% endif %}
</div>
<div class="small">
<em>{{ item.getPosition() }}</em>
</div>
</td>
<td>{{ item.getUsername() }}</td>
<td class="visible-md visible-lg">
<a href="mailto: {{ item.getEmail() }}">{{ item.getEmail() }}</a>
</td>
<td class="visible-md visible-lg">{{ item.getRole().getName() }}</td>
<td class="visible-md visible-lg">{{ dateToText(item.getLastLogin(), 'local', 'Y-m-d H:i:s', true) }}</td>
<td class="visible-md visible-lg">{{ item.getId() }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="panel-footer">
{{- include('@MauticCore/Helper/pagination.html.twig', {
'totalItems' : items|length,
'page' : page,
'limit' : limit,
'baseUrl' : path('mautic_user_index'),
'sessionVar' : 'user',
}) -}}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,195 @@
<!-- Recent activity block(audit_log table) -->
<div class="col-md-3 height-auto">
{{ customContent('right.section.top', _context) }}
<div class="panel shd-none bdr-rds-0 bdr-w-0 mb-0">
<div class="panel-heading">
<div class="panel-title">{% trans %}mautic.core.recent.activity{% endtrans %}</div>
</div>
<div class="panel-body pt-xs">
{% if logs is defined and logs is not empty %}
<ul class="media-list media-list-feed">
{% for log in logs %}
{% if log.action == 'create' and log.object == 'user' %}
{% set userPath = [] %}
{% set user_id = '1' %}
{% set usersEmail = log.details['email'][1] %}
{% set emailIsMatch = false %}
{% set usernameIsMatch = false %}
{% for user in users %}
{% if usersEmail is defined and usersEmail is not empty and user.email == usersEmail %}
{% set emailIsMatch = true %}
{% set user_id = user.id %}
{% else %}
{% set usernameIsMatch = true %}
{% set user_id = user.id %}
{% endif %}
{% endfor %}
{% if log.details['username'][1] is defined and log.details['username'][1] is not empty %}
{% if usernameIsMatch == true or emailIsMatch == true %}
{% set userPath = userPath|merge([user_id, log.details['username'][1]]) %}
{% endif %}
{% endif %}
{% elseif log.action == 'create' and log.object == 'role' %}
{% set role_id = '1' %}
{% set roleName = log.details['name'][1] %}
{% set roleIsMatch = false %}
{% for role in roles %}
{% if role.name == roleName %}
{% set roleIsMatch = true %}
{% set role_id = role.id %}
{% endif %}
{% endfor %}
{% if roleIsMatch == true %}
{% set rolePath = []|merge([role_id, roleName]) %}
{% endif %}
{% elseif log.action == 'update' and log.object == 'user' %}
{% set userPath = [] %}
{% set usersUsername, usersEmail, usersFirstName, usersLastName, usersRole, usersPosition, usersSignature = '', '', '', '', '', '', '' %}
{% set user_id = '1' %}
{% set user_username = 'admin' %}
{% set isMatch = false %}
{% for detail in log.details %}
{% if detail == 'username' %}
{% set usersUsername = detail[1] %}
{% elseif detail == 'email' %}
{% set usersEmail = detail[1] %}
{% elseif detail == 'firstname' %}
{% set usersFirstName = detail[1] %}
{% elseif detail == 'lastName' %}
{% set usersLastName = detail[1] %}
{% elseif detail == 'role' %}
{% set usersRole = detail[1] %}
{% elseif detail == 'position' %}
{% set usersPosition = detail[1] %}
{% elseif detail == 'signature' %}
{% set usersSignature = detail[1] %}
{% endif %}
{% endfor %}
{% for user in users %}
{% if user.email == usersEmail or user.userName == usersUsername or user.firstName == usersFirstName
or user.lastName == usersLastName or user.role == usersRole
or user.position == usersPosition or user.signature == usersSignature %}
{% set isMatch = true %}
{% set user_id = user.id %}
{% set user_username = user.userName %}
{% endif %}
{% endfor %}
{% if isMatch == true %}
{% set userPath = userPath|merge([user_id, user_username]) %}
{% endif %}
{% elseif log.action == 'update' and log.object == 'role' %}
{% set name, description, rowPermissions = '', '', '' %}
{% set role_id = '1' %}
{% set roleName = 'Admininstrator' %}
{% set isMatch = false %}
{% for detail in log.details %}
{% if detail == 'name' %}
{% set name = detail[1] %}
{% elseif detail == 'description' %}
{% set description = detail[1] %}
{% elseif detail == 'rawPermissions' %}
{% set rowPermissions = detail[1] %}
{% endif %}
{% endfor %}
{% for role in roles %}
{% if role.name == name or role.description == description or
role.rawPermissions == rowPermissions %}
{% set isMatch = true %}
{% set role_id = role.id %}
{% set roleName = role.name %}
{% endif %}
{% endfor %}
{% if isMatch == true %}
{% set rolePath = []|merge([role_id, roleName]) %}
{% endif %}
{% endif %}
<li class="media">
<div class="media-object pull-left">
{% if log.action == 'login' %}
<span class="figure featured">
<span class="ri-login-circle-line"></span>
</span>
{% elseif log.action == 'update' %}
<span class="figure"></span>
{% else %}
<span class="figure"></span>
{% endif %}
</div>
<div class="media-body">
{% if log.object == 'user' %}
{% if log.action == 'update' %}
{{ 'mautic.user.user.form.user'|trans }}
<a href="{{ path('mautic_user_action', {objectAction: 'edit', objectId: userPath[0]}) }}" data-toggle="ajax">
{{ userPath[1] }}</a>
{{ 'mautic.user.role.form.updated_by'|trans }}
{% elseif log.action == 'create' %}
<a href="{{ path('mautic_user_action', {objectAction: 'edit', objectId: userPath[0]}) }}" data-toggle="ajax">
{{ userPath[1] }}</a>
{{ 'mautic.user.user.form.created_by'|trans }}
{% endif %}
{% elseif log.object == 'role' %}
{% if log.action == 'create' %}
{{ 'mautic.role.role'|trans }}
<a href="{{ path('mautic_role_action', {objectAction: 'edit', objectId: rolePath[0]}) }}" data-toggle="ajax">
{{ rolePath[1] }}</a>
{{ 'mautic.user.user.form.created_by'|trans }}
{% elseif log.action == 'update' %}
{{ 'mautic.role.role'|trans }}
<a href="{{ path('mautic_role_action', {objectAction: 'edit', objectId: rolePath[0]}) }}" data-toggle="ajax">
{{ rolePath[1] }}</a>
{{ 'mautic.user.role.form.updated_by'|trans }}
{% endif %}
{% elseif log.object == 'security' %}
{% if log.action == 'login' %}
{{ 'mautic.user.user.form.user'|trans }}
{% elseif log.action == 'update' %}
{{ 'mautic.user.user.form.updated_by'|trans }}
{% endif %}
{% endif %}
{% if log.userId is defined and log.userId is not empty %}
<a href="{{ path('mautic_user_action', {objectAction: 'edit', objectId: log.userId}) }}" data-toggle="ajax">{{ log.userName }}</a>
{% if log.action == 'login' %}{{ 'mautic.user.user.form.login_by'|trans }}{% endif %}
{% else %}
{{ log.userName }}
{% endif %}
<p class="fs-12 dark-sm">
<small>
{{ dateToFull(log.dateAdded) }}</small>
</p>
</div>
</li>
{% endfor %}
</ul>
{% endif %}
</div>
</div>
{{ customContent('right.section.bottom', _context) }}
</div>
<!-- Recent activity block(audit_log table) -->

View File

@@ -0,0 +1,61 @@
<?php
namespace Mautic\UserBundle\Security\Authentication;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface;
use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface;
use Symfony\Component\Security\Http\SecurityRequestAttributes;
class AuthenticationHandler implements AuthenticationSuccessHandlerInterface, AuthenticationFailureHandlerInterface
{
public function __construct(
private RouterInterface $router,
) {
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token): ?Response
{
// Remove post_logout if set
$request->getSession()->remove('post_logout');
$format = $request->request->get('format');
if ('json' == $format) {
$array = ['success' => true];
$response = new Response(json_encode($array));
$response->headers->set('Content-Type', 'application/json');
return $response;
} else {
$redirectUrl = $request->getSession()->get('_security.main.target_path', $this->router->generate('mautic_dashboard_index'));
return new RedirectResponse($redirectUrl);
}
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response
{
// Remove post_logout if set
$request->getSession()->remove('post_logout');
$format = $request->request->get('format');
if ('json' == $format) {
$array = ['success' => false, 'message' => $exception->getMessage()];
$response = new Response(json_encode($array));
$response->headers->set('Content-Type', 'application/json');
return $response;
} else {
$request->getSession()->set(SecurityRequestAttributes::AUTHENTICATION_ERROR, $exception);
return new RedirectResponse($this->router->generate('login'));
}
}
}

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace Mautic\UserBundle\Security\Authentication\Token\Permissions;
use Doctrine\ORM\EntityManagerInterface;
use FOS\OAuthServerBundle\Model\TokenInterface as OAuthTokenInterface;
use FOS\OAuthServerBundle\Security\Authenticator\Token\OAuthToken;
use Mautic\ApiBundle\Entity\oAuth2\AccessToken;
use Mautic\ApiBundle\Entity\oAuth2\Client;
use Mautic\UserBundle\Entity\PermissionRepository;
use Mautic\UserBundle\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
use Symfony\Component\Security\Core\User\UserInterface;
class TokenPermissions
{
public function __construct(private TokenStorageInterface $tokenStorage, private PermissionRepository $permissionRepository, private EntityManagerInterface $entityManager)
{
}
/**
* Set the active permissions on the current user.
*/
public function setActivePermissionsOnAuthToken(TokenInterface|OAuthTokenInterface|null $token = null): ?UserInterface
{
if (null === $token) {
$token = $this->tokenStorage->getToken();
}
if (null === $token) {
return null;
}
$user = $token->getUser();
\assert(null === $user || $user instanceof User);
// If no user is associated with a token, it's a client credentials grant type. Handle accordingly.
if (null === $user && ($token instanceof OAuthToken || $token instanceof OAuthTokenInterface)) {
$user = $this->assignRoleFromToken($token->getToken());
}
if (null !== $user) {
$this->setPermissionsOnUser($user);
}
if (null === $user) {
throw new \RuntimeException('The user should be either already set in the token, or come from assignRoleFromToken.');
}
$token->setUser($user);
if ($token instanceof TokenInterface) {
$this->tokenStorage->setToken($token);
}
return $user;
}
/**
* Handle permission for Client Credential grant type.
*/
private function assignRoleFromToken(string $tokenIdentifier): User
{
/** @var AccessToken|null $accessToken assert ill yield phpstan error. */
$accessToken = $this->entityManager->getRepository(AccessToken::class)->findOneBy(['token' => $tokenIdentifier]);
if (null === $accessToken) {
throw new UserNotFoundException('API access token not found.');
}
$client = $accessToken->getClient();
if (!$client instanceof Client) {
// There are no tests for this part, so an exception will reveal any inconsistencies earlier.
throw new \RuntimeException('The client is not a valid API client.');
}
$role = $client->getRole();
// Create a pseudo user and assign the role
$user = new User();
$user->setRole($role);
// Set for the audit log and the entity's "created by user" metadata which takes the first and last name
$user->setFirstName($client->getName());
$user->setLastName(sprintf('[%s]', $client->getId()));
$user->setUsername($user->getName());
defined('MAUTIC_AUDITLOG_USER') || define('MAUTIC_AUDITLOG_USER', $user->getName());
return $user;
}
private function setPermissionsOnUser(User $user): void
{
if (!$user->isAdmin() && (null === $user->getActivePermissions() || [] === $user->getActivePermissions())) {
$activePermissions = $this->permissionRepository->getPermissionsByRole($user->getRole());
$user->setActivePermissions($activePermissions);
}
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace Mautic\UserBundle\Security\Authentication\Token;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\AbstractToken;
use Symfony\Component\Security\Core\User\UserInterface;
class PluginToken extends AbstractToken
{
private ?string $providerKey;
/**
* @param UserInterface|string|null $user
* @param array<string> $roles
*/
public function __construct(
?string $providerKey,
private ?string $authenticatingService = null,
$user = null,
private string $credentials = '',
array $roles = [],
private ?Response $response = null,
) {
parent::__construct($roles);
if ('' === $providerKey) {
throw new \InvalidArgumentException('$providerKey must not be empty.');
}
if (is_string($user)) {
$user = null;
}
if (null !== $user) {
$this->setUser($user);
}
$this->providerKey = $providerKey;
}
public function getCredentials(): string
{
return $this->credentials;
}
public function getProviderKey(): ?string
{
return $this->providerKey;
}
public function getAuthenticatingService(): ?string
{
return $this->authenticatingService;
}
public function getResponse(): ?Response
{
return $this->response;
}
/**
* @return array<int, mixed>
*/
public function __serialize(): array
{
return array_merge([$this->authenticatingService, $this->credentials, $this->providerKey, parent::__serialize()]);
}
/**
* @param array<int, mixed> $data
*/
public function __unserialize(array $data): void
{
[$this->authenticatingService, $this->credentials, $this->providerKey, $parentArray] = $data;
parent::__unserialize($parentArray);
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Mautic\UserBundle\Security\Authenticator;
use OAuth2\OAuth2ServerException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
class Oauth2Authenticator extends \FOS\OAuthServerBundle\Security\Authenticator\Oauth2Authenticator
{
public function supports(Request $request): ?bool
{
// needed until the oAuth2 library will not be updated to 4.0.5
return null !== $this->serverService->getBearerToken($request);
}
/**
* A BC compatible response.
*/
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
$previous = $exception->getPrevious();
if ($previous instanceof OAuth2ServerException) {
return $previous->getHttpResponse();
}
return parent::onAuthenticationFailure($request, $exception);
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Mautic\UserBundle\Security\Authenticator\Passport\Badge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface;
class PasswordStrengthBadge implements BadgeInterface
{
private bool $resolved = false;
public function __construct(private ?string $presentedPassword)
{
}
public function getPresentedPassword(): ?string
{
return $this->presentedPassword;
}
public function setResolved(): void
{
$this->resolved = true;
}
public function isResolved(): bool
{
return $this->resolved;
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Mautic\UserBundle\Security\Authenticator\Passport\Badge;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface;
class PluginBadge implements BadgeInterface
{
public function __construct(private ?TokenInterface $preAuthenticatedToken, private ?Response $pluginResponse, private ?string $authenticatingService)
{
}
public function getPreAuthenticatedToken(): ?TokenInterface
{
return $this->preAuthenticatedToken;
}
public function getPluginResponse(): ?Response
{
return $this->pluginResponse;
}
public function getAuthenticatingService(): ?string
{
return $this->authenticatingService;
}
public function isResolved(): bool
{
return null !== $this->preAuthenticatedToken || null !== $this->pluginResponse;
}
}

View File

@@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
namespace Mautic\UserBundle\Security\Authenticator;
use Mautic\PluginBundle\Helper\IntegrationHelper;
use Mautic\UserBundle\Entity\User;
use Mautic\UserBundle\Event\AuthenticationEvent;
use Mautic\UserBundle\Security\Authentication\AuthenticationHandler;
use Mautic\UserBundle\Security\Authentication\Token\Permissions\TokenPermissions;
use Mautic\UserBundle\Security\Authentication\Token\PluginToken;
use Mautic\UserBundle\Security\Authenticator\Passport\Badge\PluginBadge;
use Mautic\UserBundle\UserEvents;
use OAuth2\OAuth2;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\LazyResponseException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
use Symfony\Component\Security\Http\Event\InteractiveLoginEvent;
use Symfony\Component\Security\Http\SecurityEvents;
use Symfony\Component\Security\Http\SecurityRequestAttributes;
final class PluginAuthenticator extends AbstractAuthenticator
{
public function __construct(private TokenPermissions $tokenPermissions, private EventDispatcherInterface $dispatcher, private IntegrationHelper $integrationHelper, private UserProviderInterface $userProvider, private AuthenticationHandler $authenticationHandler, private OAuth2 $oAuth2, private LoggerInterface $logger, private string $firewallName)
{
}
public function supports(Request $request): ?bool
{
// Pass to oAuth2 if the token is present, but try to execute in case the Request does not look like oAuth2.
return null === $this->oAuth2->getBearerToken($request) ? null : false;
}
public function authenticate(Request $request): Passport
{
$authenticatingService = $request->get('integration');
\assert(null === $authenticatingService || is_string($authenticatingService));
$token = new PluginToken($this->firewallName, $authenticatingService);
$user = null;
$response = null;
$authenticated = false;
$authenticatedToken = null;
// Try authenticating with a plugin
if ($this->dispatcher->hasListeners(UserEvents::USER_PRE_AUTHENTICATION)) {
$integrations = $this->integrationHelper->getIntegrationObjects($authenticatingService, ['sso_service'], false, null, true);
$loginCheck = 'mautic_sso_login_check' === $request->attributes->get('_route');
$authEvent = new AuthenticationEvent(
null,
$token,
$this->userProvider,
$request,
$loginCheck,
$authenticatingService,
$integrations
);
$authEvent = $this->dispatcher->dispatch($authEvent, UserEvents::USER_PRE_AUTHENTICATION);
\assert($authEvent instanceof AuthenticationEvent);
if ($authenticated = $authEvent->isAuthenticated()) {
$eventToken = $authEvent->getToken();
$authenticatingService = $authEvent->getAuthenticatingService();
// Return passport with the token set in the event, if the event set a different token.
if ($eventToken !== $token) {
return new SelfValidatingPassport(
new UserBadge($eventToken->getUserIdentifier(), function () use ($eventToken): UserInterface {
return $eventToken->getUser();
}),
[new PluginBadge($eventToken, null, $authenticatingService)]
);
}
// Set the user in the token.
$user = $authEvent->getUser();
if (null === $user) {
throw new \RuntimeException('User must be set in the authenticated token.');
}
$authenticatedToken = $eventToken;
$authenticatedToken->setUser($user);
}
$response = $authEvent->getResponse();
if (!$authenticated && $loginCheck && null === $response) {
// Set an empty JSON response
$response = new JsonResponse([]);
}
}
// The check is intended to catch: Plugin authenticator must be authenticated and have $user. oAuth should have a response.
if (!$user instanceof User && !$authenticated && null === $response) {
throw new AuthenticationException('mautic.user.auth.error.invalidlogin');
}
// Otherwise if the plugin authenticator has a response, then pass it to the Symfony.
if (null === $user && !$authenticated && null !== $response) {
throw new LazyResponseException($response);
}
return new SelfValidatingPassport(
new UserBadge(
$user instanceof User ? $user->getUserIdentifier() : $user,
function (string $userIdentifier) use ($user): UserInterface {
if ($user instanceof User) {
return $user;
}
return $this->userProvider->loadUserByIdentifier($userIdentifier);
}
),
[new PluginBadge($authenticatedToken, $response, $authenticatingService)]
);
}
public function createToken(Passport $passport, string $firewallName): TokenInterface
{
$pluginBadge = $passport->getBadge(PluginBadge::class);
\assert($pluginBadge instanceof PluginBadge);
$userBadge = $passport->getBadge(UserBadge::class);
\assert($userBadge instanceof UserBadge);
// A custom token has not been set by the plugin, so create a new one. Mainly used for oAuth.
if (null === $token = $pluginBadge->getPreAuthenticatedToken()) {
$user = $userBadge->getUser();
$token = new PluginToken(
$this->firewallName,
$pluginBadge->getAuthenticatingService(),
$user,
($user instanceof User) ? $user->getPassword() : '',
($user instanceof User) ? $user->getRoles() : [],
$pluginBadge->getPluginResponse()
);
}
$this->tokenPermissions->setActivePermissionsOnAuthToken($token);
return $token;
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
if (!$token instanceof PluginToken) {
// Maybe this need to be replaced with assert, but as no tests cover this, an exception will be noticed earlier.
throw new \RuntimeException('Token is not a PluginToken');
}
if ('api' === $this->firewallName) {
return $token->getResponse();
}
$this->logger->info(sprintf('User "%s" has been authenticated successfully', $token->getUserIdentifier()));
$session = $request->getSession();
$session->remove(SecurityRequestAttributes::AUTHENTICATION_ERROR);
$loginEvent = new InteractiveLoginEvent($request, $token);
$this->dispatcher->dispatch($loginEvent, SecurityEvents::INTERACTIVE_LOGIN);
$response = null;
if (null === $token->getResponse()) {
$response = $this->authenticationHandler->onAuthenticationSuccess($request, $token);
}
return $response;
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
$this->logger->info(sprintf('Authentication request failed: %s', $exception->getMessage()));
// Gets app/bundles/UserBundle/Security/Firewall/AuthenticationListener.php:74 and till the end of the method referenced.
if (in_array($this->firewallName, ['api', 'v2api'])) {
// Continue with another authentication.
return null;
}
return $this->authenticationHandler->onAuthenticationFailure($request, $exception);
}
}

View File

@@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
namespace Mautic\UserBundle\Security\Authenticator;
use Mautic\PluginBundle\Helper\IntegrationHelper;
use Mautic\UserBundle\Entity\User;
use Mautic\UserBundle\Event\AuthenticationEvent;
use Mautic\UserBundle\Security\Authentication\Token\PluginToken;
use Mautic\UserBundle\UserEvents;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface;
use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\InteractiveAuthenticatorInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\HttpUtils;
use Symfony\Component\Security\Http\SecurityRequestAttributes;
/**
* This is a modified copy of the \Symfony\Component\Security\Http\Authenticator\FormLoginAuthenticator
* Replaces \Mautic\UserBundle\Security\Authenticator\FormAuthenticator.
*/
final class SsoAuthenticator extends AbstractAuthenticator implements InteractiveAuthenticatorInterface
{
/**
* @var array<mixed>
*/
private array $options;
/**
* @param array<mixed> $options
*/
public function __construct(array $options, private HttpUtils $httpUtils, private UserProviderInterface $userProvider, private AuthenticationSuccessHandlerInterface $successHandler, private AuthenticationFailureHandlerInterface $failureHandler, private IntegrationHelper $integrationHelper, private EventDispatcherInterface $dispatcher)
{
if ([] === $options) {
throw new \RuntimeException('$options parameter is empty. Did you forgot to configure?');
}
$this->options = array_merge([
'username_parameter' => '_username',
'password_parameter' => '_password',
'integration_parameter' => 'integration',
'post_only' => true,
'enable_csrf' => true,
'csrf_parameter' => '_csrf_token',
'csrf_token_id' => 'authenticate',
], $options);
}
public function supports(Request $request): bool
{
if (true === $this->options['post_only'] && !$request->isMethod(Request::METHOD_POST)) {
return false;
}
if (!$this->httpUtils->checkRequestPath($request, $this->options['check_path'])) {
return false;
}
if (true === $this->options['form_only'] && 'form' !== $request->getContentTypeFormat()) {
return false;
}
if (true === $this->options['post_only']) {
return $request->request->has($this->options['integration_parameter']);
}
return $request->query->has($this->options['integration_parameter'])
|| $request->request->has($this->options['integration_parameter']);
}
public function authenticate(Request $request): Passport
{
$credentials = $this->getCredentials($request);
$authenticatingService = $credentials['integration'] ?? null;
$passport = new Passport(
new UserBadge($credentials['username'], function (string $userIdentifier) use ($request, $authenticatingService, $credentials): ?User {
/** @var User|null $user */
$user = null;
try {
$user = $this->userProvider->loadUserByIdentifier($userIdentifier);
} catch (UserNotFoundException) {
// Do nothing. Will try to authenticate by username.
}
// Try authenticating with a plugin
$integrations = $this->integrationHelper->getIntegrationObjects($authenticatingService, ['sso_form'], false, null, true);
$token = new PluginToken(
null,
$authenticatingService,
$userIdentifier,
null !== $user ? ($credentials['password'] ?? null) : '',
null !== $user ? $user->getRoles() : [],
);
$authEvent = new AuthenticationEvent(
$user ?? $userIdentifier,
$token,
$this->userProvider,
$request,
false,
$authenticatingService,
$integrations
);
if ($this->dispatcher->hasListeners(UserEvents::USER_FORM_AUTHENTICATION)) {
$authEvent = $this->dispatcher->dispatch($authEvent, UserEvents::USER_FORM_AUTHENTICATION);
}
if ($authEvent->isAuthenticated()) {
$user = $authEvent->getUser();
// This line is most likely will never happen. Keep it until this is thoroughly tested manually.
if (null !== $user && !$user instanceof User) {
return null;
}
return $user;
}
if ($authEvent->isFailed()) {
throw new AuthenticationException($authEvent->getFailedAuthenticationMessage());
}
if (!$user instanceof User) {
return null;
}
return $user;
}),
new PasswordCredentials($credentials['password'] ?? null),
[new RememberMeBadge()]
);
if ($this->options['enable_csrf']) {
$passport->addBadge(new CsrfTokenBadge($this->options['csrf_token_id'], $credentials['csrf_token']));
}
return $passport;
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return $this->successHandler->onAuthenticationSuccess($request, $token);
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response
{
return $this->failureHandler->onAuthenticationFailure($request, $exception);
}
/**
* @return array<string, mixed>
*/
private function getCredentials(Request $request): array
{
$credentials = [];
$credentials['csrf_token'] = $request->get($this->options['csrf_parameter']);
if ($this->options['post_only']) {
$credentials['username'] = $request->request->get($this->options['username_parameter']);
$credentials['password'] = $request->request->get($this->options['password_parameter']) ?? '';
$credentials['integration'] = $request->request->get($this->options['integration_parameter']);
} else {
$credentials['username'] = $request->get($this->options['username_parameter']);
$credentials['password'] = $request->get($this->options['password_parameter']) ?? '';
$credentials['integration'] = $request->get($this->options['integration_parameter']);
}
if (!\is_string($credentials['username'])) {
throw new BadRequestHttpException(sprintf('The key "%s" must be a string, "%s" given.', $this->options['username_parameter'], \gettype($credentials['username'])));
}
$credentials['username'] = trim($credentials['username']);
if (\strlen($credentials['username']) > UserBadge::MAX_USERNAME_LENGTH) {
throw new BadCredentialsException('Invalid username.');
}
if (null !== $credentials['integration'] && !\is_string($credentials['integration'])) {
throw new BadRequestHttpException(sprintf('The key "%s" must be a string or null, "%s" given.', $this->options['integration_parameter'], \gettype($credentials['integration'])));
}
$request->getSession()->set(SecurityRequestAttributes::LAST_USERNAME, $credentials['username']);
if (!\is_string($credentials['password'])) {
throw new BadRequestHttpException(sprintf('The key "%s" must be a string, "%s" given.', $this->options['password_parameter'], \gettype($credentials['password'])));
}
return $credentials;
}
public function isInteractive(): bool
{
return true;
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Mautic\UserBundle\Security;
use Symfony\Component\Security\Core\Authentication\Token\AbstractToken;
final class DummyToken extends AbstractToken
{
public function getCredentials(): string
{
return '';
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Mautic\UserBundle\Security\EntryPoint;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface;
class MainEntryPoint implements AuthenticationEntryPointInterface
{
public function __construct(private UrlGeneratorInterface $urlGenerator, private bool $samlEnabled)
{
}
public function start(Request $request, ?AuthenticationException $authException = null): Response
{
// as per https://docs.mautic.org/en/5.x/authentication/authentication.html#logging-in
// log in always as SAML for all requests.
// todo: task for testers: enable saml, and check if regular login page is available
$route = (string) $request->attributes->get('_route');
if ($this->samlEnabled && 'login' !== $route && 'mautic_user_logincheck' !== $route) {
// As the system doesn't know the IDP of the service, we can spare one redirect,
// and redirect the user straight to discovery.
return new RedirectResponse($this->urlGenerator->generate('lightsaml_sp.discovery'));
}
return new RedirectResponse($this->urlGenerator->generate('login'));
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace Mautic\UserBundle\Security\Permissions;
use Mautic\CoreBundle\Security\Permissions\AbstractPermissions;
use Mautic\UserBundle\Form\Type\PermissionListType;
use Symfony\Component\Form\FormBuilderInterface;
class UserPermissions extends AbstractPermissions
{
public function __construct($params)
{
parent::__construct($params);
$this->permissions = [
'profile' => [
'editusername' => 1,
'editemail' => 2,
'editposition' => 4,
'editname' => 8,
'full' => 1024,
],
];
$this->addStandardPermissions('users', false);
$this->addStandardPermissions('roles', false);
}
public function getName(): string
{
return 'user';
}
public function buildForm(FormBuilderInterface &$builder, array $options, array $data): void
{
$this->addStandardFormFields('user', 'users', $builder, $data, false);
$this->addStandardFormFields('user', 'roles', $builder, $data, false);
$builder->add(
'user:profile',
PermissionListType::class,
[
'choices' => [
'mautic.user.account.permissions.editname' => 'editname',
'mautic.user.account.permissions.editusername' => 'editusername',
'mautic.user.account.permissions.editemail' => 'editemail',
'mautic.user.account.permissions.editposition' => 'editposition',
'mautic.user.account.permissions.editall' => 'full',
],
'label' => 'mautic.user.permissions.profile',
'data' => (!empty($data['profile']) ? $data['profile'] : []),
'bundle' => 'user',
'level' => 'profile',
]
);
}
}

Some files were not shown because too many files have changed in this diff Show More