0

We are in the process of implementing SSO and using Joomla as the IDP. In order to understand how we represent data, let's consider the following tables:

  • jos_sso_identities: A table that stores all the identities of users, including non-Joomla identities. This is a new table and it has username, password and email among other fields
  • jos_sso_users: A table that maps identities to applications where foreign_id is a field that represents the id of the user in the database of the SP
  • jos_users: Well-known Joomla table for users
  • jos_sso_apps: A table where we store the apps an identity may have a user

The Joomla site is stored inside jos_sso_apps with an id of 1. All other apps have an id > 1.

An identity may have a Joomla user (then this is a superuser) or may have only users in the SPs using the SSO. We have implemented full database support as well as plugins that handle authentication, registration, user save, etc and endpoints that directly respond to server-side curl requests from non-Joomla SPs.

So, for the purpose of this question we have two main kinds of identities:

  1. Superusers are identities who have a Joomla user as well as a jos_sso_users record with app_id being 1 and foreign_id being equal to the corresponding jos_users.id valu
  2. Lower admins are identities who have no Joomla user, but jos_sso_users records whose app_id is > 1 and foreign_id being equal to the id said user has inside the SP

We intend to adjust Joomla's Forgot Password feature so that it will refer to jos_sso_identities rather than jos_sso_users. My first research found the file located at components/com_users/src/Model/ResetModel.php representing the class of Joomla\Component\Users\Site\Model\ResetModel and I would like to override this model so that it will search for emails inside jos_sso_identities rather than jos_users so that even the identities who have no Joomla users can recover their passwords.

Currently it seems that I need to override processResetConfirm:

    public function processResetConfirm($data)
    {
        // Get the form.
        $form = $this->getResetConfirmForm();

        // Check for an error.
        if ($form instanceof \Exception) {
            return $form;
        }

        // Filter and validate the form data.
        $data   = $form->filter($data);
        $return = $form->validate($data);

        // Check for an error.
        if ($return instanceof \Exception) {
            return $return;
        }

        // Check the validation results.
        if ($return === false) {
            // Get the validation messages from the form.
            foreach ($form->getErrors() as $formError) {
                $this->setError($formError->getMessage());
            }

            return false;
        }

        // Find the user id for the given token.
        $db    = $this->getDatabase();
        $query = $db->getQuery(true)
            ->select($db->quoteName(['activation', 'id', 'block']))
            ->from($db->quoteName('#__users'))
            ->where($db->quoteName('username') . ' = :username')
            ->bind(':username', $data['username']);

        // Get the user id.
        $db->setQuery($query);

        try {
            $user = $db->loadObject();
        } catch (\RuntimeException $e) {
            return new \Exception(Text::sprintf('COM_USERS_DATABASE_ERROR', $e->getMessage()), 500);
        }

        // Check for a user.
        if (empty($user)) {
            $this->setError(Text::_('COM_USERS_USER_NOT_FOUND'));

            return false;
        }

        if (!$user->activation) {
            $this->setError(Text::_('COM_USERS_USER_NOT_FOUND'));

            return false;
        }

        // Verify the token
        if (!UserHelper::verifyPassword($data['token'], $user->activation)) {
            $this->setError(Text::_('COM_USERS_USER_NOT_FOUND'));

            return false;
        }

        // Make sure the user isn't blocked.
        if ($user->block) {
            $this->setError(Text::_('COM_USERS_USER_BLOCKED'));

            return false;
        }

        // Push the user data into the session.
        $app = Factory::getApplication();
        $app->setUserState('com_users.reset.token', $user->activation);
        $app->setUserState('com_users.reset.user', $user->id);

        return true;
    }

and processResetRequest:

    public function processResetRequest($data)
    {
        $app = Factory::getApplication();

        // Get the form.
        $form = $this->getForm();

        $data['email'] = PunycodeHelper::emailToPunycode($data['email']);

        // Check for an error.
        if ($form instanceof \Exception) {
            return $form;
        }

        // Filter and validate the form data.
        $data   = $form->filter($data);
        $return = $form->validate($data);

        // Check for an error.
        if ($return instanceof \Exception) {
            return $return;
        }

        // Check the validation results.
        if ($return === false) {
            // Get the validation messages from the form.
            foreach ($form->getErrors() as $formError) {
                $this->setError($formError->getMessage());
            }

            return false;
        }

        // Find the user id for the given email address.
        $db    = $this->getDatabase();
        $query = $db->getQuery(true)
            ->select($db->quoteName('id'))
            ->from($db->quoteName('#__users'))
            ->where('LOWER(' . $db->quoteName('email') . ') = LOWER(:email)')
            ->bind(':email', $data['email']);

        // Get the user object.
        $db->setQuery($query);

        try {
            $userId = $db->loadResult();
        } catch (\RuntimeException $e) {
            $this->setError(Text::sprintf('COM_USERS_DATABASE_ERROR', $e->getMessage()));

            return false;
        }

        // Check for a user.
        if (empty($userId)) {
            $this->setError(Text::_('COM_USERS_INVALID_EMAIL'));

            return false;
        }

        // Get the user object.
        $user = User::getInstance($userId);

        // Make sure the user isn't blocked.
        if ($user->block) {
            $this->setError(Text::_('COM_USERS_USER_BLOCKED'));

            return false;
        }

        // Make sure the user isn't a Super Admin.
        if ($user->authorise('core.admin')) {
            $this->setError(Text::_('COM_USERS_REMIND_SUPERADMIN_ERROR'));

            return false;
        }

        // Make sure the user has not exceeded the reset limit
        if (!$this->checkResetLimit($user)) {
            $resetLimit = (int) Factory::getApplication()->getParams()->get('reset_time');
            $this->setError(Text::plural('COM_USERS_REMIND_LIMIT_ERROR_N_HOURS', $resetLimit));

            return false;
        }

        // Set the confirmation token.
        $token       = ApplicationHelper::getHash(UserHelper::genRandomPassword());
        $hashedToken = UserHelper::hashPassword($token);

        $user->activation = $hashedToken;

        $event = AbstractEvent::create(
            'onUserBeforeResetRequest',
            [
                'subject' => $user,
            ]
        );
        $app->getDispatcher()->dispatch($event->getName(), $event);

        // Save the user to the database.
        if (!$user->save(true)) {
            return new \Exception(Text::sprintf('COM_USERS_USER_SAVE_FAILED', $user->getError()), 500);
        }

        // Assemble the password reset confirmation link.
        $mode = $app->get('force_ssl', 0) == 2 ? 1 : (-1);
        $link = 'index.php?option=com_users&view=reset&layout=confirm&token=' . $token;

        // Put together the email template data.
        $data              = $user->getProperties();
        $data['sitename']  = $app->get('sitename');
        $data['link_text'] = Route::_($link, false, $mode);
        $data['link_html'] = Route::_($link, true, $mode);
        $data['token']     = $token;

        $mailer = new MailTemplate('com_users.password_reset', $app->getLanguage()->getTag());
        $mailer->addTemplateData($data);
        $mailer->addRecipient($user->email, $user->name);

        // Try to send the password reset request email.
        try {
            $return = $mailer->send();
        } catch (\Exception $exception) {
            try {
                Log::add(Text::_($exception->getMessage()), Log::WARNING, 'jerror');

                $return = false;
            } catch (\RuntimeException $exception) {
                $app->enqueueMessage(Text::_($exception->errorMessage()), 'warning');

                $return = false;
            }
        }

        // Check for an error.
        if ($return !== true) {
            return new \Exception(Text::_('COM_USERS_MAIL_FAILED'), 500);
        }

        $event = AbstractEvent::create(
            'onUserAfterResetRequest',
            [
                'subject' => $user,
            ]
        );
        $app->getDispatcher()->dispatch($event->getName(), $event);

        return true;
    }

It is very much possible that we will need to override other assets too in order to achieve this goal and of course this could be achieved in a very hacky way that would involve changing the reference from jos_users to the table of jos_sso_identities in this context, but that would be a very volatile solution which would greatly increase the difficulty of applying Joomla updates.

So the question is as follows: How can I override ResetModel methods in such a way that it will use my jos_sso_identities table instead of jos_users table in a neat way, that is, I would preferably create a new class that extends ResetModel and overrides the methods and then Joomla would put in use my new class and execute its methods instead of ResetModel. So how could I tell ResetController to use my custom class instead of the class Joomla has provided for ResetModel?

1 Answer 1

0

I think this should work, but haven't tested it!

You can override the Model classes of Joomla components as follows:

Create a system plugin that listens for the onAfterExtensionBoot event.

When your listener gets called then check if it's com_users and if so change that component's Dependency Injection container entries to include your version of the MVCFactory class instead of the standard Joomla MVCFactory.

You have to write your own MVCFactory class so that when com_users asks for the Model then return your Model class instead of the Joomla one. Your MVCFactory class overrides the Joomla MVCFactory, but has its own createModel() function.

Have a look at https://manual.joomla.org/docs/building-extensions/plugins/plugin-examples/system-plugin-router-rules where a system plugin does a similar thing - overriding the router rules for com_content by replacing the standard RouterFactory with a custom RouterFactory.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.